Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 packages/studio/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"types": "./dist/index.d.ts"
},
"scripts": {
"dev": "vite",
"dev": "bun --bun ./node_modules/.bin/vite --host 127.0.0.1",
"build": "vite build && tsup",
"typecheck": "tsc --noEmit",
"test": "vitest run",
Expand Down
2 changes: 0 additions & 2 deletions packages/studio/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ import { useToast } from "./hooks/useToast";
import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader";
import { useStudioUrlState } from "./hooks/useStudioUrlState";
import { useEffectiveTimelineDuration } from "./hooks/useEffectiveTimelineDuration";
import { useAudioSoloBridge } from "./hooks/useAudioSoloBridge";
import {
buildStudioContextValue,
useGlobalFileDrop,
Expand Down Expand Up @@ -82,7 +81,6 @@ export function StudioApp() {
const [previewDocumentVersion, refreshPreviewDocumentVersion] = usePreviewDocumentVersion();
const [blockPreview, setBlockPreview] = useState<BlockPreviewInfo | null>(null);
const previewIframeRef = useRef<HTMLIFrameElement | null>(null);
useAudioSoloBridge(previewIframeRef);
const activeCompPathRef = useRef(activeCompPath);
activeCompPathRef.current = activeCompPath;
const leftSidebarRef = useRef<LeftSidebarHandle>(null);
Expand Down
72 changes: 15 additions & 57 deletions packages/studio/src/components/StudioRightPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
import type { StudioRightPanelProps } from "./StudioRightPanel.types";

export type { StudioRightPanelProps };
Expand All @@ -20,15 +20,15 @@ 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";
import { timelineKeysForSelections } from "../utils/studioHelpers";
import { canHideSelections } from "../utils/timelineInspector";
import { useInspectorSplitResize } from "../hooks/useInspectorSplitResize";
import { useRemoveBackground } from "../hooks/useRemoveBackground";

// fallow-ignore-next-line complexity
export function StudioRightPanel({
Expand Down Expand Up @@ -164,14 +164,6 @@ export function StudioRightPanel({
handleInspectorSplitResizeMove,
handleInspectorSplitResizeEnd,
} = useInspectorSplitResize();
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 @@ -238,52 +230,7 @@ export function StudioRightPanel({
],
);

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 handleRemoveBackground = useRemoveBackground(projectId, refreshFileTree, showToast);

/**
* A dial being dragged writes to the preview and stops there.
Expand All @@ -300,6 +247,17 @@ export function StudioRightPanel({
[handleDomAttributeLiveCommit],
);
const handleHideAllSelected = () => {
// Audio has no visual to hide, and `data-hidden` on an audio element is what
// MUTES it — preview silences it and the render drops it from the mix. The
// timeline withholds the eye on an audio track for that reason
// (`visible={!isAudioTrack}`), and the single-selection panel gates the same
// write on `audioSelection`; this multi-selection path was the way back to
// it. Checked here as well as in the panel because the button is not the
// only caller.
if (!canHideSelections(domEditGroupSelections)) {
showToast("Audio can't be hidden — use the group's own controls", "info");
return;
}
const { elements } = usePlayerStore.getState();
const keys = timelineKeysForSelections(domEditGroupSelections, elements, activeCompPath);
if (keys.length > 0) void onToggleElementHidden?.(keys, true);
Expand Down
104 changes: 101 additions & 3 deletions packages/studio/src/components/editor/PropertyPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ afterEach(() => {
vi.resetModules();
});

function baseElement() {
function baseElement(): NonNullable<PropertyPanelProps["element"]> {
return {
element: document.createElement("div"),
id: "mono-label",
Expand Down Expand Up @@ -81,7 +81,7 @@ function nonTextElement() {
// flat multi-field layer list (FlatTextLayerList + FlatTextFieldEditor) —
// must not double-render the "Text" heading (FlatGroup's own heading; this
// component never renders one of its own).
function multiFieldTextElement() {
function multiFieldTextElement(): NonNullable<PropertyPanelProps["element"]> {
const base = baseElement();
return {
...base,
Expand Down Expand Up @@ -177,6 +177,43 @@ function sixGroupElement() {
};
}

/** An `<audio>` clip: placed on the timeline, but nothing a tween could move. */
function audioClipElement() {
const element = document.createElement("audio");
return {
...baseElement(),
element,
id: "vo-1",
selector: "#vo-1",
label: "Vo 1",
tagName: "audio",
textFields: [],
dataAttributes: { start: "1", duration: "3" },
};
}

/**
* A mixer bus: no clip range at all, and no box either.
*
* Carries a `data-start` on purpose. A real bus has none — its automation clock
* is composition time — but the timing gate has to refuse the TAG rather than
* merely fall out of a missing attribute, or something writing one would put
* Start/Duration back on a thing that has no range.
*/
function audioBusElement() {
const element = document.createElement("hf-audio-group");
return {
...baseElement(),
element,
id: "voiceover",
selector: "#voiceover",
label: "Voiceover",
tagName: "hf-audio-group",
textFields: [],
dataAttributes: { start: "0", duration: "8" },
};
}

const INFERRED_TIMING_ANIMATION = {
id: "a1",
targetSelector: "#inferred-anim",
Expand All @@ -196,7 +233,7 @@ const INFERRED_TIMING_ANIMATION = {

async function renderPanel(
flatEnabled: boolean,
elementOverride: ReturnType<typeof baseElement> = baseElement(),
elementOverride: NonNullable<PropertyPanelProps["element"]> = baseElement(),
propsOverride: Partial<PropertyPanelProps> = {},
currentTime?: number,
) {
Expand Down Expand Up @@ -952,3 +989,64 @@ describe("PropertyPanel — flat group entrance animation scoping (fix round)",
RENDER_TIMEOUT_MS,
);
});

describe("PropertyPanel — Motion is for things that move", () => {
it.each([
["a custom music tag", () => document.createElement("music")],
[
"an element with an audio source",
() => {
const element = document.createElement("div");
element.setAttribute("src", "voiceover.mp3");
return element;
},
],
])("recognizes %s through the shared audio predicate", async (_label, makeElement) => {
const fixture = {
...audioClipElement(),
element: makeElement(),
tagName: "div",
};
const { host, root } = await renderPanel(true, fixture);
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((node) => node.textContent ?? "");
expect(titles.some((title) => title.includes("Motion"))).toBe(false);
expect(titles.some((title) => title.includes("Timing"))).toBe(true);
act(() => root.unmount());
});

it(
"calls the section Timing on an audio clip, and offers no tween editor",
async () => {
const { host, root } = await renderPanel(true, audioClipElement());
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((el) => el.textContent ?? "");
// The clip's placement survives — it is still a clip on a track.
expect(titles.some((t) => t.includes("Timing"))).toBe(true);
// "Motion" named the tween editor, which an <audio> element has no
// transform, opacity or box for. Showing it was the panel gating on
// handler presence rather than on the element.
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);

it(
"offers a bus neither — it has no clip range to edit",
async () => {
const { host, root } = await renderPanel(true, audioBusElement());
const titles = Array.from(
host.querySelectorAll<HTMLElement>("[data-flat-group-collapsed], [data-flat-group-open]"),
).map((el) => el.textContent ?? "");
expect(titles.some((t) => t.includes("Motion"))).toBe(false);
expect(titles.some((t) => t.includes("Timing"))).toBe(false);
// It is still a mixer bus: the reason to select one at all.
expect(titles.some((t) => t.includes("Audio FX"))).toBe(true);
act(() => root.unmount());
},
RENDER_TIMEOUT_MS,
);
});
16 changes: 15 additions & 1 deletion packages/studio/src/components/editor/PropertyPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { type PropertyPanelProps } from "./propertyPanelHelpers";
import { GestureRecordPanelButton } from "./GestureRecordControl";
import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState";
import { DesignPanelInputProvider } from "../../contexts/DesignPanelInputContext";
import { isAudioDomElement } from "../../utils/timelineInspector";

// Re-export helpers that external consumers import from this module
export {
Expand Down Expand Up @@ -119,6 +120,19 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementHidden = isSelectedElementHidden(timelineElements, selectedElementId);
const visibilityToggleLabel = selectedElementHidden ? "Show element" : "Hide element";
/**
* An audio element gets no hide control here.
*
* On an audio track "hidden" and "muted" are not similar operations, they are
* the SAME operation with two names (groups doc §2.1) — which is why the
* timeline's eye became the mute rather than growing a sibling. A second copy
* in the panel, still called "Hide element", is precisely the thing that step
* removed: "Two controls that silence a track, sitting next to each other,
* differing only in a distinction the author cannot see." An
* `<hf-audio-group>` has no visual to hide at all, and its mute lives on its
* own row.
*/
const audioSelection = isAudioDomElement(element?.element);
// Live during playback, the store's when paused — see the hook. Shared with the
// audio FX panel, which follows the playhead for the same reason: a value the
// timeline drives has to be shown moving, not frozen at what the attribute says.
Expand Down Expand Up @@ -309,7 +323,7 @@ export const PropertyPanel = memo(function PropertyPanel(props: PropertyPanelPro
selectedElementId={selectedElementId}
selectedElementHidden={selectedElementHidden}
visibilityLabel={visibilityToggleLabel}
onToggleHidden={onToggleElementHidden}
onToggleHidden={audioSelection ? undefined : onToggleElementHidden}
/>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,78 @@ describe("PropertyPanelEmptyState — flat multi-select", () => {
expect(onClearSelection).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});

// A layout group is a positioned wrapper around a bounding box; an <audio>
// clip has none (offsetWidth/Height are 0), so grouping audio produced a 0x0
// div with inline left/top on elements that are never laid out. Withheld
// rather than offered-then-refused.
const audioElements = (tags: string[]) =>
tags.map((tag, i) => ({
id: `el-${i}`,
selector: `#el-${i}`,
label: `El ${i}`,
tagName: tag,
element: document.createElement(tag),
})) as unknown as DomEditSelection[];

it("withholds both actions when the selection includes audio", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["audio", "audio"])}
onGroupSelection={vi.fn()}
onHideAllSelected={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
// Hiding is visibility, and `data-hidden` on audio is what MUTES it — the
// timeline withholds the eye on an audio track for that reason, and this
// panel was the way back to the same write.
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
// The list still names what is selected; only the actions go.
expect(host.textContent).toContain("2 elements selected");
act(() => root.unmount());
});

it("withholds it for a mixed selection too, since the wrapper would still take audio in", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["div", "audio"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).toBeNull();
act(() => root.unmount());
});

it("counts an <hf-audio-group> bus as audio too", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["hf-audio-group", "div"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).toBeNull();
act(() => root.unmount());
});

it("still offers both for a selection of layout elements", () => {
const { host, root } = renderInto(
<PropertyPanelEmptyState
flat
multiSelectCount={2}
multiSelectedElements={audioElements(["div", "span"])}
onGroupSelection={vi.fn()}
/>,
);
expect(host.querySelector('[data-flat-multiselect-group="true"]')).not.toBeNull();
expect(host.querySelector('[data-flat-multiselect-hide-all="true"]')).not.toBeNull();
act(() => root.unmount());
});
});
Loading
Loading