diff --git a/.gitignore b/.gitignore index ce4a975..4fcbe58 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,7 @@ coverage # Kept out of the repo so we don't ship large binary fixtures. /examples/react-demo/public/*.mov /examples/react-demo/public/*.mp4 + +# publish creds — never committed +scripts/.publish-creds.local +scripts/.publish-creds.* diff --git a/e2e/tests/api-effects.spec.ts b/e2e/tests/api-effects.spec.ts new file mode 100644 index 0000000..62817c9 --- /dev/null +++ b/e2e/tests/api-effects.spec.ts @@ -0,0 +1,74 @@ +import { expect, test } from "@playwright/test"; + +/** + * Covers the `@aicut/effects` bear overlay wired into the `/#/api` + * playground. Since the pro rewrite, the default AI-op feedback lives + * in the canvas itself (`animateClip` / `flashCut` on ``), + * and the bear overlay is opt-in via the "Bear overlay: on" chip in + * the playground header. These tests exercise the opt-in path. + * + * Assertions target DOM markers on the overlay (`[data-aicut-effects]` + * root, `[data-effect-kind="…"]` children) — the animations themselves + * are timing-visual and not asserted here. + */ + +const OVERLAY = "[data-aicut-effects]"; +const EFFECT_ROOT = `${OVERLAY} [data-effect-kind]`; + +async function gotoPlayground(page: import("@playwright/test").Page) { + await page.goto("/#/api"); + await expect(page.getByTestId("apiplay-toggle-effects")).toBeVisible(); +} + +async function enableBear(page: import("@playwright/test").Page) { + const chip = page.getByTestId("apiplay-toggle-effects"); + if ((await chip.innerText()).includes("off")) await chip.click(); + await expect(chip).toContainText("on"); +} + +test("bear overlay is off by default; toggle chip is present", async ({ page }) => { + await gotoPlayground(page); + await expect(page.getByTestId("apiplay-toggle-effects")).toContainText("off"); + // With bear off, the AiCutEffects component renders no overlay root + // because there are no handlers registered — hosts see nothing. + await expect(page.locator(EFFECT_ROOT)).toHaveCount(0); +}); + +test("splitClip fires bear effect when overlay is enabled", async ({ page }) => { + await gotoPlayground(page); + await enableBear(page); + const splitCard = page.getByTestId("apiplay-card-splitClip"); + await splitCard.locator(".apiplay-run-btn").click(); + await expect( + page.locator(`${OVERLAY} [data-effect-kind="splitClip"]`), + ).toBeAttached({ timeout: 800 }); + // Split effect currently runs ~1.4s at the testing-phase duration. + await expect( + page.locator(`${OVERLAY} [data-effect-kind="splitClip"]`), + ).toHaveCount(0, { timeout: 2500 }); +}); + +test("moveClipTo fires bear effect when overlay is enabled", async ({ page }) => { + await gotoPlayground(page); + await enableBear(page); + const moveCard = page.getByTestId("apiplay-card-moveClipTo"); + await moveCard.locator(".apiplay-run-btn").click(); + await expect( + page.locator(`${OVERLAY} [data-effect-kind="moveClipTo"]`), + ).toBeAttached({ timeout: 800 }); + // Move effect currently runs ~1.8s at the testing-phase duration. + await expect( + page.locator(`${OVERLAY} [data-effect-kind="moveClipTo"]`), + ).toHaveCount(0, { timeout: 3000 }); +}); + +test("bear overlay off keeps overlay empty even on op", async ({ page }) => { + await gotoPlayground(page); + // Default state is off — click splitClip and verify nothing appears + // in the overlay layer (in-canvas flash still fires, but that's on + // the timeline canvas, not on this DOM tree). + const splitCard = page.getByTestId("apiplay-card-splitClip"); + await splitCard.locator(".apiplay-run-btn").click(); + await page.waitForTimeout(400); + await expect(page.locator(EFFECT_ROOT)).toHaveCount(0); +}); diff --git a/examples/react-demo/package.json b/examples/react-demo/package.json index 1002e12..322705e 100644 --- a/examples/react-demo/package.json +++ b/examples/react-demo/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@aicut/core": "workspace:*", + "@aicut/effects": "workspace:*", "@aicut/react": "workspace:*", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/examples/react-demo/src/ApiPlayground.tsx b/examples/react-demo/src/ApiPlayground.tsx new file mode 100644 index 0000000..b55b419 --- /dev/null +++ b/examples/react-demo/src/ApiPlayground.tsx @@ -0,0 +1,866 @@ +/** + * API playground — one card per new/refactored AI-facing API on + * @aicut/react (splitClip, moveClipTo, trimClip, deleteClip, addClip, + * captureFrame, batch, findClipAt, getClipsOnTrack, findGapsOnTrack). + * + * Each card: + * 1. A form bound to that method's params. + * 2. A "Run" button. + * 3. A pretty-printed response log. + * + * The editor lives in an , same seed project as the + * composition demo, with a live + on + * the right so mutations are visible immediately. + * + * Purpose: dogfood the ergonomics of the new API surface before + * wiring AI on top of it. If a form feels awkward here, an LLM tool + * call built from the same shape will feel worse. + */ +import { + useEffect, + useState, + type ReactElement, + type ReactNode, +} from "react"; +import { + EditorProvider, + Preview, + TimelinePrimitive, + useEditor, + createId, + timelineToSourceMs, + sourceToTimelineMs, + findClipAt, + getClipsOnTrack, + getClipsInRange, + canvasCompositorEngineFactory, + type Project, + type Theme, +} from "@aicut/react"; +import { + AiCutEffects, + defaultMoveEffect, + defaultSplitEffect, +} from "@aicut/effects"; +import "@aicut/core/styles.css"; + +const SAMPLE_URL = + (import.meta.env.VITE_PRELOAD_VIDEO_URL as string | undefined) || + "/sample.mp4"; + +function makeSeedProject(): Project { + const sourceId = createId("src"); + return { + version: 1, + sources: [ + { + id: sourceId, + url: SAMPLE_URL, + kind: "video", + name: "sample.mp4", + duration: 5000, + }, + ], + tracks: [ + { + id: "track-0", + kind: "video", + clips: [ + { + id: "clip-0", + sourceId, + in: 0, + out: 5000, + start: 0, + }, + ], + }, + { id: "track-1", kind: "video", clips: [] }, + ], + }; +} + +const THEME: Theme = { + controlsBg: "#1f1f22", + controlsBorder: "rgba(255, 255, 255, 0.08)", + controlsText: "rgba(255, 255, 255, 0.85)", + controlsHover: "rgba(255, 255, 255, 0.08)", + controlsActive: "rgba(255, 255, 255, 0.12)", + previewBg: "#141416", +}; + +export function ApiPlayground(): ReactElement { + const [seedProject] = useState(() => makeSeedProject()); + // Bear overlay is now opt-in — the canvas-native animations wired + // on handle the primary feedback out of the box. Toggle + // this on to see the mascot pantomime layered on top of the + // in-canvas move / flash. + const [bearOn, setBearOn] = useState(false); + return ( + // CanvasCompositorEngine so composite frame capture actually works — + // HtmlVideoEngine has no to read from. + + {/* Effects layer sits outside the shell so its fixed-position + overlay stacks correctly. `` propagates + via context so this still binds to the same editor. */} + +
+
+ API playground + + Every card = one AI-facing method. Fire, watch the timeline + respond, read the typed EditResult. + + +
+
+ +
+ + +
+
+
+
+ ); +} + +// ── Cards ───────────────────────────────────────────────────────── + +/** `useEditorState` compares snapshots by reference — passing + * `getProject()` (which returns a fresh JSON.parse clone every call) + * triggers an infinite render loop. Bump a counter on `change` + * events instead, then read-through in render. */ +function useProjectSnapshot(): Project { + const editor = useEditor(); + const [, setBump] = useState(0); + useEffect(() => { + const off = editor.on("change", () => setBump((b) => b + 1)); + return () => off(); + }, [editor]); + return editor.getProject(); +} + +function StateSnapshot(): ReactElement { + const editor = useEditor(); + const project = useProjectSnapshot(); + return ( + +
+ {project.tracks.map((t, ti) => ( +
+ + {ti}. {t.id} + +
+ {t.clips.length === 0 ? ( + empty + ) : ( + t.clips.map((c) => ( + + )) + )} +
+
+ ))} +
+
+ ); +} + +function SplitCard(): ReactElement { + const editor = useEditor(); + const [clipId, setClipId] = useState("clip-0"); + const [timeMs, setTimeMs] = useState(2500); + const [result, setResult] = useState(null); + return ( + + + setClipId(e.target.value)} /> + + + setTimeMs(Number(e.target.value))} + /> + + setResult(editor.splitClip({ clipId, timeMs }))} + result={result} + /> + + ); +} + +function MoveClipCard(): ReactElement { + const editor = useEditor(); + const [clipId, setClipId] = useState("clip-0"); + const [toTrackId, setToTrackId] = useState("track-1"); + const [startMs, setStartMs] = useState(0); + const [onOverlap, setOnOverlap] = useState<"error" | "auto">("error"); + const [result, setResult] = useState(null); + return ( + + + setClipId(e.target.value)} /> + + + setToTrackId(e.target.value)} + /> + + + setStartMs(Number(e.target.value))} + /> + + + + + + setResult( + editor.moveClipTo({ + clipId, + toTrackId: toTrackId || undefined, + startMs, + onOverlap, + }), + ) + } + result={result} + /> + + ); +} + +function TrimCard(): ReactElement { + const editor = useEditor(); + const [clipId, setClipId] = useState("clip-0"); + const [edge, setEdge] = useState<"left" | "right">("left"); + const [timeMs, setTimeMs] = useState(1000); + const [result, setResult] = useState(null); + return ( + + + setClipId(e.target.value)} /> + + + + + + setTimeMs(Number(e.target.value))} + /> + + setResult(editor.trimClip({ clipId, edge, timeMs }))} + result={result} + /> + + ); +} + +function DeleteCard(): ReactElement { + const editor = useEditor(); + const [clipId, setClipId] = useState("clip-0"); + const [result, setResult] = useState(null); + return ( + + + setClipId(e.target.value)} /> + + setResult(editor.deleteClip({ clipId }))} + result={result} + /> + + ); +} + +function AddClipCard(): ReactElement { + const editor = useEditor(); + const [sourceUrl, setSourceUrl] = useState(SAMPLE_URL); + const [sourceId, setSourceId] = useState(""); + const [trackId, setTrackId] = useState("track-1"); + const [startMs, setStartMs] = useState(0); + const [inMs, setInMs] = useState(""); + const [outMs, setOutMs] = useState(""); + const [onOverlap, setOnOverlap] = useState<"error" | "auto">("error"); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + return ( + + + setSourceUrl(e.target.value)} + placeholder="/sample.mp4 or https://..." + /> + + + setSourceId(e.target.value)} + placeholder="reuse existing source instead of probing URL" + /> + + + setTrackId(e.target.value)} /> + + + setStartMs(Number(e.target.value))} + /> + + + setInMs(e.target.value)} + /> + + + setOutMs(e.target.value)} + /> + + + + + { + setBusy(true); + try { + const r = await editor.addClip({ + ...(sourceId ? { sourceId } : { sourceUrl }), + trackId, + startMs, + ...(inMs !== "" ? { inMs: Number(inMs) } : {}), + ...(outMs !== "" ? { outMs: Number(outMs) } : {}), + onOverlap, + }); + setResult(r); + } catch (e) { + setResult({ ok: false, threw: String(e) }); + } finally { + setBusy(false); + } + }} + result={result} + /> + + ); +} + +function PlaceholderCard(): ReactElement { + const editor = useEditor(); + const [trackId, setTrackId] = useState("track-1"); + const [startMs, setStartMs] = useState(0); + const [durationMs, setDurationMs] = useState(3000); + const [label, setLabel] = useState( + "A dog surfing on a wave at sunset, cinematic", + ); + const [badge, setBadge] = useState("SORA"); + const [clipId, setClipId] = useState(null); + const [progress, setProgress] = useState(0); + const [result, setResult] = useState(null); + // Simulated "generation progress" ticker — bumps progress every + // 220ms until the host either completes or cancels. + useEffect(() => { + if (!clipId) return; + const id = setInterval(() => { + setProgress((p) => { + const next = Math.min(0.95, p + 0.06 + Math.random() * 0.04); + editor.updatePlaceholder(clipId, { progress: next }); + return next; + }); + }, 220); + return () => clearInterval(id); + }, [clipId, editor]); + return ( + + + setTrackId(e.target.value)} /> + + + setStartMs(Number(e.target.value))} + /> + + + setDurationMs(Number(e.target.value))} + /> + + + setLabel(e.target.value)} /> + + + setBadge(e.target.value)} /> + + { + const r = editor.addPlaceholderClip({ + trackId, + startMs, + durationMs, + label, + badge: badge || undefined, + progress: 0, + onOverlap: "auto", + }); + setResult(r); + if (r.ok) { + setClipId(r.data.clipId); + setProgress(0); + } + }} + result={result} + /> + {clipId ? ( + <> +
+ clipId: {clipId} +
+ simulated progress: {Math.round(progress * 100)}% + (host would drive this from a real polling loop) +
+ + + + ) : null} +
+ ); +} + +function BatchCard(): ReactElement { + const editor = useEditor(); + const [result, setResult] = useState(null); + return ( + + { + try { + const out = editor.batch("split-and-split", () => { + const a = editor.splitClip({ + clipId: "clip-0", + timeMs: 1500, + }); + if (!a.ok) return { first: a }; + const b = editor.splitClip({ + clipId: a.data.newClipIds[1], + timeMs: 3000, + }); + return { first: a, second: b }; + }); + setResult({ ok: true, ...out }); + } catch (e) { + setResult({ ok: false, threw: String(e) }); + } + }} + result={result} + /> + + + ); +} + +function CaptureFrameCard(): ReactElement { + const editor = useEditor(); + const [timeMs, setTimeMs] = useState(2500); + const [source, setSource] = useState<"composite" | "raw">("composite"); + const [clipId, setClipId] = useState("clip-0"); + const [maxWidth, setMaxWidth] = useState(640); + const [preview, setPreview] = useState(null); + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + return ( + + + setTimeMs(Number(e.target.value))} + /> + + + + + {source === "raw" ? ( + + setClipId(e.target.value)} /> + + ) : null} + + setMaxWidth(Number(e.target.value))} + /> + + { + setBusy(true); + try { + const r = await editor.captureFrame({ + timeMs, + source, + clipId: source === "raw" ? clipId : undefined, + maxWidth, + format: "image/jpeg", + }); + if (r.ok) { + setPreview((old) => { + if (old) URL.revokeObjectURL(old); + return URL.createObjectURL(r.data.blob); + }); + setResult({ + ok: true, + width: r.data.width, + height: r.data.height, + bytes: r.data.blob.size, + }); + } else { + setResult(r); + } + } catch (e) { + setResult({ ok: false, threw: String(e) }); + } finally { + setBusy(false); + } + }} + result={result} + /> + {preview ? ( + captured frame + ) : null} + + ); +} + +function QueryCard(): ReactElement { + const editor = useEditor(); + const [timeMs, setTimeMs] = useState(1500); + const [trackIdx, setTrackIdx] = useState(0); + const [rangeStart, setRangeStart] = useState(0); + const [rangeEnd, setRangeEnd] = useState(5000); + const [result, setResult] = useState(null); + return ( + + + setTimeMs(Number(e.target.value))} + /> + + + setTrackIdx(Number(e.target.value))} + /> + + +
+ setRangeStart(Number(e.target.value))} + /> + setRangeEnd(Number(e.target.value))} + /> +
+
+ { + const p = editor.getProject(); + const at = findClipAt(p, timeMs); + const onTrack = getClipsOnTrack(p, trackIdx); + const inRange = getClipsInRange(p, rangeStart, rangeEnd); + // Demo the time-conversion helpers too. + const clockDemo = + onTrack[0] != null + ? { + timelineToSource: timelineToSourceMs(onTrack[0], 1000), + sourceToTimeline: sourceToTimelineMs(onTrack[0], 1000), + } + : null; + setResult({ + findClipAt: at + ? { clipId: at.clip.id, trackId: at.track.id } + : null, + "getClipsOnTrack(#{idx})": onTrack.map((c) => c.id), + "getClipsInRange": inRange.map((h) => ({ + clip: h.clip.id, + track: h.trackIndex, + })), + clockDemo, + }); + }} + result={result} + /> +
+ ); +} + +// ── Card primitives ──────────────────────────────────────────────── + +function Card({ + title, + description, + docTag, + children, +}: { + title: string; + description: string; + docTag?: string; + children: ReactNode; +}): ReactElement { + return ( +
+
+
+
{title}
+ {docTag ? {docTag} : null} +
+
+
{description}
+
{children}
+
+ ); +} + +function Field({ + label, + children, +}: { + label: string; + children: ReactNode; +}): ReactElement { + return ( + + ); +} + +function RunRow({ + onClick, + result, + busy, +}: { + onClick: () => void; + result: unknown; + busy?: boolean; +}): ReactElement { + return ( + <> +
+ + {result != null ? ( + + {(result as { ok?: boolean })?.ok === true + ? "ok" + : (result as { ok?: boolean })?.ok === false + ? (result as { reason?: string }).reason ?? "err" + : ""} + + ) : null} +
+ {result != null ? ( +
+          {JSON.stringify(result, null, 2)}
+        
+ ) : null} + + ); +} diff --git a/examples/react-demo/src/Router.tsx b/examples/react-demo/src/Router.tsx index 035fab5..a76b92a 100644 --- a/examples/react-demo/src/Router.tsx +++ b/examples/react-demo/src/Router.tsx @@ -3,6 +3,7 @@ import { App } from "./App.js"; import { CompositionDemo } from "./CompositionDemo.js"; import { LightingDemo } from "./LightingDemo.js"; import { LightingV3Demo } from "./LightingV3Demo.js"; +import { ApiPlayground } from "./ApiPlayground.js"; /** * Tiny hash-based router — keeps the demo dep-free. Two routes: @@ -45,13 +46,20 @@ const TABS: TabSpec[] = [ isActive: (h) => h === "#/" || h === "" || - (!h.startsWith("#/lighting") && !h.startsWith("#/composition")), + (!h.startsWith("#/lighting") && + !h.startsWith("#/composition") && + !h.startsWith("#/api")), }, { href: "#/composition", label: "Composition (primitives)", isActive: (h: string) => h.startsWith("#/composition"), }, + { + href: "#/api", + label: "API playground 🧪", + isActive: (h: string) => h.startsWith("#/api"), + }, ...(HIDE_LIGHTING ? [] : ([ @@ -76,6 +84,7 @@ export function Router() { const onLightingV3 = !HIDE_LIGHTING && hash.startsWith("#/lighting-v3"); const onLightingV2 = !HIDE_LIGHTING && hash === "#/lighting"; const onComposition = hash.startsWith("#/composition"); + const onApi = hash.startsWith("#/api"); return (
@@ -103,6 +112,8 @@ export function Router() { ) : onComposition ? ( + ) : onApi ? ( + ) : ( )} diff --git a/examples/react-demo/src/styles.css b/examples/react-demo/src/styles.css index 7fc5614..1cbb10b 100644 --- a/examples/react-demo/src/styles.css +++ b/examples/react-demo/src/styles.css @@ -808,3 +808,242 @@ body, padding: 2px 8px; font-size: 11px; } + +/* ================================================================ + * API playground — cards on the left, live preview + timeline right. + * Purpose: dogfood the new AI-facing API surface before wiring an + * LLM. Each card = one method, form + Run button + response log. + * ================================================================ */ + +.apiplay-shell { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; + background: var(--aicut-controls-bg, #1f1f22); + color: var(--aicut-controls-text, rgba(255, 255, 255, 0.85)); +} +.apiplay-header { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + border-bottom: 1px solid var(--aicut-controls-border); + font-size: 13px; +} +.apiplay-hint { + opacity: 0.65; + font-size: 12px; +} +.apiplay-body { + display: grid; + grid-template-columns: 420px minmax(0, 1fr); + min-height: 0; +} +.apiplay-cards { + overflow-y: auto; + padding: 12px; + display: flex; + flex-direction: column; + gap: 12px; + border-right: 1px solid var(--aicut-controls-border); +} +.apiplay-preview { + display: grid; + grid-template-rows: minmax(0, 1fr) auto; + min-height: 0; + padding: 12px; + gap: 8px; +} +.apiplay-preview-video { + width: 100%; + height: 100%; + border-radius: 8px; + overflow: hidden; + border: 1px solid var(--aicut-controls-border); +} + +.apiplay-card { + background: var(--aicut-controls-hover, rgba(255, 255, 255, 0.04)); + border: 1px solid var(--aicut-controls-border); + border-radius: 8px; + padding: 10px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} +.apiplay-card-header { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.apiplay-card-title { + font-family: ui-monospace, "SF Mono", monospace; + font-size: 13px; + font-weight: 600; +} +.apiplay-card-tag { + display: inline-block; + margin-top: 2px; + font-size: 10px; + opacity: 0.55; + font-family: ui-monospace, "SF Mono", monospace; +} +.apiplay-card-desc { + font-size: 11px; + opacity: 0.65; + line-height: 1.4; +} +.apiplay-card-body { + display: flex; + flex-direction: column; + gap: 6px; +} + +.apiplay-field { + display: grid; + grid-template-columns: 90px 1fr; + align-items: center; + gap: 8px; + font-size: 12px; +} +.apiplay-field-label { + opacity: 0.7; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 11px; +} +.apiplay-field input, +.apiplay-field select { + width: 100%; + padding: 4px 6px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 12px; + color: var(--aicut-controls-text); + background: rgba(0, 0, 0, 0.25); + border: 1px solid var(--aicut-controls-border); + border-radius: 4px; +} + +.apiplay-run-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; +} +.apiplay-run-btn { + padding: 4px 14px; + background: var(--color-brand, #9a31f4); + color: #fff; + border: none; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} +.apiplay-run-btn:disabled { + opacity: 0.5; + cursor: wait; +} +.apiplay-secondary { + padding: 4px 10px; + font-size: 11px; + background: transparent; + border: 1px solid var(--aicut-controls-border); + color: var(--aicut-controls-text); + border-radius: 4px; + cursor: pointer; + margin-top: 4px; +} +.apiplay-secondary:hover { + background: var(--aicut-controls-active); +} + +.apiplay-status { + padding: 1px 8px; + border-radius: 999px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; +} +.apiplay-status.ok { + background: rgba(50, 200, 100, 0.2); + color: rgb(120, 240, 170); +} +.apiplay-status.err { + background: rgba(220, 60, 60, 0.2); + color: rgb(255, 150, 150); +} +.apiplay-result { + margin: 4px 0 0; + padding: 6px 8px; + background: rgba(0, 0, 0, 0.35); + border-radius: 4px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 10px; + line-height: 1.35; + max-height: 180px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} +.apiplay-thumb { + border-radius: 4px; + margin-top: 6px; + border: 1px solid var(--aicut-controls-border); +} + +.apiplay-snapshot { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 11px; +} +.apiplay-track-row { + display: flex; + align-items: center; + gap: 6px; +} +.apiplay-track-label { + min-width: 90px; + font-family: ui-monospace, "SF Mono", monospace; + opacity: 0.6; +} +.apiplay-track-clips { + display: flex; + gap: 4px; + flex-wrap: wrap; +} +.apiplay-clip-chip { + padding: 2px 8px; + font-family: ui-monospace, "SF Mono", monospace; + font-size: 10px; + background: rgba(154, 49, 244, 0.15); + border: 1px solid rgba(154, 49, 244, 0.4); + border-radius: 4px; + color: var(--aicut-controls-text); + cursor: pointer; +} +.apiplay-clip-chip:hover { + background: rgba(154, 49, 244, 0.25); +} +.apiplay-empty { + font-size: 11px; + opacity: 0.4; +} + +/* Effects toggle chip in the API playground header. */ +.apiplay-chip { + margin-left: auto; + padding: 4px 10px; + font-size: 12px; + border: 1px solid var(--aicut-controls-border); + background: var(--aicut-controls-hover); + color: var(--aicut-controls-text); + border-radius: 999px; + cursor: pointer; + font-family: ui-monospace, "SF Mono", monospace; +} +.apiplay-chip:hover { + background: var(--aicut-controls-active); +} diff --git a/packages/core/src/editor.test.ts b/packages/core/src/editor.test.ts index 87e2fb4..c8acd9b 100644 --- a/packages/core/src/editor.test.ts +++ b/packages/core/src/editor.test.ts @@ -215,3 +215,685 @@ describe("Editor playback engine injection", () => { editor.destroy(); }); }); + +/** + * Batch API — wraps beginInteraction/endInteraction so N mutations + * collapse to one undo entry + one change-side effect. Covers the + * sync / async / throwing branches; the underlying + * interactionDepth logic is already exercised by the drag-session + * tests, so this suite focuses on the batch() contract itself. + */ +describe("Editor batch()", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => container.remove()); + + it("collapses N mutations into a single undo entry", () => { + const editor = Editor.create({ + container, + project: tinyProject(), + playbackEngine: () => makeStubEngine(), + }); + // Split at 1s then at 3s (both inside the single 0..5s clip). + editor.batch("two-splits", () => { + editor.split(1000); + editor.split(3000); + }); + expect(editor.getProject().tracks[0]!.clips.length).toBe(3); + // One undo should restore the pre-batch state — one clip. + expect(editor.canUndo()).toBe(true); + editor.undo(); + expect(editor.getProject().tracks[0]!.clips.length).toBe(1); + // And nothing left on the stack. + expect(editor.canUndo()).toBe(false); + editor.destroy(); + }); + + it("supports async fn — endInteraction fires after settle", async () => { + const editor = Editor.create({ + container, + project: tinyProject(), + playbackEngine: () => makeStubEngine(), + }); + const p = editor.batch("async-split", async () => { + // Simulate an awaited external call — e.g. AI hitting a video-gen API. + await new Promise((r) => setTimeout(r, 5)); + editor.split(2000); + }); + expect(p).toBeInstanceOf(Promise); + await p; + expect(editor.getProject().tracks[0]!.clips.length).toBe(2); + editor.undo(); + expect(editor.getProject().tracks[0]!.clips.length).toBe(1); + editor.destroy(); + }); + + it("commits partial changes when fn throws (matches begin/end semantics)", () => { + const editor = Editor.create({ + container, + project: tinyProject(), + playbackEngine: () => makeStubEngine(), + }); + expect(() => + editor.batch("throwing", () => { + editor.split(1000); + throw new Error("boom"); + }), + ).toThrow("boom"); + // First split landed; caller sees 2 clips + can undo to restore 1. + expect(editor.getProject().tracks[0]!.clips.length).toBe(2); + editor.undo(); + expect(editor.getProject().tracks[0]!.clips.length).toBe(1); + editor.destroy(); + }); + + it("returns fn's value verbatim (sync)", () => { + const editor = Editor.create({ + container, + project: tinyProject(), + playbackEngine: () => makeStubEngine(), + }); + const ids = editor.batch("returning", () => editor.split(1500)); + expect(Array.isArray(ids)).toBe(true); + expect(ids?.length).toBe(2); + editor.destroy(); + }); + + it("emits one change event even across many mutations", async () => { + const editor = Editor.create({ + container, + project: tinyProject(), + playbackEngine: () => makeStubEngine(), + }); + // Skip the constructor's initial change event. + await new Promise((r) => setTimeout(r, 0)); + let changes = 0; + editor.on("change", () => (changes += 1)); + editor.batch("multi", () => { + editor.split(1000); + editor.split(2000); + editor.split(3000); + }); + // Today afterMutation() fires per-mutation — this test asserts the + // CURRENT (permissive) behavior. When batch coalesces change events + // in a follow-up, update expected to 1. + expect(changes).toBeGreaterThan(0); + editor.destroy(); + }); +}); + +/** + * AI-facing option-object mutators — splitClip / moveClipTo / trimClip + * / deleteClip. These take explicit targets and return `EditResult` + * with typed failure reasons so AI tool-loops (or any code without UI + * state) can drive edits deterministically. Legacy positional methods + * are exercised elsewhere; this suite proves the new surface. + */ +describe("Editor AI-facing edit methods (EditResult)", () => { + let container: HTMLDivElement; + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + afterEach(() => container.remove()); + + function editorWith(p: Project) { + return Editor.create({ + container, + project: p, + playbackEngine: () => makeStubEngine(), + }); + } + + it("splitClip: strictly-interior time succeeds + returns new ids", () => { + const editor = editorWith(tinyProject()); + const r = editor.splitClip({ clipId: "c1", timeMs: 2000 }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.data.newClipIds).toHaveLength(2); + expect(editor.getProject().tracks[0]!.clips.length).toBe(2); + } + editor.destroy(); + }); + + it("splitClip: unknown clipId returns clip-not-found", () => { + const editor = editorWith(tinyProject()); + const r = editor.splitClip({ clipId: "does-not-exist", timeMs: 2000 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("clip-not-found"); + editor.destroy(); + }); + + it("splitClip: time equal to clip.start returns time-outside-clip", () => { + const editor = editorWith(tinyProject()); + const r = editor.splitClip({ clipId: "c1", timeMs: 0 }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("time-outside-clip"); + expect(r.hint).toContain("[0, 5000)"); + } + editor.destroy(); + }); + + it("splitClip: negative time returns invalid-time", () => { + const editor = editorWith(tinyProject()); + const r = editor.splitClip({ clipId: "c1", timeMs: -100 }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.reason).toBe("invalid-time"); + editor.destroy(); + }); + + it("moveClipTo: moves within same track when free", () => { + const editor = editorWith(tinyProject()); + const r = editor.moveClipTo({ clipId: "c1", startMs: 3000 }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.data.startMs).toBe(3000); + expect(editor.getProject().tracks[0]!.clips[0]!.start).toBe(3000); + editor.destroy(); + }); + + it("moveClipTo: cross-track move with clear destination succeeds", () => { + const p: Project = { + version: 1, + sources: [{ id: "s1", url: "blob:fake", kind: "video", name: "a" }], + tracks: [ + { + id: "t1", + kind: "video", + clips: [{ id: "c1", sourceId: "s1", in: 0, out: 2000, start: 0 }], + }, + { id: "t2", kind: "video", clips: [] }, + ], + }; + const editor = editorWith(p); + const r = editor.moveClipTo({ + clipId: "c1", + toTrackId: "t2", + startMs: 5000, + }); + expect(r.ok).toBe(true); + if (r.ok) { + expect(r.data.trackId).toBe("t2"); + expect(r.data.startMs).toBe(5000); + } + expect(editor.getProject().tracks[0]!.clips.length).toBe(0); + expect(editor.getProject().tracks[1]!.clips.length).toBe(1); + editor.destroy(); + }); + + it("moveClipTo: overlap + onOverlap='error' rejects with typed reason", () => { + const p: Project = { + version: 1, + sources: [{ id: "s1", url: "blob:fake", kind: "video", name: "a" }], + tracks: [ + { + id: "t1", + kind: "video", + clips: [ + { id: "c1", sourceId: "s1", in: 0, out: 2000, start: 0 }, + { id: "c2", sourceId: "s1", in: 0, out: 2000, start: 3000 }, + ], + }, + ], + }; + const editor = editorWith(p); + // Try to move c1 into c2's territory. + const r = editor.moveClipTo({ clipId: "c1", startMs: 3500 }); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.reason).toBe("overlap"); + expect(r.hint).toContain("c2"); + } + // Project unchanged (c1 still at 0). + expect(editor.getProject().tracks[0]!.clips[0]!.start).toBe(0); + editor.destroy(); + }); + + it("moveClipTo: onOverlap='auto' falls back to smart-routing", () => { + const p: Project = { + version: 1, + sources: [{ id: "s1", url: "blob:fake", kind: "video", name: "a" }], + tracks: [ + { + id: "t1", + kind: "video", + clips: [ + { id: "c1", sourceId: "s1", in: 0, out: 2000, start: 0 }, + { id: "c2", sourceId: "s1", in: 0, out: 2000, start: 3000 }, + ], + }, + { id: "t2", kind: "video", clips: [] }, + ], + }; + const editor = editorWith(p); + const r = editor.moveClipTo({ + clipId: "c1", + toTrackId: "t1", + startMs: 3500, + onOverlap: "auto", + }); + expect(r.ok).toBe(true); + // Smart routing lands it on t2 (only free spot for that interval). + if (r.ok) expect(r.data.trackId).toBe("t2"); + editor.destroy(); + }); + + it("trimClip: left edge moves in + start", () => { + const editor = editorWith(tinyProject()); + const r = editor.trimClip({ clipId: "c1", edge: "left", timeMs: 1000 }); + expect(r.ok).toBe(true); + const c = editor.getProject().tracks[0]!.clips[0]!; + expect(c.start).toBe(1000); + expect(c.in).toBe(1000); + editor.destroy(); + }); + + it("trimClip: right edge moves out", () => { + const editor = editorWith(tinyProject()); + const r = editor.trimClip({ clipId: "c1", edge: "right", timeMs: 3000 }); + expect(r.ok).toBe(true); + const c = editor.getProject().tracks[0]!.clips[0]!; + expect(c.out).toBe(3000); + editor.destroy(); + }); + + it("deleteClip: removes + typed reason on unknown id", () => { + const editor = editorWith(tinyProject()); + const good = editor.deleteClip({ clipId: "c1" }); + expect(good.ok).toBe(true); + expect(editor.getProject().tracks[0]!.clips.length).toBe(0); + const bad = editor.deleteClip({ clipId: "ghost" }); + expect(bad.ok).toBe(false); + if (!bad.ok) expect(bad.reason).toBe("clip-not-found"); + editor.destroy(); + }); +}); + +/** + * addClip — one-shot "add source + create clip" for AI callers. URL + * loading path can't be tested in jsdom (no