Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/sdk/guides/editing-affordances.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ interface EditingSectionApplicability {
}
```

`sections.colorGrading` is element-level only: it tells you this particular element supports color grading controls. Whether to show the color grading panel at all is still governed by your own feature flag — AND the two conditions together.
`sections.colorGrading` is element-level only: it tells you this particular element supports color grading controls. Studio shows the color grading panel for supported media elements.

## End-to-end example

Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/editing/affordances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,9 @@ describe("resolveEditingAffordances — sections", () => {
expect(s).toMatchObject({ media: true, colorGrading: false });
});

it("img: colorGrading but not media", () => {
it("img: media + colorGrading", () => {
const s = resolveEditingAffordances(baseFacts({ tag: "img" })).sections;
expect(s).toMatchObject({ media: false, colorGrading: true });
expect(s).toMatchObject({ media: true, colorGrading: true });
});

it("editable text on a plain element: text section", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/editing/affordances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities {
export function resolveEditingSections(facts: EditableElementFacts): EditingSectionApplicability {
return {
text: facts.hasEditableText && !facts.isCompositionHost && !facts.isInsideLockedComposition,
media: facts.tag === "video" || facts.tag === "audio",
media: facts.tag === "video" || facts.tag === "audio" || facts.tag === "img",
colorGrading: facts.tag === "video" || facts.tag === "img",
timing: facts.hasTimingStart || facts.animationCount > 0,
animation: facts.animationCount > 0,
Expand Down
10 changes: 6 additions & 4 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@ export function StudioApp() {
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState();

// sessionStorage-backed: fires once per tab, survives HMR remounts
useEffect(() => {
if (resolving || waitingForServer) return;
if (hasFiredSessionStart()) return;
Expand Down Expand Up @@ -487,7 +486,12 @@ export function StudioApp() {
refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime}
inspectorButtonActive={inspectorButtonActive}
inspectorPanelActive={inspectorPanelActive}
onExport={() => void renderQueue.startRender(undefined)}
onExport={() => {
void (async () => {
await previewPersistence.waitForPendingDomEditSaves();
await renderQueue.startRender(undefined);
})();
}}
/>
{previewPersistence.domEditSaveQueuePaused && (
<SaveQueuePausedBanner
Expand All @@ -501,8 +505,6 @@ export function StudioApp() {
onSelectComposition={handleSelectComposition}
/>
)}
{/* Timeline stage stays mounted (just hidden) in storyboard mode,
so preview/player/gesture/render state survives the toggle. */}
<div
className={`flex flex-1 min-h-0${
viewModeValue.viewMode === "storyboard" ? " hidden" : ""
Expand Down
113 changes: 109 additions & 4 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -27,6 +28,13 @@ import { usePanelLayoutContext } from "../contexts/PanelLayoutContext";
import { useFileManagerContext } from "../contexts/FileManagerContext";
import { useDomEditContext } from "../contexts/DomEditContext";
import { usePlayerStore } from "../player";
import { waitForMediaJob } from "./studioMediaJobs";
import {
applyColorGradingScopeUpdate,
EMPTY_COLOR_GRADING_SCOPE_RESULT,
type ColorGradingScope,
} from "./studioColorGradingScope";
import type { BackgroundRemovalProgress } from "./editor/propertyPanelTypes";

const MIN_INSPECTOR_SPLIT_PERCENT = 20;
const MAX_INSPECTOR_SPLIT_PERCENT = 75;
Expand Down Expand Up @@ -82,6 +90,7 @@ export function StudioRightPanel({
previewIframeRef,
projectId,
activeCompPath,
showToast,
compositionDimensions,
waitForPendingDomEditSaves,
renderQueue,
Expand Down Expand Up @@ -136,8 +145,10 @@ export function StudioRightPanel({
projectDir,
handleImportFiles,
handleImportFonts,
refreshFileTree,
readProjectFile,
writeProjectFile,
fileTree,
} = useFileManagerContext();

// Discrete ops (toggle, reorder, add/delete, hotspot): persist immediately,
Expand Down Expand Up @@ -172,6 +183,14 @@ export function StudioRightPanel({
startPercent: number;
height: number;
} | null>(null);
const backgroundRemovalAbortRef = useRef<AbortController | null>(null);

useEffect(
() => () => {
backgroundRemovalAbortRef.current?.abort();
},
[],
);

const renderJobs = renderQueue.jobs as RenderJob[];
const inspectorTabActive = rightPanelTab === "design" || rightPanelTab === "layers";
Expand Down Expand Up @@ -233,6 +252,90 @@ export function StudioRightPanel({
splitDragRef.current = null;
}, []);

const handleApplyColorGradingScope = useCallback(
async (scope: ColorGradingScope, value: string | null) =>
applyColorGradingScopeUpdate({
scope,
value,
selectedSourceFile: domEditSelection?.sourceFile || activeCompPath || "index.html",
fileTree,
projectId,
domEditSaveTimestampRef,
waitForPendingDomEditSaves,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
showToast,
}).catch((error) => {
showToast(
`Couldn't apply color grading: ${error instanceof Error ? error.message : String(error)}`,
"error",
);
return EMPTY_COLOR_GRADING_SCOPE_RESULT;
}),
[
activeCompPath,
domEditSaveTimestampRef,
domEditSelection?.sourceFile,
fileTree,
projectId,
readProjectFile,
recordEdit,
reloadPreview,
showToast,
waitForPendingDomEditSaves,
writeProjectFile,
],
);

const handleRemoveBackground = useCallback(
// fallow-ignore-next-line complexity
async (
inputPath: string,
options: {
createBackgroundPlate?: boolean;
quality?: "fast" | "balanced" | "best";
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => {
const response = await fetch(
`/api/projects/${encodeURIComponent(projectId)}/media/remove-background`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
inputPath,
createBackgroundPlate: options.createBackgroundPlate === true,
quality: options.quality ?? "balanced",
}),
},
);
const data = (await response.json().catch(() => ({}))) as {
jobId?: string;
error?: string;
};
if (!response.ok || !data.jobId) {
throw new Error(data.error || `Background removal failed (${response.status})`);
}
showToast("Removing background...", "info");
backgroundRemovalAbortRef.current?.abort();
const controller = new AbortController();
backgroundRemovalAbortRef.current = controller;
try {
const result = await waitForMediaJob(data.jobId, options.onProgress, controller.signal);
await refreshFileTree();
showToast(`Created transparent asset: ${result.outputPath.split("/").pop()}`, "info");
return result;
} finally {
if (backgroundRemovalAbortRef.current === controller) {
backgroundRemovalAbortRef.current = null;
}
}
},
[projectId, refreshFileTree, showToast],
);

const propertyPanel = (
<PropertyPanel
projectId={projectId}
Expand All @@ -246,7 +349,9 @@ export function StudioRightPanel({
onSetStyle={handleDomStyleCommit}
onSetAttribute={handleDomAttributeCommit}
onSetAttributeLive={handleDomAttributeLiveCommit}
onApplyColorGradingScope={handleApplyColorGradingScope}
onSetHtmlAttribute={handleDomHtmlAttributeCommit}
onRemoveBackground={handleRemoveBackground}
onSetManualOffset={handleDomPathOffsetCommit}
onSetManualSize={handleDomBoxSizeCommit}
onSetManualRotation={handleDomRotationCommit}
Expand Down Expand Up @@ -325,14 +430,14 @@ export function StudioRightPanel({
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
</div>
<div
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
className="flex min-w-0 flex-shrink-0 flex-col overflow-hidden border-l border-neutral-800 bg-neutral-900"
style={{ width: rightWidth }}
>
{captionEditMode ? (
<CaptionPropertyPanel iframeRef={previewIframeRef} />
) : (
<>
<div className="flex items-center gap-1 border-b border-neutral-800 px-3 py-2">
<div className="flex min-w-0 items-center gap-1 overflow-hidden border-b border-neutral-800 px-3 py-2">
{STUDIO_INSPECTOR_PANELS_ENABLED && (
<>
<Tooltip label="Element styles and properties" side="bottom">
Expand Down Expand Up @@ -390,7 +495,7 @@ export function StudioRightPanel({
</button>
</Tooltip>
</div>
<div className="min-h-0 flex-1">
<div className="min-h-0 min-w-0 flex-1 overflow-hidden">
{rightPanelTab === "block-params" && activeBlockParams ? (
<BlockParamsPanel
blockName={activeBlockParams.blockName}
Expand All @@ -406,7 +511,7 @@ export function StudioRightPanel({
onPersistNotes={onPersistSlideshowNotes}
/>
) : layersPaneOpen && designPaneOpen ? (
<div ref={splitContainerRef} className="flex h-full min-h-0 flex-col">
<div ref={splitContainerRef} className="flex h-full min-h-0 min-w-0 flex-col">
<div
className="min-h-[120px] overflow-hidden"
style={{ flexBasis: `${layersPanePercent}%`, flexShrink: 0 }}
Expand Down
9 changes: 7 additions & 2 deletions packages/studio/src/components/StudioToast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ interface StudioToastProps {
onDismiss?: () => void;
}

// fallow-ignore-next-line complexity
export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
const isError = tone === "error";
return (
Expand All @@ -14,7 +15,7 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
style={onDismiss ? { cursor: "pointer" } : undefined}
>
<div
className="relative flex items-center gap-3 overflow-hidden rounded-2xl pl-4 pr-2 py-3 text-[12px]"
className="relative flex max-w-[min(420px,calc(100vw-48px))] items-center gap-3 overflow-hidden rounded-2xl py-3 pl-4 pr-2 text-[12px]"
style={{
background: isError
? "linear-gradient(135deg, rgba(127,29,29,0.55), rgba(80,10,10,0.45))"
Expand All @@ -29,7 +30,11 @@ export function StudioToast({ message, tone, onDismiss }: StudioToastProps) {
].join(", "),
}}
>
<span className={isError ? "text-red-200" : "text-neutral-200"}>{message}</span>
<span
className={`min-w-0 break-words leading-5 ${isError ? "text-red-200" : "text-neutral-200"}`}
>
{message}
</span>
{onDismiss && (
<button
type="button"
Expand Down
35 changes: 18 additions & 17 deletions packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,7 @@ import { TextSection, StyleSections } from "./propertyPanelSections";
import { GsapAnimationSection } from "./GsapAnimationSection";
import { PropertyPanel3dTransform } from "./propertyPanel3dTransform";
import { KeyframeNavigation } from "./KeyframeNavigation";
import {
STUDIO_COLOR_GRADING_ENABLED,
STUDIO_GSAP_PANEL_ENABLED,
STUDIO_KEYFRAMES_ENABLED,
} from "./manualEditingAvailability";
import { STUDIO_GSAP_PANEL_ENABLED, STUDIO_KEYFRAMES_ENABLED } from "./manualEditingAvailability";
import { usePlayerStore, liveTime } from "../../player";
import { TimingSection } from "./propertyPanelTimingSection";
import { type PropertyPanelProps } from "./propertyPanelHelpers";
Expand Down Expand Up @@ -57,7 +53,9 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetStyle,
onSetAttribute,
onSetAttributeLive,
onApplyColorGradingScope,
onSetHtmlAttribute,
onRemoveBackground,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
Expand Down Expand Up @@ -352,30 +350,33 @@ export const PropertyPanel = memo(function PropertyPanel({
onSetAttribute={onSetAttribute}
/>
)}
{sections.media && (
<MediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
/>
)}

{STUDIO_COLOR_GRADING_ENABLED && sections.colorGrading && (
{sections.colorGrading && (
<ColorGradingSection
key={[
element.id ?? "",
element.hfId ?? "",
element.selector ?? "",
String(element.selectorIndex ?? ""),
].join("|")}
projectId={projectId}
element={element}
assets={assets}
previewIframeRef={previewIframeRef}
onImportAssets={onImportAssets}
onSetAttributeLive={onSetAttributeLive}
onApplyScope={onApplyColorGradingScope}
/>
)}

{sections.media && (
<MediaSection
projectDir={projectDir}
element={element}
styles={styles}
onSetStyle={onSetStyle}
onSetAttribute={onSetAttribute}
onSetHtmlAttribute={onSetHtmlAttribute}
onRemoveBackground={onRemoveBackground}
/>
)}

Expand Down
Loading
Loading