From 05fb0875a9f008e5d58e44463e283ffbdeedb75d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 5 Jul 2026 21:47:27 -0400 Subject: [PATCH 1/2] feat(cli): parakeet ASR engine for transcribe (--engine) + HYPERFRAMES_PYTHON override --- packages/cli/src/audio/providers.ts | 2 +- .../cli/src/commands/auth/status-guidance.ts | 2 +- packages/cli/src/commands/transcribe.ts | 58 +++++-- packages/cli/src/commands/tts.ts | 4 +- packages/cli/src/templates/_shared/AGENTS.md | 2 +- packages/cli/src/templates/_shared/CLAUDE.md | 2 +- packages/cli/src/tts/python.ts | 15 +- packages/cli/src/tts/synthesize.ts | 5 +- packages/cli/src/whisper/parakeet.test.ts | 37 +++++ packages/cli/src/whisper/parakeet.ts | 151 ++++++++++++++++++ 10 files changed, 260 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/whisper/parakeet.test.ts create mode 100644 packages/cli/src/whisper/parakeet.ts diff --git a/packages/cli/src/audio/providers.ts b/packages/cli/src/audio/providers.ts index a3eb3d4890..d7981c24e1 100644 --- a/packages/cli/src/audio/providers.ts +++ b/packages/cli/src/audio/providers.ts @@ -1,7 +1,7 @@ /** * Which voice / music engine a workflow will actually use, and whether * its local dependencies are present. Mirrors the resolution order the - * hyperframes-media skill scripts use, so `auth status` and `doctor` + * media-use skill scripts use, so `auth status` and `doctor` * report the same engine the render pipeline would pick: * * voice: HeyGen Starfish → ElevenLabs (key + `elevenlabs`) → Kokoro (local) diff --git a/packages/cli/src/commands/auth/status-guidance.ts b/packages/cli/src/commands/auth/status-guidance.ts index 08f5244262..cb4269fe2a 100644 --- a/packages/cli/src/commands/auth/status-guidance.ts +++ b/packages/cli/src/commands/auth/status-guidance.ts @@ -63,7 +63,7 @@ function offlineEngineLines(engines?: OfflineEngineLine[]): string[] { * so it's left to the docs — not dangled here as a command a fresh machine * can't run. Names the local fallback so "no key" never reads as a failure, * and never steers users toward a per-repo `.env`. Mirrors the - * hyperframes-media skill's Preflight section. + * media-use skill's Preflight section. */ export function buildUnconfiguredLines( ctx: UnconfiguredContext, diff --git a/packages/cli/src/commands/transcribe.ts b/packages/cli/src/commands/transcribe.ts index d4add30ad2..0c4ec4405f 100644 --- a/packages/cli/src/commands/transcribe.ts +++ b/packages/cli/src/commands/transcribe.ts @@ -1,6 +1,8 @@ +// fallow-ignore-file code-duplication import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { existsSync, writeFileSync } from "node:fs"; +import { findParakeet, transcribeWithParakeet } from "../whisper/parakeet.js"; type CaptionExportFormat = "srt" | "vtt"; @@ -41,6 +43,12 @@ export default defineCommand({ description: "Project directory (default: current directory)", alias: "d", }, + engine: { + type: "string", + description: + "ASR engine: auto (Parakeet if installed, else whisper), parakeet, or whisper. Default: auto. Parakeet is more accurate and faster; enable with `uv pip install parakeet-mlx`.", + alias: "e", + }, model: { type: "string", description: `Whisper model (default: ${DEFAULT_MODEL}). Options: tiny.en, base.en, small.en, medium.en, large-v3`, @@ -111,8 +119,9 @@ export default defineCommand({ return importTranscript(inputPath, dir, args.json); } - // ── Transcribe mode: run whisper ───────────────────────────────────── + // ── Transcribe mode: run the ASR engine ────────────────────────────── return transcribeAudio(inputPath, dir, { + engine: args.engine, model: args.model, language: args.language, json: args.json, @@ -213,25 +222,45 @@ async function exportTranscript( // Transcribe audio/video with whisper // --------------------------------------------------------------------------- +// fallow-ignore-next-line complexity async function transcribeAudio( inputPath: string, dir: string, - opts: { model?: string; language?: string; json?: boolean; optional?: boolean }, + opts: { engine?: string; model?: string; language?: string; json?: boolean; optional?: boolean }, ): Promise { const { transcribe } = await import("../whisper/transcribe.js"); const { loadTranscript, patchCaptionHtml, stripBeforeOnset } = await import("../whisper/normalize.js"); + // Engine: auto (Parakeet if installed, else whisper), or forced parakeet/whisper. + const engine = (opts.engine ?? "auto").toLowerCase(); + if (engine !== "auto" && engine !== "parakeet" && engine !== "whisper") { + failWith(`Unknown --engine: ${opts.engine}. Use auto, parakeet, or whisper.`, !!opts.json); + } + const useParakeet = engine === "parakeet" || (engine === "auto" && !!findParakeet()); + const model = opts.model ?? DEFAULT_MODEL; + // --model selects the whisper model only; Parakeet uses its own fixed model. + if (useParakeet && opts.model && !opts.json) { + console.error( + c.dim(` Note: --model applies to the whisper engine only; ignored under Parakeet.`), + ); + } + const label = useParakeet ? "Parakeet" : model; const spin = opts.json ? null : clack.spinner(); - spin?.start(`Transcribing with ${c.accent(model)}...`); + spin?.start(`Transcribing with ${c.accent(label)}...`); try { - const result = await transcribe(inputPath, dir, { - model, - language: opts.language, - onProgress: spin ? (msg) => spin.message(msg) : undefined, - }); + const result = useParakeet + ? transcribeWithParakeet(inputPath, dir, { + language: opts.language, + onProgress: spin ? (msg) => spin.message(msg) : undefined, + }) + : await transcribe(inputPath, dir, { + model, + language: opts.language, + onProgress: spin ? (msg) => spin.message(msg) : undefined, + }); let { words } = loadTranscript(result.transcriptPath); @@ -253,7 +282,8 @@ async function transcribeAudio( console.log( JSON.stringify({ ok: true, - model, + engine: useParakeet ? "parakeet" : "whisper", + model: useParakeet ? "parakeet-tdt-0.6b-v3" : model, wordCount: words.length, durationSeconds: result.durationSeconds, speechOnsetSeconds: result.speechOnsetSeconds, @@ -272,7 +302,15 @@ async function transcribeAudio( ); } } catch (err) { - const message = err instanceof Error ? err.message : String(err); + // Surface the last few lines of the ASR subprocess's stderr, which + // execFileSync captures but otherwise drops on the floor — that's where + // parakeet-mlx / whisper report the actual failure cause. + const stderr = + err && typeof err === "object" && "stderr" in err && err.stderr + ? String(err.stderr).trim().split("\n").slice(-3).join("\n") + : ""; + const base = err instanceof Error ? err.message : String(err); + const message = stderr ? `${base}\n${stderr}` : base; // whisper-cpp is an optional prerequisite, not part of the CLI. When it is // simply unavailable (no binary, no toolchain to build one), that is a setup diff --git a/packages/cli/src/commands/tts.ts b/packages/cli/src/commands/tts.ts index bf0bc03c1b..8ad0d3f412 100644 --- a/packages/cli/src/commands/tts.ts +++ b/packages/cli/src/commands/tts.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; import { existsSync, readFileSync } from "node:fs"; @@ -48,7 +49,7 @@ export default defineCommand({ output: { type: "string", description: "Output file path (default: speech.wav in current directory)", - alias: "o", + alias: ["o", "out"], }, voice: { type: "string", @@ -76,6 +77,7 @@ export default defineCommand({ default: false, }, }, + // fallow-ignore-next-line complexity async run({ args }) { // ── List voices mode ────────────────────────────────────────────── if (args.list) { diff --git a/packages/cli/src/templates/_shared/AGENTS.md b/packages/cli/src/templates/_shared/AGENTS.md index 72183329f0..b72d7e5e6c 100644 --- a/packages/cli/src/templates/_shared/AGENTS.md +++ b/packages/cli/src/templates/_shared/AGENTS.md @@ -17,7 +17,7 @@ **Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above. -The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-creative`, `/hyperframes-cli`, `/hyperframes-media`, `/hyperframes-registry`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent. +The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent. > **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`. diff --git a/packages/cli/src/templates/_shared/CLAUDE.md b/packages/cli/src/templates/_shared/CLAUDE.md index 72183329f0..b72d7e5e6c 100644 --- a/packages/cli/src/templates/_shared/CLAUDE.md +++ b/packages/cli/src/templates/_shared/CLAUDE.md @@ -17,7 +17,7 @@ **Porting an existing composition?** `/remotion-to-hyperframes` translates a Remotion (React) composition into HyperFrames HTML — a source migration, separate from the creation workflows above. -The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-creative`, `/hyperframes-cli`, `/hyperframes-media`, `/hyperframes-registry`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent. +The domain skills (`/hyperframes-core`, `/hyperframes-animation`, `/hyperframes-creative`, `/hyperframes-cli`, `/media-use`, `/hyperframes-registry`) and the full capability map live inside `/hyperframes` — it is the single source of truth for which skill handles which intent. > **Tailwind v4 projects** (`hyperframes init --tailwind`): see `/hyperframes-core` → `references/tailwind.md`. diff --git a/packages/cli/src/tts/python.ts b/packages/cli/src/tts/python.ts index 13e6111d40..5c849b06a5 100644 --- a/packages/cli/src/tts/python.ts +++ b/packages/cli/src/tts/python.ts @@ -8,8 +8,21 @@ import { execFileSync } from "node:child_process"; -/** Locate a `python3` (or `python`) on PATH that reports as Python 3. */ +/** Locate a Python 3: `HYPERFRAMES_PYTHON` env override first, then PATH. */ export function findPython(): string | undefined { + const override = process.env.HYPERFRAMES_PYTHON; + if (override) { + try { + const version = execFileSync(override, ["--version"], { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + timeout: 5000, + }); + if (/Python 3/.test(version)) return override; + } catch { + // fall through to the PATH probe + } + } for (const name of ["python3", "python"]) { try { const cmd = process.platform === "win32" ? "where" : "which"; diff --git a/packages/cli/src/tts/synthesize.ts b/packages/cli/src/tts/synthesize.ts index cc887b81f2..fefdc52c88 100644 --- a/packages/cli/src/tts/synthesize.ts +++ b/packages/cli/src/tts/synthesize.ts @@ -110,6 +110,7 @@ export interface SynthesizeResult { /** * Synthesize text to speech using Kokoro-82M via kokoro-onnx. */ +// fallow-ignore-next-line complexity export async function synthesize( text: string, outputPath: string, @@ -124,13 +125,13 @@ export async function synthesize( const python = findPython(); if (!python) { throw new Error( - "Python 3 is required for text-to-speech. Install Python 3.8+ and run: pip install kokoro-onnx soundfile", + "Python 3 is required for text-to-speech. Install Python 3.10+ and run: pip install kokoro-onnx soundfile (or point HYPERFRAMES_PYTHON at a venv python that has them)", ); } if (!hasPythonPackage(python, "kokoro_onnx")) { throw new Error( - "The kokoro-onnx package is not installed. Run: pip install kokoro-onnx soundfile", + "The kokoro-onnx package is not installed. Run: pip install kokoro-onnx soundfile (or point HYPERFRAMES_PYTHON at a venv python that has them)", ); } diff --git a/packages/cli/src/whisper/parakeet.test.ts b/packages/cli/src/whisper/parakeet.test.ts new file mode 100644 index 0000000000..e707e45208 --- /dev/null +++ b/packages/cli/src/whisper/parakeet.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { mergeTokensToWords } from "./parakeet.js"; + +describe("mergeTokensToWords", () => { + it("joins Parakeet sub-word tokens into words on the space boundary", () => { + const words = mergeTokensToWords({ + text: "Hello everyone. Um,", + sentences: [ + { + tokens: [ + { text: " H", start: 0.0, end: 0.24 }, + { text: "ello", start: 0.24, end: 0.48 }, + { text: " everyone.", start: 0.48, end: 1.28 }, + { text: " Um,", start: 1.28, end: 1.92 }, + ], + }, + ], + }); + expect(words).toEqual([ + { text: "Hello", start: 0.0, end: 0.48 }, + { text: "everyone.", start: 0.48, end: 1.28 }, + { text: "Um,", start: 1.28, end: 1.92 }, + ]); + }); + + it("spans sentences and tolerates missing tokens", () => { + expect(mergeTokensToWords({}).length).toBe(0); + const words = mergeTokensToWords({ + sentences: [ + { tokens: [{ text: "Hi", start: 0, end: 0.2 }] }, + { tokens: [{ text: " there", start: 0.5, end: 0.9 }] }, + ], + }); + expect(words.map((w) => w.text)).toEqual(["Hi", "there"]); + expect(words[1]!.start).toBe(0.5); + }); +}); diff --git a/packages/cli/src/whisper/parakeet.ts b/packages/cli/src/whisper/parakeet.ts new file mode 100644 index 0000000000..a53c18bfa7 --- /dev/null +++ b/packages/cli/src/whisper/parakeet.ts @@ -0,0 +1,151 @@ +/** + * Parakeet-TDT transcription engine (via parakeet-mlx on Apple Silicon). + * + * The higher-accuracy alternative to the whisper.cpp engine: NVIDIA Parakeet + * beats whisper-large-v3 on the Open ASR Leaderboard (~6.05% vs 7.44% avg WER, + * and 4.73% vs 5.96% on noisy audio where whisper-v3 hallucinates), while being + * 5-10x faster. Covers English + 25 European languages; whisper stays the + * multilingual fallback. + * + * Like the Kokoro TTS path, this is a user-installed local model: we DETECT it + * and, if absent, tell the user how to enable it (no auto-install). parakeet-mlx + * emits sub-word TOKENS; we merge them into the word timestamps the rest of the + * pipeline consumes. + */ + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { homedir, tmpdir } from "node:os"; +import { basename, extname, join } from "node:path"; +import type { Word } from "./normalize.js"; +import type { TranscribeResult } from "./transcribe.js"; + +const DEFAULT_MODEL = "mlx-community/parakeet-tdt-0.6b-v3"; +const PARAKEET_INSTALL = + "uv venv ~/.venvs/parakeet && VIRTUAL_ENV=~/.venvs/parakeet uv pip install parakeet-mlx"; + +/** Verify a candidate binary actually runs (mirrors the --version gate on + * HYPERFRAMES_PYTHON) so a stale $HYPERFRAMES_PARAKEET path can't shadow a + * working install on PATH. */ +function isRunnable(bin: string): boolean { + try { + execFileSync(bin, ["--help"], { stdio: ["ignore", "ignore", "ignore"], timeout: 10000 }); + return true; + } catch { + return false; + } +} + +/** Locate the `parakeet-mlx` runner: env override, the documented venv, then PATH. */ +export function findParakeet(): string | undefined { + const candidates = [ + process.env.HYPERFRAMES_PARAKEET, + join(homedir(), ".venvs", "parakeet", "bin", "parakeet-mlx"), + ].filter((p): p is string => Boolean(p)); + + for (const path of candidates) { + if (existsSync(path) && isRunnable(path)) return path; + } + try { + const which = process.platform === "win32" ? "where" : "which"; + const out = execFileSync(which, ["parakeet-mlx"], { + encoding: "utf-8", + stdio: ["ignore", "pipe", "ignore"], + timeout: 5000, + }); + const first = out + .split(/\r?\n/) + .map((s) => s.trim()) + .find(Boolean); + if (first && existsSync(first)) return first; + } catch { + // not on PATH + } + return undefined; +} + +interface ParakeetToken { + text?: string; + start?: number; + end?: number; +} +interface ParakeetJson { + text?: string; + sentences?: { tokens?: ParakeetToken[] }[]; +} + +/** + * Merge Parakeet's sub-word tokens (" H", "ello", ...) into words on the space + * boundary: a token starting with a space (or the first token) begins a word; + * the rest append. Produces the { text, start, end } words the pipeline uses. + */ +function tokenBounds(token: ParakeetToken): { text: string; start: number; end: number } { + const text = typeof token.text === "string" ? token.text : ""; + const start = typeof token.start === "number" ? token.start : 0; + const end = typeof token.end === "number" ? token.end : start; + return { text, start, end }; +} + +export function mergeTokensToWords(parakeet: ParakeetJson): Word[] { + const words: Word[] = []; + for (const sentence of parakeet.sentences ?? []) { + for (const token of sentence.tokens ?? []) { + const { text, start, end } = tokenBounds(token); + if (text.startsWith(" ") || words.length === 0) { + words.push({ text: text.trim(), start, end }); + } else { + const w = words[words.length - 1]!; + w.text += text; + w.end = end; + } + } + } + return words.filter((w) => w.text.length > 0); +} + +interface ParakeetOptions { + language?: string; + model?: string; + onProgress?: (message: string) => void; +} + +/** Transcribe with Parakeet and write `transcript.json` (Word[]) into `dir`. */ +export function transcribeWithParakeet( + inputPath: string, + dir: string, + options?: ParakeetOptions, +): TranscribeResult { + const runner = findParakeet(); + if (!runner) { + throw new Error( + `parakeet-mlx not found. Enable the Parakeet engine with:\n ${PARAKEET_INSTALL}\n(or use --engine whisper)`, + ); + } + + const model = options?.model ?? DEFAULT_MODEL; + // First run pulls the model from HuggingFace (~600MB) — cue it so the wait + // doesn't read as a hang. HF caches at ~/.cache/huggingface/hub/models--. + const cached = existsSync( + join(homedir(), ".cache", "huggingface", "hub", `models--${model.replace(/\//g, "--")}`), + ); + options?.onProgress?.( + cached ? "Transcribing with Parakeet..." : "Downloading Parakeet model (first run, ~600MB)...", + ); + const workDir = mkdtempSync(join(tmpdir(), "hyperframes-parakeet-")); + try { + const argv = [inputPath, "--model", model, "--output-format", "json", "--output-dir", workDir]; + if (options?.language) argv.push("--language", options.language); + execFileSync(runner, argv, { stdio: ["ignore", "pipe", "pipe"], timeout: 1_800_000 }); + + const produced = join(workDir, `${basename(inputPath, extname(inputPath))}.json`); + if (!existsSync(produced)) throw new Error("Parakeet did not produce output."); + const words = mergeTokensToWords(JSON.parse(readFileSync(produced, "utf-8")) as ParakeetJson); + + const transcriptPath = join(dir, "transcript.json"); + writeFileSync(transcriptPath, JSON.stringify(words, null, 2)); + const durationSeconds = words.length > 0 ? words[words.length - 1]!.end : 0; + return { transcriptPath, wordCount: words.length, durationSeconds, speechOnsetSeconds: null }; + } finally { + rmSync(workDir, { recursive: true, force: true }); + } +} From 4edd01f7dc877d642512478e5e32e0286c1cce8c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sun, 5 Jul 2026 21:47:28 -0400 Subject: [PATCH 2/2] feat(studio): non-destructive crop + cross-project asset view --- packages/core/src/editing/affordances.test.ts | 19 +- packages/core/src/editing/affordances.ts | 13 + packages/studio-server/src/createStudioApi.ts | 2 + .../src/helpers/sourceMutation.test.ts | 11 + .../src/routes/globalAssets.test.ts | 62 +++++ .../studio-server/src/routes/globalAssets.ts | 44 ++++ packages/studio/src/App.tsx | 13 +- .../src/components/StudioPreviewArea.tsx | 8 + .../src/components/StudioRightPanel.tsx | 6 + .../components/editor/DomEditCropHandles.tsx | 238 ++++++++++++++++++ .../src/components/editor/DomEditOverlay.tsx | 213 ++++++++-------- .../components/editor/DomEditRotateHandle.tsx | 41 +++ .../components/editor/PropertyPanel.test.ts | 48 ++++ .../src/components/editor/PropertyPanel.tsx | 39 +-- .../editor/PropertyPanelEmptyState.tsx | 33 +++ .../src/components/editor/SnapToolbar.tsx | 21 +- .../src/components/editor/clipPathHelpers.ts | 113 +++++++++ .../editor/domEditOverlayCrop.test.ts | 106 ++++++++ .../components/editor/domEditOverlayCrop.ts | 115 +++++++++ .../editor/domEditOverlayGeometry.ts | 14 ++ .../components/editor/domEditOverlayShape.ts | 39 +++ .../src/components/editor/domEditingTypes.ts | 3 + .../src/components/editor/marqueeCommit.ts | 5 +- .../components/editor/propertyPanelHelpers.ts | 44 +--- .../editor/propertyPanelStyleSections.tsx | 76 +++++- .../components/editor/propertyPanelTypes.ts | 2 + .../components/editor/snapTargetCollection.ts | 5 +- .../editor/useDomEditCompositionRect.ts | 68 +++++ .../editor/useDomEditOverlayGestures.ts | 19 +- .../editor/useDomEditOverlayRects.ts | 8 +- .../src/components/sidebar/AssetsTab.test.ts | 87 +++++++ .../src/components/sidebar/AssetsTab.tsx | 128 ++++++++-- .../src/components/sidebar/BlocksTab.tsx | 3 +- .../components/sidebar/GlobalAssetsView.tsx | 91 +++++++ .../src/hooks/domSelectionTestHarness.ts | 1 + packages/studio/src/hooks/useCropMode.ts | 91 +++++++ .../src/hooks/useDomEditCommits.test.tsx | 2 + .../src/player/components/ShortcutsPanel.tsx | 9 + .../studio/src/player/store/playerStore.ts | 14 ++ 39 files changed, 1646 insertions(+), 208 deletions(-) create mode 100644 packages/studio-server/src/routes/globalAssets.test.ts create mode 100644 packages/studio-server/src/routes/globalAssets.ts create mode 100644 packages/studio/src/components/editor/DomEditCropHandles.tsx create mode 100644 packages/studio/src/components/editor/DomEditRotateHandle.tsx create mode 100644 packages/studio/src/components/editor/PropertyPanelEmptyState.tsx create mode 100644 packages/studio/src/components/editor/clipPathHelpers.ts create mode 100644 packages/studio/src/components/editor/domEditOverlayCrop.test.ts create mode 100644 packages/studio/src/components/editor/domEditOverlayCrop.ts create mode 100644 packages/studio/src/components/editor/domEditOverlayShape.ts create mode 100644 packages/studio/src/components/editor/useDomEditCompositionRect.ts create mode 100644 packages/studio/src/components/sidebar/AssetsTab.test.ts create mode 100644 packages/studio/src/components/sidebar/GlobalAssetsView.tsx create mode 100644 packages/studio/src/hooks/useCropMode.ts diff --git a/packages/core/src/editing/affordances.test.ts b/packages/core/src/editing/affordances.test.ts index 1b20070844..4e42434357 100644 --- a/packages/core/src/editing/affordances.test.ts +++ b/packages/core/src/editing/affordances.test.ts @@ -47,9 +47,26 @@ describe("resolveEditingAffordances — capabilities", () => { expect(a.capabilities.reasonIfDisabled).toContain("generated by a script"); }); - it("composition root: edit styles only, no move/resize", () => { + it("composition root: edit styles only, no move/resize, and NOT croppable (it is the canvas)", () => { const a = resolveEditingAffordances(baseFacts({ isCompositionRoot: true })); expect(a.capabilities).toMatchObject({ canEditStyles: true, canMove: false, canResize: false }); + expect(a.capabilities.canCrop).toBe(false); + }); + + it("sub-composition host in master view: styles locked but STILL croppable (viewport clip)", () => { + const a = resolveEditingAffordances(baseFacts({ isCompositionHost: true, isMasterView: true })); + expect(a.capabilities.canEditStyles).toBe(false); // internals are edited by drilling in + expect(a.capabilities.canCrop).toBe(true); // crop is a viewport clip on the host in the parent source + }); + + it("script-generated / locked elements are not croppable", () => { + expect( + resolveEditingAffordances(baseFacts({ existsInSource: false })).capabilities.canCrop, + ).toBe(false); + expect( + resolveEditingAffordances(baseFacts({ isInsideLockedComposition: true })).capabilities + .canCrop, + ).toBe(false); }); it("absolute + left/top + identity transform: canMove", () => { diff --git a/packages/core/src/editing/affordances.ts b/packages/core/src/editing/affordances.ts index ecf77c37bf..d430844c05 100644 --- a/packages/core/src/editing/affordances.ts +++ b/packages/core/src/editing/affordances.ts @@ -9,6 +9,11 @@ export interface DomEditCapabilities { canSelect: boolean; canEditStyles: boolean; + /** Can take a non-destructive `clip-path: inset()` crop. Broader than + * canEditStyles: a sub-composition host can be cropped from the parent view + * (the crop is a viewport clip that persists on the host in the parent + * source), even though its internal styles are edited by drilling in. */ + canCrop: boolean; /** Directly editable authored left/top style fields. Canvas drag uses manual edits instead. */ canMove: boolean; /** Directly editable authored width/height style fields. Canvas resize uses manual edits instead. */ @@ -104,6 +109,7 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities { return { canSelect: !facts.isInsideLockedComposition, canEditStyles: false, + canCrop: false, canMove: false, canResize: false, canApplyManualOffset: false, @@ -119,6 +125,7 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities { return { canSelect: true, canEditStyles: false, + canCrop: false, canMove: false, canResize: false, canApplyManualOffset: false, @@ -132,6 +139,7 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities { return { canSelect: true, canEditStyles: true, + canCrop: false, // the root defines the canvas/preview bounds — nothing to crop against canMove: false, canResize: false, canApplyManualOffset: false, @@ -161,10 +169,15 @@ function resolveCapabilities(facts: EditableElementFacts): DomEditCapabilities { : "Select an internal layer to transform it."; const canEditStyles = !(facts.isCompositionHost && facts.isMasterView); + // Crop is broader than style editing: a sub-composition host CAN be cropped + // from the parent view (a viewport clip persisted on the host in the parent + // source), even though its internal styles are edited by drilling in. + const canCrop = true; return { canSelect: true, canEditStyles, + canCrop, canMove, canResize, canApplyManualOffset: canApplyManualGeometry, diff --git a/packages/studio-server/src/createStudioApi.ts b/packages/studio-server/src/createStudioApi.ts index 1b1fecb510..5976ced33a 100644 --- a/packages/studio-server/src/createStudioApi.ts +++ b/packages/studio-server/src/createStudioApi.ts @@ -12,6 +12,7 @@ import { registerFontRoutes } from "./routes/fonts.js"; import { registerRegistryRoutes } from "./routes/registry.js"; import { registerSelectionRoutes } from "./routes/selection.js"; import { registerMediaRoutes } from "./routes/media.js"; +import { registerGlobalAssetRoutes } from "./routes/globalAssets.js"; /** * Create a Hono sub-app with all studio API routes. @@ -34,6 +35,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerWaveformRoutes(api, adapter); registerFontRoutes(api); registerRegistryRoutes(api, adapter); + registerGlobalAssetRoutes(api); return api; } diff --git a/packages/studio-server/src/helpers/sourceMutation.test.ts b/packages/studio-server/src/helpers/sourceMutation.test.ts index 28925cadbd..476fceac26 100644 --- a/packages/studio-server/src/helpers/sourceMutation.test.ts +++ b/packages/studio-server/src/helpers/sourceMutation.test.ts @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { parseHTML } from "linkedom"; import { describe, expect, it } from "vitest"; import { @@ -56,6 +57,16 @@ describe("patchElementInHtml", () => { expect(result).toContain('id="hero"'); }); + it("patches a 4-side clip-path inset inline style", () => { + const { html: result, matched } = patchElementInHtml(FIXTURE, { id: "hero" }, [ + { type: "inline-style", property: "clip-path", value: "inset(10px 20px 30px 40px)" }, + ]); + + expect(matched).toBe(true); + expect(result).toMatch(/clip-path:\s*inset\(10px 20px 30px 40px\)/); + expect(result).toContain('id="hero"'); + }); + it("patches inline style by class selector", () => { const { html: result } = patchElementInHtml(FIXTURE, { selector: ".hero-heading" }, [ { type: "inline-style", property: "font-size", value: "72px" }, diff --git a/packages/studio-server/src/routes/globalAssets.test.ts b/packages/studio-server/src/routes/globalAssets.test.ts new file mode 100644 index 0000000000..2d39b00017 --- /dev/null +++ b/packages/studio-server/src/routes/globalAssets.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { readGlobalAssets, toPublicAsset } from "./globalAssets"; + +describe("readGlobalAssets", () => { + let home: string; + beforeEach(() => { + home = mkdtempSync(join(tmpdir(), "mu-global-")); + }); + afterEach(() => rmSync(home, { recursive: true, force: true })); + + function writeManifest(lines: string[]) { + mkdirSync(join(home, ".media"), { recursive: true }); + writeFileSync(join(home, ".media", "manifest.jsonl"), lines.join("\n")); + } + + it("returns [] when there is no global manifest", () => { + expect(readGlobalAssets(home)).toEqual([]); + }); + + it("returns only reusable records", () => { + writeManifest([ + JSON.stringify({ id: "bgm_001", type: "bgm", reusable: true, sha: "a" }), + JSON.stringify({ id: "tmp_001", type: "sfx", reusable: false, sha: "b" }), + ]); + const assets = readGlobalAssets(home); + expect(assets.map((a) => a.id)).toEqual(["bgm_001"]); + }); + + it("skips malformed lines instead of throwing (torn write)", () => { + writeManifest([ + JSON.stringify({ id: "bgm_001", reusable: true }), + "{ not valid json", + "", + JSON.stringify({ id: "img_001", reusable: true }), + ]); + expect(readGlobalAssets(home).map((a) => a.id)).toEqual(["bgm_001", "img_001"]); + }); +}); + +describe("toPublicAsset", () => { + it("drops the absolute cached_path before it reaches the browser (m13)", () => { + const pub = toPublicAsset({ + id: "bgm_001", + type: "bgm", + description: "calm", + sha: "abc", + entity: "Acme", + cached_path: "/Users/someone/.media/bgm/bgm_001.mp3", + }); + expect(pub).toEqual({ + id: "bgm_001", + type: "bgm", + description: "calm", + sha: "abc", + entity: "Acme", + }); + expect("cached_path" in pub).toBe(false); + }); +}); diff --git a/packages/studio-server/src/routes/globalAssets.ts b/packages/studio-server/src/routes/globalAssets.ts new file mode 100644 index 0000000000..b7eef0c8ea --- /dev/null +++ b/packages/studio-server/src/routes/globalAssets.ts @@ -0,0 +1,44 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import type { Hono } from "hono"; + +// Non-project-scoped route: the media-use global asset cache (~/.media). Lets the +// Studio Asset tab show assets resolved in OTHER projects (cross-project reuse). +// Reads ONLY the global manifest — no path params, no arbitrary fs access. + +export interface GlobalAssetRecord { + id?: string; + type?: string; + description?: string; + sha?: string; + cached_path?: string; + entity?: string; +} + +/** Parse the global manifest (~/.media/manifest.jsonl) into reusable records. */ +export function readGlobalAssets(home = homedir()): GlobalAssetRecord[] { + const manifestPath = join(home, ".media", "manifest.jsonl"); + if (!existsSync(manifestPath)) return []; + const out: GlobalAssetRecord[] = []; + for (const line of readFileSync(manifestPath, "utf8").split("\n")) { + if (!line.trim()) continue; + try { + const rec = JSON.parse(line); + if (rec && rec.reusable) out.push(rec); + } catch { + // skip malformed lines — a torn write shouldn't 500 the panel + } + } + return out; +} + +// Fields the Studio panel actually renders. Deliberately omits cached_path — +// an absolute ~/.media filesystem path has no business reaching the browser (m13). +export function toPublicAsset(r: GlobalAssetRecord): GlobalAssetRecord { + return { id: r.id, type: r.type, description: r.description, sha: r.sha, entity: r.entity }; +} + +export function registerGlobalAssetRoutes(api: Hono): void { + api.get("/assets/global", (c) => c.json({ assets: readGlobalAssets().map(toPublicAsset) })); +} diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index 625bda1a91..1a61f880d7 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -37,6 +37,7 @@ import { import type { DomEditSelection } from "./components/editor/domEditing"; import { StudioHeader } from "./components/StudioHeader"; import { useGestureCommit } from "./hooks/useGestureCommit"; +import { useCropModeProps } from "./hooks/useCropMode"; import { STUDIO_KEYFRAMES_ENABLED } from "./components/editor/manualEditingAvailability"; import { GestureTrailOverlay } from "./components/editor/GestureTrailOverlay"; import { StudioLeftSidebar } from "./components/StudioLeftSidebar"; @@ -82,6 +83,7 @@ export function StudioApp() { const [refreshKey, setRefreshKey] = useState(0); const [previewDocumentVersion, setPreviewDocumentVersion] = useState(0); const [blockPreview, setBlockPreview] = useState(null); + const cropModeProps = useCropModeProps(); const previewIframeRef = useRef(null); const activeCompPathRef = useRef(activeCompPath); activeCompPathRef.current = activeCompPath; @@ -369,6 +371,7 @@ export function StudioApp() { isGestureRecordingRef, }); handleToggleRecordingRef.current = handleToggleRecording; + const recordingToggle = STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined; const canvasRectRef = useRef(null); useLayoutEffect(() => { if (gestureState !== "recording" || !previewIframe) { @@ -537,10 +540,9 @@ export function StudioApp() { shouldShowSelectedDomBounds={shouldShowSelectedDomBounds} isGestureRecording={gestureState === "recording"} recordingState={gestureState} - onToggleRecording={ - STUDIO_KEYFRAMES_ENABLED ? handleToggleRecording : undefined - } + onToggleRecording={recordingToggle} blockPreview={blockPreview} + {...cropModeProps} gestureOverlay={ gestureState === "recording" && previewIframe ? ( )} diff --git a/packages/studio/src/components/StudioPreviewArea.tsx b/packages/studio/src/components/StudioPreviewArea.tsx index f4f4131857..9b90b8dc19 100644 --- a/packages/studio/src/components/StudioPreviewArea.tsx +++ b/packages/studio/src/components/StudioPreviewArea.tsx @@ -69,6 +69,8 @@ export interface StudioPreviewAreaProps { isGestureRecording?: boolean; recordingState?: GestureRecordingState; onToggleRecording?: () => void; + cropMode?: boolean; + onCropModeChange?: (active: boolean) => void; gestureOverlay?: ReactNode; } @@ -93,6 +95,8 @@ export function StudioPreviewArea({ isGestureRecording, recordingState, onToggleRecording, + cropMode, + onCropModeChange, blockPreview, gestureOverlay, }: StudioPreviewAreaProps) { @@ -132,6 +136,7 @@ export function StudioPreviewArea({ handleDomGroupPathOffsetCommit, handleDomBoxSizeCommit, handleDomRotationCommit, + handleDomStyleCommit, handleGsapRemoveKeyframe, handleGsapMoveKeyframeToPlayhead, handleGsapMoveKeyframe, @@ -375,6 +380,9 @@ export function StudioPreviewArea({ onGroupPathOffsetCommit={handleDomGroupPathOffsetCommit} onBoxSizeCommit={handleDomBoxSizeCommit} onRotationCommit={handleDomRotationCommit} + onStyleCommit={handleDomStyleCommit} + cropMode={cropMode} + onCropModeChange={onCropModeChange} gridVisible={snapPrefs.gridVisible} gridSpacing={snapPrefs.gridSpacing} recordingState={recordingState} diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 2b5f2a7e9d..dbc446df17 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -51,6 +51,8 @@ export interface StudioRightPanelProps { recordingState?: "idle" | "recording" | "preview"; recordingDuration?: number; onToggleRecording?: () => void; + cropMode?: boolean; + onCropModeChange?: (active: boolean) => void; /** Dependencies for the Slideshow persist callback, threaded from App.tsx. */ sdkSession: Composition | null; reloadPreview: () => void; @@ -70,6 +72,8 @@ export function StudioRightPanel({ recordingState, recordingDuration, onToggleRecording, + cropMode, + onCropModeChange, sdkSession, reloadPreview, domEditSaveTimestampRef, @@ -393,6 +397,8 @@ export function StudioRightPanel({ recordingState={recordingState} recordingDuration={recordingDuration} onToggleRecording={onToggleRecording} + cropMode={cropMode} + onCropModeChange={onCropModeChange} /> ); diff --git a/packages/studio/src/components/editor/DomEditCropHandles.tsx b/packages/studio/src/components/editor/DomEditCropHandles.tsx new file mode 100644 index 0000000000..3e4c9c01c4 --- /dev/null +++ b/packages/studio/src/components/editor/DomEditCropHandles.tsx @@ -0,0 +1,238 @@ +import { useEffect, useRef, useState, type PointerEvent as ReactPointerEvent } from "react"; +import type { DomEditSelection } from "./domEditing"; +import type { OverlayRect } from "./domEditOverlayGeometry"; +import { + type CropEdge, + cropRectFromInsets, + readElementCropInsets, + resolveCropInsetFromEdgeDrag, + resolveCropInsetFromMoveDrag, +} from "./domEditOverlayCrop"; +import { buildInsetClipPathSides, type ClipPathInsetSides } from "./clipPathHelpers"; + +interface CropGestureState { + edge: CropEdge | "move"; + pointerId: number; + startX: number; + startY: number; + startInsets: ClipPathInsetSides; + didMove: boolean; +} + +interface DomEditCropHandlesProps { + selection: DomEditSelection; + overlayRect: OverlayRect; + onStyleCommit?: (property: string, value: string) => Promise | void; +} + +function handleCenter( + edge: CropEdge, + rect: { left: number; top: number; width: number; height: number }, +) { + if (edge === "top") return { left: rect.left + rect.width / 2, top: rect.top }; + if (edge === "right") return { left: rect.left + rect.width, top: rect.top + rect.height / 2 }; + if (edge === "bottom") return { left: rect.left + rect.width / 2, top: rect.top + rect.height }; + return { left: rect.left, top: rect.top + rect.height / 2 }; +} + +const EDGES: CropEdge[] = ["top", "right", "bottom", "left"]; + +/** + * Pro-editor crop: while crop mode is active the element's clip is lifted so + * the FULL content stays visible; the cropped-out region is dimmed and the + * edge handles sit on the crop lines. Dragging updates the crop live; release + * commits `clip-path: inset(...)` through the normal style-commit path (one + * undo step per drag). Leaving crop mode re-applies the committed crop. + */ +export function DomEditCropHandles({ + selection, + overlayRect, + onStyleCommit, +}: DomEditCropHandlesProps) { + const gestureRef = useRef(null); + const [state, setState] = useState(() => { + const parsed = readElementCropInsets(selection.element); + return { + element: selection.element, + insets: { + top: parsed.top, + right: parsed.right, + bottom: parsed.bottom, + left: parsed.left, + } as ClipPathInsetSides, + radius: parsed.radius, + }; + }); + + // Re-sync when the selection element changes (reselect, undo/redo reload). + if (state.element !== selection.element) { + const parsed = readElementCropInsets(selection.element); + setState({ + element: selection.element, + insets: { top: parsed.top, right: parsed.right, bottom: parsed.bottom, left: parsed.left }, + radius: parsed.radius, + }); + } + + // The value to re-apply when crop mode ends (latest committed crop). + const committedRef = useRef(null); + { + const hasCrop = + state.insets.top > 0 || + state.insets.right > 0 || + state.insets.bottom > 0 || + state.insets.left > 0; + committedRef.current = hasCrop ? buildInsetClipPathSides(state.insets, state.radius) : null; + } + + // Lift the clip while crop mode is active so the full content shows through + // the dim; restore the committed crop on exit/unmount. + const liftedRef = useRef(false); + useEffect(() => { + const el = selection.element; + el.style.setProperty("clip-path", "none"); + liftedRef.current = true; + return () => { + liftedRef.current = false; + if (committedRef.current) el.style.setProperty("clip-path", committedRef.current); + else el.style.removeProperty("clip-path"); + }; + }, [selection.element]); + + const scaleX = overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1; + const scaleY = overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1; + const width = overlayRect.width / scaleX; + const height = overlayRect.height / scaleY; + const cropRect = cropRectFromInsets(overlayRect, state.insets, scaleX, scaleY); + + const startCropGesture = (edge: CropEdge | "move", event: ReactPointerEvent) => { + if (!onStyleCommit) return; + event.preventDefault(); + event.stopPropagation(); + event.currentTarget.setPointerCapture(event.pointerId); + gestureRef.current = { + edge, + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + startInsets: state.insets, + didMove: false, + }; + }; + + const updateCropGesture = (event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + const drag = { + startInsets: gesture.startInsets, + deltaX: event.clientX - gesture.startX, + deltaY: event.clientY - gesture.startY, + scaleX, + scaleY, + }; + const nextInsets = + gesture.edge === "move" + ? resolveCropInsetFromMoveDrag(drag) + : resolveCropInsetFromEdgeDrag({ ...drag, edge: gesture.edge, width, height }); + gesture.didMove = true; + setState((prev) => ({ ...prev, insets: nextInsets })); + }; + + const finishCropGesture = (event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + gestureRef.current = null; + if (!gesture.didMove) return; + // Commit to the file; the commit path re-applies the value to the live + // element, so lift it back to "none" afterwards — full content + dim is + // the crop-mode presentation. + const el = selection.element; + void Promise.resolve( + onStyleCommit?.("clip-path", buildInsetClipPathSides(state.insets, state.radius)), + ).then(() => { + if (liftedRef.current) el.style.setProperty("clip-path", "none"); + }); + }; + + const cancelCropGesture = (event: ReactPointerEvent) => { + const gesture = gestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + setState((prev) => ({ ...prev, insets: gesture.startInsets })); + gestureRef.current = null; + }; + + return ( + <> + {/* Dim everything of the element outside the crop region. */} +
+
+
+ {/* Crop frame — drag it to move the whole crop window. */} +
startCropGesture("move", event)} + onPointerMove={updateCropGesture} + onPointerUp={finishCropGesture} + onPointerCancel={cancelCropGesture} + /> + {EDGES.map((edge) => { + const center = handleCenter(edge, cropRect); + const vertical = edge === "left" || edge === "right"; + return ( +
+ /> )}
{ + if (cropMode) { + e.preventDefault(); + e.stopPropagation(); + return; + } if (!allowCanvasMovement || e.shiftKey) return; + const now = Date.now(); + const isDoubleClick = now - lastBoxPointerDownAtRef.current < 400; + lastBoxPointerDownAtRef.current = now; + if (isDoubleClick && onCropModeChange && selection.capabilities.canCrop) { + lastBoxPointerDownAtRef.current = 0; + e.preventDefault(); + e.stopPropagation(); + onCropModeChange(true); + return; + } if (selection.capabilities.canApplyManualOffset) { gestures.startGesture("drag", e); return; @@ -540,10 +518,28 @@ export const DomEditOverlay = memo(function DomEditOverlay({ onMouseDown={suppressBoxMouseDown} onClick={handleBoxClick} > - {allowCanvasMovement && selection.capabilities.canApplyManualSize && ( + {cropOutlineInsetPx && ( +
+ )} + {allowCanvasMovement && !cropMode && selection.capabilities.canApplyManualSize && (
{ e.stopPropagation(); gestures.startGesture("resize", e); @@ -551,6 +547,13 @@ export const DomEditOverlay = memo(function DomEditOverlay({ /> )}
+ {cropMode && ( + + )} )} {childRects.length > 0 && diff --git a/packages/studio/src/components/editor/DomEditRotateHandle.tsx b/packages/studio/src/components/editor/DomEditRotateHandle.tsx new file mode 100644 index 0000000000..846539c50c --- /dev/null +++ b/packages/studio/src/components/editor/DomEditRotateHandle.tsx @@ -0,0 +1,41 @@ +import type { PointerEvent as ReactPointerEvent } from "react"; +import type { OverlayRect } from "./domEditOverlayGeometry"; + +/** Rotate grab-handle above the selection. Anchors to the crop outline when + * the element is cropped so it stays next to what's visible on screen. */ +export function DomEditRotateHandle({ + overlayRect, + cropOutlineInsetPx, + onStartRotate, +}: { + overlayRect: OverlayRect; + cropOutlineInsetPx?: { top: number; right: number; bottom: number; left: number }; + onStartRotate: (e: ReactPointerEvent) => void; +}) { + const inset = cropOutlineInsetPx ?? { top: 0, right: 0, bottom: 0, left: 0 }; + const visibleLeft = overlayRect.left + inset.left; + const visibleWidth = Math.max(0, overlayRect.width - inset.left - inset.right); + const visibleTop = overlayRect.top + inset.top; + return ( +
+
+
+ ); +} diff --git a/packages/studio/src/components/editor/PropertyPanel.test.ts b/packages/studio/src/components/editor/PropertyPanel.test.ts index d3c2939faf..12c5747175 100644 --- a/packages/studio/src/components/editor/PropertyPanel.test.ts +++ b/packages/studio/src/components/editor/PropertyPanel.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + buildInsetClipPathSides, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, getClipPathInsetPx, @@ -7,6 +8,7 @@ import { inferBoxShadowPreset, inferClipPathPreset, normalizePanelPxValue, + parseInsetClipPathSides, setCssFilterFunctionPx, } from "./PropertyPanel"; @@ -49,6 +51,52 @@ describe("PropertyPanel style helpers", () => { expect(getClipPathInsetPx("circle(50% at 50% 50%)")).toBe(0); }); + it("builds and parses 4-side inset clip paths without losing radius", () => { + expect(buildInsetClipPathSides({ top: 10, right: 20, bottom: 30, left: 40 }, 6)).toBe( + "inset(10px 20px 30px 40px round 6px)", + ); + expect(parseInsetClipPathSides("inset(10px 20px 30px 40px round 6px)")).toEqual({ + top: 10, + right: 20, + bottom: 30, + left: 40, + radius: 6, + }); + }); + + it("emits the single-value inset form when all sides are equal", () => { + expect(buildInsetClipPathSides({ top: 12.5, right: 12.5, bottom: 12.5, left: 12.5 })).toBe( + "inset(12.5px)", + ); + expect(parseInsetClipPathSides("inset(12.5px)")).toEqual({ + top: 12.5, + right: 12.5, + bottom: 12.5, + left: 12.5, + radius: 0, + }); + expect(getClipPathInsetPx("inset(12.5px 12.5px 12.5px 12.5px)")).toBe(12.5); + }); + + it("accepts CSS shorthand inset values and rejects unsupported clip paths", () => { + expect(parseInsetClipPathSides("inset(10px 20px)")).toEqual({ + top: 10, + right: 20, + bottom: 10, + left: 20, + radius: 0, + }); + expect(parseInsetClipPathSides("inset(10px 20px 30px)")).toEqual({ + top: 10, + right: 20, + bottom: 30, + left: 20, + radius: 0, + }); + expect(parseInsetClipPathSides("inset(10%)")).toBeNull(); + expect(parseInsetClipPathSides("circle(50% at 50% 50%)")).toBeNull(); + }); + it("keeps stroke width and style edits visually effective", () => { expect(buildStrokeWidthStyleUpdates("3px", "none")).toEqual([ ["border-width", "3px"], diff --git a/packages/studio/src/components/editor/PropertyPanel.tsx b/packages/studio/src/components/editor/PropertyPanel.tsx index 972fe04547..1d3eaa4121 100644 --- a/packages/studio/src/components/editor/PropertyPanel.tsx +++ b/packages/studio/src/components/editor/PropertyPanel.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useMemo, useRef, useState } from "react"; -import { Eye, Layers, Move } from "../../icons/SystemIcons"; +import { Move } from "../../icons/SystemIcons"; import { InspectorHeaderActions } from "./InspectorHeaderActions"; import { useStudioShellContext } from "../../contexts/StudioContext"; import { readStudioBoxSize, readStudioPathOffset, readStudioRotation } from "./manualEdits"; @@ -27,9 +27,11 @@ import { usePlayerStore, liveTime } from "../../player"; import { TimingSection } from "./propertyPanelTimingSection"; import { type PropertyPanelProps } from "./propertyPanelHelpers"; import { GestureRecordPanelButton } from "./GestureRecordControl"; +import { PropertyPanelEmptyState } from "./PropertyPanelEmptyState"; // Re-export helpers that external consumers import from this module export { + buildInsetClipPathSides, buildStrokeStyleUpdates, buildStrokeWidthStyleUpdates, getCssFilterFunctionPx, @@ -37,6 +39,7 @@ export { inferBoxShadowPreset, inferClipPathPreset, normalizePanelPxValue, + parseInsetClipPathSides, setCssFilterFunctionPx, } from "./propertyPanelHelpers"; @@ -94,6 +97,8 @@ export const PropertyPanel = memo(function PropertyPanel({ recordingState, recordingDuration, onToggleRecording, + cropMode, + onCropModeChange, }: PropertyPanelProps) { const styles = element?.computedStyles ?? EMPTY_STYLES; const { showToast } = useStudioShellContext(); @@ -160,35 +165,7 @@ export const PropertyPanel = memo(function PropertyPanel({ }; if (!element) { - return ( -
-
- {multiSelectCount > 1 ? ( - <> - -

- {multiSelectCount} elements selected -

-

- Select a single element to edit its properties. Click an element in the preview or - use the timeline layer panel. -

- - ) : ( - <> - -

- Select an element in the preview. -

-

- The inspector is tuned for element edits with safer geometry controls, color - picking, and cleaner grouped layer controls. -

- - )} -
-
- ); + return ; } const manualOffsetEditingDisabled = !element.capabilities.canApplyManualOffset; @@ -581,6 +558,8 @@ export const PropertyPanel = memo(function PropertyPanel({ onSetStyle={onSetStyle} onImportAssets={onImportAssets} gsapBorderRadius={gsapBorderRadius} + cropMode={cropMode} + onCropModeChange={onCropModeChange} /> )}
diff --git a/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx new file mode 100644 index 0000000000..7070a9e366 --- /dev/null +++ b/packages/studio/src/components/editor/PropertyPanelEmptyState.tsx @@ -0,0 +1,33 @@ +import { Eye, Layers } from "../../icons/SystemIcons"; + +export function PropertyPanelEmptyState({ multiSelectCount }: { multiSelectCount: number }) { + return ( +
+
+ {multiSelectCount > 1 ? ( + <> + +

+ {multiSelectCount} elements selected +

+

+ Select a single element to edit its properties. Click an element in the preview or use + the timeline layer panel. +

+ + ) : ( + <> + +

+ Select an element in the preview. +

+

+ The inspector is tuned for element edits with safer geometry controls, color picking, + and cleaner grouped layer controls. +

+ + )} +
+
+ ); +} diff --git a/packages/studio/src/components/editor/SnapToolbar.tsx b/packages/studio/src/components/editor/SnapToolbar.tsx index cd406c2faf..683d4df56e 100644 --- a/packages/studio/src/components/editor/SnapToolbar.tsx +++ b/packages/studio/src/components/editor/SnapToolbar.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useEffect, useRef, useState } from "react"; -import { MagnetStraight, GridFour, Path } from "@phosphor-icons/react"; +import { Crop, MagnetStraight, GridFour, Path } from "@phosphor-icons/react"; import { readStudioUiPreferences, writeStudioUiPreferences } from "../../utils/studioUiPreferences"; import { usePlayerStore } from "../../player/store/playerStore"; @@ -39,6 +39,9 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb const motionPathCreateAvailable = usePlayerStore((s) => s.motionPathCreateAvailable); const motionPathArmed = usePlayerStore((s) => s.motionPathArmed); const setMotionPathArmed = usePlayerStore((s) => s.setMotionPathArmed); + const cropAvailable = usePlayerStore((s) => s.cropAvailable); + const cropMode = usePlayerStore((s) => s.cropMode); + const setCropMode = usePlayerStore((s) => s.setCropMode); const popoverRef = useRef(null); const gridButtonRef = useRef(null); @@ -99,6 +102,22 @@ export const SnapToolbar = memo(function SnapToolbar({ onSnapChange }: SnapToolb className="absolute top-2 right-2 z-50 flex items-center gap-1" onPointerDown={(e) => e.stopPropagation()} > + {cropAvailable && ( + + )} {motionPathCreateAvailable && (
+ {showClipInsetSides && ( +
+
+ commitClipInsetSide("top", next)} + /> + commitClipInsetSide("right", next)} + /> + commitClipInsetSide("bottom", next)} + /> + commitClipInsetSide("left", next)} + /> +
+
+ )} +
diff --git a/packages/studio/src/components/editor/propertyPanelTypes.ts b/packages/studio/src/components/editor/propertyPanelTypes.ts index cb918cb905..8c07b9d2f0 100644 --- a/packages/studio/src/components/editor/propertyPanelTypes.ts +++ b/packages/studio/src/components/editor/propertyPanelTypes.ts @@ -108,4 +108,6 @@ export interface PropertyPanelProps { recordingState?: "idle" | "recording" | "preview"; recordingDuration?: number; onToggleRecording?: () => void; + cropMode?: boolean; + onCropModeChange?: (active: boolean) => void; } diff --git a/packages/studio/src/components/editor/snapTargetCollection.ts b/packages/studio/src/components/editor/snapTargetCollection.ts index bd9393331e..3988b43cd5 100644 --- a/packages/studio/src/components/editor/snapTargetCollection.ts +++ b/packages/studio/src/components/editor/snapTargetCollection.ts @@ -1,9 +1,8 @@ -// fallow-ignore-file unused-file // fallow-ignore-file code-duplication import type { DomEditSelection } from "./domEditing"; import { isElementVisibleForOverlay, - toOverlayRect, + toVisibleOverlayRect, type OverlayRect, } from "./domEditOverlayGeometry"; import { @@ -106,7 +105,7 @@ export function collectSnapContext(input: { id: string; }> = []; for (let i = 0; i < elements.length; i++) { - const rect = toOverlayRect(input.overlayEl, input.iframe, elements[i]); + const rect = toVisibleOverlayRect(input.overlayEl, input.iframe, elements[i]); if (rect) entries.push({ rect, id: `snap-target-${i}` }); } diff --git a/packages/studio/src/components/editor/useDomEditCompositionRect.ts b/packages/studio/src/components/editor/useDomEditCompositionRect.ts new file mode 100644 index 0000000000..a950f9fff3 --- /dev/null +++ b/packages/studio/src/components/editor/useDomEditCompositionRect.ts @@ -0,0 +1,68 @@ +import { useState, type RefObject } from "react"; +import { useMountEffect } from "../../hooks/useMountEffect"; + +export interface DomEditCompositionRect { + left: number; + top: number; + width: number; + height: number; + scaleX: number; + scaleY: number; +} + +function sameRect(a: DomEditCompositionRect, b: DomEditCompositionRect): boolean { + const d = (k: keyof DomEditCompositionRect) => Math.abs(a[k] - b[k]); + return ( + d("left") < 0.5 && + d("top") < 0.5 && + d("width") < 0.5 && + d("height") < 0.5 && + d("scaleX") < 0.001 && + d("scaleY") < 0.001 + ); +} + +export function useDomEditCompositionRect({ + iframeRef, + overlayRef, +}: { + iframeRef: RefObject; + overlayRef: RefObject; +}): DomEditCompositionRect { + const [compRect, setCompRect] = useState({ + left: 0, + top: 0, + width: 0, + height: 0, + scaleX: 1, + scaleY: 1, + }); + + useMountEffect(() => { + let frame = 0; + // fallow-ignore-next-line complexity + const update = () => { + frame = requestAnimationFrame(update); + const iframe = iframeRef.current; + const overlayEl = overlayRef.current; + if (!iframe || !overlayEl) return; + const iRect = iframe.getBoundingClientRect(); + const oRect = overlayEl.getBoundingClientRect(); + const left = iRect.left - oRect.left; + const top = iRect.top - oRect.top; + if (iRect.width <= 0 || iRect.height <= 0) return; + const doc = iframe.contentDocument; + const root = doc?.querySelector("[data-composition-id]") ?? doc?.documentElement; + const dw = Number.parseFloat(root?.getAttribute("data-width") ?? ""); + const dh = Number.parseFloat(root?.getAttribute("data-height") ?? ""); + const scaleX = dw > 0 ? iRect.width / dw : 1; + const scaleY = dh > 0 ? iRect.height / dh : 1; + const next = { left, top, width: iRect.width, height: iRect.height, scaleX, scaleY }; + setCompRect((prev) => (sameRect(prev, next) ? prev : next)); + }; + frame = requestAnimationFrame(update); + return () => cancelAnimationFrame(frame); + }); + + return compRect; +} diff --git a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts index a67653bda7..bce380b309 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayGestures.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayGestures.ts @@ -46,6 +46,7 @@ import { startGesture as _startGesture, startGroupDrag as _startGroupDrag, } from "./domEditOverlayStartGesture"; +import { hugRectForElement } from "./domEditOverlayCrop"; import { resolveSnapAdjustment, resolveResizeSnapAdjustment, @@ -183,12 +184,18 @@ export function createDomEditOverlayGestureHandlers(opts: UseDomEditOverlayGestu if (g.kind === "drag") { const sc = g.snapContext; if (sc?.snapEnabled && sc.targets.length > 0) { - const movingRect = { - left: g.originLeft, - top: g.originTop, - width: g.originWidth, - height: g.originHeight, - }; + // Snap the element's VISIBLE (crop-hugged) edges, not the full bounds. + const movingRect = hugRectForElement( + { + left: g.originLeft, + top: g.originTop, + width: g.originWidth, + height: g.originHeight, + editScaleX: g.editScaleX, + editScaleY: g.editScaleY, + }, + g.selection.element, + ); const allTargets = sc.compositionTarget ? [...sc.targets, sc.compositionTarget] : sc.targets; diff --git a/packages/studio/src/components/editor/useDomEditOverlayRects.ts b/packages/studio/src/components/editor/useDomEditOverlayRects.ts index df7e12b5fb..70d35db452 100644 --- a/packages/studio/src/components/editor/useDomEditOverlayRects.ts +++ b/packages/studio/src/components/editor/useDomEditOverlayRects.ts @@ -4,6 +4,7 @@ */ import { useRef, useState, type RefObject } from "react"; import { useMountEffect } from "../../hooks/useMountEffect"; +import { hugRectForElement } from "./domEditOverlayCrop"; import { type DomEditSelection, findElementForSelection } from "./domEditing"; import { type GroupOverlayItem, @@ -15,7 +16,7 @@ import { rectsEqual, resolveElementForOverlay, selectionCacheKey, - toOverlayRect, + toVisibleOverlayRect, } from "./domEditOverlayGeometry"; function childRectsEqual(a: OverlayRect[], b: OverlayRect[]): boolean { @@ -164,7 +165,7 @@ export function useDomEditOverlayRects({ for (let i = 0; i < descendants.length; i++) { const child = descendants[i] as HTMLElement; if (!child.getBoundingClientRect) continue; - const r = toOverlayRect(overlayEl, iframe, child); + const r = toVisibleOverlayRect(overlayEl, iframe, child); if (r && r.width > 2 && r.height > 2) nextChildRects.push(r); } if (!childRectsEqual(childRectsRef.current, nextChildRects)) { @@ -203,7 +204,8 @@ export function useDomEditOverlayRects({ if (liveGroupKeys.has(key)) continue; liveGroupKeys.add(key); const el = resolveGroupElement(doc, groupSelection); - const rect = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null; + const base = el ? groupAwareOverlayRect(overlayEl, iframe, el) : null; + const rect = base && el ? { ...base, ...hugRectForElement(base, el) } : base; if (el && rect) nextGroupItems.push({ key, selection: groupSelection, element: el, rect }); } diff --git a/packages/studio/src/components/sidebar/AssetsTab.test.ts b/packages/studio/src/components/sidebar/AssetsTab.test.ts new file mode 100644 index 0000000000..74b5c916e5 --- /dev/null +++ b/packages/studio/src/components/sidebar/AssetsTab.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { filterByUsage, countUsage, deriveUsedPaths } from "./AssetsTab"; +import { globalAssetRows } from "./GlobalAssetsView"; + +const assets = ["bgm.mp3", "logo.png", "orphan.wav"]; +const used = new Set(["bgm.mp3", "logo.png"]); + +describe("filterByUsage", () => { + it("returns everything for 'all'", () => { + expect(filterByUsage(assets, used, "all")).toEqual(assets); + }); + + it("keeps only referenced assets for 'used'", () => { + expect(filterByUsage(assets, used, "used")).toEqual(["bgm.mp3", "logo.png"]); + }); + + it("keeps only unreferenced assets for 'unused'", () => { + expect(filterByUsage(assets, used, "unused")).toEqual(["orphan.wav"]); + }); + + it("treats everything as unused when nothing is referenced", () => { + expect(filterByUsage(assets, new Set(), "used")).toEqual([]); + expect(filterByUsage(assets, new Set(), "unused")).toEqual(assets); + }); +}); + +describe("deriveUsedPaths", () => { + it("matches the asset-list format across every src shape", () => { + const used = deriveUsedPaths([ + { src: "assets/logo.png" }, // raw authored relative path + { src: "/api/projects/demo/preview/assets/bgm.mp3" }, // served form + { src: "./assets/icon.svg" }, // ./-prefixed + { src: "assets/clip.mp4?v=2" }, // cache-busted + {}, // no src — skipped + ]); + expect(used.has("assets/logo.png")).toBe(true); + expect(used.has("assets/bgm.mp3")).toBe(true); + expect(used.has("assets/icon.svg")).toBe(true); + expect(used.has("assets/clip.mp4")).toBe(true); + expect(used.size).toBe(4); + }); + + it("an authored relative src lines up with the asset entry (the live bug class)", () => { + const used = deriveUsedPaths([{ src: "assets/logo.png" }]); + // asset-list entries are project-relative (see serveUrl = preview/${asset}) + expect(filterByUsage(["assets/logo.png", "assets/orphan.wav"], used, "used")).toEqual([ + "assets/logo.png", + ]); + }); +}); + +describe("countUsage", () => { + it("counts used vs unused", () => { + expect(countUsage(assets, used)).toEqual({ used: 2, unused: 1 }); + }); + + it("is all-unused with an empty used set", () => { + expect(countUsage(assets, new Set())).toEqual({ used: 0, unused: 3 }); + }); +}); + +describe("globalAssetRows", () => { + const recs = [ + { id: "bgm_001", type: "bgm", description: "calm ambient" }, + { id: "img_001", type: "image", entity: "Acme" }, + { sha: "abc", type: "sfx" }, + ]; + + it("maps records to display rows with a sensible label", () => { + const rows = globalAssetRows(recs); + expect(rows).toEqual([ + { id: "bgm_001", type: "bgm", label: "calm ambient" }, + { id: "img_001", type: "image", label: "Acme" }, + { id: "abc", type: "sfx", label: "abc" }, + ]); + }); + + it("filters by id / type / description / entity, case-insensitively", () => { + expect(globalAssetRows(recs, "ACME").map((r) => r.id)).toEqual(["img_001"]); + expect(globalAssetRows(recs, "bgm").map((r) => r.id)).toEqual(["bgm_001"]); + expect(globalAssetRows(recs, "ambient").map((r) => r.id)).toEqual(["bgm_001"]); + }); + + it("empty query returns all", () => { + expect(globalAssetRows(recs, " ").length).toBe(3); + }); +}); diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index 137ec33ad5..fb99cc3765 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication import { memo, useState, useCallback, useRef, useMemo, useEffect } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, FONT_EXT } from "../../utils/mediaTypes"; @@ -14,6 +15,7 @@ import { FILTER_ORDER, } from "./assetHelpers"; import { AudioRow } from "./AudioRow"; +import { GlobalAssetsView } from "./GlobalAssetsView"; interface AssetsTabProps { projectId: string; @@ -172,6 +174,51 @@ function ImageCard({ ); } +export type UsageFilter = "all" | "used" | "unused"; + +/** Filter assets by whether the composition references them. Pure — unit-tested. */ +export function filterByUsage( + assets: string[], + usedPaths: Set, + usageFilter: UsageFilter, +): string[] { + if (usageFilter === "used") return assets.filter((a) => usedPaths.has(a)); + if (usageFilter === "unused") return assets.filter((a) => !usedPaths.has(a)); + return assets; +} + +/** Count used vs unused over a media set. Pure — unit-tested. */ +export function countUsage( + assets: string[], + usedPaths: Set, +): { used: number; unused: number } { + let used = 0; + for (const a of assets) if (usedPaths.has(a)) used++; + return { used, unused: assets.length - used }; +} + +/** + * Project-relative asset paths referenced by composition elements — the set the + * "in use" badge, used-first sort, and usage filter all key on. Element src is + * the raw authored value (timelineElementHelpers sets entry.src = + * getAttribute("src")), so it can be a relative path ("assets/x.png"), a + * "./"-prefixed path, the served "/api/projects//preview/assets/x.png" form, + * or carry a ?query — normalize all of them to the bare project path so they + * match the asset-list entries. Pure — unit-tested. + */ +export function deriveUsedPaths(elements: Array<{ src?: string }>): Set { + const paths = new Set(); + for (const el of elements) { + if (!el.src) continue; + const s = el.src + .replace(/^\/api\/projects\/[^/]+\/preview\//, "") // strip the dev serve prefix + .replace(/^\.?\//, "") // strip leading ./ or / + .split(/[?#]/)[0]; // drop query / hash + if (s) paths.add(s); + } + return paths; +} + export const AssetsTab = memo(function AssetsTab({ projectId, assets, @@ -183,7 +230,11 @@ export const AssetsTab = memo(function AssetsTab({ const [dragOver, setDragOver] = useState(false); const [copiedPath, setCopiedPath] = useState(null); const [activeFilter, setActiveFilter] = useState("all"); + const [usageFilter, setUsageFilter] = useState<"all" | "used" | "unused">("all"); const [searchQuery, setSearchQuery] = useState(""); + // Cross-project view: the global media-use cache (~/.media). The view itself + // (GlobalAssetsView) owns its fetch — AssetsTab only tracks which scope is active. + const [viewMode, setViewMode] = useState<"local" | "global">("local"); const [manifest, setManifest] = useState< Map >(new Map()); @@ -246,19 +297,11 @@ export const AssetsTab = memo(function AssetsTab({ }, []); const elements = usePlayerStore((s) => s.elements); - const usedPaths = useMemo(() => { - const paths = new Set(); - for (const el of elements) { - if (el.src) { - const src = el.src.replace(/^\/api\/projects\/[^/]+\/preview\//, ""); - paths.add(src); - } - } - return paths; - }, [elements]); + const usedPaths = useMemo(() => deriveUsedPaths(elements), [elements]); const mediaAssets = useMemo(() => { - const all = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)); + const media = assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)); + const all = filterByUsage(media, usedPaths, usageFilter); if (!searchQuery) return all; const q = searchQuery.toLowerCase(); return all.filter((a) => { @@ -266,7 +309,7 @@ export const AssetsTab = memo(function AssetsTab({ const rec = manifest.get(a); return rec?.description?.toLowerCase().includes(q); }); - }, [assets, searchQuery, manifest]); + }, [assets, searchQuery, manifest, usageFilter, usedPaths]); const categorized = useMemo(() => { const groups: Record = { audio: [], images: [], video: [], fonts: [] }; @@ -291,6 +334,17 @@ export const AssetsTab = memo(function AssetsTab({ return c; }, [mediaAssets, categorized]); + // Usage counts over the full media set (independent of the active usage filter, + // so the chips don't show their own filtered totals). + const usageCounts = useMemo( + () => + countUsage( + assets.filter((a) => MEDIA_EXT.test(a) || FONT_EXT.test(a)), + usedPaths, + ), + [assets, usedPaths], + ); + const visibleCategories = activeFilter === "all" ? FILTER_ORDER.filter((c) => categorized[c].length > 0) @@ -308,6 +362,22 @@ export const AssetsTab = memo(function AssetsTab({ > {/* Header — matches design panel Section pattern */}
+ {/* Scope toggle — this project's assets vs the global media-use cache */} +
+ {(["local", "global"] as const).map((m) => ( + + ))} +
{/* Import */} {onImport && ( <> @@ -377,8 +447,8 @@ export const AssetsTab = memo(function AssetsTab({
)} - {/* Filter chips — panel-input style */} - {mediaAssets.length > 0 && ( + {/* Filter chips — panel-input style (local view only) */} + {viewMode === "local" && mediaAssets.length > 0 && (
) : null, )} + {/* Usage filter — show only assets the composition references, or only the unused ones */} + {usageCounts.used > 0 && usageCounts.unused > 0 && ( + <> +
)}
{/* Asset list */}
- {mediaAssets.length === 0 ? ( + {viewMode === "global" ? ( + + ) : mediaAssets.length === 0 ? (
+ !q + ? true + : [r.id, r.type, r.description, r.entity].some( + (f) => f && String(f).toLowerCase().includes(q), + ), + ) + .map((r) => ({ + id: r.id ?? r.sha ?? "asset", + type: r.type ?? "asset", + label: r.description || r.entity || r.id || r.sha || "asset", + })); +} + +export function GlobalAssetsView({ searchQuery }: { searchQuery: string }) { + const [records, setRecords] = useState(null); + useEffect(() => { + let cancelled = false; + fetch("/api/assets/global") + .then((r) => (r.ok ? r.json() : { assets: [] })) + .then((d) => { + if (!cancelled) setRecords(Array.isArray(d.assets) ? d.assets : []); + }) + .catch(() => { + if (!cancelled) setRecords([]); + }); + return () => { + cancelled = true; + }; + }, []); + + const rows = useMemo(() => globalAssetRows(records ?? [], searchQuery), [records, searchQuery]); + + if (records === null) { + return

Loading global assets…

; + } + if (rows.length === 0) { + return ( +

+ No assets in the global cache yet. Resolved media is promoted to ~/.media and + becomes reusable across projects. +

+ ); + } + return ( +
+
+ {rows.length} reusable across all projects +
+ {rows.map((row) => ( +
+ + {row.type} + + {row.label} +
+ ))} +
+ ); +} diff --git a/packages/studio/src/hooks/domSelectionTestHarness.ts b/packages/studio/src/hooks/domSelectionTestHarness.ts index 527c8c0e7c..78810ca5c8 100644 --- a/packages/studio/src/hooks/domSelectionTestHarness.ts +++ b/packages/studio/src/hooks/domSelectionTestHarness.ts @@ -30,6 +30,7 @@ export function makeSelection(label: string, element: HTMLElement): DomEditSelec capabilities: { canSelect: true, canEditStyles: true, + canCrop: true, canMove: true, canResize: true, canApplyManualOffset: true, diff --git a/packages/studio/src/hooks/useCropMode.ts b/packages/studio/src/hooks/useCropMode.ts new file mode 100644 index 0000000000..56363ea9d1 --- /dev/null +++ b/packages/studio/src/hooks/useCropMode.ts @@ -0,0 +1,91 @@ +import { useEffect, useMemo, useReducer } from "react"; +import { usePlayerStore } from "../player"; + +export interface CropModeProps { + cropMode: boolean; + onCropModeChange: (active: boolean) => void; +} + +/** Crop mode lives in the player store so the canvas toolbar, the Clip panel, + * and the overlay all share one switch without prop threading. */ +export function useCropModeProps(): CropModeProps { + const cropMode = usePlayerStore((s) => s.cropMode); + const setCropMode = usePlayerStore((s) => s.setCropMode); + return useMemo( + () => ({ + cropMode, + onCropModeChange: setCropMode, + }), + [cropMode, setCropMode], + ); +} + +import type { OverlayRect } from "../components/editor/domEditOverlayGeometry"; +import type { DomEditSelection } from "../components/editor/domEditing"; +import { readElementCropInsets } from "../components/editor/domEditOverlayCrop"; + +/** Overlay-side crop state: Escape-to-exit, toolbar availability publishing, + * and the box clip that makes the selection outline hug the cropped region. + * The box div itself always sits at the FULL element bounds — gestures write + * its position directly during drags, so moving/resizing it in React would + * fight them. The hug is purely visual: the element's inset clip-path scaled + * into overlay space and applied to the box. */ +export function useCropOverlay(params: { + selection: DomEditSelection | null; + groupCount: number; + cropMode: boolean; + onCropModeChange?: (active: boolean) => void; + overlayRect: OverlayRect | null; +}) { + const { selection, groupCount, cropMode, onCropModeChange, overlayRect } = params; + + useEffect(() => { + if (!cropMode || !onCropModeChange) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onCropModeChange(false); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [cropMode, onCropModeChange]); + + // Publish availability so the canvas toolbar shows the Crop button only + // when the selection can take a clip-path crop. + const setCropAvailable = usePlayerStore((s) => s.setCropAvailable); + const cropAvailable = Boolean(selection && groupCount <= 1 && selection.capabilities.canCrop); + useEffect(() => { + setCropAvailable(cropAvailable); + return () => setCropAvailable(false); + }, [cropAvailable, setCropAvailable]); + + // Crop-mode exit restores the element's clip in an effect cleanup — after + // this hook already read it. One forced re-render picks up the fresh insets + // so the selection box hugs the crop immediately. + const [, bumpAfterExit] = useReducer((x: number) => x + 1, 0); + useEffect(() => { + if (!cropMode) bumpAfterExit(); + }, [cropMode]); + + const cropInsets = selection ? readElementCropInsets(selection.element) : null; + const hasCropInsets = Boolean( + cropInsets && + (cropInsets.top > 0 || cropInsets.right > 0 || cropInsets.bottom > 0 || cropInsets.left > 0), + ); + + // Scaled insets for the crop outline child + the resize-handle shift. The + // box div itself stays border-less at full bounds; a child draws the + // outline ON the crop boundary (a clip on the box would swallow the + // border everywhere the crop edge doesn't touch the element edge). + const sx = overlayRect && overlayRect.editScaleX > 0 ? overlayRect.editScaleX : 1; + const sy = overlayRect && overlayRect.editScaleY > 0 ? overlayRect.editScaleY : 1; + const cropOutlineInsetPx = + cropInsets && hasCropInsets && !cropMode + ? { + top: cropInsets.top * sy, + right: cropInsets.right * sx, + bottom: cropInsets.bottom * sy, + left: cropInsets.left * sx, + } + : undefined; + + return { hasCropInsets, cropOutlineInsetPx }; +} diff --git a/packages/studio/src/hooks/useDomEditCommits.test.tsx b/packages/studio/src/hooks/useDomEditCommits.test.tsx index 99e9069735..b56f04fe18 100644 --- a/packages/studio/src/hooks/useDomEditCommits.test.tsx +++ b/packages/studio/src/hooks/useDomEditCommits.test.tsx @@ -1,3 +1,4 @@ +// fallow-ignore-file code-duplication // @vitest-environment happy-dom import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; @@ -183,6 +184,7 @@ function createSelection( capabilities: { canSelect: true, canEditStyles: true, + canCrop: true, canMove: true, canResize: true, canApplyManualOffset: true, diff --git a/packages/studio/src/player/components/ShortcutsPanel.tsx b/packages/studio/src/player/components/ShortcutsPanel.tsx index 52b39e7506..e90a9e1872 100644 --- a/packages/studio/src/player/components/ShortcutsPanel.tsx +++ b/packages/studio/src/player/components/ShortcutsPanel.tsx @@ -60,6 +60,15 @@ const SHORTCUT_SECTIONS = [ { key: "⇧ Drag", label: "Uniform resize" }, ], }, + { + title: "Crop", + hints: [ + { key: "DblClick", label: "Crop selected element" }, + { key: "Drag edge", label: "Adjust crop side" }, + { key: "Drag frame", label: "Move crop window" }, + { key: "Esc", label: "Exit crop (or click outside)" }, + ], + }, { title: "Panels", hints: [ diff --git a/packages/studio/src/player/store/playerStore.ts b/packages/studio/src/player/store/playerStore.ts index ee78be3fb8..53f3a569e2 100644 --- a/packages/studio/src/player/store/playerStore.ts +++ b/packages/studio/src/player/store/playerStore.ts @@ -111,6 +111,15 @@ interface PlayerState { autoKeyframeEnabled: boolean; setAutoKeyframeEnabled: (enabled: boolean) => void; + /** Crop mode. Armed from the preview toolbar, the Clip panel, or a + * double-click on a croppable selection; while armed, edge handles on the + * selection adjust a non-destructive clip-path inset. `available` is + * published by DomEditOverlay when the selection can be cropped. */ + cropMode: boolean; + setCropMode: (active: boolean) => void; + cropAvailable: boolean; + setCropAvailable: (available: boolean) => void; + /** Multi-select: additional selected elements beyond selectedElementId. */ selectedElementIds: Set; toggleSelectedElementId: (id: string) => void; @@ -246,6 +255,11 @@ export const usePlayerStore = create((set, get) => ({ setMotionPathCreateAvailable: (available) => set({ motionPathCreateAvailable: available }), autoKeyframeEnabled: true, setAutoKeyframeEnabled: (enabled) => set({ autoKeyframeEnabled: enabled }), + cropMode: false, + setCropMode: (active) => set({ cropMode: active }), + cropAvailable: false, + setCropAvailable: (available) => + set(available ? { cropAvailable: true } : { cropAvailable: false, cropMode: false }), selectedElementIds: new Set(), toggleSelectedElementId: (id: string) =>