diff --git a/packages/studio/src/components/EditorShell.tsx b/packages/studio/src/components/EditorShell.tsx index 73bfe6694fc..e6bdcf6ab01 100644 --- a/packages/studio/src/components/EditorShell.tsx +++ b/packages/studio/src/components/EditorShell.tsx @@ -17,7 +17,10 @@ import type { BlockPreviewInfo } from "./sidebar/BlocksTab"; import type { GestureRecordingState } from "./editor/GestureRecordControl"; import { useTimelineSelectionPreviewSync } from "../hooks/useTimelineSelectionPreviewSync"; import { StudioAgentTools } from "../webmcp/StudioAgentTools"; -import type { TimelineDropPlacement } from "../player/components/timelineCallbacks"; +import type { + TimelineDropCallbacks, + TimelineDropPlacement, +} from "../player/components/timelineCallbacks"; import type { TimelineProps } from "../player/components/TimelineTypes"; import { Dock } from "./dock/Dock"; @@ -34,10 +37,7 @@ export interface EditorShellProps extends TimelineEditCallbackDeps { timelineToolbar: ReactNode; renderClipContent: RenderClipContent; handleTimelineElementDelete: (element: TimelineElement) => Promise | void; - handleTimelineAssetDrop: ( - assetPath: string, - placement: TimelineDropPlacement, - ) => Promise | void; + handleTimelineAssetDrop: NonNullable; handleTimelineBlockDrop?: ( blockName: string, placement: TimelineDropPlacement, @@ -246,7 +246,7 @@ interface EditorShellBodyProps { timelineToolbar: ReactNode; renderClipContent: RenderClipContent; onFileDrop: (files: File[], placement?: TimelineDropPlacement) => Promise | void; - onAssetDrop: (assetPath: string, placement: TimelineDropPlacement) => Promise | void; + onAssetDrop: NonNullable; onBlockDrop?: (blockName: string, placement: TimelineDropPlacement) => Promise | void; onCompositionDrop?: ( sourcePath: string, diff --git a/packages/studio/src/components/StudioHeader.embed.dom.test.tsx b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx new file mode 100644 index 00000000000..c6c30e47468 --- /dev/null +++ b/packages/studio/src/components/StudioHeader.embed.dom.test.tsx @@ -0,0 +1,79 @@ +// @vitest-environment happy-dom + +/** Embedded in promptwise: no header; the Timeline tab's toolbar drives Inspector and Export by message. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const layout = { rightCollapsed: true, setRightCollapsed: vi.fn(), setRightPanelTab: vi.fn() }; + +vi.mock("../promptwise/embed", () => ({ embeddedInPromptwise: true })); +vi.mock("../contexts/StudioContext", () => ({ + useStudioShellContext: () => ({ + projectId: "773d81ab-4ac0-4f9d-acd7-4cd8838312dd", + renderQueue: { isRendering: false, ffmpegMissing: false }, + }), +})); +vi.mock("../contexts/PanelLayoutContext", () => ({ usePanelLayoutContext: () => layout })); +vi.mock("../utils/studioTelemetry", () => ({ trackStudioEvent: vi.fn() })); + +const { StudioHeader } = await import("./StudioHeader"); + +function command(name: string) { + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { type: "promptwise:command", command: name }, + source: window.parent, + }), + ); + }); +} + +it("draws no header, runs the toolbar's presses and reports their state", () => { + const posted: unknown[] = []; + const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => { + posted.push(message); + }); + const onExport = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => + root.render( + , + ), + ); + + expect(host.innerHTML).toBe(""); + expect(posted).toEqual([{ type: "promptwise:state", inspectorOpen: false, rendering: false }]); + + command("inspector"); + expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("design"); + expect(layout.setRightCollapsed).toHaveBeenLastCalledWith(false); + + command("export"); + expect(layout.setRightPanelTab).toHaveBeenLastCalledWith("renders"); + expect(onExport).toHaveBeenCalledOnce(); + + // Only the parent page may drive Studio, and only with the commands it knows. + window.dispatchEvent( + new MessageEvent("message", { data: { type: "promptwise:command", command: "export" } }), + ); + command("delete-everything"); + expect(onExport).toHaveBeenCalledOnce(); + + act(() => root.unmount()); + host.remove(); + postMessage.mockRestore(); +}); diff --git a/packages/studio/src/components/StudioHeader.tsx b/packages/studio/src/components/StudioHeader.tsx index 87a3a019592..4347e015756 100644 --- a/packages/studio/src/components/StudioHeader.tsx +++ b/packages/studio/src/components/StudioHeader.tsx @@ -7,6 +7,8 @@ import { Button, cn, Tooltip } from "./ui"; import { ButtonGroup, ButtonGroupItem } from "../boardui/components/base/buttons/button-group"; import { Dock } from "./dock/Dock"; import { PromptwiseMark } from "../promptwise/PromptwiseMark"; +import { embeddedInPromptwise } from "../promptwise/embed"; +import { usePromptwiseToolbar } from "../promptwise/usePromptwiseToolbar"; import { ShowThemeToggle, ThemeToggle } from "./ThemeToggle"; interface StudioHeaderProps { @@ -55,6 +57,39 @@ export const StudioHeader = memo(function StudioHeader({ const isRendering = renderQueue.isRendering; const ffmpegMissing = renderQueue.ffmpegMissing; + const toggleInspector = () => { + if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) { + trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false }); + setRightPanelTab("design"); + setRightCollapsed(false); + return; + } + trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); + // Keep the current selection when collapsing the Inspector — closing + // the panel shouldn't deselect the element. + setRightCollapsed(true); + }; + + const exportVideo = () => { + if (isRendering) return; + setRightPanelTab("renders"); + setRightCollapsed(false); + // Without an encoder this render cannot finish, so the click + // delivers the user to the prompt that fixes it instead of + // queueing a job that exists only to fail. Disabling the button + // would leave them staring at a dead control with no route to + // the explanation. + if (ffmpegMissing) return; + onExport?.(); + }; + + usePromptwiseToolbar( + { inspectorOpen: inspectorButtonActive, rendering: isRendering }, + { inspector: toggleInspector, export: exportVideo }, + ); + // promptwise's Timeline tab has its own toolbar and tab name, so a second header would only repeat them. + if (embeddedInPromptwise) return null; + return (
@@ -104,18 +139,7 @@ export const StudioHeader = memo(function StudioHeader({ aria-pressed={inspectorButtonActive} selected={inspectorButtonActive} leadingIcon={RiLayoutRightLine} - onClick={() => { - if (shouldOpenInspector(rightCollapsed, inspectorPanelActive)) { - trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: false }); - setRightPanelTab("design"); - setRightCollapsed(false); - return; - } - trackStudioEvent("panel_toggle", { panel: "inspector", collapsed: true }); - // Keep the current selection when collapsing the Inspector — closing - // the panel shouldn't deselect the element. - setRightCollapsed(true); - }} + onClick={toggleInspector} > Inspector @@ -137,18 +161,7 @@ export const StudioHeader = memo(function StudioHeader({ variant="primary" data-testid="header-export" disabled={isRendering} - onClick={() => { - if (isRendering) return; - setRightPanelTab("renders"); - setRightCollapsed(false); - // Without an encoder this render cannot finish, so the click - // delivers the user to the prompt that fixes it instead of - // queueing a job that exists only to fail. Disabling the button - // would leave them staring at a dead control with no route to - // the explanation. - if (ffmpegMissing) return; - onExport?.(); - }} + onClick={exportVideo} > {isRendering ? "Rendering…" : "Export"} diff --git a/packages/studio/src/components/TimelineToolbar.embed.dom.test.tsx b/packages/studio/src/components/TimelineToolbar.embed.dom.test.tsx new file mode 100644 index 00000000000..a65478d8dda --- /dev/null +++ b/packages/studio/src/components/TimelineToolbar.embed.dom.test.tsx @@ -0,0 +1,44 @@ +// @vitest-environment happy-dom + +/** Embedded in promptwise the toolbar starts as its own Timeline's does; the rest opens with More. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../promptwise/embed", async (original) => ({ + ...(await original()), + embeddedInPromptwise: true, +})); +vi.mock("../contexts/StudioContext", () => ({ + useStudioShellContextOptional: () => ({ + previewIframeRef: { current: null }, + editHistory: { canUndo: false, canRedo: false }, + handleUndo: vi.fn(), + handleRedo: vi.fn(), + }), +})); + +const { TimelineToolbar } = await import("./TimelineToolbar"); + +it("shows history, the tool, split and zoom, and the other tools once More is pressed", () => { + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render( {}} />)); + const labels = () => + [...host.querySelectorAll("button, input")].map((el) => el.getAttribute("aria-label") ?? ""); + const rare = ["Toggle timeline snapping", "Toggle ripple edit", "Add beat at playhead"]; + + expect(labels()).toEqual( + expect.arrayContaining(["Split at playhead", "Fit timeline to width", "Zoom out", "Zoom in"]), + ); + for (const label of rare) expect(labels()).not.toContain(label); + + act(() => host.querySelector('[aria-label="More tools"]')!.click()); + expect(labels()).toEqual(expect.arrayContaining([...rare, "Fewer tools"])); + + act(() => root.unmount()); + host.remove(); +}); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index 5f93035e9ca..8cc01cb442e 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -1,6 +1,7 @@ -import { memo, useEffect, useRef, type ReactNode } from "react"; +import { memo, useEffect, useRef, useState, type ReactNode } from "react"; import { ArrowsOutLineHorizontal, + DotsThree, FrameCorners, Image, Magnet, @@ -26,6 +27,7 @@ import { zoomTimelineToSelectedClips, } from "../player/components/timelineZoomInput"; import { usePlayerStore, type TimelineElement } from "../player"; +import { embeddedInPromptwise } from "../promptwise/embed"; import { Tooltip } from "./ui"; import { AudioMetersIcon } from "./icons/AudioMetersIcon"; import { RippleEditIcon } from "./icons/RippleEditIcon"; @@ -101,7 +103,12 @@ function resolveKeyframeToggleState( const isMotionPath = Boolean(arcAnimation); if (!isPlayheadWithinTween(animation, currentTime)) { - return { state: "inactive", isMotionPath, pathEndpoint: false, willExtend: true }; + return { + state: "inactive", + isMotionPath, + pathEndpoint: false, + willExtend: true, + }; } const percentage = computeElementPercentage(currentTime, session.domEditSelection, animation); @@ -163,6 +170,9 @@ export const TimelineToolbar = memo(function TimelineToolbar({ showKeyframes = true, rightActions, }: TimelineToolbarProps) { + // Embedded in promptwise the toolbar starts as its own editor's does: history, the tool, split and + // zoom. Snapping, ripple, meters, keyframes, beats and the thumbnail and selection zooms open with More. + const [allTools, setAllTools] = useState(!embeddedInPromptwise); const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled); const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled); const rippleEditEnabled = usePlayerStore((s) => s.rippleEditEnabled); @@ -232,150 +242,154 @@ export const TimelineToolbar = memo(function TimelineToolbar({ {/* Divider: tool-mode | editing-actions */}
{rightActions} - - - - - - + {allTools && ( + <> + + + + + + + + )}
diff --git a/packages/studio/src/components/dock/dockLayout.test.ts b/packages/studio/src/components/dock/dockLayout.test.ts index bddf62e8da8..40c3a1271c2 100644 --- a/packages/studio/src/components/dock/dockLayout.test.ts +++ b/packages/studio/src/components/dock/dockLayout.test.ts @@ -132,3 +132,40 @@ describe("applySideMinimums", () => { expect(timeline.group.minimumWidth).toBe(120); }); }); + +describe("buildEditLayout on a narrow dock", () => { + let api: DockviewApi; + beforeEach(() => { + ({ api } = mountBareDockview()); + }); + afterEach(() => { + api.dispose(); + document.body.innerHTML = ""; + }); + + it("puts the preview over the timeline and one hidden side group beside it, as in promptwise's tab", () => { + api.layout(640, 820); + buildEditLayout(api, 640); + const side = api.getPanel("design")!.group; + expect(side.panels.map((panel) => panel.id)).toEqual([ + "design", + "layers", + "renders", + "compositions", + "assets", + ]); + expect(side.api.isVisible).toBe(false); + expect(api.getPanel("preview")!.group.api.isVisible).toBe(true); + expect(api.getPanel("preview")!.group.width).toBe(640); + expect(api.getPanel("timeline")!.group.width).toBe(640); + expect(api.getPanel("timeline")!.group.height).toBe(260); + expect(api.getPanel("code")).toBeUndefined(); + }); + + it("keeps the three columns on a wide one", () => { + api.layout(1200, 700); + buildEditLayout(api, 1200); + expect(api.getPanel("compositions")!.group).not.toBe(api.getPanel("design")!.group); + expect(api.getPanel("design")!.group.api.isVisible).toBe(true); + }); +}); diff --git a/packages/studio/src/components/dock/dockLayout.ts b/packages/studio/src/components/dock/dockLayout.ts index 94886d93aea..acd9f8c1a35 100644 --- a/packages/studio/src/components/dock/dockLayout.ts +++ b/packages/studio/src/components/dock/dockLayout.ts @@ -10,6 +10,11 @@ const MIN_SIDE_W_FLOOR = 120; const DEFAULT_TIMELINE_H = 360; const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const; const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const; +/** Below this dock width (promptwise's Timeline tab beside its chat) two side columns would crush the preview. */ +const COMPACT_DOCK_W = 1000; +const COMPACT_SIDE = ["design", "layers", "renders", "compositions", "assets"] as const; +/** Room for three tracks under the toolbar and ruler; the preview gets the rest. */ +const COMPACT_TIMELINE_H = 260; /** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */ export function defaultSideWidths(viewportWidth: number) { @@ -99,12 +104,20 @@ function addSideColumn( return first; } -/** The default Edit layout: [library | preview | inspector] over a full-width timeline. */ +/** + * The default Edit layout: [library | preview | inspector] over a full-width timeline, or on a narrow + * dock the preview over the timeline with one side group beside the preview, shown when asked for + * (Inspector, a selection) since it would leave the preview too little room. + */ export function buildEditLayout( api: DockviewApi, viewportWidth: number, panels: readonly PanelId[] = PANEL_IDS, ) { + if (viewportWidth < COMPACT_DOCK_W) { + buildCompactLayout(api, viewportWidth, panels); + return; + } api.clear(); const widths = defaultSideWidths(viewportWidth); addRegisteredPanel(api, "preview"); @@ -116,3 +129,15 @@ export function buildEditLayout( if (right) api.getPanel(right)?.group.api.setSize({ width: widths.right }); api.getPanel("timeline")?.group.api.setSize({ height: DEFAULT_TIMELINE_H }); } + +function buildCompactLayout(api: DockviewApi, viewportWidth: number, panels: readonly PanelId[]) { + api.clear(); + addRegisteredPanel(api, "preview"); + addRegisteredPanel(api, "timeline", { referencePanel: "preview", direction: "below" }); + const side = addSideColumn(api, COMPACT_SIDE, panels, "right"); + applySideMinimums(api, viewportWidth); + api.getPanel("timeline")?.group.api.setSize({ height: COMPACT_TIMELINE_H }); + const group = side ? api.getPanel(side)?.group : undefined; + group?.api.setSize({ width: Math.max(MIN_SIDE_W, defaultSideWidths(viewportWidth).right) }); + group?.api.setVisible(false); +} diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx new file mode 100644 index 00000000000..2b21a6f6328 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.embed.dom.test.tsx @@ -0,0 +1,37 @@ +// @vitest-environment happy-dom + +/** In promptwise most changes go through the agent, so its button leads the empty Design panel. */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; + +(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +vi.mock("../../promptwise/embed", async (original) => ({ + ...(await original()), + embeddedInPromptwise: true, +})); + +const { PropertyPanelEmptyState } = await import("./PropertyPanelEmptyState"); + +it("puts the agent first, as the panel's one primary button, and asks promptwise to open its chat", () => { + const posted: unknown[] = []; + const postMessage = vi.spyOn(window.parent, "postMessage").mockImplementation((message) => { + posted.push(message); + }); + const host = document.createElement("div"); + document.body.append(host); + const root = createRoot(host); + act(() => root.render()); + + const text = host.textContent ?? ""; + expect(text.indexOf("Ask the agent")).toBeLessThan(text.indexOf("Record a gesture")); + const agent = host.querySelector("button")!; + expect(agent.textContent).toContain("Ask the agent"); + act(() => agent.click()); + expect(posted).toEqual([{ type: "promptwise:ask-agent" }]); + + act(() => root.unmount()); + host.remove(); + postMessage.mockRestore(); +}); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx index 2848c1ee0e3..1578a77bb71 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.test.tsx @@ -28,7 +28,7 @@ describe("PropertyPanelEmptyState — flat empty", () => { expect(host.textContent).toContain("Nothing selected"); expect(host.textContent).toContain("Record a gesture"); // The agent chip only shows inside promptwise, whose chat it opens. - expect(host.textContent).not.toContain("Describe a change to the agent"); + expect(host.textContent).not.toContain("Ask the agent"); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx index 067d9068210..8385c190baa 100644 --- a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -3,6 +3,7 @@ import type { DomEditSelection } from "./domEditingTypes"; import { canHideSelections } from "../../utils/timelineInspector"; import { useTrackDesignInput } from "../../contexts/DesignPanelInputContext"; import { askPromptwiseAgent, embeddedInPromptwise } from "../../promptwise/embed"; +import { Button } from "../ui"; function FlatEmptyState() { return ( @@ -24,6 +25,17 @@ function FlatEmptyState() { Click any element on the canvas to edit it, or drag to select several.
+ {/* In promptwise the agent is the way most changes are made, so it comes first. */} + {embeddedInPromptwise && ( + + )} ● @@ -33,16 +45,6 @@ function FlatEmptyState() { R - {embeddedInPromptwise && ( - - )}
); diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx index 8d5c72a2d55..63637d484b5 100644 --- a/packages/studio/src/components/editor/SnapToolbar.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.tsx @@ -5,6 +5,10 @@ import { usePlayerStore } from "../../player/store/playerStore"; import { usePreviewOverlayContext } from "./PreviewOverlayProvider"; import { ownsPlainKeys } from "../../utils/typingTarget"; +/** A preview overlay button that's off: BoardUI's light surface with a hairline, readable over any frame. */ +const overlayButtonOff = + "bg-background-primary-default/90 text-foreground-icon-secondary shadow-xs ring-1 ring-border-button-default hover:bg-background-primary-hover hover:text-foreground-icon-primary"; + // fallow-ignore-next-line complexity export const SnapToolbar = memo(function SnapToolbar() { const [gridPopoverOpen, setGridPopoverOpen] = useState(false); @@ -87,9 +91,7 @@ export const SnapToolbar = memo(function SnapToolbar() { diff --git a/packages/studio/src/player/components/TextClipContent.tsx b/packages/studio/src/player/components/TextClipContent.tsx index 1ca8d3c58de..b0c6c2cbd17 100644 --- a/packages/studio/src/player/components/TextClipContent.tsx +++ b/packages/studio/src/player/components/TextClipContent.tsx @@ -13,11 +13,11 @@ export function textClipBackground(text: TimelineText): string { export function TextClipContent({ text }: { text: TimelineText }) { return (
{text.value} diff --git a/packages/studio/src/player/components/TimelineShortcutHint.tsx b/packages/studio/src/player/components/TimelineShortcutHint.tsx index 252586b6158..44880d81df7 100644 --- a/packages/studio/src/player/components/TimelineShortcutHint.tsx +++ b/packages/studio/src/player/components/TimelineShortcutHint.tsx @@ -6,7 +6,7 @@ interface TimelineShortcutHintProps { export function TimelineShortcutHint({ theme }: TimelineShortcutHintProps) { return ( -
+
Promise | void; - onAssetDrop?: (assetPath: string, placement: TimelineDropPlacement) => Promise | void; + /** May answer with the clip it placed, which a drop of several files lines the next one up after. */ + onAssetDrop?: ( + assetPath: string, + placement: TimelineDropPlacement, + ) => Promise | void; onBlockDrop?: ( blockName: string, placement: { start: number; track: number }, diff --git a/packages/studio/src/player/components/timelineDragDrop.test.tsx b/packages/studio/src/player/components/timelineDragDrop.test.tsx index 7f9ffdbc401..0446f7f6aeb 100644 --- a/packages/studio/src/player/components/timelineDragDrop.test.tsx +++ b/packages/studio/src/player/components/timelineDragDrop.test.tsx @@ -4,6 +4,7 @@ import React, { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, describe, expect, it, vi, type Mock } from "vitest"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; +import { PROMPTWISE_ASSET_MIME } from "../../promptwise/embed"; import { usePlayerStore, type TimelineElement } from "../store/playerStore"; import { createTimelineRowGeometry } from "./timelineLayout"; import { resolveDropInsertRow, useTimelineAssetDrop } from "./timelineDragDrop"; @@ -383,4 +384,44 @@ describe("useTimelineAssetDrop new-track drops", () => { expect(onBlockDrop).toHaveBeenCalledWith("b", { start: 10, track: 1 }); act(() => view.root.unmount()); }); + // promptwise's chat drags its files as a list of URLs under its own type. + it("places files dragged out of promptwise's chat one after the other, each at its own address", async () => { + const lengths: Record = { + "https://media.test/a.mp4": 4, + "https://media.test/b.png": 5, + }; + const onAssetDrop = vi.fn(async (url: string, placement: { start: number; track: number }) => ({ + id: url, + tag: "video", + start: placement.start, + duration: lengths[url] ?? 1, + track: placement.track, + })); + const view = renderHarness(onAssetDrop); + const payload = JSON.stringify([ + { url: "https://media.test/a.mp4", video: true }, + { url: "javascript:alert(1)" }, + { url: "https://media.test/b.png" }, + ]); + const transfer: DropTransfer = { + types: [PROMPTWISE_ASSET_MIME], + files: [], + dropEffect: "none", + getData: (type) => (type === PROMPTWISE_ASSET_MIME ? payload : ""), + }; + + await act(async () => { + view.api.handleAssetDragOver(dragEvent(transfer, 80, 20)); + view.api.handleAssetDrop(dragEvent(transfer, 80, 20)); + }); + + expect(transfer.dropEffect).toBe("copy"); + expect( + onAssetDrop.mock.calls.map(([url, placement]) => [url, placement.start, placement.track]), + ).toEqual([ + ["https://media.test/a.mp4", 0, 0], + ["https://media.test/b.png", 4, 0], + ]); + act(() => view.root.unmount()); + }); }); diff --git a/packages/studio/src/player/components/timelineDragDrop.ts b/packages/studio/src/player/components/timelineDragDrop.ts index 8c9680fb894..e7bde93fbb4 100644 --- a/packages/studio/src/player/components/timelineDragDrop.ts +++ b/packages/studio/src/player/components/timelineDragDrop.ts @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { TIMELINE_ASSET_MIME, TIMELINE_BLOCK_MIME } from "../../utils/timelineAssetDrop"; +import { PROMPTWISE_ASSET_MIME, promptwiseAssetUrls } from "../../promptwise/embed"; import { parseTimelineCompositionPayload, TIMELINE_COMPOSITION_MIME, @@ -52,7 +53,33 @@ function applyJsonDropPayload( /** Only file and asset drops are written by the asset op that can open a track. */ function canInsertTrackFor(types: readonly string[]): boolean { - return types.includes("Files") || types.includes(TIMELINE_ASSET_MIME); + return ( + types.includes("Files") || + types.includes(TIMELINE_ASSET_MIME) || + types.includes(PROMPTWISE_ASSET_MIME) + ); +} + +/** + * Files dragged out of promptwise's chat land as clips that play from their own address, one after the + * other from the drop point, on the asset drop's own path. + */ +function applyPromptwiseDrop( + transfer: DataTransfer, + onAssetDrop: TimelineDropCallbacks["onAssetDrop"], + placement: TimelineDropPlacement, +): boolean { + if (!onAssetDrop || !Array.from(transfer.types).includes(PROMPTWISE_ASSET_MIME)) return false; + const urls = promptwiseAssetUrls(transfer.getData(PROMPTWISE_ASSET_MIME)); + if (urls.length === 0) return false; + invokeDropCallback(async () => { + let next = placement; + for (const url of urls) { + const clip = await onAssetDrop(url, next); + if (clip) next = { start: clip.start + clip.duration, track: clip.track }; + } + }); + return true; } /** The row boundary a pointer at `contentY` opens a new track at, or null over a row. */ @@ -100,7 +127,7 @@ function placeDrop( return alignDropStart(aimed, elements, geometry.pixelsPerSecond); } -function invokeDropCallback(callback: () => Promise | void): void { +function invokeDropCallback(callback: () => Promise | void): void { try { void Promise.resolve(callback()).catch(() => undefined); } catch { @@ -122,7 +149,7 @@ function applyTypedJsonDrop( transfer: DataTransfer, mime: string, field: "name" | "path", - apply: ((value: string, placement: TimelineDropPlacement) => Promise | void) | undefined, + apply: ((value: string, placement: TimelineDropPlacement) => Promise | void) | undefined, placement: TimelineDropPlacement, ): boolean { if (!apply || !Array.from(transfer.types).includes(mime)) return false; @@ -230,7 +257,7 @@ export function useTimelineAssetDrop({ (e: React.DragEvent) => { const types = Array.from(e.dataTransfer.types); const hasFiles = types.includes("Files"); - const hasAsset = types.includes(TIMELINE_ASSET_MIME); + const hasAsset = types.includes(TIMELINE_ASSET_MIME) || types.includes(PROMPTWISE_ASSET_MIME); const hasBlock = types.includes(TIMELINE_BLOCK_MIME); const hasComposition = types.includes(TIMELINE_COMPOSITION_MIME); if (!hasFiles && !hasAsset && !hasBlock && !hasComposition) return; @@ -290,6 +317,7 @@ export function useTimelineAssetDrop({ } if (applyFileDrop(e.dataTransfer, onFileDrop, placement)) return; + if (applyPromptwiseDrop(e.dataTransfer, onAssetDrop, placement)) return; if (applyTypedJsonDrop(e.dataTransfer, TIMELINE_ASSET_MIME, "path", onAssetDrop, placement)) { return; } diff --git a/packages/studio/src/promptwise/embed.ts b/packages/studio/src/promptwise/embed.ts index 2fa085534e5..194f36488ec 100644 --- a/packages/studio/src/promptwise/embed.ts +++ b/packages/studio/src/promptwise/embed.ts @@ -3,3 +3,26 @@ export const embeddedInPromptwise = typeof window !== "undefined" && window.pare export function askPromptwiseAgent() { window.parent.postMessage({ type: "promptwise:ask-agent" }, "*"); } + +/** Where a film opens in promptwise's tab: a second in, where its first shot has arrived, not its empty first frame. */ +export function openingTime(duration: number): number { + return Math.min(1, duration / 2); +} + +/** What promptwise puts on an image, video or sound dragged out of its chat: a JSON list of `{ url }`. */ +export const PROMPTWISE_ASSET_MIME = "application/x-bebra-asset"; + +/** The http(s) URLs of the files a promptwise drag carries, in order; none for anything else. */ +export function promptwiseAssetUrls(payload: string): string[] { + try { + const assets: unknown = JSON.parse(payload); + if (!Array.isArray(assets)) return []; + return assets.flatMap((asset: unknown) => { + const url = + typeof asset === "object" && asset !== null ? (asset as { url?: unknown }).url : null; + return typeof url === "string" && /^https?:\/\//.test(url) ? [url] : []; + }); + } catch { + return []; + } +} diff --git a/packages/studio/src/promptwise/theme.css b/packages/studio/src/promptwise/theme.css index ad40ce20a80..6ada32c735f 100644 --- a/packages/studio/src/promptwise/theme.css +++ b/packages/studio/src/promptwise/theme.css @@ -24,7 +24,8 @@ --color-text-off: var(--color-text-tertiary); --panel-shell-bg: var(--color-background-secondary-default); - --panel-strip-bg: var(--color-background-secondary-default); + /* The tab strip sits inside each panel's card, as in promptwise's own panels. */ + --panel-strip-bg: var(--panel-card-bg); /* The promptwise orange. */ --color-accent: light-dark(var(--color-accent-600), var(--color-accent-500)); @@ -46,12 +47,116 @@ padding: 0 4px; } -/* BoardUI's card radius on the dock's panels and their tabs. */ +/* BoardUI's card radius on the dock's panels. */ .hf-dock .dv-groupview { border-radius: 16px; } -.hf-dock .dv-tab, +/* Tabs are BoardUI's gray pill tabs (promptwise's PillTab "gray"): the shown one on the tertiary background. */ +.hf-dock .dv-tabs-and-actions-container { + padding: 6px 6px 0; +} + +.hf-dock .dv-tab.dv-tab, +.hf-dock-tab-fill { + height: 28px; + border-radius: var(--radius-md); +} + .hf-dock-tab-fill { - border-radius: 10px 10px 0 0; + background-color: var(--color-background-tertiary-default); +} + +/* The Shift+drag hint shows while the pointer is over the timeline's panel, not all the time. */ +.hf-timeline-shortcut-hint { + opacity: 0; + transition: opacity 150ms ease-out; +} + +.dv-groupview:hover .hf-timeline-shortcut-hint { + opacity: 1; +} + +/* Clips look like promptwise's own Timeline's: flat blocks with no title band or shadow, the name a small + * chip on pictures, and a text layer as its words in the interface's type on the tertiary surface. Effect + * badges show on the clip in hand. Audio clips keep Studio's fill, which their waveform is drawn for. */ +:root { + --timeline-clip-radius: 8px; + --timeline-clip-band-height: 0px; + --timeline-clip-shadow: none; + --timeline-clip-highlight: transparent; +} + +/* components.css comes after this file, so :root is what lets these win over its title band. */ +:root .timeline-clip:not(.is-audio) .timeline-clip__label { + top: 4px; + left: 6px; + width: fit-content; + max-width: calc(100% - 12px); + height: 16px; + padding: 0 4px; + border-radius: 4px; + background: var(--timeline-clip-chip-bg); + font-weight: 400; + line-height: 16px; + /* Over the filmstrip, which now starts at the clip's top. */ + z-index: 6; +} + +.timeline-clip:not(.is-hovered, .is-selected) .timeline-clip__label [data-testid="clip-badges"] { + display: none; +} + +.timeline-clip:has(.timeline-text-clip) + .timeline-clip__label:not(:has([data-testid="clip-badges"])), +.timeline-clip:has(.timeline-text-clip):not(.is-hovered, .is-selected) .timeline-clip__label { + display: none; +} + +.timeline-text-clip { + background: var(--color-background-tertiary-default) !important; +} + +.timeline-text-clip__words { + color: var(--color-text-primary) !important; + font-family: inherit !important; + font-size: 11px; + font-weight: 400 !important; +} + +/* Embedded in promptwise's tab, a panel alone in its group needs no tab to name it. */ +:root[data-embedded] .hf-dock .dv-tabs-and-actions-container.dv-single-tab { + display: none; +} + +/* The dock's dividers are promptwise's sidebar edge: no coloured line, and under the pointer or while it's + * dragged the white grip with three lines (ResizeGrip), turned on its side between the preview and the timeline. */ +.hf-dock.hf-dock { + --dv-active-sash-color: transparent; + --grip-line: var(--color-foreground-icon-quaternary); +} + +.hf-dock .dv-sash:hover::after, +.hf-dock .dv-sash.dv-active::after, +.hf-dock .dv-sash:focus-visible::after { + content: ""; + position: absolute; + top: 50%; + left: 50%; + width: 15px; + height: 25px; + translate: -50% -50%; + border: 1px solid var(--color-border-button-default); + border-radius: 4px; + background: + linear-gradient(var(--grip-line), var(--grip-line)) calc(50% - 3px) 50% / 1px 13px no-repeat, + linear-gradient(var(--grip-line), var(--grip-line)) 50% 50% / 1px 13px no-repeat, + linear-gradient(var(--grip-line), var(--grip-line)) calc(50% + 3px) 50% / 1px 13px no-repeat, + var(--color-background-primary-default); + box-shadow: var(--shadow-xs); + pointer-events: none; +} + +.hf-dock .dv-split-view-container.dv-vertical > .dv-sash-container > .dv-sash::after { + rotate: 90deg; } diff --git a/packages/studio/src/promptwise/usePromptwiseToolbar.ts b/packages/studio/src/promptwise/usePromptwiseToolbar.ts new file mode 100644 index 00000000000..fcc5e0bafa3 --- /dev/null +++ b/packages/studio/src/promptwise/usePromptwiseToolbar.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef } from "react"; +import { embeddedInPromptwise } from "./embed"; + +export type PromptwiseCommand = "inspector" | "export"; + +export interface PromptwiseToolbarState { + inspectorOpen: boolean; + rendering: boolean; +} + +function isCommand(value: unknown): value is PromptwiseCommand { + return value === "inspector" || value === "export"; +} + +/** + * Embedded in promptwise, Studio draws no header: the Timeline tab's toolbar carries Inspector and + * Export. It sends their presses as promptwise:command messages, and Studio sends back their state. + */ +export function usePromptwiseToolbar( + state: PromptwiseToolbarState, + commands: Record void>, +) { + const latest = useRef(commands); + latest.current = commands; + + useEffect(() => { + if (!embeddedInPromptwise) return; + const onMessage = (event: MessageEvent) => { + if (event.source !== window.parent) return; + const data: unknown = event.data; + if (typeof data !== "object" || data === null) return; + const { type, command } = data as { type?: unknown; command?: unknown }; + if (type === "promptwise:command" && isCommand(command)) latest.current[command](); + }; + window.addEventListener("message", onMessage); + return () => window.removeEventListener("message", onMessage); + }, []); + + const { inspectorOpen, rendering } = state; + useEffect(() => { + if (!embeddedInPromptwise) return; + window.parent.postMessage({ type: "promptwise:state", inspectorOpen, rendering }, "*"); + }, [inspectorOpen, rendering]); +} diff --git a/packages/studio/src/styles/hex-baseline.json b/packages/studio/src/styles/hex-baseline.json index 7c7b5e47a54..1828395b9bd 100644 --- a/packages/studio/src/styles/hex-baseline.json +++ b/packages/studio/src/styles/hex-baseline.json @@ -1,5 +1,5 @@ { - "total": 236, + "total": 235, "files": { "packages/studio/src/captions/components/CaptionOverlay.tsx": 5, "packages/studio/src/captions/components/CaptionTimeline.tsx": 11, @@ -43,7 +43,6 @@ "packages/studio/src/player/components/AudioWaveform.tsx": 2, "packages/studio/src/player/components/BeatStrip.tsx": 6, "packages/studio/src/player/components/CompositionThumbnail.tsx": 2, - "packages/studio/src/player/components/PlayerControls.tsx": 1, "packages/studio/src/player/components/SpeedMenu.tsx": 2, "packages/studio/src/promptwise/theme.css": 11, "packages/studio/src/styles/components.css": 48, diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 20a77cc8df7..3ce6c764f93 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -2,7 +2,7 @@ import { buildProjectApiPath } from "./projectRouting"; import { isTypingTarget } from "./typingTarget"; import type { TimelineElement } from "../player/store/playerStore"; import type { DomEditSelection } from "../components/editor/domEditing"; -import type { TimelineAssetKind } from "./timelineAssetDrop"; +import { isRemoteAsset, type TimelineAssetKind } from "./timelineAssetDrop"; import { roundToCenti } from "./rounding"; import { studioApiFetch } from "./studioApiFetch"; import { encodeUrlPath } from "@hyperframes/parsers"; @@ -354,7 +354,9 @@ export async function resolveDroppedAssetDuration( const media = document.createElement(kind === "video" ? "video" : "audio"); media.preload = "metadata"; - media.src = buildProjectApiPath(projectId, `/preview/${encodeUrlPath(assetPath)}`); + media.src = isRemoteAsset(assetPath) + ? assetPath + : buildProjectApiPath(projectId, `/preview/${encodeUrlPath(assetPath)}`); const duration = await new Promise((resolve) => { const timeout = window.setTimeout(() => resolve(DEFAULT_TIMELINE_ASSET_DURATION[kind]), 3000); @@ -396,6 +398,8 @@ export async function resolveDroppedAssetHasAudio( kind: TimelineAssetKind, ): Promise { if (kind !== "video") return false; + // The project's probe can't reach a remote file: it lands audible, as a converted timeline's videos do. + if (isRemoteAsset(assetPath)) return true; return (await resolveAssetHasAudio(projectId, assetPath)) === true; } diff --git a/packages/studio/src/utils/timelineAssetDrop.test.ts b/packages/studio/src/utils/timelineAssetDrop.test.ts index 246a5b51dbc..bc7128e7b83 100644 --- a/packages/studio/src/utils/timelineAssetDrop.test.ts +++ b/packages/studio/src/utils/timelineAssetDrop.test.ts @@ -306,3 +306,27 @@ it("writes a dropped file's name as a URL, so %, #, ? and apostrophes load", () }); expect(html).toContain('src="../assets/50%25%20off%20%231%3F%27s%20take.mp4"'); }); + +describe("a file at its own address (promptwise's media)", () => { + it("is known by its path, before any query", () => { + expect(getTimelineAssetKind("https://media.test/t/1/9f3b.MP4?v=2")).toBe("video"); + expect(getTimelineAssetKind("https://media.test/t/1/logo.png#x")).toBe("image"); + expect(getTimelineAssetKind("https://media.test/t/1/notes.txt")).toBeNull(); + }); + + it("plays from that address as it is, with only the attribute's own characters escaped", () => { + const html = buildTimelineAssetInsertHtml({ + id: "shot", + hfId: "hf-1", + assetPath: 'https://media.test/t/1/a b.mp4?x=1&y="2"', + kind: "video", + start: 0, + duration: 4, + track: 0, + zIndex: 1, + hasAudio: true, + }); + expect(html).toContain('src="https://media.test/t/1/a b.mp4?x=1&y="2""'); + expect(html).toContain('data-has-audio="true"'); + }); +}); diff --git a/packages/studio/src/utils/timelineAssetDrop.ts b/packages/studio/src/utils/timelineAssetDrop.ts index 14186263a50..49fe4e9b67b 100644 --- a/packages/studio/src/utils/timelineAssetDrop.ts +++ b/packages/studio/src/utils/timelineAssetDrop.ts @@ -11,10 +11,17 @@ const FALLBACK_TIMELINE_FILE_DROP_DURATION = 5; export type TimelineAssetKind = "image" | "video" | "audio"; +/** A file at its own http(s) address (promptwise's media), which a clip plays from there instead of from the project. */ +export function isRemoteAsset(assetPath: string): boolean { + return /^https?:\/\//.test(assetPath); +} + export function getTimelineAssetKind(assetPath: string): TimelineAssetKind | null { - if (IMAGE_EXT.test(assetPath)) return "image"; - if (VIDEO_EXT.test(assetPath)) return "video"; - if (AUDIO_EXT.test(assetPath)) return "audio"; + // A remote file's kind is in its path, before any query. + const path = isRemoteAsset(assetPath) ? assetPath.replace(/[?#].*$/, "") : assetPath; + if (IMAGE_EXT.test(path)) return "image"; + if (VIDEO_EXT.test(path)) return "video"; + if (AUDIO_EXT.test(path)) return "audio"; return null; } @@ -106,7 +113,11 @@ export function buildTimelineAssetInsertHtml(input: { /** Video only: true inserts `data-has-audio="true"` with no `muted`. Unknown or false stays muted. */ hasAudio?: boolean; }): string { - const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${encodeUrlPath(input.assetPath)}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`; + // A remote file keeps its address as it is; only the quote and ampersand an attribute can't hold are escaped. + const src = isRemoteAsset(input.assetPath) + ? input.assetPath.replace(/&/g, "&").replace(/"/g, """) + : encodeUrlPath(input.assetPath); + const sharedAttrs = `id="${input.id}" data-hf-id="${input.hfId}" class="clip" src="${src}" data-start="${input.start}" data-duration="${input.duration}" data-track-index="${input.track}"`; const geometry = input.geometry ?? { left: 0, top: 0, width: 640, height: 360 }; const visualStyles = `position: absolute; left: ${geometry.left}px; top: ${geometry.top}px; width: ${geometry.width}px; height: ${geometry.height}px; object-fit: contain; z-index: ${input.zIndex}`;