From 5fd2f6ec480a01726a18adeb4d77f95e8580d6e5 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Sat, 15 Aug 2026 00:07:40 -0700 Subject: [PATCH 01/19] chore(provenance): add reproducible FreeCut package baseline --- .github/workflows/reproducible-package.yml | 49 +++ docs/pr2-provenance-and-packaging.md | 46 +++ package.json | 2 + provenance/asset-inventory.json | 42 +++ provenance/dependency-inventory.json | 85 +++++ provenance/freecut-baseline.json | 101 ++++++ scripts/package-reproducible.mjs | 387 +++++++++++++++++++++ 7 files changed, 712 insertions(+) create mode 100644 .github/workflows/reproducible-package.yml create mode 100644 docs/pr2-provenance-and-packaging.md create mode 100644 provenance/asset-inventory.json create mode 100644 provenance/dependency-inventory.json create mode 100644 provenance/freecut-baseline.json create mode 100644 scripts/package-reproducible.mjs diff --git a/.github/workflows/reproducible-package.yml b/.github/workflows/reproducible-package.yml new file mode 100644 index 000000000..5cf59cdfe --- /dev/null +++ b/.github/workflows/reproducible-package.yml @@ -0,0 +1,49 @@ +name: Reproducible Package Baseline + +on: + pull_request: + push: + branches: + - main + - staging + +permissions: + contents: read + +jobs: + package: + name: Verify and package baseline + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.14.0' + cache: npm + + - name: Select pinned npm + run: npm install --global npm@11.8.0 + + - name: Install locked dependencies + run: npm ci --ignore-scripts + + - name: Verify provenance, dependency, and asset inventories + run: npm run verify:provenance + + - name: Build and package + run: npm run package:reproducible + + - name: Rebuild and compare package bytes + shell: bash + run: | + artifact="$(find artifacts -maxdepth 1 -type f -name 'freecut-*.tar.gz' -print -quit)" + test -n "$artifact" + cp "$artifact" "$RUNNER_TEMP/freecut-first.tar.gz" + npm run package:reproducible + cmp --silent "$RUNNER_TEMP/freecut-first.tar.gz" "$artifact" diff --git a/docs/pr2-provenance-and-packaging.md b/docs/pr2-provenance-and-packaging.md new file mode 100644 index 000000000..b8028bee4 --- /dev/null +++ b/docs/pr2-provenance-and-packaging.md @@ -0,0 +1,46 @@ +# PR2 provenance and reproducible packaging + +This fork baseline is the mechanical deliverable for [CodePress issue #5319](https://github.com/quantfive/codepress/issues/5319), PR 2. It records the selected FreeCut source and makes a clean install, build, and package check repeatable. It does not add editor behavior, embed commands, CodePress integration, or the PR 3 adapter. + +## Source of record + +The selected upstream is [`walterlow/freecut`](https://github.com/walterlow/freecut), the repository named by issue #5319. The requested [`FreeCutEditor/freecut`](https://github.com/FreeCutEditor/freecut) path was not resolvable when the fork was created. The fork is [`quantfive/freecut`](https://github.com/quantfive/freecut). + +The exact source revision is: + +```text +4d62e8082c5eb387a96275bcbd323d28f6e41a62 +``` + +`provenance/freecut-baseline.json` records the commit, Git tree, deterministic `git archive` checksum, fork URL, and the retained MIT license checksum. The source archive checksum is computed over: + +```bash +SOURCE_DATE_EPOCH=0 git archive --format=tar \ + --prefix=freecut-4d62e8082c5eb387a96275bcbd323d28f6e41a62/ \ + 4d62e8082c5eb387a96275bcbd323d28f6e41a62 | shasum -a 256 +``` + +The existing `LICENSE`, `src/infrastructure/audio/THIRD_PARTY_LICENSE`, and Anime4K `NOTICE.md` are retained and copied into the package artifact's notices directory. The existing bundled Anime4K weights remain unchanged; their upstream attribution is preserved in that notice. + +## Inventories + +- `provenance/dependency-inventory.json` records every direct runtime and development dependency exactly as declared by `package.json`, plus the lockfile version and checksum. The lockfile is the install source of truth; this PR does not upgrade dependencies. +- `provenance/asset-inventory.json` records tracked public assets, source preview assets, and the bundled Anime4K model/notice directory by file count, byte count, and a canonical SHA-256 inventory hash. +- `provenance/freecut-baseline.json` lists the optional model identifiers and network services that are deliberately not package inputs. + +The optional model code remains in the upstream source, but its weights/caches are not downloaded by the package command. The baseline also excludes the loopback headless `/v1` service, remote font/Lottie services, model/CDN endpoints, Remotion, and all CodePress backend/UI/command-contract work. These exclusions are documentation and packaging boundaries only; they do not change runtime behavior. + +## Clean reproducible package + +Use Node.js 22.14.0 and npm 11.8.0, matching the CI workflow: + +```bash +npm install --global npm@11.8.0 +npm ci --ignore-scripts +npm run verify:provenance +npm run package:reproducible +``` + +`package:reproducible` removes the ignored `dist/` output, runs the production build, verifies provenance/inventory checks, and writes `artifacts/freecut-.tar.gz`. The archive has sorted paths, normalized metadata, zero timestamps, and uid/gid 0. It contains the built `dist/`, package manifests, provenance manifests, the MIT license, and retained notices. + +Run the command twice and compare the resulting archive with `cmp` to verify byte-for-byte reproducibility. CI performs that comparison on every pull request and push to `main` or `staging`. diff --git a/package.json b/package.json index e93f1a6d3..3fd29b7ca 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "dev:quiet": "vp dev --host --mode perf", "dev:compare": "node scripts/run-dev-and-perf.mjs", "build": "vp build", + "verify:provenance": "node scripts/package-reproducible.mjs --verify-only", + "package:reproducible": "node scripts/package-reproducible.mjs", "build:perf": "vp build --mode perf", "lint": "vp lint src headless vite.config.ts", "lint:fix": "vp lint src headless vite.config.ts --fix", diff --git a/provenance/asset-inventory.json b/provenance/asset-inventory.json new file mode 100644 index 000000000..430f5afeb --- /dev/null +++ b/provenance/asset-inventory.json @@ -0,0 +1,42 @@ +{ + "schema": "freecut-pr2-asset-inventory/v1", + "hashAlgorithm": "sha256", + "hashInput": "Sorted tracked file records encoded as path\\0byteCount\\0fileSha256\\n; the path is repository-relative and uses POSIX separators.", + "roots": [ + { + "path": "public", + "kind": "public static assets and vendored runtime files", + "fileCount": 29, + "byteCount": 21860045, + "sha256": "6b1ad3214755ed907957f379982188a5e19faf29aa267564f21ed94552f29e94" + }, + { + "path": "src/features/editor/components/transition-preview/frame-a.svg", + "kind": "transition preview asset", + "fileCount": 1, + "byteCount": 706, + "sha256": "cae3704ccb0193cfc4974dbf59107927e7694080e9c413d84059ab6572abcc5e" + }, + { + "path": "src/features/editor/components/transition-preview/frame-b.svg", + "kind": "transition preview asset", + "fileCount": 1, + "byteCount": 898, + "sha256": "c6ee514594706fca2f4dc35e3cd24a562714dd45d7844a0a867d4552e375e221" + }, + { + "path": "src/features/effects/components/effect-thumbnail/sample.svg", + "kind": "effect thumbnail asset", + "fileCount": 1, + "byteCount": 1166, + "sha256": "9d93be5689130f439b447f14294683a0af321b0cb9cc2c12850f6b0b181b49f9" + }, + { + "path": "src/infrastructure/upscale/models", + "kind": "bundled Anime4K weights and retained notice/source", + "fileCount": 5, + "byteCount": 47322, + "sha256": "e3a2c4bbe898303f3ff1a4dd7d69b12aaacdd078d8a6a76a66971258155864d0" + } + ] +} diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json new file mode 100644 index 000000000..736277ee1 --- /dev/null +++ b/provenance/dependency-inventory.json @@ -0,0 +1,85 @@ +{ + "schema": "freecut-pr2-dependency-inventory/v1", + "generatedFrom": "package.json", + "packageName": "freecut", + "packageVersion": "0.0.0", + "packageJsonSha256": "b1b55899c8b54d0bea585df254ed9934e4b74f30fbc362b0ce1bcf8f29aab05e", + "lockfile": { + "path": "package-lock.json", + "lockfileVersion": 3, + "sha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e" + }, + "directDependencies": { + "dependencies": { + "@hookform/resolvers": "5.2.2", + "@huggingface/transformers": "4.1.0", + "@lottiefiles/dotlottie-web": "0.76.0", + "@mediabunny/aac-encoder": "1.50.8", + "@mediabunny/ac3": "1.50.8", + "@mediabunny/mp3-encoder": "1.50.8", + "@mediabunny/prores": "1.50.8", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-label": "2.1.8", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-progress": "1.1.8", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.8", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.4", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-tooltip": "1.2.8", + "@tanstack/react-router": "1.168.22", + "@tanstack/react-virtual": "3.13.24", + "class-variance-authority": "0.7.1", + "clsx": "2.1.1", + "fflate": "0.8.2", + "gifuct-js": "2.1.2", + "i18next": "25.6.0", + "i18next-browser-languagedetector": "8.2.0", + "idb": "8.0.3", + "kokoro-js": "1.2.1", + "lucide-react": "0.468.0", + "mediabunny": "1.50.8", + "motion": "12.40.0", + "onnxruntime-web": "1.26.0-dev.20260410-5e55544225", + "react": "19.2.5", + "react-colorful": "5.6.1", + "react-dom": "19.2.5", + "react-hook-form": "7.72.1", + "react-hotkeys-hook": "5.2.4", + "react-i18next": "16.2.4", + "react-resizable-panels": "3.0.6", + "sonner": "2.0.7", + "tailwind-merge": "2.6.1", + "tailwindcss-animate": "1.0.7", + "zod": "4.3.6", + "zundo": "2.3.0", + "zustand": "5.0.12" + }, + "devDependencies": { + "@tailwindcss/vite": "4.2.2", + "@tanstack/router-cli": "1.166.33", + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "@types/node": "22.19.17", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "@vitejs/plugin-react": "6.0.1", + "@vitest/coverage-v8": "4.1.10", + "@webgpu/types": "0.1.69", + "jsdom": "27.4.0", + "playwright": "1.60.0", + "tailwindcss": "4.2.2", + "typescript": "7.0.2", + "vite-plus": "0.2.4" + } + } +} diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json new file mode 100644 index 000000000..8059af585 --- /dev/null +++ b/provenance/freecut-baseline.json @@ -0,0 +1,101 @@ +{ + "schema": "freecut-pr2-provenance-baseline/v1", + "issue": "https://github.com/quantfive/codepress/issues/5319", + "scope": "PR 2 — FreeCut fork provenance and reproducible package baseline", + "upstream": { + "repository": "https://github.com/walterlow/freecut", + "fork": "https://github.com/quantfive/freecut", + "requestedRepository": "https://github.com/FreeCutEditor/freecut", + "requestedRepositoryStatus": "unavailable at baseline creation", + "selectionReason": "Issue #5319 identifies walterlow/freecut as the selected FreeCut source; the requested FreeCutEditor/freecut path was not resolvable.", + "revision": "4d62e8082c5eb387a96275bcbd323d28f6e41a62", + "revisionType": "git commit", + "tree": "54bf2dcc81213ba6669a6ecbed307e78dd552810", + "archive": { + "format": "tar", + "prefix": "freecut-4d62e8082c5eb387a96275bcbd323d28f6e41a62", + "sha256": "6429d2a2441502e923469332d997f3ddcb36bcfd968626e578a06b2cf9cbc7ad" + } + }, + "license": { + "spdx": "MIT", + "path": "LICENSE", + "sha256": "af5b32e70d6c471cfb5f02b72dc289fd82622b7fb155c5a0332d1cc751533373" + }, + "retainedNotices": [ + { + "path": "src/infrastructure/audio/THIRD_PARTY_LICENSE", + "sha256": "a1a33180d02960ab1c5de36cf20b1a2f0fe9888d83826ad263da5db52f1b183b" + }, + { + "path": "src/infrastructure/upscale/models/NOTICE.md", + "sha256": "0f82ace9b56ee97027c91838ece69c073fb2f4e58db441d23b80baab0c5a78fd" + } + ], + "dependencies": { + "packageJson": "package.json", + "packageJsonSha256": "b1b55899c8b54d0bea585df254ed9934e4b74f30fbc362b0ce1bcf8f29aab05e", + "lockfile": "package-lock.json", + "lockfileVersion": 3, + "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", + "inventory": "provenance/dependency-inventory.json" + }, + "assets": { + "inventory": "provenance/asset-inventory.json", + "hashAlgorithm": "sha256" + }, + "excludedOptionalModels": [ + "Olicorne/parakeet-tdt-0.6b-v3-smoothquant-onnx", + "onnx-community/whisper-tiny_timestamped", + "onnx-community/whisper-base_timestamped", + "onnx-community/whisper-small_timestamped", + "onnx-community/whisper-large-v3-turbo_timestamped", + "onnx-community/gemma-4-E4B-it-ONNX", + "LiquidAI/LFM2.5-VL-450M-ONNX", + "Xenova/all-MiniLM-L6-v2", + "Xenova/clip-vit-base-patch32", + "Xenova/musicgen-small", + "onnx-community/Kokoro-82M-v1.0-ONNX", + "Supertone/supertonic-3", + "OpenMOSS-Team/MOSS-TTS-Nano-100M-ONNX", + "OpenMOSS-Team/MOSS-Audio-Tokenizer-Nano-ONNX", + "walterlow/RIFE_fp32_timestep" + ], + "excludedOptionalServices": [ + "FreeCut headless loopback render service and its unauthenticated /v1 API", + "Hugging Face model APIs and model/CDN downloads", + "jsDelivr and esm.sh runtime CDN downloads", + "Google Fonts CSS API and remote font downloads", + "LottieFiles public GraphQL API and asset CDN", + "CodePress backend, command contract, adapters, and UI", + "Remotion and any procedural renderer integration" + ], + "packaging": { + "nodeMajor": 22, + "ciNodeVersion": "22.14.0", + "packageManager": "npm@11.8.0", + "installCommand": "npm ci --ignore-scripts", + "buildCommand": "npm run build", + "packageCommand": "npm run package:reproducible", + "artifactPattern": "artifacts/freecut-.tar.gz", + "archiveFormat": "deterministic tar.gz with sorted paths, zeroed mtimes, and uid/gid 0", + "contents": [ + "dist/", + "LICENSE", + "notices/", + "package.json", + "package-lock.json", + "provenance/" + ] + }, + "ciVerification": { + "workflow": ".github/workflows/reproducible-package.yml", + "checks": [ + "source revision ancestry, tree, and archive checksum", + "MIT license and retained notice checksums", + "package and lockfile checksum plus direct dependency inventory", + "tracked asset counts, byte totals, and checksums", + "two package runs compare byte-for-byte" + ] + } +} diff --git a/scripts/package-reproducible.mjs b/scripts/package-reproducible.mjs new file mode 100644 index 000000000..a64118b4c --- /dev/null +++ b/scripts/package-reproducible.mjs @@ -0,0 +1,387 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { execFileSync, spawnSync } from 'node:child_process' +import { deflateRawSync } from 'node:zlib' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const MANIFEST_PATH = path.join(ROOT, 'provenance/freecut-baseline.json') +const DEPENDENCY_INVENTORY_PATH = path.join(ROOT, 'provenance/dependency-inventory.json') +const ASSET_INVENTORY_PATH = path.join(ROOT, 'provenance/asset-inventory.json') +const verifyOnly = process.argv.includes('--verify-only') + +function fail(message) { + throw new Error(`[reproducible-package] ${message}`) +} + +function assertCondition(condition, message) { + if (!condition) fail(message) +} + +function readJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) + } catch (error) { + fail(`could not read JSON ${path.relative(ROOT, filePath)}: ${error.message}`) + } +} + +function sha256Bytes(bytes) { + return crypto.createHash('sha256').update(bytes).digest('hex') +} + +function sha256File(relativePath) { + const filePath = path.join(ROOT, relativePath) + assertCondition(fs.existsSync(filePath), `missing file: ${relativePath}`) + return sha256Bytes(fs.readFileSync(filePath)) +} + +function stableValue(value) { + if (Array.isArray(value)) return value.map(stableValue) + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, stableValue(value[key])]), + ) + } + return value +} + +function equalJson(left, right) { + return JSON.stringify(stableValue(left)) === JSON.stringify(stableValue(right)) +} + +function gitOutput(args) { + try { + return execFileSync('git', args, { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 512 * 1024 * 1024, + }).trim() + } catch (error) { + fail(`git ${args.join(' ')} failed: ${error.message}`) + } +} + +function gitFiles(pathSpec) { + const output = gitOutput(['ls-files', '--', pathSpec]) + return output ? output.split('\n').filter(Boolean).sort(compareStrings) : [] +} + +function compareStrings(left, right) { + if (left < right) return -1 + if (left > right) return 1 + return 0 +} + +function verifySource(manifest) { + const source = manifest.upstream + assertCondition(source.repository === 'https://github.com/walterlow/freecut', 'unexpected upstream repository') + assertCondition(source.fork === 'https://github.com/quantfive/freecut', 'unexpected fork repository') + assertCondition(/^[0-9a-f]{40}$/.test(source.revision), 'upstream revision must be a full git SHA') + + gitOutput(['cat-file', '-e', `${source.revision}^{commit}`]) + const head = gitOutput(['rev-parse', 'HEAD']) + const ancestry = spawnSync('git', ['merge-base', '--is-ancestor', source.revision, head], { + cwd: ROOT, + stdio: 'ignore', + }) + assertCondition(ancestry.status === 0, `${source.revision} is not an ancestor of HEAD ${head}`) + + const tree = gitOutput(['rev-parse', `${source.revision}^{tree}`]) + assertCondition(tree === source.tree, `source tree mismatch: expected ${source.tree}, got ${tree}`) + + const archive = spawnSync( + 'git', + [ + 'archive', + '--format=tar', + `--prefix=${source.archive.prefix}/`, + source.revision, + ], + { + cwd: ROOT, + encoding: null, + env: { ...process.env, SOURCE_DATE_EPOCH: '0' }, + maxBuffer: 512 * 1024 * 1024, + }, + ) + assertCondition(archive.status === 0, `could not archive source revision: ${archive.stderr?.toString() ?? ''}`) + const archiveSha256 = sha256Bytes(archive.stdout) + assertCondition( + archiveSha256 === source.archive.sha256, + `source archive checksum mismatch: expected ${source.archive.sha256}, got ${archiveSha256}`, + ) + + console.log(`[reproducible-package] source ${source.repository}@${source.revision} verified`) +} + +function verifyNotices(manifest) { + assertCondition(manifest.license.spdx === 'MIT', 'the retained project license must remain MIT') + assertCondition( + sha256File(manifest.license.path) === manifest.license.sha256, + `license checksum mismatch: ${manifest.license.path}`, + ) + const licenseText = fs.readFileSync(path.join(ROOT, manifest.license.path), 'utf8') + assertCondition(licenseText.includes('MIT License'), 'LICENSE does not contain the MIT heading') + assertCondition( + licenseText.includes('Permission is hereby granted'), + 'LICENSE does not contain the MIT permission notice', + ) + + for (const notice of manifest.retainedNotices) { + assertCondition( + sha256File(notice.path) === notice.sha256, + `retained notice checksum mismatch: ${notice.path}`, + ) + } + console.log('[reproducible-package] MIT license and retained notices verified') +} + +function verifyDependencies(manifest) { + const packageJson = readJson(path.join(ROOT, manifest.dependencies.packageJson)) + const lockfile = readJson(path.join(ROOT, manifest.dependencies.lockfile)) + const inventory = readJson(path.join(ROOT, manifest.dependencies.inventory)) + + const packageJsonSha256 = sha256File(manifest.dependencies.packageJson) + const lockfileSha256 = sha256File(manifest.dependencies.lockfile) + assertCondition( + packageJsonSha256 === manifest.dependencies.packageJsonSha256, + `package.json checksum mismatch: expected ${manifest.dependencies.packageJsonSha256}, got ${packageJsonSha256}`, + ) + assertCondition( + lockfileSha256 === manifest.dependencies.lockfileSha256, + `package-lock.json checksum mismatch: expected ${manifest.dependencies.lockfileSha256}, got ${lockfileSha256}`, + ) + assertCondition(lockfile.lockfileVersion === manifest.dependencies.lockfileVersion, 'lockfile version mismatch') + assertCondition(inventory.packageName === packageJson.name, 'dependency inventory package name mismatch') + assertCondition(inventory.packageVersion === packageJson.version, 'dependency inventory package version mismatch') + assertCondition(inventory.packageJsonSha256 === packageJsonSha256, 'dependency inventory package checksum mismatch') + assertCondition(inventory.lockfile.sha256 === lockfileSha256, 'dependency inventory lockfile checksum mismatch') + assertCondition(inventory.lockfile.lockfileVersion === lockfile.lockfileVersion, 'dependency inventory lockfile version mismatch') + + const directDependencies = { + dependencies: packageJson.dependencies ?? {}, + devDependencies: packageJson.devDependencies ?? {}, + } + assertCondition( + equalJson(inventory.directDependencies, directDependencies), + 'direct dependency inventory differs from package.json', + ) + + const lockRoot = lockfile.packages?.[''] + assertCondition(lockRoot, 'package-lock.json does not contain the root package') + assertCondition(equalJson(lockRoot.dependencies ?? {}, packageJson.dependencies ?? {}), 'lockfile dependencies differ from package.json') + assertCondition( + equalJson(lockRoot.devDependencies ?? {}, packageJson.devDependencies ?? {}), + 'lockfile devDependencies differ from package.json', + ) + console.log( + `[reproducible-package] dependency inventory verified (${Object.keys(directDependencies.dependencies).length} runtime, ${Object.keys(directDependencies.devDependencies).length} development)`, + ) +} + +function trackedAssetRecords(pathSpec) { + const files = gitFiles(pathSpec) + const records = files.map((file) => { + const bytes = fs.readFileSync(path.join(ROOT, file)) + return { path: file, bytes: bytes.length, sha256: sha256Bytes(bytes) } + }) + const hashInput = records + .sort((left, right) => compareStrings(left.path, right.path)) + .map((record) => `${record.path}\0${record.bytes}\0${record.sha256}\n`) + .join('') + return { + fileCount: records.length, + byteCount: records.reduce((total, record) => total + record.bytes, 0), + sha256: sha256Bytes(hashInput), + } +} + +function verifyAssets(manifest) { + const inventory = readJson(path.join(ROOT, manifest.assets.inventory)) + assertCondition(inventory.hashAlgorithm === manifest.assets.hashAlgorithm, 'asset hash algorithm mismatch') + for (const root of inventory.roots) { + const actual = trackedAssetRecords(root.path) + assertCondition(actual.fileCount === root.fileCount, `asset count mismatch for ${root.path}`) + assertCondition(actual.byteCount === root.byteCount, `asset byte count mismatch for ${root.path}`) + assertCondition(actual.sha256 === root.sha256, `asset checksum mismatch for ${root.path}`) + } + console.log(`[reproducible-package] asset inventory verified (${inventory.roots.length} roots)`) +} + +function verifyBaseline() { + const manifest = readJson(MANIFEST_PATH) + assertCondition(manifest.schema === 'freecut-pr2-provenance-baseline/v1', 'unexpected provenance schema') + assertCondition(Number(process.versions.node.split('.')[0]) >= manifest.packaging.nodeMajor, 'Node.js 22 or newer is required') + verifySource(manifest) + verifyNotices(manifest) + verifyDependencies(manifest) + verifyAssets(manifest) + return manifest +} + +function copyTree(sourcePath, targetPath) { + const sourceStat = fs.lstatSync(sourcePath) + if (sourceStat.isSymbolicLink()) fail(`symbolic links are not allowed in package input: ${sourcePath}`) + if (sourceStat.isDirectory()) { + fs.mkdirSync(targetPath, { recursive: true }) + for (const entry of fs.readdirSync(sourcePath).sort(compareStrings)) { + copyTree(path.join(sourcePath, entry), path.join(targetPath, entry)) + } + return + } + fs.mkdirSync(path.dirname(targetPath), { recursive: true }) + fs.copyFileSync(sourcePath, targetPath) +} + +function addStageFile(stageRoot, sourceRelativePath, targetRelativePath = sourceRelativePath) { + const sourcePath = path.join(ROOT, sourceRelativePath) + const targetPath = path.join(stageRoot, targetRelativePath) + assertCondition(fs.existsSync(sourcePath), `package input is missing: ${sourceRelativePath}`) + fs.mkdirSync(path.dirname(targetPath), { recursive: true }) + fs.copyFileSync(sourcePath, targetPath) +} + +function collectArchiveEntries(directoryPath, archivePrefix) { + const entries = [{ type: 'directory', name: `${archivePrefix}/`, sourcePath: directoryPath }] + for (const entry of fs.readdirSync(directoryPath).sort(compareStrings)) { + const sourcePath = path.join(directoryPath, entry) + const archiveName = `${archivePrefix}/${entry}` + const stat = fs.lstatSync(sourcePath) + if (stat.isSymbolicLink()) fail(`symbolic links are not allowed in package input: ${sourcePath}`) + if (stat.isDirectory()) entries.push(...collectArchiveEntries(sourcePath, archiveName)) + else if (stat.isFile()) entries.push({ type: 'file', name: archiveName, sourcePath }) + else fail(`unsupported package input: ${sourcePath}`) + } + return entries +} + +function writeStringField(header, offset, length, value) { + const bytes = Buffer.from(value) + assertCondition(bytes.length <= length, `tar header field is too long: ${value}`) + bytes.copy(header, offset) +} + +function writeOctalField(header, offset, length, value) { + const text = Math.floor(value).toString(8).padStart(length - 1, '0') + '\0' + writeStringField(header, offset, length, text) +} + +function tarHeader(entry) { + const header = Buffer.alloc(512) + writeStringField(header, 0, 100, entry.name) + writeOctalField(header, 100, 8, entry.type === 'directory' ? 0o755 : 0o644) + writeOctalField(header, 108, 8, 0) + writeOctalField(header, 116, 8, 0) + const size = entry.type === 'file' ? fs.statSync(entry.sourcePath).size : 0 + writeOctalField(header, 124, 12, size) + writeOctalField(header, 136, 12, 0) + header.fill(0x20, 148, 156) + header[156] = entry.type === 'directory' ? 0x35 : 0x30 + writeStringField(header, 257, 6, 'ustar\0') + writeStringField(header, 263, 2, '00') + const checksum = header.reduce((total, byte) => total + byte, 0) + writeStringField(header, 148, 8, `${checksum.toString(8).padStart(6, '0')}\0 `) + return header +} + +function createTar(entries) { + const chunks = [] + for (const entry of entries) { + chunks.push(tarHeader(entry)) + if (entry.type !== 'file') continue + const bytes = fs.readFileSync(entry.sourcePath) + chunks.push(bytes) + const remainder = bytes.length % 512 + if (remainder !== 0) chunks.push(Buffer.alloc(512 - remainder)) + } + chunks.push(Buffer.alloc(1024)) + return Buffer.concat(chunks) +} + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256) + for (let index = 0; index < table.length; index += 1) { + let value = index + for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1 + table[index] = value >>> 0 + } + return table +})() + +function crc32(bytes) { + let value = 0xffffffff + for (const byte of bytes) value = CRC32_TABLE[(value ^ byte) & 0xff] ^ (value >>> 8) + return (value ^ 0xffffffff) >>> 0 +} + +function createDeterministicGzip(tarBytes) { + const header = Buffer.from([0x1f, 0x8b, 0x08, 0, 0, 0, 0, 0, 0x02, 0xff]) + const compressed = deflateRawSync(tarBytes, { level: 9 }) + const trailer = Buffer.alloc(8) + trailer.writeUInt32LE(crc32(tarBytes), 0) + trailer.writeUInt32LE(tarBytes.length >>> 0, 4) + return Buffer.concat([header, compressed, trailer]) +} + +function runBuild() { + fs.rmSync(path.join(ROOT, 'dist'), { recursive: true, force: true }) + const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm' + const result = spawnSync(npm, ['run', 'build'], { + cwd: ROOT, + env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, + stdio: 'inherit', + }) + assertCondition(result.status === 0, 'npm run build failed') +} + +function createPackage(manifest) { + const artifactDirectory = path.join(ROOT, 'artifacts') + const artifactPath = path.join(artifactDirectory, `freecut-${manifest.upstream.revision}.tar.gz`) + const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'freecut-reproducible-')) + const packageRoot = path.join(stage, 'freecut') + + try { + copyTree(path.join(ROOT, 'dist'), path.join(packageRoot, 'dist')) + addStageFile(packageRoot, 'LICENSE') + addStageFile(packageRoot, 'README.md') + addStageFile(packageRoot, 'package.json') + addStageFile(packageRoot, 'package-lock.json') + addStageFile(packageRoot, 'src/infrastructure/audio/THIRD_PARTY_LICENSE', 'notices/THIRD_PARTY_LICENSE') + addStageFile(packageRoot, 'src/infrastructure/upscale/models/NOTICE.md', 'notices/upscale-models-NOTICE.md') + addStageFile(packageRoot, 'provenance/freecut-baseline.json') + addStageFile(packageRoot, 'provenance/dependency-inventory.json') + addStageFile(packageRoot, 'provenance/asset-inventory.json') + + const entries = collectArchiveEntries(packageRoot, 'freecut') + const gzipBytes = createDeterministicGzip(createTar(entries)) + fs.mkdirSync(artifactDirectory, { recursive: true }) + fs.writeFileSync(artifactPath, gzipBytes) + console.log(`[reproducible-package] wrote ${path.relative(ROOT, artifactPath)} (${gzipBytes.length} bytes)`) + console.log(`[reproducible-package] artifact sha256 ${sha256Bytes(gzipBytes)}`) + } finally { + fs.rmSync(stage, { recursive: true, force: true }) + } +} + +function main() { + const manifest = verifyBaseline() + if (verifyOnly) { + console.log('[reproducible-package] verification-only run passed') + return + } + runBuild() + verifyBaseline() + createPackage(manifest) +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} From 11d63a96eb8cc3bc4fd8a1bbe17499ec0175295c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Sat, 15 Aug 2026 00:56:30 -0700 Subject: [PATCH 02/19] feat(editor): add CodePress command adapter boundary --- .fallowrc.json | 44 +- src/features/editor/codepress/README.md | 41 + src/features/editor/codepress/adapter.test.ts | 558 ++++++++ src/features/editor/codepress/adapter.ts | 455 ++++++ src/features/editor/codepress/contract.ts | 1274 +++++++++++++++++ .../editor/codepress/controlled-editor.ts | 10 + src/features/editor/codepress/document.ts | 298 ++++ src/features/editor/codepress/edit-engine.ts | 883 ++++++++++++ .../editor/codepress/fixtures/README.md | 8 + .../fixtures/errors/idempotency-conflict.json | 20 + .../fixtures/errors/revision-conflict.json | 27 + .../editor/codepress/fixtures/index.json | 10 + .../fixtures/invalid/invalid-caption-cue.json | 31 + .../fixtures/valid/caption-batch.json | 71 + .../fixtures/valid/core-edit-batch.json | 150 ++ src/features/editor/codepress/index.ts | 8 + src/features/editor/codepress/interfaces.ts | 137 ++ src/features/editor/codepress/timing.ts | 154 ++ src/features/editor/codepress/translation.ts | 269 ++++ 19 files changed, 4446 insertions(+), 2 deletions(-) create mode 100644 src/features/editor/codepress/README.md create mode 100644 src/features/editor/codepress/adapter.test.ts create mode 100644 src/features/editor/codepress/adapter.ts create mode 100644 src/features/editor/codepress/contract.ts create mode 100644 src/features/editor/codepress/controlled-editor.ts create mode 100644 src/features/editor/codepress/document.ts create mode 100644 src/features/editor/codepress/edit-engine.ts create mode 100644 src/features/editor/codepress/fixtures/README.md create mode 100644 src/features/editor/codepress/fixtures/errors/idempotency-conflict.json create mode 100644 src/features/editor/codepress/fixtures/errors/revision-conflict.json create mode 100644 src/features/editor/codepress/fixtures/index.json create mode 100644 src/features/editor/codepress/fixtures/invalid/invalid-caption-cue.json create mode 100644 src/features/editor/codepress/fixtures/valid/caption-batch.json create mode 100644 src/features/editor/codepress/fixtures/valid/core-edit-batch.json create mode 100644 src/features/editor/codepress/index.ts create mode 100644 src/features/editor/codepress/interfaces.ts create mode 100644 src/features/editor/codepress/timing.ts create mode 100644 src/features/editor/codepress/translation.ts diff --git a/.fallowrc.json b/.fallowrc.json index 55d18a070..df3950b4c 100644 --- a/.fallowrc.json +++ b/.fallowrc.json @@ -49,10 +49,19 @@ "src/features/effects/components/panels/gpu-wheels-panel.tsx", "src/features/effects/components/panels/gpu-curves-panel.tsx", "src/features/timeline/components/timeline.tsx", - "src/features/timeline/services/reverse-conform-service.ts" + "src/features/timeline/services/reverse-conform-service.ts", + // PR3's contract validator and pure edit engine are intentionally + // branch-dense boundary code; they are covered by conformance tests and + // should not be treated as UI health hotspots. + "src/features/editor/codepress/adapter.ts", + "src/features/editor/codepress/contract.ts", + "src/features/editor/codepress/document.ts", + "src/features/editor/codepress/edit-engine.ts", + "src/features/editor/codepress/timing.ts", + "src/features/editor/codepress/translation.ts" ] }, - "usedClassMembers": ["addEventListener", "removeEventListener"], + "usedClassMembers": ["addEventListener", "removeEventListener", "subscribe"], "rules": {}, "ignoreExports": [ { @@ -124,6 +133,37 @@ { "file": "src/features/editor/deps/timeline-store.ts", "exports": ["*"] + }, + { + // Controlled PR3 package surface: these exports are consumed by the + // future CodePress shell/worker boundary rather than the current app + // entrypoint. Keep them visible to type/lint/tests, not dead-code trim. + "file": "src/features/editor/codepress/adapter.ts", + "exports": ["*"] + }, + { + "file": "src/features/editor/codepress/contract.ts", + "exports": ["*"] + }, + { + "file": "src/features/editor/codepress/controlled-editor.ts", + "exports": ["*"] + }, + { + "file": "src/features/editor/codepress/document.ts", + "exports": ["*"] + }, + { + "file": "src/features/editor/codepress/edit-engine.ts", + "exports": ["*"] + }, + { + "file": "src/features/editor/codepress/timing.ts", + "exports": ["*"] + }, + { + "file": "src/features/editor/codepress/translation.ts", + "exports": ["*"] } ] } diff --git a/src/features/editor/codepress/README.md b/src/features/editor/codepress/README.md new file mode 100644 index 000000000..8ea9f2157 --- /dev/null +++ b/src/features/editor/codepress/README.md @@ -0,0 +1,41 @@ +# Controlled CodePress/FreeCut boundary + +This directory is the FreeCut-side PR3 adapter boundary. Its public surface is +the versioned, integer-microsecond command/document contract and explicit +controlled ports: + +- `ControlledEditorPort` owns an in-memory document while mounted; +- `ControlledEditEngine` applies a bounded command list atomically and is safe + to run in a worker; +- `document.ts` is the only frame-native translation seam; it maps typed + FreeCut frame documents to/from the controlled microsecond document; +- `ControlledRenderer` receives a frame-aligned request and returns a + renderer-owned payload; +- host interfaces (`ProjectStore`, `AssetResolver`, job, presence, upload, and + telemetry ports) keep CodePress persistence and media resolution outside + FreeCut. + +`CodePressCommandAdapter` validates a PR1-shaped batch, checks the current +revision and preconditions, applies it through the pure edit engine, records +idempotent replays, and publishes the accepted controlled document. A failed +command never replaces the document. + +The existing `headless/` browser harness and its localhost `/v1` service are +not imported here and remain development-only implementation seams. + +## Timing rule + +The public contract is integer microseconds. FreeCut positions are integer +frames, so all mutation timestamps are required to equal the deterministic +canonical integer-microsecond representation of a frame at the document FPS. +`timing.ts` uses rational `BigInt` arithmetic for alignment and nearest-frame +conversion; it does not depend on a browser clock or floating-point remainder. + +## Ripple and captions + +`ripple_delete` operates on `[start_us, end_us)` in the selected tracks (or all +tracks for `track_ids: null`). Downstream items shift left by the exact frame +delta. An item crossing both boundaries is split deterministically: the +left-hand fragment keeps the original ID and the right-hand fragment receives a +stable `:ripple-right` ID. Caption cues use the same interval semantics and +remain ordinary caption-track items in the controlled document. diff --git a/src/features/editor/codepress/adapter.test.ts b/src/features/editor/codepress/adapter.test.ts new file mode 100644 index 000000000..7b3888d4f --- /dev/null +++ b/src/features/editor/codepress/adapter.test.ts @@ -0,0 +1,558 @@ +// @vitest-environment node + +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vite-plus/test' +import { + CodePressCommandAdapter, + FrameTimingError, + assertFrameAligned, + controlledDocumentToFreeCutDocument, + createCodePressCommandAdapter, + freeCutDocumentToControlledDocument, + framesToMicroseconds, + isFrameAligned, + isVideoCommandError, + translateCommandBatchToFrames, + validateCommandBatch, + validateTimelineState, +} from './index' +import type { + ClipItem, + EditCommandBatch, + MediaReference, + TextItem, + TimelineState, +} from './contract' +import type { ControlledEditorDocument } from './interfaces' + +function readFixture(relativePath: string): T { + return JSON.parse( + readFileSync(new URL(`./fixtures/${relativePath}`, import.meta.url), 'utf8'), + ) as T +} + +interface ValidFixture { + timeline: TimelineState + request: EditCommandBatch + expect: { request_valid: boolean; command_types: string[]; command_count: number } +} + +const videoMedia: MediaReference = { + media_id: 'media-video', + media_kind: 'video', + content_hash: 'sha256:video-v1', + duration_us: 30_000_000, + availability: { mode: 'cloud', cloud: { object_id: 'object-video' } }, +} + +const audioMedia: MediaReference = { + media_id: 'media-audio', + media_kind: 'audio', + content_hash: 'sha256:audio-v1', + duration_us: 30_000_000, + availability: { mode: 'cloud', cloud: { object_id: 'object-audio' } }, +} + +function clip(overrides: Partial = {}): ClipItem { + return { + item_type: 'clip', + item_id: 'clip-a', + track_id: 'track-video', + media_id: 'media-video', + media_kind: 'video', + timeline_start_us: 0, + timeline_end_us: 1_000_000, + source_start_us: 0, + source_end_us: 1_000_000, + ...overrides, + } +} + +function text(overrides: Partial = {}): TextItem { + return { + item_type: 'text', + item_id: 'text-a', + track_id: 'track-video', + timeline_start_us: 0, + timeline_end_us: 1_000_000, + text: 'Text', + ...overrides, + } +} + +function timeline(overrides: Partial = {}): TimelineState { + return { + contract_version: 1, + schema_version: 1, + timeline_id: 'timeline-test', + revision: 0, + duration_us: 10_000_000, + media: [videoMedia, audioMedia], + tracks: [ + { + track_id: 'track-video', + kind: 'video', + name: 'Video', + locked: false, + muted: false, + items: [clip()], + }, + { + track_id: 'track-captions', + kind: 'caption', + name: 'Captions', + language: 'en', + locked: false, + muted: false, + items: [], + }, + ], + ...overrides, + } +} + +function documentFor(next: TimelineState, fps = 30): ControlledEditorDocument { + return { timeline: next, fps, width: 1920, height: 1080 } +} + +function applyRequest(adapter: CodePressCommandAdapter, request: EditCommandBatch) { + const result = adapter.apply(request) + expect(result.status).toBe('applied') + if (result.status !== 'applied') throw new Error('request was not applied') + return result +} + +describe('PR1 conformance fixtures', () => { + it.each(['valid/core-edit-batch.json', 'valid/caption-batch.json'])('accepts %s', (path) => { + const fixture = readFixture(path) + const timelineResult = validateTimelineState(fixture.timeline) + const requestResult = validateCommandBatch(fixture.request) + expect(timelineResult.ok).toBe(true) + expect(requestResult.ok).toBe(true) + if (!requestResult.ok) return + expect(requestResult.value.commands.map((command) => command.type)).toEqual( + fixture.expect.command_types, + ) + expect(requestResult.value.commands).toHaveLength(fixture.expect.command_count) + }) + + it('rejects the canonical invalid caption-cue fixture', () => { + const fixture = readFixture<{ request: EditCommandBatch; expect: { error_codes: string[] } }>( + 'invalid/invalid-caption-cue.json', + ) + const result = validateCommandBatch(fixture.request) + expect(result.ok).toBe(false) + if (result.ok) return + expect(result.errors.map((entry) => entry.code)).toEqual(fixture.expect.error_codes) + expect(result.errors[0]?.details).toMatchObject({ + kind: 'invalid_request', + path: 'commands[0].cues[0]', + }) + }) + + it.each(['errors/revision-conflict.json', 'errors/idempotency-conflict.json'])( + 'keeps the canonical structured error shape for %s', + (path) => { + const fixture = readFixture<{ + error: unknown + expect: { code: string; retryable: boolean } + }>(path) + expect(isVideoCommandError(fixture.error)).toBe(true) + expect(fixture.error).toMatchObject({ + code: fixture.expect.code, + retryable: fixture.expect.retryable, + details: { kind: fixture.expect.code }, + }) + }, + ) +}) + +describe('deterministic microsecond/frame conversion', () => { + it('uses exact integer arithmetic for frame-aligned timestamps', () => { + expect(assertFrameAligned(1_000_000, 30)).toBe(30) + expect(framesToMicroseconds(30, 30)).toBe(1_000_000) + const ntsc = { numerator: 30_000n, denominator: 1_001n, value: 30_000 / 1_001 } + expect(framesToMicroseconds(30, ntsc)).toBe(1_001_000) + expect(assertFrameAligned(1_001_000, ntsc)).toBe(30) + expect(isFrameAligned(33_334, 30)).toBe(false) + expect(() => assertFrameAligned(33_334, 30)).toThrow(FrameTimingError) + }) + + it('translates every public timestamp in a batch before application', () => { + const fixture = readFixture('valid/core-edit-batch.json') + const translated = translateCommandBatchToFrames(fixture.request, 30) + const ripple = translated.commands.find((command) => command.type === 'ripple_delete') + expect(ripple).toMatchObject({ start_frame: 60, end_frame: 75 }) + const split = translated.commands[0] + expect(split).toMatchObject({ at_timeline_frame: 120, at_source_frame: 120 }) + }) +}) + +describe('controlled command adapter', () => { + it('round-trips a frame-native FreeCut document through the typed boundary', () => { + const frameDocument = { + timelineId: 'timeline-frame', + revision: 4, + fps: 30, + durationInFrames: 180, + media: [videoMedia], + tracks: [ + { + id: 'track-video', + kind: 'video' as const, + name: 'Video', + locked: false, + muted: false, + items: [ + { + type: 'video' as const, + id: 'clip-frame', + trackId: 'track-video', + mediaId: 'media-video', + from: 30, + durationInFrames: 60, + sourceStart: 0, + sourceEnd: 60, + }, + ], + }, + ], + width: 1920, + height: 1080, + } + const controlled = freeCutDocumentToControlledDocument(frameDocument) + expect(controlled.timeline.tracks[0]?.items[0]).toMatchObject({ + item_id: 'clip-frame', + timeline_start_us: 1_000_000, + timeline_end_us: 3_000_000, + }) + const roundTrip = controlledDocumentToFreeCutDocument(controlled) + expect(roundTrip).toMatchObject({ + timelineId: 'timeline-frame', + revision: 4, + durationInFrames: 180, + fps: 30, + }) + expect(roundTrip.tracks[0]?.items[0]).toMatchObject({ + id: 'clip-frame', + from: 30, + durationInFrames: 60, + }) + }) + + it('applies the canonical core fixture atomically and reports normalized effects', () => { + const fixture = readFixture('valid/core-edit-batch.json') + const adapter = new CodePressCommandAdapter({ document: documentFor(fixture.timeline) }) + const result = applyRequest(adapter, fixture.request) + expect(result.previous_revision).toBe(7) + expect(result.resulting_revision).toBe(8) + expect(result.timeline.duration_us).toBe(11_500_000) + expect(result.commands.map((command) => command.command_type)).toEqual( + fixture.expect.command_types, + ) + expect(result.commands[2]?.effect.timeline_delta_us).toBe(-500_000) + expect(adapter.getSnapshot().revision).toBe(8) + }) + + it('applies caption cues, preserves cue order, and supports cue styles', () => { + const fixture = readFixture('valid/caption-batch.json') + const adapter = new CodePressCommandAdapter({ document: documentFor(fixture.timeline) }) + const result = applyRequest(adapter, fixture.request) + const captionTrack = result.timeline.tracks.find((track) => track.track_id === 'track-captions') + expect( + captionTrack?.items.map((item) => + item.item_type === 'caption_cue' ? item.cue_id : item.item_id, + ), + ).toEqual(['cue-hello', 'cue-next']) + + const styled = applyRequest(adapter, { + contract_version: 1, + timeline_id: 'timeline-caption', + operation_id: 'operation-captions-style', + idempotency_key: 'timeline-caption:style:1', + base_revision: 3, + preconditions: [{ type: 'item_exists', item_id: 'cue-hello' }], + commands: [ + { + command_id: 'command-style', + type: 'set_caption_style', + track_id: 'track-captions', + cue_ids: ['cue-hello'], + style: { color: '#00ff00', alignment: 'center' }, + }, + ], + }) + const styledCue = styled.timeline.tracks[0]?.items[0] + expect(styledCue).toMatchObject({ + cue_id: 'cue-hello', + style: { color: '#00ff00', alignment: 'center' }, + }) + }) + + it('ripple-deletes deterministically across tracks and leaves selected tracks isolated', () => { + const base = timeline({ + tracks: [ + { + track_id: 'track-video', + kind: 'video', + name: 'Video', + locked: false, + muted: false, + items: [ + clip({ + item_id: 'before', + timeline_start_us: 0, + timeline_end_us: 1_000_000, + source_end_us: 1_000_000, + }), + clip({ + item_id: 'after', + timeline_start_us: 3_000_000, + timeline_end_us: 4_000_000, + source_start_us: 3_000_000, + source_end_us: 4_000_000, + }), + ], + }, + { + track_id: 'track-captions', + kind: 'caption', + name: 'Captions', + language: 'en', + locked: false, + muted: false, + items: [ + { + item_type: 'caption_cue', + cue_id: 'cue-after', + track_id: 'track-captions', + start_us: 3_000_000, + end_us: 4_000_000, + text: 'After', + }, + ], + }, + ], + }) + const adapter = new CodePressCommandAdapter({ document: documentFor(base) }) + const allTracks = applyRequest(adapter, { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'ripple-all', + idempotency_key: 'ripple-all:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'ripple', + type: 'ripple_delete', + start_us: 1_000_000, + end_us: 2_000_000, + track_ids: null, + }, + ], + }) + const videoAfter = allTracks.timeline.tracks[0]?.items.find( + (item) => itemIdForTest(item) === 'after', + ) + const captionAfter = allTracks.timeline.tracks[1]?.items.find( + (item) => itemIdForTest(item) === 'cue-after', + ) + expect(videoAfter).toMatchObject({ timeline_start_us: 2_000_000, timeline_end_us: 3_000_000 }) + expect(captionAfter).toMatchObject({ start_us: 2_000_000, end_us: 3_000_000 }) + expect(allTracks.timeline.duration_us).toBe(9_000_000) + + const isolated = new CodePressCommandAdapter({ document: documentFor(base) }) + const selected = applyRequest(isolated, { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'ripple-video', + idempotency_key: 'ripple-video:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'ripple-video', + type: 'ripple_delete', + start_us: 1_000_000, + end_us: 2_000_000, + track_ids: ['track-video'], + }, + ], + }) + expect(selected.timeline.tracks[1]?.items[0]).toMatchObject({ + start_us: 3_000_000, + end_us: 4_000_000, + }) + expect(selected.timeline.duration_us).toBe(10_000_000) + }) + + it('keeps the left fragment ID and derives a stable right fragment for a crossing item', () => { + const crossing = new CodePressCommandAdapter({ + document: documentFor( + timeline({ + duration_us: 8_000_000, + tracks: [ + { + track_id: 'track-video', + kind: 'video', + name: 'Video', + locked: false, + muted: false, + items: [ + clip({ + item_id: 'crossing', + timeline_start_us: 0, + timeline_end_us: 4_000_000, + source_start_us: 0, + source_end_us: 4_000_000, + }), + ], + }, + { + track_id: 'track-captions', + kind: 'caption', + name: 'Captions', + language: 'en', + locked: false, + muted: false, + items: [], + }, + ], + }), + ), + }) + const result = applyRequest(crossing, { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'ripple-crossing', + idempotency_key: 'ripple-crossing:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'ripple-crossing-command', + type: 'ripple_delete', + start_us: 1_000_000, + end_us: 2_000_000, + track_ids: ['track-video'], + }, + ], + }) + const items = result.timeline.tracks[0]?.items + expect(items?.map(itemIdForTest)).toEqual(['crossing', 'crossing:ripple-right']) + expect(items?.[0]).toMatchObject({ + timeline_start_us: 0, + timeline_end_us: 1_000_000, + source_end_us: 1_000_000, + }) + expect(items?.[1]).toMatchObject({ + timeline_start_us: 1_000_000, + timeline_end_us: 3_000_000, + source_start_us: 2_000_000, + source_end_us: 4_000_000, + }) + }) + + it('does not mutate the controlled document when a later command fails', () => { + const adapter = new CodePressCommandAdapter({ document: documentFor(timeline()) }) + const result = adapter.apply({ + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'atomic-failure', + idempotency_key: 'atomic-failure:1', + base_revision: 0, + preconditions: [], + commands: [ + { command_id: 'remove-ghost', type: 'remove_item', item_id: 'ghost' }, + { + command_id: 'add-text', + type: 'add_text', + track_id: 'track-video', + item: text({ item_id: 'would-not-land' }), + }, + ], + }) + expect(result.status).toBe('rejected') + expect(adapter.getSnapshot().revision).toBe(0) + expect(adapter.getSnapshot().document.timeline.tracks[0]?.items).toHaveLength(1) + }) + + it('returns explicit revision and idempotency conflicts', () => { + const adapter = createCodePressCommandAdapter({ document: documentFor(timeline()) }) + const request: EditCommandBatch = { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'move-once', + idempotency_key: 'move:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'move', + type: 'move_item', + item_id: 'clip-a', + to_track_id: 'track-video', + timeline_start_us: 1_000_000, + index: 0, + }, + ], + } + const applied = applyRequest(adapter, request) + const replayed = adapter.apply(request) + expect(replayed.status).toBe('replayed') + if (replayed.status === 'replayed') + expect(replayed.replayed_operation_id).toBe(applied.operation_id) + + const idempotencyConflict = adapter.apply({ + ...request, + operation_id: 'different-operation', + commands: [{ ...request.commands[0]!, command_id: 'different-command' }], + }) + expect(idempotencyConflict).toMatchObject({ + status: 'rejected', + error: { code: 'idempotency_conflict', retryable: false }, + }) + + const stale = adapter.apply({ ...request, operation_id: 'stale', idempotency_key: 'stale:1' }) + expect(stale).toMatchObject({ + status: 'rejected', + error: { + code: 'revision_conflict', + retryable: true, + details: { rebase: { automatic: false, retry_with_revision: 1 } }, + }, + }) + }) + + it('keeps render and media state outside the command/document contract', async () => { + const renderer = { + renderFrame: (request: { + frame: number + time_us: number + width: number + height: number + document: ControlledEditorDocument + }) => ({ + frame: request.frame, + time_us: request.time_us, + width: request.width, + height: request.height, + payload: { source: 'renderer-only' }, + }), + } + const adapter = new CodePressCommandAdapter({ document: documentFor(timeline()), renderer }) + await expect(adapter.renderFrame(1_000_000)).resolves.toMatchObject({ + frame: 30, + time_us: 1_000_000, + payload: { source: 'renderer-only' }, + }) + await expect(adapter.renderFrame(33_334)).rejects.toThrow(FrameTimingError) + expect(JSON.stringify(adapter.getSnapshot().document.timeline)).not.toContain('/Users/') + expect(JSON.stringify(adapter.getSnapshot().document.timeline)).not.toContain('file://') + }) +}) + +function itemIdForTest(item: TimelineState['tracks'][number]['items'][number]): string { + return item.item_type === 'caption_cue' ? item.cue_id : item.item_id +} diff --git a/src/features/editor/codepress/adapter.ts b/src/features/editor/codepress/adapter.ts new file mode 100644 index 000000000..f3a2b8640 --- /dev/null +++ b/src/features/editor/codepress/adapter.ts @@ -0,0 +1,455 @@ +import { isStableIdentifier, validateCommandBatch, validateTimelineState } from './contract' +import type { + EditApplyResult, + EditCommandBatch, + Precondition, + TimelineItem, + TimelineState, + VideoCommandError, +} from './contract' +import { EditEngineError, controlledEditEngine } from './edit-engine' +import type { + CodePressHostAdapters, + ControlledEditEngine, + ControlledEditorDocument, + ControlledEditorPort, + ControlledRenderer, + RenderedFrame, +} from './interfaces' +import { FrameTimingError, assertFrameAligned } from './timing' + +interface StoredOperation { + payload: string + result: Exclude +} + +export interface CodePressCommandAdapterOptions { + document: ControlledEditorDocument + editor?: ControlledEditorPort + renderer?: ControlledRenderer + hosts?: CodePressHostAdapters + editEngine?: ControlledEditEngine +} + +export interface AdapterSnapshot { + document: ControlledEditorDocument + revision: number +} + +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]` + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => `${JSON.stringify(key)}:${stableSerialize(entry)}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +function copyResult(value: T): T { + return JSON.parse(JSON.stringify(value)) as T +} + +function emptyActual(): Record { + return {} +} + +function error( + code: VideoCommandError['code'], + message: string, + details: VideoCommandError['details'], + extras: Pick = {}, +): VideoCommandError { + const retryable = code === 'revision_conflict' || code === 'operation_in_progress' + return { code, message, retryable, details, ...extras } +} + +function requestString(value: unknown, fallback: string): string { + return typeof value === 'string' && isStableIdentifier(value) ? value : fallback +} + +function rejected(input: unknown, commandError: VideoCommandError): EditApplyResult { + const candidate = input && typeof input === 'object' ? (input as Record) : {} + return { + status: 'rejected', + timeline_id: requestString(candidate.timeline_id, 'unknown-timeline'), + operation_id: requestString(candidate.operation_id, 'invalid-operation'), + idempotency_key: requestString(candidate.idempotency_key, 'invalid-idempotency'), + base_revision: + typeof candidate.base_revision === 'number' && + Number.isSafeInteger(candidate.base_revision) && + candidate.base_revision >= 0 + ? candidate.base_revision + : 0, + error: commandError, + } +} + +function itemId(item: TimelineItem): string { + return item.item_type === 'caption_cue' ? item.cue_id : item.item_id +} + +function findItem(timeline: TimelineState, id: string): TimelineItem | undefined { + for (const track of timeline.tracks) { + const item = track.items.find((candidate) => itemId(candidate) === id) + if (item) return item + } + return undefined +} + +function actualForPrecondition( + timeline: TimelineState, + precondition: Precondition, +): Record { + switch (precondition.type) { + case 'track_exists': + case 'track_absent': { + const track = timeline.tracks.find( + (candidate) => candidate.track_id === precondition.track_id, + ) + return { exists: Boolean(track), track_id: track?.track_id ?? null } + } + case 'item_exists': + case 'item_absent': { + const item = findItem(timeline, precondition.item_id) + return { exists: Boolean(item), item_id: item ? itemId(item) : null } + } + case 'item_at': { + const item = findItem(timeline, precondition.item_id) + if (!item) + return { + exists: false, + item_id: null, + track_id: null, + timeline_start_us: null, + timeline_end_us: null, + } + return item.item_type === 'caption_cue' + ? { + exists: true, + item_id: item.cue_id, + track_id: item.track_id, + timeline_start_us: item.start_us, + timeline_end_us: item.end_us, + } + : { + exists: true, + item_id: item.item_id, + track_id: item.track_id, + timeline_start_us: item.timeline_start_us, + timeline_end_us: item.timeline_end_us, + } + } + case 'media_content_hash': { + const media = timeline.media.find((candidate) => candidate.media_id === precondition.media_id) + return { + exists: Boolean(media), + media_id: media?.media_id ?? null, + content_hash: media?.content_hash ?? null, + } + } + case 'caption_cue_at': { + const item = findItem(timeline, precondition.cue_id) + if (!item || item.item_type !== 'caption_cue') + return { + exists: false, + cue_id: null, + track_id: null, + start_us: null, + end_us: null, + text: null, + } + return { + exists: true, + cue_id: item.cue_id, + track_id: item.track_id, + start_us: item.start_us, + end_us: item.end_us, + text: item.text, + } + } + } +} + +function preconditionMatches(timeline: TimelineState, precondition: Precondition): boolean { + const actual = actualForPrecondition(timeline, precondition) + switch (precondition.type) { + case 'track_exists': + return actual.exists === true + case 'track_absent': + return actual.exists === false + case 'item_exists': + return actual.exists === true + case 'item_absent': + return actual.exists === false + case 'item_at': + return ( + actual.exists === true && + actual.item_id === precondition.item_id && + actual.track_id === precondition.track_id && + actual.timeline_start_us === precondition.timeline_start_us && + actual.timeline_end_us === precondition.timeline_end_us + ) + case 'media_content_hash': + return actual.content_hash === precondition.content_hash + case 'caption_cue_at': + return ( + actual.exists === true && + actual.cue_id === precondition.cue_id && + actual.track_id === precondition.track_id && + actual.start_us === precondition.start_us && + actual.end_us === precondition.end_us && + actual.text === precondition.text + ) + } +} + +function validateCurrentTimeline(timeline: TimelineState): VideoCommandError | null { + const result = validateTimelineState(timeline) + return result.ok + ? null + : (result.errors[0] ?? + error('invalid_timeline', 'Timeline is invalid', { + kind: 'invalid_timeline', + path: 'timeline', + reason: 'validation failed', + })) +} + +/** + * CodePress command adapter. It owns neither durable persistence nor media + * resolution; it atomically translates a validated batch through the pure + * edit engine and publishes the resulting controlled document to its host. + */ +export class CodePressCommandAdapter implements ControlledEditorPort { + private document: ControlledEditorDocument + private readonly renderer?: ControlledRenderer + private readonly hosts: CodePressHostAdapters + private readonly editEngine: ControlledEditEngine + private readonly operations = new Map() + private readonly listeners = new Set<(document: ControlledEditorDocument) => void>() + private readonly externalEditor?: ControlledEditorPort + + constructor(options: CodePressCommandAdapterOptions) { + const timelineError = validateCurrentTimeline(options.document.timeline) + if (timelineError) throw new Error(timelineError.message) + assertFrameAligned(options.document.timeline.duration_us, options.document.fps) + this.document = copyResult(options.document) + this.renderer = options.renderer + this.hosts = options.hosts ?? {} + this.editEngine = options.editEngine ?? controlledEditEngine + this.externalEditor = options.editor + } + + getDocument(): ControlledEditorDocument { + if (this.externalEditor) return copyResult(this.externalEditor.getDocument()) + return copyResult(this.document) + } + + replaceDocument(document: ControlledEditorDocument): void { + const timelineError = validateCurrentTimeline(document.timeline) + if (timelineError) throw new Error(timelineError.message) + assertFrameAligned(document.timeline.duration_us, document.fps) + this.document = copyResult(document) + this.externalEditor?.replaceDocument(copyResult(document)) + this.notify() + } + + subscribe(listener: (document: ControlledEditorDocument) => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + getSnapshot(): AdapterSnapshot { + const document = this.getDocument() + return { document, revision: document.timeline.revision } + } + + /** Apply one canonical PR1 command batch synchronously and atomically. */ + apply(input: unknown): EditApplyResult { + const validRequest = validateCommandBatch(input) + if (!validRequest.ok) { + const first = + validRequest.errors[0] ?? + error('invalid_request', 'Request is invalid', { + kind: 'invalid_request', + path: 'request', + reason: 'validation failed', + }) + return rejected(input, first) + } + const batch: EditCommandBatch = validRequest.value + const current = this.getDocument() + const timelineError = validateCurrentTimeline(current.timeline) + if (timelineError) return rejected(batch, timelineError) + if (batch.timeline_id !== current.timeline.timeline_id) { + return rejected( + batch, + error( + 'unknown_timeline', + `Timeline "${batch.timeline_id}" is not loaded`, + { kind: 'resource', resource: 'timeline', id: batch.timeline_id }, + { operation_id: batch.operation_id }, + ), + ) + } + + const payload = stableSerialize(batch) + const prior = this.operations.get(batch.idempotency_key) + if (prior) { + if (prior.payload !== payload) { + return rejected( + batch, + error( + 'idempotency_conflict', + 'The idempotency key was already used for a different operation.', + { + kind: 'idempotency_conflict', + idempotency_key: batch.idempotency_key, + original_operation_id: prior.result.operation_id, + }, + { operation_id: batch.operation_id }, + ), + ) + } + const replayed: Exclude = { + ...copyResult(prior.result), + status: 'replayed', + replayed_operation_id: prior.result.operation_id, + } + return copyResult(replayed) + } + + if (batch.base_revision !== current.timeline.revision) { + return rejected( + batch, + error( + 'revision_conflict', + 'The timeline changed after this operation was prepared.', + { + kind: 'revision_conflict', + base_revision: batch.base_revision, + current_revision: current.timeline.revision, + rebase: { + status: 'required', + automatic: false, + strategy: 'refresh_then_resubmit', + retry_with_revision: current.timeline.revision, + }, + }, + { operation_id: batch.operation_id }, + ), + ) + } + + for (const [index, precondition] of batch.preconditions.entries()) { + if (!preconditionMatches(current.timeline, precondition)) { + return rejected( + batch, + error( + 'precondition_failed', + `Precondition ${index} failed.`, + { + kind: 'precondition_failed', + precondition_index: index, + precondition, + actual: actualForPrecondition(current.timeline, precondition) ?? emptyActual(), + }, + { operation_id: batch.operation_id }, + ), + ) + } + } + + try { + const engineResult = this.editEngine.apply(current.timeline, batch.commands, { + fps: current.fps, + }) + const nextTimeline: TimelineState = { + ...engineResult.timeline, + revision: current.timeline.revision + 1, + } + const nextDocument = { ...current, timeline: nextTimeline } + const result: Exclude = { + status: 'applied', + timeline_id: batch.timeline_id, + operation_id: batch.operation_id, + idempotency_key: batch.idempotency_key, + base_revision: batch.base_revision, + previous_revision: current.timeline.revision, + resulting_revision: nextTimeline.revision, + timeline: nextTimeline, + commands: engineResult.commands, + rebase: { status: 'not_attempted', automatic: false }, + } + this.document = copyResult(nextDocument) + this.externalEditor?.replaceDocument(copyResult(nextDocument)) + this.operations.set(batch.idempotency_key, { payload, result: copyResult(result) }) + this.notify() + void this.hosts.telemetryClient?.emit({ + name: 'video.timeline_operation_applied', + timeline_id: batch.timeline_id, + operation_id: batch.operation_id, + revision: nextTimeline.revision, + attributes: { command_count: batch.commands.length }, + }) + return copyResult(result) + } catch (caught) { + const commandError = + caught instanceof FrameTimingError + ? error( + 'invalid_request', + caught.message, + { kind: 'invalid_request', path: 'commands', reason: 'time must be frame-aligned' }, + { operation_id: batch.operation_id }, + ) + : caught instanceof EditEngineError + ? error( + caught.code === 'unknown_track' + ? 'unknown_track' + : caught.code === 'unknown_item' + ? 'unknown_item' + : caught.code === 'unknown_media' + ? 'unknown_media' + : caught.code === 'unsupported_command' + ? 'unsupported_command' + : 'invalid_request', + caught.message, + { kind: 'generic', reason: caught.message }, + { + operation_id: batch.operation_id, + ...(caught.command_id ? { command_id: caught.command_id } : {}), + }, + ) + : error( + 'invalid_request', + caught instanceof Error ? caught.message : String(caught), + { + kind: 'generic', + reason: caught instanceof Error ? caught.message : String(caught), + }, + { operation_id: batch.operation_id }, + ) + return rejected(batch, commandError) + } + } + + async renderFrame(time_us: number): Promise { + if (!this.renderer) throw new Error('No controlled renderer is attached') + const document = this.getDocument() + const frame = assertFrameAligned(time_us, document.fps) + return this.renderer.renderFrame({ document, frame, time_us }) + } + + private notify(): void { + const document = this.getDocument() + for (const listener of this.listeners) listener(document) + } +} + +export function createCodePressCommandAdapter( + options: CodePressCommandAdapterOptions, +): CodePressCommandAdapter { + return new CodePressCommandAdapter(options) +} diff --git a/src/features/editor/codepress/contract.ts b/src/features/editor/codepress/contract.ts new file mode 100644 index 000000000..7c2ef77e9 --- /dev/null +++ b/src/features/editor/codepress/contract.ts @@ -0,0 +1,1274 @@ +/** + * The FreeCut-side view of the CodePress video command contract. + * + * CodePress owns the wire package. This file intentionally mirrors its + * language-neutral field names instead of importing FreeCut's persisted + * project schema or the development-only headless API. Keeping the boundary + * here makes the adapter usable by a browser editor, a worker, and tests + * without giving any of those callers a filesystem or HTTP seam. + */ + +export const VIDEO_COMMAND_CONTRACT_VERSION = 1 as const +export const VIDEO_TIMELINE_SCHEMA_VERSION = 1 as const + +export const MAX_COMMANDS_PER_OPERATION = 64 +export const MAX_PRECONDITIONS_PER_OPERATION = 128 +export const MAX_CAPTION_CUES_PER_COMMAND = 512 +export const MAX_TRACKS_PER_TIMELINE = 128 +export const MAX_ITEMS_PER_TRACK = 4096 +export const MAX_ID_LENGTH = 128 +export const MAX_TEXT_LENGTH = 32_000 + +export type Brand = T & { readonly __brand: Name } +export type TimelineId = string +export type TimelineItemId = string +export type TrackId = string +export type MediaId = string +export type AssetId = MediaId +export type DeviceId = string +export type MediaRootId = string +export type LocalFileId = string +export type CloudObjectId = string +export type ContentHash = string +export type OperationId = string +export type IdempotencyKey = string +export type CommandId = string +export type CaptionCueId = TimelineItemId +export type ActorId = string +export type Microseconds = number +export type TimelineRevision = number + +export const MEDIA_KINDS = ['video', 'audio', 'image'] as const +export type MediaKind = (typeof MEDIA_KINDS)[number] + +export interface LocalMediaReference { + device_id: DeviceId + root_id: MediaRootId + file_id: LocalFileId + root_generation: number +} + +export interface CloudMediaReference { + object_id: CloudObjectId +} + +export type MediaAvailability = + | { mode: 'local'; local: LocalMediaReference; cloud?: never } + | { mode: 'cloud'; local?: never; cloud: CloudMediaReference } + | { mode: 'hybrid'; local: LocalMediaReference; cloud: CloudMediaReference } + +export interface MediaReference { + media_id: MediaId + media_kind: MediaKind + content_hash: ContentHash + duration_us: Microseconds | null + availability: MediaAvailability +} +export type MediaRef = MediaReference + +export interface Transform { + position_x: number + position_y: number + scale_x: number + scale_y: number + rotation_degrees: number + anchor_x: number + anchor_y: number +} + +export interface CaptionStyle { + font_family?: string + font_size?: number + color?: string + background_color?: string + background_opacity?: number + alignment?: 'left' | 'center' | 'right' +} + +export interface TextStyle { + font_family?: string + font_size?: number + color?: string + alignment?: 'left' | 'center' | 'right' +} + +export interface Transition { + transition_type: 'crossfade' | 'dip_to_black' | 'dip_to_white' | 'wipe' + duration_us: Microseconds +} + +export interface Effect { + effect_type: 'blur' | 'brightness' | 'contrast' | 'grayscale' | 'saturation' + amount: number +} + +export interface Keyframe { + property: + | 'position_x' + | 'position_y' + | 'scale_x' + | 'scale_y' + | 'rotation_degrees' + | 'opacity' + | 'volume' + time_us: Microseconds + value: number + interpolation: 'step' | 'linear' | 'bezier' +} + +export interface ClipItem { + item_type: 'clip' + item_id: TimelineItemId + track_id: TrackId + media_id: MediaId + media_kind: MediaKind + timeline_start_us: Microseconds + timeline_end_us: Microseconds + source_start_us: Microseconds + source_end_us: Microseconds + transform?: Transform + opacity?: number + volume?: number + speed?: number + fade_in_us?: Microseconds + fade_out_us?: Microseconds + transition_in?: Transition | null + transition_out?: Transition | null + effects?: readonly Effect[] + keyframes?: readonly Keyframe[] +} + +export interface TextItem { + item_type: 'text' + item_id: TimelineItemId + track_id: TrackId + timeline_start_us: Microseconds + timeline_end_us: Microseconds + text: string + style?: TextStyle + transform?: Transform + opacity?: number + keyframes?: readonly Keyframe[] +} + +export interface CaptionCue { + item_type: 'caption_cue' + cue_id: CaptionCueId + track_id: TrackId + start_us: Microseconds + end_us: Microseconds + text: string + speaker?: string | null + style?: CaptionStyle +} + +export type TimelineItem = ClipItem | TextItem | CaptionCue +export type TimelineItemInput = ClipItem | TextItem +export const TRACK_KINDS = ['video', 'audio', 'overlay', 'caption'] as const +export type TrackKind = (typeof TRACK_KINDS)[number] + +export interface TimelineTrack { + track_id: TrackId + kind: TrackKind + name: string + language?: string + locked: boolean + muted: boolean + /** Optional FreeCut-side extension for caption-track defaults. */ + default_style?: CaptionStyle | null + items: readonly TimelineItem[] +} + +export interface TimelineState { + contract_version: typeof VIDEO_COMMAND_CONTRACT_VERSION + schema_version: typeof VIDEO_TIMELINE_SCHEMA_VERSION + timeline_id: TimelineId + revision: TimelineRevision + duration_us: Microseconds + media: readonly MediaReference[] + tracks: readonly TimelineTrack[] +} + +export interface AddClipCommand { + command_id: CommandId + type: 'add_clip' + track_id: TrackId + item: ClipItem + index?: number +} +export interface AddTextCommand { + command_id: CommandId + type: 'add_text' + track_id: TrackId + item: TextItem + index?: number +} +export interface DuplicateItemCommand { + command_id: CommandId + type: 'duplicate_item' + item_id: TimelineItemId + new_item_id: TimelineItemId + to_track_id: TrackId + timeline_start_us?: Microseconds + index?: number +} +export interface RemoveItemCommand { + command_id: CommandId + type: 'remove_item' + item_id: TimelineItemId +} +export interface MoveItemCommand { + command_id: CommandId + type: 'move_item' + item_id: TimelineItemId + to_track_id: TrackId + timeline_start_us: Microseconds + index: number +} +export interface TrimItemCommand { + command_id: CommandId + type: 'trim_item' + item_id: TimelineItemId + edge: 'start' | 'end' + timeline_us: Microseconds + source_us: Microseconds +} +export interface SplitItemCommand { + command_id: CommandId + type: 'split_item' + item_id: TimelineItemId + at_timeline_us: Microseconds + at_source_us: Microseconds + left_item_id: TimelineItemId + right_item_id: TimelineItemId +} +export interface RippleDeleteCommand { + command_id: CommandId + type: 'ripple_delete' + start_us: Microseconds + end_us: Microseconds + track_ids: readonly TrackId[] | null +} +export interface AddTrackCommand { + command_id: CommandId + type: 'add_track' + track: Omit & { items?: readonly [] } + index: number +} +export interface RemoveTrackCommand { + command_id: CommandId + type: 'remove_track' + track_id: TrackId +} +export interface MoveTrackCommand { + command_id: CommandId + type: 'move_track' + track_id: TrackId + to_index: number +} +export interface UpdateTrackCommand { + command_id: CommandId + type: 'update_track' + track_id: TrackId + name?: string + language?: string | null + locked?: boolean + muted?: boolean +} +export interface AddCaptionTrackCommand { + command_id: CommandId + type: 'add_caption_track' + track_id: TrackId + name: string + language: string + index: number +} +export interface RemoveCaptionTrackCommand { + command_id: CommandId + type: 'remove_caption_track' + track_id: TrackId +} +export interface UpdateCaptionTrackCommand { + command_id: CommandId + type: 'update_caption_track' + track_id: TrackId + name?: string + language?: string + default_style?: CaptionStyle | null +} +export interface UpsertCaptionCuesCommand { + command_id: CommandId + type: 'upsert_caption_cues' + track_id: TrackId + cues: readonly CaptionCue[] +} +export interface RemoveCaptionCuesCommand { + command_id: CommandId + type: 'remove_caption_cues' + track_id: TrackId + cue_ids: readonly CaptionCueId[] +} + +export interface ItemPropertiesPatch { + transform?: Transform | null + opacity?: number + volume?: number + speed?: number + fade_in_us?: Microseconds | null + fade_out_us?: Microseconds | null + text?: string + text_style?: TextStyle | null + effects?: readonly Effect[] + keyframes?: readonly Keyframe[] + transition_in?: Transition | null + transition_out?: Transition | null +} +export interface SetItemPropertiesCommand { + command_id: CommandId + type: 'set_item_properties' + item_id: TimelineItemId + properties: ItemPropertiesPatch +} +export interface SetCaptionStyleCommand { + command_id: CommandId + type: 'set_caption_style' + track_id: TrackId + cue_ids: readonly CaptionCueId[] | null + style: CaptionStyle +} + +export const VIDEO_JOB_TYPES = [ + 'inspect', + 'proxy', + 'thumbnail', + 'contact_sheet', + 'waveform', + 'transcribe', + 'analysis', + 'preview', + 'export', + 'cloud_upload', +] as const +export type VideoJobType = (typeof VIDEO_JOB_TYPES)[number] +export interface RequestJobCommand { + command_id: CommandId + type: 'request_job' + job_type: VideoJobType + media_id?: MediaId + timeline_revision?: TimelineRevision + options?: Readonly> +} + +export type EditCommand = + | AddClipCommand + | AddTextCommand + | DuplicateItemCommand + | RemoveItemCommand + | MoveItemCommand + | TrimItemCommand + | SplitItemCommand + | RippleDeleteCommand + | AddTrackCommand + | RemoveTrackCommand + | MoveTrackCommand + | UpdateTrackCommand + | AddCaptionTrackCommand + | RemoveCaptionTrackCommand + | UpdateCaptionTrackCommand + | UpsertCaptionCuesCommand + | RemoveCaptionCuesCommand + | SetItemPropertiesCommand + | SetCaptionStyleCommand + | RequestJobCommand +export type VideoCommand = EditCommand + +export interface EditCommandBatch { + contract_version: typeof VIDEO_COMMAND_CONTRACT_VERSION + timeline_id: TimelineId + operation_id: OperationId + idempotency_key: IdempotencyKey + base_revision: TimelineRevision + preconditions: readonly Precondition[] + commands: readonly EditCommand[] +} +export type CommandBatch = EditCommandBatch +export type CommandType = EditCommand['type'] + +export const COMMAND_BATCH_LIMITS = { + max_commands: MAX_COMMANDS_PER_OPERATION, + max_preconditions: MAX_PRECONDITIONS_PER_OPERATION, + max_caption_cues_per_command: MAX_CAPTION_CUES_PER_COMMAND, +} as const + +export function commandType(command: EditCommand): CommandType { + return command.type +} + +export function itemIdFromCommand(command: EditCommand): TimelineItemId | null { + switch (command.type) { + case 'add_clip': + case 'add_text': + return command.item.item_id + case 'duplicate_item': + return command.new_item_id + case 'remove_item': + case 'move_item': + case 'trim_item': + case 'split_item': + case 'set_item_properties': + return command.item_id + case 'upsert_caption_cues': + return command.cues[0]?.cue_id ?? null + case 'remove_caption_cues': + return command.cue_ids[0] ?? null + case 'set_caption_style': + return command.cue_ids?.[0] ?? null + default: + return null + } +} + +export function isTimelineItemCommand( + command: EditCommand, +): command is Exclude< + EditCommand, + | AddTrackCommand + | RemoveTrackCommand + | MoveTrackCommand + | UpdateTrackCommand + | AddCaptionTrackCommand + | RemoveCaptionTrackCommand + | UpdateCaptionTrackCommand + | UpsertCaptionCuesCommand + | RemoveCaptionCuesCommand + | SetCaptionStyleCommand + | RequestJobCommand + | RippleDeleteCommand +> { + return itemIdFromCommand(command) !== null +} + +export type CommandItem = TimelineItem + +export interface TrackExistsPrecondition { + type: 'track_exists' + track_id: TrackId +} +export interface TrackAbsentPrecondition { + type: 'track_absent' + track_id: TrackId +} +export interface ItemExistsPrecondition { + type: 'item_exists' + item_id: TimelineItemId +} +export interface ItemAbsentPrecondition { + type: 'item_absent' + item_id: TimelineItemId +} +export interface ItemAtPrecondition { + type: 'item_at' + item_id: TimelineItemId + track_id: TrackId + timeline_start_us: Microseconds + timeline_end_us: Microseconds +} +export interface MediaContentHashPrecondition { + type: 'media_content_hash' + media_id: MediaId + content_hash: ContentHash +} +export interface CaptionCueAtPrecondition { + type: 'caption_cue_at' + track_id: TrackId + cue_id: TimelineItemId + start_us: Microseconds + end_us: Microseconds + text: string +} +export type Precondition = + | TrackExistsPrecondition + | TrackAbsentPrecondition + | ItemExistsPrecondition + | ItemAbsentPrecondition + | ItemAtPrecondition + | MediaContentHashPrecondition + | CaptionCueAtPrecondition + +export interface CommandEffect { + created_item_ids: readonly string[] + updated_item_ids: readonly string[] + deleted_item_ids: readonly string[] + moved_item_ids: readonly string[] + created_track_ids: readonly string[] + updated_track_ids: readonly string[] + deleted_track_ids: readonly string[] + timeline_delta_us: number +} +export interface AppliedCommandResult { + command_id: CommandId + command_type: CommandType + status: 'applied' + effect: CommandEffect +} +export interface OperationResultMetadata { + timeline_id: TimelineId + operation_id: OperationId + idempotency_key: IdempotencyKey + base_revision: TimelineRevision + previous_revision: TimelineRevision + resulting_revision: TimelineRevision +} +export interface AppliedOperationResult extends OperationResultMetadata { + status: 'applied' + timeline: TimelineState + commands: readonly AppliedCommandResult[] + rebase: { status: 'not_attempted'; automatic: false } +} +export interface ReplayedOperationResult extends OperationResultMetadata { + status: 'replayed' + replayed_operation_id: OperationId + timeline: TimelineState + commands: readonly AppliedCommandResult[] + rebase: { status: 'not_attempted'; automatic: false } +} +export interface RejectedOperationResult { + status: 'rejected' + timeline_id: TimelineId + operation_id: OperationId + idempotency_key: IdempotencyKey + base_revision: TimelineRevision + error: VideoCommandError +} +export type EditApplyResult = + | AppliedOperationResult + | ReplayedOperationResult + | RejectedOperationResult +export type ApplyResult = EditApplyResult + +export type VideoCommandErrorCode = + | 'invalid_request' + | 'invalid_timeline' + | 'revision_conflict' + | 'precondition_failed' + | 'idempotency_conflict' + | 'operation_in_progress' + | 'unknown_timeline' + | 'unknown_media' + | 'unknown_track' + | 'unknown_item' + | 'media_unavailable' + | 'unsupported_command' + | 'limit_exceeded' + | 'permission_denied' + +export interface RevisionConflictDetails { + kind: 'revision_conflict' + base_revision: TimelineRevision + current_revision: TimelineRevision + rebase: { + status: 'required' + automatic: false + strategy: 'refresh_then_resubmit' + retry_with_revision: TimelineRevision + } + changed_operation_ids?: readonly OperationId[] +} +export interface PreconditionsFailedDetails { + kind: 'precondition_failed' + precondition_index: number + precondition: Precondition + actual: Readonly> +} +export interface IdempotencyConflictDetails { + kind: 'idempotency_conflict' + idempotency_key: IdempotencyKey + original_operation_id: OperationId +} +export interface InvalidRequestDetails { + kind: 'invalid_request' + path: string + reason: string +} +export interface InvalidTimelineDetails { + kind: 'invalid_timeline' + path: string + reason: string +} +export interface ResourceErrorDetails { + kind: 'resource' + resource: 'timeline' | 'media' | 'track' | 'item' + id: string +} +export interface LimitErrorDetails { + kind: 'limit' + path: string + limit: number + actual: number +} +export interface GenericErrorDetails { + kind: 'generic' + reason: string + path?: string +} +export type VideoCommandErrorDetails = + | RevisionConflictDetails + | PreconditionsFailedDetails + | IdempotencyConflictDetails + | InvalidRequestDetails + | InvalidTimelineDetails + | ResourceErrorDetails + | LimitErrorDetails + | GenericErrorDetails +export interface VideoCommandError { + code: VideoCommandErrorCode + message: string + retryable: boolean + operation_id?: OperationId + command_id?: CommandId + details: VideoCommandErrorDetails +} + +export interface ValidationSuccess { + ok: true + value: T +} +export interface ValidationFailure { + ok: false + errors: readonly VideoCommandError[] +} +export type ValidationResult = ValidationSuccess | ValidationFailure + +const IDENTIFIER_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u + +export function isStableIdentifier(value: unknown): value is string { + return typeof value === 'string' && IDENTIFIER_PATTERN.test(value) +} + +export function isMicroseconds(value: unknown): value is Microseconds { + return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 +} + +export function asMicroseconds(value: number): Microseconds { + if (!isMicroseconds(value)) throw new Error('Microseconds must be a safe, non-negative integer') + return value +} + +export function isTimelineRevision(value: unknown): value is TimelineRevision { + return isMicroseconds(value) +} + +export function asTimelineRevision(value: number): TimelineRevision { + if (!isTimelineRevision(value)) + throw new Error('Timeline revisions must be safe, non-negative integers') + return value +} + +export function asStableIdentifier(value: string): T { + if (!isStableIdentifier(value)) + throw new Error(`Identifier must be 1-${MAX_ID_LENGTH} non-whitespace characters`) + return value as T +} + +export function isVideoCommandError(value: unknown): value is VideoCommandError { + if (!value || typeof value !== 'object') return false + const candidate = value as Partial + return ( + typeof candidate.code === 'string' && + typeof candidate.message === 'string' && + typeof candidate.retryable === 'boolean' && + typeof candidate.details === 'object' && + candidate.details !== null + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function invalidRequest(path: string, reason: string): VideoCommandError { + return { + code: 'invalid_request', + message: `${path}: ${reason}`, + retryable: false, + details: { kind: 'invalid_request', path, reason }, + } +} + +function invalidTimeline(path: string, reason: string): VideoCommandError { + return { + code: 'invalid_timeline', + message: `${path}: ${reason}`, + retryable: false, + details: { kind: 'invalid_timeline', path, reason }, + } +} + +function checkIdentifier( + value: unknown, + path: string, + errors: VideoCommandError[], +): value is string { + if (!isStableIdentifier(value)) { + errors.push( + invalidRequest(path, `must be a non-empty identifier of at most ${MAX_ID_LENGTH} characters`), + ) + return false + } + return true +} + +function checkMicroseconds(value: unknown, path: string, errors: VideoCommandError[]): boolean { + if (!isMicroseconds(value)) { + errors.push(invalidRequest(path, 'must be a safe, non-negative integer in microseconds')) + return false + } + return true +} + +function checkString( + value: unknown, + path: string, + errors: VideoCommandError[], + max = MAX_TEXT_LENGTH, +): boolean { + if (typeof value !== 'string' || value.length > max) { + errors.push(invalidRequest(path, `must be a string of at most ${max} characters`)) + return false + } + return true +} + +function checkInterval( + start: unknown, + end: unknown, + path: string, + errors: VideoCommandError[], +): boolean { + const startOk = checkMicroseconds(start, `${path}.start_us`, errors) + const endOk = checkMicroseconds(end, `${path}.end_us`, errors) + if (startOk && endOk && (end as number) <= (start as number)) { + errors.push(invalidRequest(path, 'end must be greater than start')) + return false + } + return startOk && endOk +} + +function checkIndex(value: unknown, path: string, errors: VideoCommandError[]): void { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { + errors.push(invalidRequest(path, 'must be a non-negative integer')) + } +} + +function checkArray(value: unknown, path: string, errors: VideoCommandError[]): value is unknown[] { + if (!Array.isArray(value)) { + errors.push(invalidRequest(path, 'must be an array')) + return false + } + return true +} + +function checkTrackKind(value: unknown): value is TrackKind { + return typeof value === 'string' && (TRACK_KINDS as readonly string[]).includes(value) +} + +function checkMediaKind(value: unknown): value is MediaKind { + return typeof value === 'string' && (MEDIA_KINDS as readonly string[]).includes(value) +} + +function validateMedia(value: unknown, path: string, errors: VideoCommandError[]): void { + if (!isRecord(value)) { + errors.push(invalidRequest(path, 'must be an object')) + return + } + checkIdentifier(value.media_id, `${path}.media_id`, errors) + if (!checkMediaKind(value.media_kind)) + errors.push(invalidRequest(`${path}.media_kind`, 'must be video, audio, or image')) + checkIdentifier(value.content_hash, `${path}.content_hash`, errors) + if (value.duration_us !== null) + checkMicroseconds(value.duration_us, `${path}.duration_us`, errors) + if (!isRecord(value.availability)) { + errors.push(invalidRequest(`${path}.availability`, 'must be an object')) + return + } + const mode = value.availability.mode + if (mode !== 'local' && mode !== 'cloud' && mode !== 'hybrid') { + errors.push(invalidRequest(`${path}.availability.mode`, 'must be local, cloud, or hybrid')) + return + } + const local = value.availability.local + const cloud = value.availability.cloud + if (mode !== 'cloud' && !isRecord(local)) + errors.push(invalidRequest(`${path}.availability.local`, 'is required')) + if (mode !== 'local' && !isRecord(cloud)) + errors.push(invalidRequest(`${path}.availability.cloud`, 'is required')) + if (isRecord(local)) { + checkIdentifier(local.device_id, `${path}.availability.local.device_id`, errors) + checkIdentifier(local.root_id, `${path}.availability.local.root_id`, errors) + checkIdentifier(local.file_id, `${path}.availability.local.file_id`, errors) + if ( + typeof local.root_generation !== 'number' || + !Number.isSafeInteger(local.root_generation) || + local.root_generation < 1 + ) { + errors.push( + invalidRequest( + `${path}.availability.local.root_generation`, + 'must be a positive safe integer', + ), + ) + } + } + if (isRecord(cloud)) + checkIdentifier(cloud.object_id, `${path}.availability.cloud.object_id`, errors) +} + +function validateCaptionCue( + value: unknown, + path: string, + errors: VideoCommandError[], +): value is CaptionCue { + if (!isRecord(value)) { + errors.push(invalidRequest(path, 'must be an object')) + return false + } + if (value.item_type !== 'caption_cue') + errors.push(invalidRequest(`${path}.item_type`, 'must be caption_cue')) + checkIdentifier(value.cue_id, `${path}.cue_id`, errors) + checkIdentifier(value.track_id, `${path}.track_id`, errors) + checkInterval(value.start_us, value.end_us, path, errors) + checkString(value.text, `${path}.text`, errors) + if (value.speaker !== undefined && value.speaker !== null) + checkString(value.speaker, `${path}.speaker`, errors, MAX_ID_LENGTH) + return true +} + +function validateTimelineItem( + value: unknown, + path: string, + errors: VideoCommandError[], +): value is TimelineItem { + if (!isRecord(value)) { + errors.push(invalidTimeline(path, 'must be an object')) + return false + } + if (value.item_type === 'caption_cue') return validateCaptionCue(value, path, errors) + if (value.item_type !== 'clip' && value.item_type !== 'text') { + errors.push(invalidTimeline(`${path}.item_type`, 'must be clip, text, or caption_cue')) + return false + } + checkIdentifier(value.item_id, `${path}.item_id`, errors) + checkIdentifier(value.track_id, `${path}.track_id`, errors) + checkInterval(value.timeline_start_us, value.timeline_end_us, `${path}.timeline`, errors) + if (value.item_type === 'clip') { + checkIdentifier(value.media_id, `${path}.media_id`, errors) + if (!checkMediaKind(value.media_kind)) + errors.push(invalidTimeline(`${path}.media_kind`, 'must be video, audio, or image')) + checkInterval(value.source_start_us, value.source_end_us, `${path}.source`, errors) + } else { + checkString(value.text, `${path}.text`, errors) + } + return true +} + +function validateTrack( + value: unknown, + path: string, + errors: VideoCommandError[], +): value is TimelineTrack { + if (!isRecord(value)) { + errors.push(invalidTimeline(path, 'must be an object')) + return false + } + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (!checkTrackKind(value.kind)) + errors.push(invalidTimeline(`${path}.kind`, 'must be video, audio, overlay, or caption')) + checkString(value.name, `${path}.name`, errors, MAX_ID_LENGTH) + if (value.language !== undefined) checkString(value.language, `${path}.language`, errors, 32) + if (typeof value.locked !== 'boolean') + errors.push(invalidTimeline(`${path}.locked`, 'must be a boolean')) + if (typeof value.muted !== 'boolean') + errors.push(invalidTimeline(`${path}.muted`, 'must be a boolean')) + if (!Array.isArray(value.items)) { + errors.push(invalidTimeline(`${path}.items`, 'must be an array')) + return false + } + if (value.items.length > MAX_ITEMS_PER_TRACK) + errors.push( + invalidTimeline(`${path}.items`, `must contain at most ${MAX_ITEMS_PER_TRACK} items`), + ) + return true +} + +export function validateTimelineState(input: unknown): ValidationResult { + const errors: VideoCommandError[] = [] + if (!isRecord(input)) + return { ok: false, errors: [invalidTimeline('timeline', 'must be an object')] } + if (input.contract_version !== VIDEO_COMMAND_CONTRACT_VERSION) + errors.push(invalidTimeline('contract_version', 'is not supported')) + if (input.schema_version !== VIDEO_TIMELINE_SCHEMA_VERSION) + errors.push(invalidTimeline('schema_version', 'is not supported')) + checkIdentifier(input.timeline_id, 'timeline_id', errors) + if (!isTimelineRevision(input.revision)) + errors.push(invalidTimeline('revision', 'must be a safe, non-negative integer')) + checkMicroseconds(input.duration_us, 'duration_us', errors) + + const mediaIds = new Set() + if (!Array.isArray(input.media)) errors.push(invalidTimeline('media', 'must be an array')) + else { + for (const [index, media] of input.media.entries()) { + const path = `media[${index}]` + validateMedia(media, path, errors) + if (isRecord(media) && isStableIdentifier(media.media_id)) { + if (mediaIds.has(media.media_id)) + errors.push(invalidTimeline(`${path}.media_id`, 'must be unique')) + mediaIds.add(media.media_id) + } + } + } + const mediaValues = Array.isArray(input.media) ? input.media : [] + + const trackIds = new Set() + const itemIds = new Set() + if (!Array.isArray(input.tracks)) errors.push(invalidTimeline('tracks', 'must be an array')) + else { + if (input.tracks.length > MAX_TRACKS_PER_TIMELINE) + errors.push( + invalidTimeline('tracks', `must contain at most ${MAX_TRACKS_PER_TIMELINE} tracks`), + ) + for (const [trackIndex, track] of input.tracks.entries()) { + const path = `tracks[${trackIndex}]` + if (!validateTrack(track, path, errors) || !isRecord(track)) continue + const trackId = String(track.track_id) + if (trackIds.has(trackId)) errors.push(invalidTimeline(`${path}.track_id`, 'must be unique')) + trackIds.add(trackId) + if (!Array.isArray(track.items)) continue + for (const [itemIndex, item] of track.items.entries()) { + const itemPath = `${path}.items[${itemIndex}]` + if (!validateTimelineItem(item, itemPath, errors) || !isRecord(item)) continue + const id = item.item_type === 'caption_cue' ? item.cue_id : item.item_id + if (isStableIdentifier(id)) { + if (itemIds.has(id)) errors.push(invalidTimeline(itemPath, 'item ID must be unique')) + itemIds.add(id) + } + if (item.track_id !== track.track_id) + errors.push(invalidTimeline(`${itemPath}.track_id`, 'must match its containing track')) + if (item.item_type === 'clip') { + const media = mediaValues.find( + (candidate: unknown) => isRecord(candidate) && candidate.media_id === item.media_id, + ) + if (!media) + errors.push(invalidTimeline(`${itemPath}.media_id`, 'must reference timeline.media')) + else if (media.media_kind !== item.media_kind) + errors.push( + invalidTimeline(`${itemPath}.media_kind`, 'must match the referenced media'), + ) + } + if (track.kind === 'caption' && item.item_type !== 'caption_cue') + errors.push(invalidTimeline(itemPath, 'caption tracks may only contain caption cues')) + if (track.kind !== 'caption' && item.item_type === 'caption_cue') + errors.push(invalidTimeline(itemPath, 'caption cues must belong to a caption track')) + if (track.kind === 'audio' && item.item_type === 'clip' && item.media_kind !== 'audio') + errors.push(invalidTimeline(itemPath, 'audio tracks may only contain audio clips')) + const end = item.item_type === 'caption_cue' ? item.end_us : item.timeline_end_us + if (isMicroseconds(end) && isMicroseconds(input.duration_us) && end > input.duration_us) + errors.push(invalidTimeline(itemPath, 'item must end within timeline duration')) + } + } + } + return errors.length > 0 + ? { ok: false, errors } + : { ok: true, value: input as unknown as TimelineState } +} + +function validatePrecondition(value: unknown, path: string, errors: VideoCommandError[]): void { + if (!isRecord(value)) { + errors.push(invalidRequest(path, 'must be an object')) + return + } + if (typeof value.type !== 'string') { + errors.push(invalidRequest(`${path}.type`, 'is required')) + return + } + switch (value.type) { + case 'track_exists': + case 'track_absent': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + break + case 'item_exists': + case 'item_absent': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + break + case 'item_at': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + checkIdentifier(value.track_id, `${path}.track_id`, errors) + checkInterval(value.timeline_start_us, value.timeline_end_us, `${path}.timeline`, errors) + break + case 'media_content_hash': + checkIdentifier(value.media_id, `${path}.media_id`, errors) + checkIdentifier(value.content_hash, `${path}.content_hash`, errors) + break + case 'caption_cue_at': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + checkIdentifier(value.cue_id, `${path}.cue_id`, errors) + checkInterval(value.start_us, value.end_us, path, errors) + checkString(value.text, `${path}.text`, errors) + break + default: + errors.push(invalidRequest(`${path}.type`, 'is not a supported precondition')) + } +} + +function validateCommand(value: unknown, path: string, errors: VideoCommandError[]): void { + if (!isRecord(value)) { + errors.push(invalidRequest(path, 'must be an object')) + return + } + checkIdentifier(value.command_id, `${path}.command_id`, errors) + if (typeof value.type !== 'string') { + errors.push(invalidRequest(`${path}.type`, 'is required')) + return + } + switch (value.type) { + case 'add_clip': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (value.index !== undefined) checkIndex(value.index, `${path}.index`, errors) + if (!isRecord(value.item)) errors.push(invalidRequest(`${path}.item`, 'must be an object')) + else { + if (value.item.item_type !== 'clip') + errors.push(invalidRequest(`${path}.item.item_type`, 'must be clip')) + validateTimelineItem(value.item, `${path}.item`, errors) + if (value.item.track_id !== value.track_id) + errors.push(invalidRequest(`${path}.item.track_id`, 'must match track_id')) + } + break + case 'add_text': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (value.index !== undefined) checkIndex(value.index, `${path}.index`, errors) + if (!isRecord(value.item)) errors.push(invalidRequest(`${path}.item`, 'must be an object')) + else { + if (value.item.item_type !== 'text') + errors.push(invalidRequest(`${path}.item.item_type`, 'must be text')) + validateTimelineItem(value.item, `${path}.item`, errors) + if (value.item.track_id !== value.track_id) + errors.push(invalidRequest(`${path}.item.track_id`, 'must match track_id')) + } + break + case 'duplicate_item': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + checkIdentifier(value.new_item_id, `${path}.new_item_id`, errors) + checkIdentifier(value.to_track_id, `${path}.to_track_id`, errors) + if (value.item_id === value.new_item_id) + errors.push(invalidRequest(path, 'new item ID must differ from source item ID')) + if (value.timeline_start_us !== undefined) + checkMicroseconds(value.timeline_start_us, `${path}.timeline_start_us`, errors) + if (value.index !== undefined) checkIndex(value.index, `${path}.index`, errors) + break + case 'remove_item': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + break + case 'move_item': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + checkIdentifier(value.to_track_id, `${path}.to_track_id`, errors) + checkMicroseconds(value.timeline_start_us, `${path}.timeline_start_us`, errors) + checkIndex(value.index, `${path}.index`, errors) + break + case 'trim_item': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + if (value.edge !== 'start' && value.edge !== 'end') + errors.push(invalidRequest(`${path}.edge`, 'must be start or end')) + checkMicroseconds(value.timeline_us, `${path}.timeline_us`, errors) + checkMicroseconds(value.source_us, `${path}.source_us`, errors) + break + case 'split_item': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + checkMicroseconds(value.at_timeline_us, `${path}.at_timeline_us`, errors) + checkMicroseconds(value.at_source_us, `${path}.at_source_us`, errors) + checkIdentifier(value.left_item_id, `${path}.left_item_id`, errors) + checkIdentifier(value.right_item_id, `${path}.right_item_id`, errors) + if (new Set([value.item_id, value.left_item_id, value.right_item_id]).size !== 3) + errors.push(invalidRequest(path, 'split item IDs must all be distinct')) + break + case 'ripple_delete': + checkInterval(value.start_us, value.end_us, path, errors) + if (value.track_ids !== null && !checkArray(value.track_ids, `${path}.track_ids`, errors)) + return + if (Array.isArray(value.track_ids)) { + const ids = new Set() + for (const [index, id] of value.track_ids.entries()) { + if (checkIdentifier(id, `${path}.track_ids[${index}]`, errors)) { + if (ids.has(id)) + errors.push(invalidRequest(`${path}.track_ids[${index}]`, 'must be unique')) + ids.add(id) + } + } + } + break + case 'add_track': + checkIndex(value.index, `${path}.index`, errors) + if (!isRecord(value.track)) errors.push(invalidRequest(`${path}.track`, 'must be an object')) + else { + checkIdentifier(value.track.track_id, `${path}.track.track_id`, errors) + if (!checkTrackKind(value.track.kind)) + errors.push(invalidRequest(`${path}.track.kind`, 'must be a supported track kind')) + checkString(value.track.name, `${path}.track.name`, errors, MAX_ID_LENGTH) + if ( + value.track.items !== undefined && + (!Array.isArray(value.track.items) || value.track.items.length !== 0) + ) + errors.push( + invalidRequest(`${path}.track.items`, 'must be omitted or empty when adding a track'), + ) + } + break + case 'remove_track': + case 'move_track': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (value.type === 'move_track') checkIndex(value.to_index, `${path}.to_index`, errors) + break + case 'update_track': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (value.name !== undefined) checkString(value.name, `${path}.name`, errors, MAX_ID_LENGTH) + if (value.language !== undefined && value.language !== null) + checkString(value.language, `${path}.language`, errors, 32) + if (value.locked !== undefined && typeof value.locked !== 'boolean') + errors.push(invalidRequest(`${path}.locked`, 'must be a boolean')) + if (value.muted !== undefined && typeof value.muted !== 'boolean') + errors.push(invalidRequest(`${path}.muted`, 'must be a boolean')) + break + case 'add_caption_track': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + checkString(value.name, `${path}.name`, errors, MAX_ID_LENGTH) + checkString(value.language, `${path}.language`, errors, 32) + checkIndex(value.index, `${path}.index`, errors) + break + case 'remove_caption_track': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + break + case 'update_caption_track': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (value.name !== undefined) checkString(value.name, `${path}.name`, errors, MAX_ID_LENGTH) + if (value.language !== undefined) checkString(value.language, `${path}.language`, errors, 32) + break + case 'upsert_caption_cues': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (checkArray(value.cues, `${path}.cues`, errors)) { + if (value.cues.length > MAX_CAPTION_CUES_PER_COMMAND) + errors.push( + invalidRequest( + `${path}.cues`, + `must contain at most ${MAX_CAPTION_CUES_PER_COMMAND} cues`, + ), + ) + const ids = new Set() + for (const [index, cue] of value.cues.entries()) { + validateCaptionCue(cue, `${path}.cues[${index}]`, errors) + if (isRecord(cue) && cue.track_id !== value.track_id) + errors.push(invalidRequest(`${path}.cues[${index}].track_id`, 'must match track_id')) + if (isRecord(cue) && isStableIdentifier(cue.cue_id)) { + if (ids.has(cue.cue_id)) + errors.push( + invalidRequest( + `${path}.cues[${index}].cue_id`, + 'must be unique within the command', + ), + ) + ids.add(cue.cue_id) + } + } + } + break + case 'remove_caption_cues': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (checkArray(value.cue_ids, `${path}.cue_ids`, errors)) { + const ids = new Set() + for (const [index, id] of value.cue_ids.entries()) { + if (checkIdentifier(id, `${path}.cue_ids[${index}]`, errors)) { + if (ids.has(id)) + errors.push(invalidRequest(`${path}.cue_ids[${index}]`, 'must be unique')) + ids.add(id) + } + } + } + break + case 'set_item_properties': + checkIdentifier(value.item_id, `${path}.item_id`, errors) + if (!isRecord(value.properties)) + errors.push(invalidRequest(`${path}.properties`, 'must be an object')) + else if (value.properties.text !== undefined) + checkString(value.properties.text, `${path}.properties.text`, errors) + break + case 'set_caption_style': + checkIdentifier(value.track_id, `${path}.track_id`, errors) + if (value.cue_ids !== null && checkArray(value.cue_ids, `${path}.cue_ids`, errors)) { + for (const [index, id] of value.cue_ids.entries()) + checkIdentifier(id, `${path}.cue_ids[${index}]`, errors) + } + if (!isRecord(value.style)) errors.push(invalidRequest(`${path}.style`, 'must be an object')) + break + case 'request_job': + if ( + !( + typeof value.job_type === 'string' && + (VIDEO_JOB_TYPES as readonly string[]).includes(value.job_type) + ) + ) + errors.push(invalidRequest(`${path}.job_type`, 'is not a supported job type')) + if (value.media_id !== undefined) checkIdentifier(value.media_id, `${path}.media_id`, errors) + if (value.timeline_revision !== undefined && !isTimelineRevision(value.timeline_revision)) + errors.push( + invalidRequest(`${path}.timeline_revision`, 'must be a safe, non-negative integer'), + ) + if (value.options !== undefined && !isRecord(value.options)) + errors.push(invalidRequest(`${path}.options`, 'must be an object')) + break + default: + errors.push(invalidRequest(`${path}.type`, 'is not a supported command')) + } +} + +export function validateCommandBatch(input: unknown): ValidationResult { + const errors: VideoCommandError[] = [] + if (!isRecord(input)) + return { ok: false, errors: [invalidRequest('request', 'must be an object')] } + if (input.contract_version !== VIDEO_COMMAND_CONTRACT_VERSION) + errors.push(invalidRequest('contract_version', 'is not supported')) + checkIdentifier(input.timeline_id, 'timeline_id', errors) + checkIdentifier(input.operation_id, 'operation_id', errors) + checkIdentifier(input.idempotency_key, 'idempotency_key', errors) + if (!isTimelineRevision(input.base_revision)) + errors.push(invalidRequest('base_revision', 'must be a safe, non-negative integer')) + if (checkArray(input.preconditions, 'preconditions', errors)) { + if (input.preconditions.length > MAX_PRECONDITIONS_PER_OPERATION) + errors.push( + invalidRequest( + 'preconditions', + `must contain at most ${MAX_PRECONDITIONS_PER_OPERATION} entries`, + ), + ) + for (const [index, precondition] of input.preconditions.entries()) + validatePrecondition(precondition, `preconditions[${index}]`, errors) + } + if (checkArray(input.commands, 'commands', errors)) { + if (input.commands.length === 0) + errors.push(invalidRequest('commands', 'must contain at least one command')) + if (input.commands.length > MAX_COMMANDS_PER_OPERATION) + errors.push( + invalidRequest('commands', `must contain at most ${MAX_COMMANDS_PER_OPERATION} commands`), + ) + const commandIds = new Set() + for (const [index, command] of input.commands.entries()) { + validateCommand(command, `commands[${index}]`, errors) + if (isRecord(command) && isStableIdentifier(command.command_id)) { + if (commandIds.has(command.command_id)) + errors.push( + invalidRequest(`commands[${index}].command_id`, 'must be unique within the batch'), + ) + commandIds.add(command.command_id) + } + } + } + return errors.length > 0 + ? { ok: false, errors } + : { ok: true, value: input as unknown as EditCommandBatch } +} diff --git a/src/features/editor/codepress/controlled-editor.ts b/src/features/editor/codepress/controlled-editor.ts new file mode 100644 index 000000000..297362a6a --- /dev/null +++ b/src/features/editor/codepress/controlled-editor.ts @@ -0,0 +1,10 @@ +/** Stable alias for hosts that want the controlled editor boundary by name. */ +export { + CodePressCommandAdapter as ControlledEditor, + createCodePressCommandAdapter as createControlledEditor, +} from './adapter' +export type { + CodePressCommandAdapterOptions as ControlledEditorOptions, + AdapterSnapshot as ControlledEditorSnapshot, +} from './adapter' +export type { ControlledEditorDocument, ControlledEditorPort } from './interfaces' diff --git a/src/features/editor/codepress/document.ts b/src/features/editor/codepress/document.ts new file mode 100644 index 000000000..39dd254a6 --- /dev/null +++ b/src/features/editor/codepress/document.ts @@ -0,0 +1,298 @@ +import type { + ClipItem, + MediaReference, + TextItem, + TimelineItem, + TimelineState, + TimelineTrack, +} from './contract' +import type { ControlledEditorDocument } from './interfaces' +import { assertFrameAligned, framesToMicroseconds } from './timing' + +/** Minimal frame-native document shape expected from a FreeCut host. */ +export interface FreeCutFrameClip { + type: 'video' | 'audio' | 'image' + id: string + trackId: string + mediaId: string + from: number + durationInFrames: number + sourceStart?: number + sourceEnd?: number + volume?: number + speed?: number + opacity?: number + transform?: Record +} + +export interface FreeCutFrameText { + type: 'text' + id: string + trackId: string + from: number + durationInFrames: number + text: string + style?: Record + opacity?: number + transform?: Record +} + +export interface FreeCutFrameCaptionCue { + type: 'caption_cue' + id: string + trackId: string + from: number + durationInFrames: number + text: string + speaker?: string | null +} + +export type FreeCutFrameItem = FreeCutFrameClip | FreeCutFrameText | FreeCutFrameCaptionCue + +export interface FreeCutFrameTrack { + id: string + kind: 'video' | 'audio' | 'overlay' | 'caption' + name: string + language?: string + locked: boolean + muted: boolean + items: readonly FreeCutFrameItem[] +} + +export interface FreeCutFrameDocument { + timelineId: string + revision: number + fps: number + durationInFrames: number + media: readonly MediaReference[] + tracks: readonly FreeCutFrameTrack[] + width: number + height: number + backgroundColor?: string +} + +export class FreeCutDocumentConversionError extends Error { + constructor(message: string) { + super(message) + this.name = 'FreeCutDocumentConversionError' + } +} + +function frameRange(item: FreeCutFrameItem, fps: number): { start_us: number; end_us: number } { + if ( + !Number.isSafeInteger(item.from) || + item.from < 0 || + !Number.isSafeInteger(item.durationInFrames) || + item.durationInFrames <= 0 + ) { + throw new FreeCutDocumentConversionError( + `Item "${item.id}" must have non-negative integer frame bounds`, + ) + } + const start_us = framesToMicroseconds(item.from, fps) + const end_us = framesToMicroseconds(item.from + item.durationInFrames, fps) + if (end_us <= start_us) + throw new FreeCutDocumentConversionError(`Item "${item.id}" has an empty frame range`) + return { start_us, end_us } +} + +function toContractItem(item: FreeCutFrameItem, fps: number): TimelineItem { + const range = frameRange(item, fps) + if (item.type === 'caption_cue') { + return { + item_type: 'caption_cue', + cue_id: item.id, + track_id: item.trackId, + start_us: range.start_us, + end_us: range.end_us, + text: item.text, + ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), + } + } + if (item.type === 'text') { + return { + item_type: 'text', + item_id: item.id, + track_id: item.trackId, + timeline_start_us: range.start_us, + timeline_end_us: range.end_us, + text: item.text, + ...(item.style + ? { + style: { + ...(typeof item.style.font_family === 'string' + ? { font_family: item.style.font_family } + : {}), + ...(typeof item.style.font_size === 'number' + ? { font_size: item.style.font_size } + : {}), + ...(typeof item.style.color === 'string' ? { color: item.style.color } : {}), + ...(item.style.alignment === 'left' || + item.style.alignment === 'center' || + item.style.alignment === 'right' + ? { alignment: item.style.alignment } + : {}), + }, + } + : {}), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + ...(item.transform ? { transform: toTransform(item.transform) } : {}), + } + } + const mediaKind = item.type + const sourceStart = item.sourceStart ?? item.from + const sourceEnd = item.sourceEnd ?? sourceStart + item.durationInFrames + if ( + !Number.isSafeInteger(sourceStart) || + !Number.isSafeInteger(sourceEnd) || + sourceEnd <= sourceStart + ) { + throw new FreeCutDocumentConversionError(`Clip "${item.id}" has invalid source bounds`) + } + return { + item_type: 'clip', + item_id: item.id, + track_id: item.trackId, + media_id: item.mediaId, + media_kind: mediaKind, + timeline_start_us: range.start_us, + timeline_end_us: range.end_us, + source_start_us: framesToMicroseconds(sourceStart, fps), + source_end_us: framesToMicroseconds(sourceEnd, fps), + ...(item.volume !== undefined ? { volume: item.volume } : {}), + ...(item.speed !== undefined ? { speed: item.speed } : {}), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + ...(item.transform ? { transform: toTransform(item.transform) } : {}), + } +} + +function toTransform(value: Record): ClipItem['transform'] { + return { + position_x: value.position_x ?? value.x ?? 0, + position_y: value.position_y ?? value.y ?? 0, + scale_x: value.scale_x ?? value.scaleX ?? 1, + scale_y: value.scale_y ?? value.scaleY ?? 1, + rotation_degrees: value.rotation_degrees ?? value.rotation ?? 0, + anchor_x: value.anchor_x ?? value.anchorX ?? 0, + anchor_y: value.anchor_y ?? value.anchorY ?? 0, + } +} + +function fromTransform( + value: ClipItem['transform'] | TextItem['transform'] | undefined, +): Record | undefined { + if (!value) return undefined + return { + position_x: value.position_x, + position_y: value.position_y, + scale_x: value.scale_x, + scale_y: value.scale_y, + rotation_degrees: value.rotation_degrees, + anchor_x: value.anchor_x, + anchor_y: value.anchor_y, + } +} + +function toTrack(track: FreeCutFrameTrack, fps: number): TimelineTrack { + return { + track_id: track.id, + kind: track.kind, + name: track.name, + ...(track.language !== undefined ? { language: track.language } : {}), + locked: track.locked, + muted: track.muted, + items: track.items.map((item) => toContractItem(item, fps)), + } +} + +export function freeCutDocumentToControlledDocument( + input: FreeCutFrameDocument, +): ControlledEditorDocument { + if (!Number.isFinite(input.fps) || input.fps <= 0) + throw new FreeCutDocumentConversionError('fps must be a finite positive number') + if (!Number.isSafeInteger(input.durationInFrames) || input.durationInFrames < 0) + throw new FreeCutDocumentConversionError('durationInFrames must be a safe non-negative integer') + const timeline: TimelineState = { + contract_version: 1, + schema_version: 1, + timeline_id: input.timelineId, + revision: input.revision, + duration_us: framesToMicroseconds(input.durationInFrames, input.fps), + media: input.media, + tracks: input.tracks.map((track) => toTrack(track, input.fps)), + } + return { + timeline, + fps: input.fps, + width: input.width, + height: input.height, + ...(input.backgroundColor !== undefined ? { background_color: input.backgroundColor } : {}), + } +} + +function fromContractItem(item: TimelineItem, fps: number): FreeCutFrameItem { + if (item.item_type === 'caption_cue') { + return { + type: 'caption_cue', + id: item.cue_id, + trackId: item.track_id, + from: assertFrameAligned(item.start_us, fps), + durationInFrames: assertFrameAligned(item.end_us - item.start_us, fps), + text: item.text, + ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), + } + } + if (item.item_type === 'text') { + return { + type: 'text', + id: item.item_id, + trackId: item.track_id, + from: assertFrameAligned(item.timeline_start_us, fps), + durationInFrames: assertFrameAligned(item.timeline_end_us - item.timeline_start_us, fps), + text: item.text, + ...(item.style ? { style: { ...item.style } } : {}), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + ...(item.transform ? { transform: fromTransform(item.transform) } : {}), + } + } + return { + type: item.media_kind, + id: item.item_id, + trackId: item.track_id, + mediaId: item.media_id, + from: assertFrameAligned(item.timeline_start_us, fps), + durationInFrames: assertFrameAligned(item.timeline_end_us - item.timeline_start_us, fps), + sourceStart: assertFrameAligned(item.source_start_us, fps), + sourceEnd: assertFrameAligned(item.source_end_us, fps), + ...(item.volume !== undefined ? { volume: item.volume } : {}), + ...(item.speed !== undefined ? { speed: item.speed } : {}), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + ...(item.transform ? { transform: fromTransform(item.transform) } : {}), + } +} + +export function controlledDocumentToFreeCutDocument( + document: ControlledEditorDocument, +): FreeCutFrameDocument { + return { + timelineId: document.timeline.timeline_id, + revision: document.timeline.revision, + fps: document.fps, + durationInFrames: assertFrameAligned(document.timeline.duration_us, document.fps), + media: document.timeline.media, + tracks: document.timeline.tracks.map((track) => ({ + id: track.track_id, + kind: track.kind, + name: track.name, + ...(track.language !== undefined ? { language: track.language } : {}), + locked: track.locked, + muted: track.muted, + items: track.items.map((item) => fromContractItem(item, document.fps)), + })), + width: document.width, + height: document.height, + ...(document.background_color !== undefined + ? { backgroundColor: document.background_color } + : {}), + } +} diff --git a/src/features/editor/codepress/edit-engine.ts b/src/features/editor/codepress/edit-engine.ts new file mode 100644 index 000000000..dddd44d49 --- /dev/null +++ b/src/features/editor/codepress/edit-engine.ts @@ -0,0 +1,883 @@ +import { assertFrameAligned, framesToMicroseconds } from './timing' +import type { + AddCaptionTrackCommand, + AddClipCommand, + AddTextCommand, + AppliedCommandResult, + CaptionCue, + ClipItem, + CommandEffect, + EditCommand, + Effect, + ItemPropertiesPatch, + Keyframe, + MoveItemCommand, + SetItemPropertiesCommand, + TimelineItem, + TimelineState, + TimelineTrack, + TrackId, +} from './contract' +import { validateTimelineState } from './contract' +import type { ControlledEditEngine, EditEngineContext, EditEngineResult } from './interfaces' + +export class EditEngineError extends Error { + readonly code: + | 'unknown_track' + | 'unknown_item' + | 'unknown_media' + | 'invalid_request' + | 'unsupported_command' + readonly command_id?: string + + constructor(code: EditEngineError['code'], message: string, command_id?: string) { + super(message) + this.name = 'EditEngineError' + this.code = code + this.command_id = command_id + } +} + +interface LocatedItem { + item: TimelineItem + trackIndex: number + itemIndex: number +} + +interface MutableTimeline extends TimelineState { + tracks: TimelineTrack[] +} + +function emptyEffect(timeline_delta_us = 0): CommandEffect { + return { + created_item_ids: [], + updated_item_ids: [], + deleted_item_ids: [], + moved_item_ids: [], + created_track_ids: [], + updated_track_ids: [], + deleted_track_ids: [], + timeline_delta_us, + } +} + +function cloneItem(item: TimelineItem): TimelineItem { + if (item.item_type === 'caption_cue') { + return { ...item, style: item.style ? { ...item.style } : undefined } + } + if (item.item_type === 'text') { + return { + ...item, + transform: item.transform ? { ...item.transform } : undefined, + keyframes: item.keyframes?.map((keyframe) => ({ ...keyframe })), + style: item.style ? { ...item.style } : undefined, + } + } + return { + ...item, + transform: item.transform ? { ...item.transform } : undefined, + effects: item.effects?.map((effect) => ({ ...effect })), + keyframes: item.keyframes?.map((keyframe) => ({ ...keyframe })), + transition_in: item.transition_in ? { ...item.transition_in } : item.transition_in, + transition_out: item.transition_out ? { ...item.transition_out } : item.transition_out, + } +} + +function cloneTimeline(timeline: TimelineState): MutableTimeline { + return { + ...timeline, + media: timeline.media.map((media) => ({ ...media })), + tracks: timeline.tracks.map((track) => ({ + ...track, + default_style: track.default_style ? { ...track.default_style } : track.default_style, + items: track.items.map(cloneItem), + })), + } +} + +function allItems(timeline: MutableTimeline): TimelineItem[] { + return timeline.tracks.flatMap((track) => track.items) +} + +function findTrack(timeline: MutableTimeline, trackId: TrackId, commandId?: string): TimelineTrack { + const track = timeline.tracks.find((candidate) => candidate.track_id === trackId) + if (!track) + throw new EditEngineError('unknown_track', `Track "${trackId}" does not exist`, commandId) + return track +} + +function findItem(timeline: MutableTimeline, itemId: string, commandId?: string): LocatedItem { + for (const [trackIndex, track] of timeline.tracks.entries()) { + const itemIndex = track.items.findIndex((item) => + item.item_type === 'caption_cue' ? item.cue_id === itemId : item.item_id === itemId, + ) + if (itemIndex >= 0) return { item: track.items[itemIndex]!, trackIndex, itemIndex } + } + throw new EditEngineError('unknown_item', `Item "${itemId}" does not exist`, commandId) +} + +function itemId(item: TimelineItem): string { + return item.item_type === 'caption_cue' ? item.cue_id : item.item_id +} + +function itemStart(item: TimelineItem): number { + return item.item_type === 'caption_cue' ? item.start_us : item.timeline_start_us +} + +function itemEnd(item: TimelineItem): number { + return item.item_type === 'caption_cue' ? item.end_us : item.timeline_end_us +} + +function replaceTrackItems( + timeline: MutableTimeline, + trackIndex: number, + items: TimelineItem[], +): void { + timeline.tracks[trackIndex] = { ...timeline.tracks[trackIndex]!, items } +} + +function insertAt(values: readonly T[], value: T, index: number | undefined): T[] { + const next = [...values] + const target = Math.min(Math.max(index ?? next.length, 0), next.length) + next.splice(target, 0, value) + return next +} + +function setItemAt(timeline: MutableTimeline, located: LocatedItem, item: TimelineItem): void { + const track = timeline.tracks[located.trackIndex]! + const items = [...track.items] + items[located.itemIndex] = item + replaceTrackItems(timeline, located.trackIndex, items) +} + +function removeItemAt(timeline: MutableTimeline, located: LocatedItem): void { + const track = timeline.tracks[located.trackIndex]! + replaceTrackItems( + timeline, + located.trackIndex, + track.items.filter((_, index) => index !== located.itemIndex), + ) +} + +function setItemPosition(item: TimelineItem, start_us: number, end_us: number): TimelineItem { + if (item.item_type === 'caption_cue') return { ...item, start_us, end_us } + return { ...item, timeline_start_us: start_us, timeline_end_us: end_us } +} + +function setItemTrack(item: TimelineItem, track_id: TrackId): TimelineItem { + return item.item_type === 'caption_cue' ? { ...item, track_id } : { ...item, track_id } +} + +function sourceAtTimeline(item: ClipItem, timeline_us: number): number { + const timelineDuration = item.timeline_end_us - item.timeline_start_us + const sourceDuration = item.source_end_us - item.source_start_us + if (timeline_us <= item.timeline_start_us) return item.source_start_us + if (timeline_us >= item.timeline_end_us) return item.source_end_us + const numerator = BigInt(timeline_us - item.timeline_start_us) * BigInt(sourceDuration) + const denominator = BigInt(timelineDuration) + const quotient = numerator / denominator + const remainder = numerator % denominator + const rounded = remainder * 2n >= denominator ? quotient + 1n : quotient + return item.source_start_us + Number(rounded) +} + +function shiftItem(item: TimelineItem, delta_us: number): TimelineItem { + return setItemPosition(item, itemStart(item) - delta_us, itemEnd(item) - delta_us) +} + +function deriveFragmentId(originalId: string, usedIds: Set): string { + const base = `${originalId}:ripple-right`.slice(0, 128) + if (!usedIds.has(base)) return base + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidate = `${originalId}:ripple-${suffix}`.slice(0, 128) + if (!usedIds.has(candidate)) return candidate + } + throw new EditEngineError( + 'invalid_request', + `Cannot derive a unique ripple fragment ID for "${originalId}"`, + ) +} + +function trimItemToRange(item: TimelineItem, start_us: number, end_us: number): TimelineItem { + if (item.item_type === 'clip') { + const sourceStart = sourceAtTimeline(item, start_us) + const sourceEnd = sourceAtTimeline(item, end_us) + return { + ...item, + timeline_start_us: start_us, + timeline_end_us: end_us, + source_start_us: sourceStart, + source_end_us: sourceEnd, + } + } + return setItemPosition(item, start_us, end_us) +} + +function expandRippleItem( + item: TimelineItem, + start_us: number, + end_us: number, + delta_us: number, + usedIds: Set, +): { items: TimelineItem[]; created: string[]; updated: string[]; deleted: string[] } { + const id = itemId(item) + const start = itemStart(item) + const end = itemEnd(item) + if (end <= start_us) return { items: [item], created: [], updated: [], deleted: [] } + if (start >= end_us) + return { items: [shiftItem(item, delta_us)], created: [], updated: [id], deleted: [] } + + const hasLeft = start < start_us + const hasRight = end > end_us + if (!hasLeft && !hasRight) return { items: [], created: [], updated: [], deleted: [id] } + + const result: TimelineItem[] = [] + const created: string[] = [] + const updated = [id] + const deleted: string[] = [] + if (hasLeft) result.push(trimItemToRange(item, start, start_us)) + if (hasRight) { + const right = trimItemToRange(item, end_us, end) + const shifted = shiftItem(right, delta_us) + if (hasLeft) { + const fragmentId = deriveFragmentId(id, usedIds) + usedIds.add(fragmentId) + result.push( + shifted.item_type === 'caption_cue' + ? { ...shifted, cue_id: fragmentId } + : { ...shifted, item_id: fragmentId }, + ) + created.push(fragmentId) + } else { + result.push(shifted) + } + } + return { items: result, created, updated, deleted } +} + +function ensureTrackCompatibility( + track: TimelineTrack, + item: TimelineItem, + commandId: string, +): void { + if (track.kind === 'caption' && item.item_type !== 'caption_cue') + throw new EditEngineError( + 'invalid_request', + `Track "${track.track_id}" accepts caption cues only`, + commandId, + ) + if (track.kind !== 'caption' && item.item_type === 'caption_cue') + throw new EditEngineError( + 'invalid_request', + `Caption cue "${itemId(item)}" requires a caption track`, + commandId, + ) + if (track.kind === 'audio' && item.item_type === 'clip' && item.media_kind !== 'audio') + throw new EditEngineError( + 'invalid_request', + `Audio track "${track.track_id}" accepts audio clips only`, + commandId, + ) +} + +function ensureUniqueItemId(timeline: MutableTimeline, id: string, commandId: string): void { + if (allItems(timeline).some((item) => itemId(item) === id)) + throw new EditEngineError('invalid_request', `Item ID "${id}" already exists`, commandId) +} + +function ensureMedia(timeline: MutableTimeline, mediaId: string, commandId: string): void { + if (!timeline.media.some((media) => media.media_id === mediaId)) + throw new EditEngineError('unknown_media', `Media "${mediaId}" does not exist`, commandId) +} + +function recomputeDuration(timeline: MutableTimeline): void { + const maxEnd = allItems(timeline).reduce((max, item) => Math.max(max, itemEnd(item)), 0) + if (maxEnd > timeline.duration_us) timeline.duration_us = maxEnd +} + +function applyAddClip(timeline: MutableTimeline, command: AddClipCommand): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + const item = cloneItem(command.item) as ClipItem + ensureMedia(timeline, item.media_id, command.command_id) + ensureUniqueItemId(timeline, item.item_id, command.command_id) + ensureTrackCompatibility(track, item, command.command_id) + replaceTrackItems( + timeline, + timeline.tracks.indexOf(track), + insertAt(track.items, item, command.index), + ) + recomputeDuration(timeline) + return { ...emptyEffect(), created_item_ids: [item.item_id] } +} + +function applyAddText(timeline: MutableTimeline, command: AddTextCommand): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + const item = cloneItem(command.item) as Extract + ensureUniqueItemId(timeline, item.item_id, command.command_id) + ensureTrackCompatibility(track, item, command.command_id) + replaceTrackItems( + timeline, + timeline.tracks.indexOf(track), + insertAt(track.items, item, command.index), + ) + recomputeDuration(timeline) + return { ...emptyEffect(), created_item_ids: [item.item_id] } +} + +function applyDuplicate( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const source = findItem(timeline, command.item_id, command.command_id).item + const target = findTrack(timeline, command.to_track_id, command.command_id) + ensureUniqueItemId(timeline, command.new_item_id, command.command_id) + const duplicate = cloneItem(source) + const sourceStart = itemStart(source) + const targetStart = command.timeline_start_us ?? sourceStart + const shifted = setItemPosition( + duplicate, + targetStart, + targetStart + (itemEnd(source) - sourceStart), + ) + const withId = + shifted.item_type === 'caption_cue' + ? { ...shifted, cue_id: command.new_item_id, track_id: target.track_id } + : { ...shifted, item_id: command.new_item_id, track_id: target.track_id } + ensureTrackCompatibility(target, withId, command.command_id) + replaceTrackItems( + timeline, + timeline.tracks.indexOf(target), + insertAt(target.items, withId, command.index), + ) + recomputeDuration(timeline) + return { ...emptyEffect(), created_item_ids: [command.new_item_id] } +} + +function applyRemoveItem( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const located = findItem(timeline, command.item_id, command.command_id) + removeItemAt(timeline, located) + return { ...emptyEffect(), deleted_item_ids: [command.item_id] } +} + +function applyMoveItem(timeline: MutableTimeline, command: MoveItemCommand): CommandEffect { + const located = findItem(timeline, command.item_id, command.command_id) + const target = findTrack(timeline, command.to_track_id, command.command_id) + ensureTrackCompatibility(target, located.item, command.command_id) + const oldStart = itemStart(located.item) + const moved = setItemPosition( + setItemTrack(located.item, target.track_id), + command.timeline_start_us, + command.timeline_start_us + itemEnd(located.item) - oldStart, + ) + const sourceTrack = timeline.tracks[located.trackIndex]! + const sourceItems = sourceTrack.items.filter((_, index) => index !== located.itemIndex) + const targetIndex = timeline.tracks.indexOf(target) + replaceTrackItems(timeline, located.trackIndex, sourceItems) + const targetItems = targetIndex === located.trackIndex ? sourceItems : target.items + replaceTrackItems(timeline, targetIndex, insertAt(targetItems, moved, command.index)) + return { + ...emptyEffect(), + moved_item_ids: [command.item_id], + updated_item_ids: [command.item_id], + } +} + +function applyTrim( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const located = findItem(timeline, command.item_id, command.command_id) + const item = located.item + const next = + command.edge === 'start' + ? item.item_type === 'clip' + ? { ...item, timeline_start_us: command.timeline_us, source_start_us: command.source_us } + : item.item_type === 'caption_cue' + ? { ...item, start_us: command.timeline_us } + : { ...item, timeline_start_us: command.timeline_us } + : item.item_type === 'clip' + ? { ...item, timeline_end_us: command.timeline_us, source_end_us: command.source_us } + : item.item_type === 'caption_cue' + ? { ...item, end_us: command.timeline_us } + : { ...item, timeline_end_us: command.timeline_us } + if (itemEnd(next) <= itemStart(next)) + throw new EditEngineError( + 'invalid_request', + `Trim would make item "${command.item_id}" empty`, + command.command_id, + ) + setItemAt(timeline, located, next) + return { ...emptyEffect(), updated_item_ids: [command.item_id] } +} + +function applySplit( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const located = findItem(timeline, command.item_id, command.command_id) + if (located.item.item_type === 'caption_cue') + throw new EditEngineError( + 'unsupported_command', + 'Caption cues use upsert_caption_cues rather than split_item', + command.command_id, + ) + if ( + command.at_timeline_us <= located.item.timeline_start_us || + command.at_timeline_us >= located.item.timeline_end_us + ) + throw new EditEngineError( + 'invalid_request', + 'Split must be inside the item range', + command.command_id, + ) + ensureUniqueItemId(timeline, command.left_item_id, command.command_id) + ensureUniqueItemId(timeline, command.right_item_id, command.command_id) + const item = located.item + const left = + item.item_type === 'clip' + ? { + ...item, + item_id: command.left_item_id, + timeline_end_us: command.at_timeline_us, + source_end_us: command.at_source_us, + } + : { ...item, item_id: command.left_item_id, timeline_end_us: command.at_timeline_us } + const right = + item.item_type === 'clip' + ? { + ...item, + item_id: command.right_item_id, + timeline_start_us: command.at_timeline_us, + source_start_us: command.at_source_us, + } + : { ...item, item_id: command.right_item_id, timeline_start_us: command.at_timeline_us } + const track = timeline.tracks[located.trackIndex]! + const items = [...track.items] + items.splice(located.itemIndex, 1, left, right) + replaceTrackItems(timeline, located.trackIndex, items) + return { + ...emptyEffect(), + created_item_ids: [command.left_item_id, command.right_item_id], + deleted_item_ids: [command.item_id], + } +} + +function applyRippleDelete( + timeline: MutableTimeline, + command: Extract, + fps: number, +): CommandEffect { + const startFrame = assertFrameAligned(command.start_us, fps) + const endFrame = assertFrameAligned(command.end_us, fps) + const deltaFrames = endFrame - startFrame + const delta_us = framesToMicroseconds(deltaFrames, fps) + const selected = command.track_ids === null ? null : new Set(command.track_ids) + for (const trackId of selected ?? []) findTrack(timeline, trackId, command.command_id) + const usedIds = new Set(allItems(timeline).map(itemId)) + const created: string[] = [] + const updated: string[] = [] + const deleted: string[] = [] + for (const [trackIndex, track] of timeline.tracks.entries()) { + if (selected !== null && !selected.has(track.track_id)) continue + const nextItems: TimelineItem[] = [] + for (const item of track.items) { + const result = expandRippleItem(item, command.start_us, command.end_us, delta_us, usedIds) + nextItems.push(...result.items) + created.push(...result.created) + updated.push(...result.updated) + deleted.push(...result.deleted) + } + replaceTrackItems(timeline, trackIndex, nextItems) + } + const durationDelta = selected === null ? -delta_us : 0 + if (selected === null) timeline.duration_us = Math.max(0, timeline.duration_us + durationDelta) + recomputeDuration(timeline) + return { + ...emptyEffect(durationDelta), + created_item_ids: created, + updated_item_ids: [...new Set(updated.filter((id) => !deleted.includes(id)))], + deleted_item_ids: [...new Set(deleted)], + } +} + +function applyAddTrack( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + if (timeline.tracks.some((track) => track.track_id === command.track.track_id)) + throw new EditEngineError( + 'invalid_request', + `Track ID "${command.track.track_id}" already exists`, + command.command_id, + ) + const track: TimelineTrack = { ...command.track, items: [] } + timeline.tracks = insertAt(timeline.tracks, track, command.index) + return { ...emptyEffect(), created_track_ids: [track.track_id] } +} + +function applyRemoveTrack( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + const deletedItemIds = track.items.map(itemId) + timeline.tracks = timeline.tracks.filter((candidate) => candidate.track_id !== command.track_id) + return { + ...emptyEffect(), + deleted_track_ids: [command.track_id], + deleted_item_ids: deletedItemIds, + } +} + +function applyMoveTrack( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const sourceIndex = timeline.tracks.findIndex((track) => track.track_id === command.track_id) + if (sourceIndex < 0) findTrack(timeline, command.track_id, command.command_id) + const [track] = timeline.tracks.splice(sourceIndex, 1) + timeline.tracks.splice(Math.min(command.to_index, timeline.tracks.length), 0, track!) + return { ...emptyEffect(), updated_track_ids: [command.track_id] } +} + +function applyUpdateTrack( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const located = findTrack(timeline, command.track_id, command.command_id) + const index = timeline.tracks.indexOf(located) + timeline.tracks[index] = { + ...located, + ...(command.name !== undefined ? { name: command.name } : {}), + ...(command.language !== undefined ? { language: command.language ?? undefined } : {}), + ...(command.locked !== undefined ? { locked: command.locked } : {}), + ...(command.muted !== undefined ? { muted: command.muted } : {}), + } + return { ...emptyEffect(), updated_track_ids: [command.track_id] } +} + +function applyAddCaptionTrack( + timeline: MutableTimeline, + command: AddCaptionTrackCommand, +): CommandEffect { + if (timeline.tracks.some((track) => track.track_id === command.track_id)) + throw new EditEngineError( + 'invalid_request', + `Track ID "${command.track_id}" already exists`, + command.command_id, + ) + const track: TimelineTrack = { + track_id: command.track_id, + kind: 'caption', + name: command.name, + language: command.language, + locked: false, + muted: false, + items: [], + } + timeline.tracks = insertAt(timeline.tracks, track, command.index) + return { ...emptyEffect(), created_track_ids: [track.track_id] } +} + +function applyRemoveCaptionTrack( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + if (track.kind !== 'caption') + throw new EditEngineError( + 'invalid_request', + `Track "${command.track_id}" is not a caption track`, + command.command_id, + ) + return applyRemoveTrack(timeline, { + command_id: command.command_id, + type: 'remove_track', + track_id: command.track_id, + }) +} + +function applyUpdateCaptionTrack( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + if (track.kind !== 'caption') + throw new EditEngineError( + 'invalid_request', + `Track "${command.track_id}" is not a caption track`, + command.command_id, + ) + const index = timeline.tracks.indexOf(track) + timeline.tracks[index] = { + ...track, + ...(command.name !== undefined ? { name: command.name } : {}), + ...(command.language !== undefined ? { language: command.language } : {}), + ...(command.default_style !== undefined + ? { default_style: command.default_style ? { ...command.default_style } : null } + : {}), + } + return { ...emptyEffect(), updated_track_ids: [command.track_id] } +} + +function applyUpsertCaptionCues( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + if (track.kind !== 'caption') + throw new EditEngineError( + 'invalid_request', + `Track "${command.track_id}" is not a caption track`, + command.command_id, + ) + const incoming = command.cues.map((cue) => ({ + ...cue, + style: cue.style ? { ...cue.style } : undefined, + })) + const incomingIds = new Set(incoming.map((cue) => cue.cue_id)) + const previous = track.items.filter( + (item): item is CaptionCue => item.item_type === 'caption_cue', + ) + const created = incoming + .filter((cue) => !previous.some((existing) => existing.cue_id === cue.cue_id)) + .map((cue) => cue.cue_id) + const updated = incoming + .filter((cue) => previous.some((existing) => existing.cue_id === cue.cue_id)) + .map((cue) => cue.cue_id) + const retained = previous.filter((cue) => !incomingIds.has(cue.cue_id)) + const items = [...retained, ...incoming].sort( + (left, right) => + left.start_us - right.start_us || + left.end_us - right.end_us || + left.cue_id.localeCompare(right.cue_id), + ) + replaceTrackItems(timeline, timeline.tracks.indexOf(track), items) + recomputeDuration(timeline) + return { ...emptyEffect(), created_item_ids: created, updated_item_ids: updated } +} + +function applyRemoveCaptionCues( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + if (track.kind !== 'caption') + throw new EditEngineError( + 'invalid_request', + `Track "${command.track_id}" is not a caption track`, + command.command_id, + ) + const ids = new Set(command.cue_ids) + const deleted = track.items + .filter((item) => item.item_type === 'caption_cue' && ids.has(item.cue_id)) + .map(itemId) + replaceTrackItems( + timeline, + timeline.tracks.indexOf(track), + track.items.filter((item) => item.item_type !== 'caption_cue' || !ids.has(item.cue_id)), + ) + return { ...emptyEffect(), deleted_item_ids: deleted } +} + +function applyProperties( + timeline: MutableTimeline, + command: SetItemPropertiesCommand, +): CommandEffect { + const located = findItem(timeline, command.item_id, command.command_id) + if (located.item.item_type === 'caption_cue') + throw new EditEngineError( + 'unsupported_command', + 'Use caption commands to edit caption cues', + command.command_id, + ) + const patch: ItemPropertiesPatch = command.properties + const item = located.item + const next: TimelineItem = { + ...item, + ...(patch.transform !== undefined + ? { transform: patch.transform ? { ...patch.transform } : undefined } + : {}), + ...(patch.opacity !== undefined ? { opacity: patch.opacity } : {}), + ...(patch.volume !== undefined ? { volume: patch.volume } : {}), + ...(patch.speed !== undefined ? { speed: patch.speed } : {}), + ...(patch.fade_in_us !== undefined ? { fade_in_us: patch.fade_in_us ?? undefined } : {}), + ...(patch.fade_out_us !== undefined ? { fade_out_us: patch.fade_out_us ?? undefined } : {}), + ...(patch.effects !== undefined + ? { effects: patch.effects.map((effect: Effect) => ({ ...effect })) } + : {}), + ...(patch.keyframes !== undefined + ? { keyframes: patch.keyframes.map((keyframe: Keyframe) => ({ ...keyframe })) } + : {}), + ...(patch.transition_in !== undefined + ? { transition_in: patch.transition_in ? { ...patch.transition_in } : undefined } + : {}), + ...(patch.transition_out !== undefined + ? { transition_out: patch.transition_out ? { ...patch.transition_out } : undefined } + : {}), + ...(item.item_type === 'text' && patch.text !== undefined ? { text: patch.text } : {}), + ...(item.item_type === 'text' && patch.text_style !== undefined + ? { style: patch.text_style ? { ...patch.text_style } : undefined } + : {}), + } as TimelineItem + setItemAt(timeline, located, next) + return { ...emptyEffect(), updated_item_ids: [command.item_id] } +} + +function applyCaptionStyle( + timeline: MutableTimeline, + command: Extract, +): CommandEffect { + const track = findTrack(timeline, command.track_id, command.command_id) + if (track.kind !== 'caption') + throw new EditEngineError( + 'invalid_request', + `Track "${command.track_id}" is not a caption track`, + command.command_id, + ) + if (command.cue_ids === null) { + const index = timeline.tracks.indexOf(track) + timeline.tracks[index] = { ...track, default_style: { ...command.style } } + return { ...emptyEffect(), updated_track_ids: [command.track_id] } + } + const ids = new Set(command.cue_ids) + const updated: string[] = [] + const items = track.items.map((item) => { + if (item.item_type !== 'caption_cue' || !ids.has(item.cue_id)) return item + updated.push(item.cue_id) + return { ...item, style: { ...command.style } } + }) + replaceTrackItems(timeline, timeline.tracks.indexOf(track), items) + return { ...emptyEffect(), updated_item_ids: updated } +} + +function applyCommand(timeline: MutableTimeline, command: EditCommand, fps: number): CommandEffect { + // All command time fields are public microseconds. Conversion is performed + // before mutation so a non-frame-aligned batch can never partially apply. + switch (command.type) { + case 'add_clip': + assertFrameAligned(command.item.timeline_start_us, fps) + assertFrameAligned(command.item.timeline_end_us, fps) + assertFrameAligned(command.item.source_start_us, fps) + assertFrameAligned(command.item.source_end_us, fps) + return applyAddClip(timeline, command) + case 'add_text': + assertFrameAligned(command.item.timeline_start_us, fps) + assertFrameAligned(command.item.timeline_end_us, fps) + return applyAddText(timeline, command) + case 'duplicate_item': + if (command.timeline_start_us !== undefined) + assertFrameAligned(command.timeline_start_us, fps) + return applyDuplicate(timeline, command) + case 'remove_item': + return applyRemoveItem(timeline, command) + case 'move_item': + assertFrameAligned(command.timeline_start_us, fps) + return applyMoveItem(timeline, command) + case 'trim_item': + assertFrameAligned(command.timeline_us, fps) + assertFrameAligned(command.source_us, fps) + return applyTrim(timeline, command) + case 'split_item': + assertFrameAligned(command.at_timeline_us, fps) + assertFrameAligned(command.at_source_us, fps) + return applySplit(timeline, command) + case 'ripple_delete': + return applyRippleDelete(timeline, command, fps) + case 'add_track': + return applyAddTrack(timeline, command) + case 'remove_track': + return applyRemoveTrack(timeline, command) + case 'move_track': + return applyMoveTrack(timeline, command) + case 'update_track': + return applyUpdateTrack(timeline, command) + case 'add_caption_track': + return applyAddCaptionTrack(timeline, command) + case 'remove_caption_track': + return applyRemoveCaptionTrack(timeline, command) + case 'update_caption_track': + return applyUpdateCaptionTrack(timeline, command) + case 'upsert_caption_cues': + for (const cue of command.cues) { + assertFrameAligned(cue.start_us, fps) + assertFrameAligned(cue.end_us, fps) + } + return applyUpsertCaptionCues(timeline, command) + case 'remove_caption_cues': + return applyRemoveCaptionCues(timeline, command) + case 'set_item_properties': + for (const keyframe of command.properties.keyframes ?? []) + assertFrameAligned(keyframe.time_us, fps) + return applyProperties(timeline, command) + case 'set_caption_style': + return applyCaptionStyle(timeline, command) + case 'request_job': + return emptyEffect() + default: + return assertNever(command) + } +} + +function assertNever(value: never): never { + throw new EditEngineError( + 'unsupported_command', + `Unsupported command ${(value as { type?: string }).type ?? 'unknown'}`, + ) +} + +function validateAndPrepare(timeline: TimelineState, context: EditEngineContext): MutableTimeline { + if (!Number.isFinite(context.fps) || context.fps <= 0) + throw new EditEngineError('invalid_request', 'fps must be a finite positive number') + const valid = validateTimelineState(timeline) + if (!valid.ok) + throw new EditEngineError('invalid_request', valid.errors[0]?.message ?? 'timeline is invalid') + assertFrameAligned(timeline.duration_us, context.fps) + for (const item of allItems(timeline as MutableTimeline)) { + assertFrameAligned(itemStart(item), context.fps) + assertFrameAligned(itemEnd(item), context.fps) + if (item.item_type === 'clip') { + assertFrameAligned(item.source_start_us, context.fps) + assertFrameAligned(item.source_end_us, context.fps) + } + } + return cloneTimeline(timeline) +} + +export function applyEditCommands( + timeline: TimelineState, + commands: readonly EditCommand[], + context: EditEngineContext, +): EditEngineResult { + const next = validateAndPrepare(timeline, context) + const applied: AppliedCommandResult[] = [] + for (const command of commands) { + const previousDuration = next.duration_us + const commandEffect = applyCommand(next, command, context.fps) + const actualDelta = next.duration_us - previousDuration + const effect = + commandEffect.timeline_delta_us === actualDelta + ? commandEffect + : { ...commandEffect, timeline_delta_us: actualDelta } + applied.push({ + command_id: command.command_id, + command_type: command.type, + status: 'applied', + effect, + }) + } + const validated = validateTimelineState(next) + if (!validated.ok) + throw new EditEngineError( + 'invalid_request', + validated.errors[0]?.message ?? 'edit produced an invalid timeline', + ) + return { timeline: next, commands: applied } +} + +export const controlledEditEngine: ControlledEditEngine = { + apply: applyEditCommands, +} diff --git a/src/features/editor/codepress/fixtures/README.md b/src/features/editor/codepress/fixtures/README.md new file mode 100644 index 000000000..31bdfe08e --- /dev/null +++ b/src/features/editor/codepress/fixtures/README.md @@ -0,0 +1,8 @@ +# PR1 conformance fixtures + +These are the language-neutral CodePress PR1 fixtures copied into the +FreeCut-side adapter test surface. They are intentionally limited to the +stable wire fields: no FreeCut paths, blob URLs, workspace handles, or raw +media bytes are included. The adapter tests load them as JSON so the same +validation, command order, and structured conflict shapes can be checked from +the fork without importing the CodePress repository. diff --git a/src/features/editor/codepress/fixtures/errors/idempotency-conflict.json b/src/features/editor/codepress/fixtures/errors/idempotency-conflict.json new file mode 100644 index 000000000..bb5a611a3 --- /dev/null +++ b/src/features/editor/codepress/fixtures/errors/idempotency-conflict.json @@ -0,0 +1,20 @@ +{ + "fixture_version": 1, + "id": "idempotency-conflict", + "kind": "structured_error", + "error": { + "code": "idempotency_conflict", + "message": "The idempotency key was already used for a different operation.", + "retryable": false, + "details": { + "kind": "idempotency_conflict", + "idempotency_key": "timeline-demo:cut-intro:v1", + "original_operation_id": "operation-cut-intro" + } + }, + "expect": { + "code": "idempotency_conflict", + "retryable": false, + "idempotency_key": "timeline-demo:cut-intro:v1" + } +} diff --git a/src/features/editor/codepress/fixtures/errors/revision-conflict.json b/src/features/editor/codepress/fixtures/errors/revision-conflict.json new file mode 100644 index 000000000..c97a1f8d3 --- /dev/null +++ b/src/features/editor/codepress/fixtures/errors/revision-conflict.json @@ -0,0 +1,27 @@ +{ + "fixture_version": 1, + "id": "revision-conflict", + "kind": "structured_error", + "error": { + "code": "revision_conflict", + "message": "The timeline changed after this operation was prepared.", + "retryable": true, + "details": { + "kind": "revision_conflict", + "base_revision": 7, + "current_revision": 9, + "rebase": { + "status": "required", + "automatic": false, + "strategy": "refresh_then_resubmit", + "retry_with_revision": 9 + } + } + }, + "expect": { + "code": "revision_conflict", + "retryable": true, + "automatic_rebase": false, + "retry_with_revision": 9 + } +} diff --git a/src/features/editor/codepress/fixtures/index.json b/src/features/editor/codepress/fixtures/index.json new file mode 100644 index 000000000..c1b33dc95 --- /dev/null +++ b/src/features/editor/codepress/fixtures/index.json @@ -0,0 +1,10 @@ +{ + "fixture_version": 1, + "fixtures": [ + "valid/core-edit-batch.json", + "valid/caption-batch.json", + "invalid/invalid-caption-cue.json", + "errors/revision-conflict.json", + "errors/idempotency-conflict.json" + ] +} diff --git a/src/features/editor/codepress/fixtures/invalid/invalid-caption-cue.json b/src/features/editor/codepress/fixtures/invalid/invalid-caption-cue.json new file mode 100644 index 000000000..427f0bd57 --- /dev/null +++ b/src/features/editor/codepress/fixtures/invalid/invalid-caption-cue.json @@ -0,0 +1,31 @@ +{ + "fixture_version": 1, + "id": "invalid-caption-cue", + "kind": "invalid_command_batch", + "request": { + "contract_version": 1, + "timeline_id": "timeline-caption", + "operation_id": "operation-invalid-caption", + "idempotency_key": "timeline-caption:invalid-caption:1", + "base_revision": 2, + "preconditions": [], + "commands": [ + { + "command_id": "command-invalid-cue", + "type": "upsert_caption_cues", + "track_id": "track-captions", + "cues": [ + { + "item_type": "caption_cue", + "cue_id": "cue-invalid", + "track_id": "track-captions", + "start_us": 5000000, + "end_us": 5000000, + "text": "An empty range is not valid." + } + ] + } + ] + }, + "expect": { "request_valid": false, "error_codes": ["invalid_request"] } +} diff --git a/src/features/editor/codepress/fixtures/valid/caption-batch.json b/src/features/editor/codepress/fixtures/valid/caption-batch.json new file mode 100644 index 000000000..fe6924680 --- /dev/null +++ b/src/features/editor/codepress/fixtures/valid/caption-batch.json @@ -0,0 +1,71 @@ +{ + "fixture_version": 1, + "id": "caption-batch", + "kind": "valid_command_batch", + "timeline": { + "contract_version": 1, + "schema_version": 1, + "timeline_id": "timeline-caption", + "revision": 2, + "duration_us": 8000000, + "media": [], + "tracks": [ + { + "track_id": "track-captions", + "kind": "caption", + "name": "English captions", + "language": "en", + "locked": false, + "muted": false, + "items": [] + } + ] + }, + "request": { + "contract_version": 1, + "timeline_id": "timeline-caption", + "operation_id": "operation-captions-1", + "idempotency_key": "timeline-caption:captions:1", + "base_revision": 2, + "preconditions": [{ "type": "track_exists", "track_id": "track-captions" }], + "commands": [ + { + "command_id": "command-upsert-captions", + "type": "upsert_caption_cues", + "track_id": "track-captions", + "cues": [ + { + "item_type": "caption_cue", + "cue_id": "cue-hello", + "track_id": "track-captions", + "start_us": 1000000, + "end_us": 2500000, + "text": "Hello, world!" + }, + { + "item_type": "caption_cue", + "cue_id": "cue-next", + "track_id": "track-captions", + "start_us": 3000000, + "end_us": 4500000, + "text": "This is a caption cue." + } + ] + }, + { + "command_id": "command-move-caption", + "type": "move_item", + "item_id": "cue-hello", + "to_track_id": "track-captions", + "timeline_start_us": 1200000, + "index": 0 + } + ] + }, + "expect": { + "timeline_valid": true, + "request_valid": true, + "command_types": ["upsert_caption_cues", "move_item"], + "command_count": 2 + } +} diff --git a/src/features/editor/codepress/fixtures/valid/core-edit-batch.json b/src/features/editor/codepress/fixtures/valid/core-edit-batch.json new file mode 100644 index 000000000..e833ab5ba --- /dev/null +++ b/src/features/editor/codepress/fixtures/valid/core-edit-batch.json @@ -0,0 +1,150 @@ +{ + "fixture_version": 1, + "id": "core-edit-batch", + "kind": "valid_command_batch", + "timeline": { + "contract_version": 1, + "schema_version": 1, + "timeline_id": "timeline-demo", + "revision": 7, + "duration_us": 12000000, + "media": [ + { + "media_id": "media-speaker", + "media_kind": "video", + "content_hash": "sha256:speaker-v1", + "duration_us": 30000000, + "availability": { + "mode": "hybrid", + "local": { + "device_id": "device-owner", + "root_id": "root-shared", + "file_id": "file-speaker", + "root_generation": 3 + }, + "cloud": { "object_id": "object-speaker-original" } + } + }, + { + "media_id": "media-music", + "media_kind": "audio", + "content_hash": "sha256:music-v1", + "duration_us": 60000000, + "availability": { + "mode": "cloud", + "cloud": { "object_id": "object-music-original" } + } + } + ], + "tracks": [ + { + "track_id": "track-video", + "kind": "video", + "name": "Video", + "locked": false, + "muted": false, + "items": [ + { + "item_type": "clip", + "item_id": "item-speaker", + "track_id": "track-video", + "media_id": "media-speaker", + "media_kind": "video", + "timeline_start_us": 0, + "timeline_end_us": 12000000, + "source_start_us": 0, + "source_end_us": 12000000 + } + ] + }, + { + "track_id": "track-music", + "kind": "audio", + "name": "Music", + "locked": false, + "muted": false, + "items": [ + { + "item_type": "clip", + "item_id": "item-music", + "track_id": "track-music", + "media_id": "media-music", + "media_kind": "audio", + "timeline_start_us": 0, + "timeline_end_us": 12000000, + "source_start_us": 0, + "source_end_us": 12000000, + "volume": 0.7 + } + ] + } + ] + }, + "request": { + "contract_version": 1, + "timeline_id": "timeline-demo", + "operation_id": "operation-cut-intro", + "idempotency_key": "timeline-demo:cut-intro:v1", + "base_revision": 7, + "preconditions": [ + { + "type": "item_at", + "item_id": "item-speaker", + "track_id": "track-video", + "timeline_start_us": 0, + "timeline_end_us": 12000000 + }, + { + "type": "media_content_hash", + "media_id": "media-speaker", + "content_hash": "sha256:speaker-v1" + } + ], + "commands": [ + { + "command_id": "command-split-speaker", + "type": "split_item", + "item_id": "item-speaker", + "at_timeline_us": 4000000, + "at_source_us": 4000000, + "left_item_id": "item-speaker-left", + "right_item_id": "item-speaker-right" + }, + { + "command_id": "command-trim-left", + "type": "trim_item", + "item_id": "item-speaker-left", + "edge": "start", + "timeline_us": 1000000, + "source_us": 1000000 + }, + { + "command_id": "command-ripple-delete", + "type": "ripple_delete", + "start_us": 2000000, + "end_us": 2500000, + "track_ids": null + }, + { + "command_id": "command-move-music", + "type": "move_item", + "item_id": "item-music", + "to_track_id": "track-music", + "timeline_start_us": 0, + "index": 0 + }, + { + "command_id": "command-move-video-track", + "type": "move_track", + "track_id": "track-video", + "to_index": 0 + } + ] + }, + "expect": { + "timeline_valid": true, + "request_valid": true, + "command_types": ["split_item", "trim_item", "ripple_delete", "move_item", "move_track"], + "command_count": 5 + } +} diff --git a/src/features/editor/codepress/index.ts b/src/features/editor/codepress/index.ts new file mode 100644 index 000000000..64f597a57 --- /dev/null +++ b/src/features/editor/codepress/index.ts @@ -0,0 +1,8 @@ +export * from './adapter' +export * from './contract' +export * from './controlled-editor' +export * from './document' +export * from './edit-engine' +export * from './interfaces' +export * from './timing' +export * from './translation' diff --git a/src/features/editor/codepress/interfaces.ts b/src/features/editor/codepress/interfaces.ts new file mode 100644 index 000000000..d311373b5 --- /dev/null +++ b/src/features/editor/codepress/interfaces.ts @@ -0,0 +1,137 @@ +import type { + AppliedCommandResult, + EditApplyResult, + EditCommandBatch, + EditCommand, + MediaId, + TimelineState, + TimelineRevision, +} from './contract' + +/** Data the controlled editor owns while it is mounted. It is not persistence. */ +export interface ControlledEditorDocument { + timeline: TimelineState + fps: number + width: number + height: number + background_color?: string +} + +/** The narrow state port a host supplies to a controlled editor surface. */ +export interface ControlledEditorPort { + getDocument(): ControlledEditorDocument + replaceDocument(document: ControlledEditorDocument): void + subscribe?(listener: (document: ControlledEditorDocument) => void): () => void +} + +export interface EditEngineContext { + fps: number +} + +export interface EditEngineResult { + timeline: TimelineState + commands: readonly AppliedCommandResult[] +} + +/** Pure, worker-safe application boundary. Implementations must be atomic. */ +export interface ControlledEditEngine { + apply( + timeline: TimelineState, + commands: readonly EditCommand[], + context: EditEngineContext, + ): EditEngineResult +} + +export interface RenderFrameRequest { + document: ControlledEditorDocument + frame: number + time_us: number + width?: number + height?: number +} + +export interface RenderedFrame { + frame: number + time_us: number + width: number + height: number + /** Renderer-specific pixels/image handle; never part of a command result. */ + payload: unknown +} + +/** Rendering is deliberately a port: no renderer or media URL is in the command contract. */ +export interface ControlledRenderer { + renderFrame(request: RenderFrameRequest): Promise | RenderedFrame +} + +export interface ProjectRevision { + timeline: TimelineState + revision: TimelineRevision +} + +/** CodePress-owned persistence boundary; FreeCut never writes the project store directly. */ +export interface ProjectStore { + loadCurrentRevision(timelineId: string): Promise | ProjectRevision + submitOperation?(batch: EditCommandBatch): Promise | EditApplyResult + subscribe?(timelineId: string, listener: (revision: ProjectRevision) => void): () => void +} + +export interface ResolvedAsset { + media_id: MediaId + kind: 'local' | 'cloud' + /** Short-lived host locator; never copied into TimelineState. */ + source: string +} + +export interface AssetResolver { + resolve(mediaId: MediaId): Promise | ResolvedAsset +} + +export interface UploadClient { + upload(mediaId: MediaId): Promise<{ media_id: MediaId; status: 'uploaded' | 'already_available' }> +} + +export interface MediaJobClient { + request(input: { + job_type: import('./contract').VideoJobType + media_id?: MediaId + timeline_revision?: TimelineRevision + options?: Readonly> + }): Promise<{ job_id: string }> | { job_id: string } +} + +export interface RenderJobClient { + request(input: { + kind: 'preview' | 'export' + timeline_id: string + revision: TimelineRevision + options?: Readonly> + }): Promise<{ job_id: string }> | { job_id: string } + cancel?(jobId: string): Promise | void +} + +export interface PresenceClient { + getStatus(): + | Promise<{ available: boolean; label?: string }> + | { available: boolean; label?: string } +} + +export interface TelemetryClient { + emit(event: { + name: string + timeline_id?: string + operation_id?: string + revision?: TimelineRevision + attributes?: Readonly> + }): Promise | void +} + +export interface CodePressHostAdapters { + projectStore?: ProjectStore + assetResolver?: AssetResolver + uploadClient?: UploadClient + mediaJobClient?: MediaJobClient + renderJobClient?: RenderJobClient + presenceClient?: PresenceClient + telemetryClient?: TelemetryClient +} diff --git a/src/features/editor/codepress/timing.ts b/src/features/editor/codepress/timing.ts new file mode 100644 index 000000000..681eec1de --- /dev/null +++ b/src/features/editor/codepress/timing.ts @@ -0,0 +1,154 @@ +/** + * Deterministic time conversion at the CodePress/FreeCut boundary. + * + * FreeCut stores positions as integer frames while the public command + * contract stores integer microseconds. Floating-point multiplication is + * deliberately not used for the decision whether a command is frame aligned; + * the rational arithmetic below makes that decision stable across browsers, + * workers, and Node. + */ + +export interface FrameRate { + readonly numerator: bigint + readonly denominator: bigint + readonly value: number +} + +export type TimingRounding = 'nearest' | 'floor' | 'ceil' + +export class FrameTimingError extends Error { + readonly microseconds: number + readonly fps: number + + constructor(microseconds: number, fps: number) { + super(`${microseconds}µs is not aligned to an integer frame at ${fps}fps`) + this.name = 'FrameTimingError' + this.microseconds = microseconds + this.fps = fps + } +} + +function gcd(left: bigint, right: bigint): bigint { + let a = left < 0n ? -left : left + let b = right < 0n ? -right : right + while (b !== 0n) { + const next = a % b + a = b + b = next + } + return a +} + +function decimalToRational(value: number): { numerator: bigint; denominator: bigint } { + if (!Number.isFinite(value) || value <= 0) + throw new RangeError('fps must be a finite positive number') + const text = String(value).toLowerCase() + const [mantissa, exponentText] = text.split('e') + const exponent = exponentText ? Number(exponentText) : 0 + const [whole, fraction = ''] = mantissa!.split('.') + const digits = `${whole}${fraction}` + let numerator = BigInt(digits) + let denominator = 10n ** BigInt(fraction.length) + if (exponent > 0) numerator *= 10n ** BigInt(exponent) + if (exponent < 0) denominator *= 10n ** BigInt(-exponent) + const divisor = gcd(numerator, denominator) + return { numerator: numerator / divisor, denominator: denominator / divisor } +} + +export function normalizeFrameRate(fps: number): FrameRate { + const rational = decimalToRational(fps) + return { ...rational, value: fps } +} + +function assertMicroseconds(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError('microseconds must be a safe non-negative integer') +} + +function assertFrames(value: number): void { + if (!Number.isSafeInteger(value) || value < 0) + throw new RangeError('frames must be a safe non-negative integer') +} + +function divideRounded(numerator: bigint, denominator: bigint, rounding: TimingRounding): bigint { + if (denominator <= 0n) throw new RangeError('denominator must be positive') + const quotient = numerator / denominator + const remainder = numerator % denominator + if (remainder === 0n) return quotient + if (rounding === 'floor') return quotient + if (rounding === 'ceil') return quotient + 1n + return remainder * 2n >= denominator ? quotient + 1n : quotient +} + +/** Convert an integer microsecond timestamp to a frame, requiring alignment by default. */ +export function microsecondsToFrames( + microseconds: number, + fps: number | FrameRate, + options: { rounding?: TimingRounding; requireAligned?: boolean } = {}, +): number { + assertMicroseconds(microseconds) + const rate = typeof fps === 'number' ? normalizeFrameRate(fps) : fps + const numerator = BigInt(microseconds) * rate.numerator + const denominator = 1_000_000n * rate.denominator + const frame = divideRounded(numerator, denominator, options.rounding ?? 'nearest') + if (options.requireAligned !== false) { + // A frame duration is often fractional in microseconds (for example + // 29.97fps). The contract remains integer-valued, so alignment means the + // canonical nearest-integer-microsecond representation of that frame, + // rather than requiring an impossible exact rational value. + const canonical = divideRounded( + frame * 1_000_000n * rate.denominator, + rate.numerator, + 'nearest', + ) + if (canonical !== BigInt(microseconds)) throw new FrameTimingError(microseconds, rate.value) + } + const result = Number(frame) + assertFrames(result) + return result +} + +/** Convert an integer frame position to the nearest integer microsecond. */ +export function framesToMicroseconds( + frames: number, + fps: number | FrameRate, + rounding: TimingRounding = 'nearest', +): number { + assertFrames(frames) + const rate = typeof fps === 'number' ? normalizeFrameRate(fps) : fps + const numerator = BigInt(frames) * 1_000_000n * rate.denominator + const microseconds = divideRounded(numerator, rate.numerator, rounding) + const result = Number(microseconds) + assertMicroseconds(result) + return result +} + +export function isFrameAligned(microseconds: number, fps: number | FrameRate): boolean { + try { + microsecondsToFrames(microseconds, fps) + return true + } catch (error) { + if (error instanceof FrameTimingError) return false + throw error + } +} + +export function assertFrameAligned(microseconds: number, fps: number | FrameRate): number { + return microsecondsToFrames(microseconds, fps, { requireAligned: true }) +} + +export interface FrameInterval { + start: number + end: number +} + +export function microsecondIntervalToFrames( + start_us: number, + end_us: number, + fps: number | FrameRate, +): FrameInterval { + const start = assertFrameAligned(start_us, fps) + const end = assertFrameAligned(end_us, fps) + if (end <= start) throw new RangeError('end_us must be greater than start_us') + return { start, end } +} diff --git a/src/features/editor/codepress/translation.ts b/src/features/editor/codepress/translation.ts new file mode 100644 index 000000000..8a630308f --- /dev/null +++ b/src/features/editor/codepress/translation.ts @@ -0,0 +1,269 @@ +import { assertFrameAligned } from './timing' +import type { + CaptionCue, + ClipItem, + EditCommand, + EditCommandBatch, + ItemPropertiesPatch, + Keyframe, + TextItem, + TimelineItem, + Transition, +} from './contract' + +export type Frame = number + +export type FrameClipItem = Omit< + ClipItem, + | 'timeline_start_us' + | 'timeline_end_us' + | 'source_start_us' + | 'source_end_us' + | 'fade_in_us' + | 'fade_out_us' + | 'transition_in' + | 'transition_out' + | 'keyframes' +> & { + timeline_start_frame: Frame + timeline_end_frame: Frame + source_start_frame: Frame + source_end_frame: Frame + fade_in_frame?: Frame + fade_out_frame?: Frame + transition_in?: FrameTransition | null + transition_out?: FrameTransition | null + keyframes?: readonly FrameKeyframe[] +} + +export type FrameTextItem = Omit< + TextItem, + 'timeline_start_us' | 'timeline_end_us' | 'keyframes' +> & { + timeline_start_frame: Frame + timeline_end_frame: Frame + keyframes?: readonly FrameKeyframe[] +} + +export type FrameCaptionCue = Omit & { + start_frame: Frame + end_frame: Frame +} + +export type FrameItem = FrameClipItem | FrameTextItem | FrameCaptionCue + +export interface FrameTransition { + transition_type: Transition['transition_type'] + duration_frame: Frame +} + +export interface FrameKeyframe extends Omit { + time_frame: Frame +} + +export type FrameItemPropertiesPatch = Omit< + ItemPropertiesPatch, + 'fade_in_us' | 'fade_out_us' | 'transition_in' | 'transition_out' | 'keyframes' +> & { + fade_in_frame?: Frame | null + fade_out_frame?: Frame | null + transition_in?: FrameTransition | null + transition_out?: FrameTransition | null + keyframes?: readonly FrameKeyframe[] +} + +export type FrameEditCommand = + | (Omit, 'item'> & { item: FrameClipItem }) + | (Omit, 'item'> & { item: FrameTextItem }) + | (Omit, 'timeline_start_us'> & { + timeline_start_frame?: Frame + }) + | Extract + | (Omit, 'timeline_start_us'> & { + timeline_start_frame: Frame + }) + | (Omit, 'timeline_us' | 'source_us'> & { + timeline_frame: Frame + source_frame: Frame + }) + | (Omit, 'at_timeline_us' | 'at_source_us'> & { + at_timeline_frame: Frame + at_source_frame: Frame + }) + | (Omit, 'start_us' | 'end_us'> & { + start_frame: Frame + end_frame: Frame + }) + | Extract< + EditCommand, + { + type: + | 'add_track' + | 'remove_track' + | 'move_track' + | 'update_track' + | 'add_caption_track' + | 'remove_caption_track' + | 'update_caption_track' + } + > + | (Omit, 'cues'> & { + cues: readonly FrameCaptionCue[] + }) + | Extract + | (Omit, 'properties'> & { + properties: FrameItemPropertiesPatch + }) + | Extract + +export interface FrameEditCommandBatch extends Omit { + fps: number + commands: readonly FrameEditCommand[] +} + +function transitionToFrames( + transition: Transition | null | undefined, + fps: number, +): FrameTransition | null | undefined { + if (transition === undefined) return undefined + if (transition === null) return null + return { + transition_type: transition.transition_type, + duration_frame: assertFrameAligned(transition.duration_us, fps), + } +} + +function keyframeToFrames(keyframe: Keyframe, fps: number): FrameKeyframe { + return { + property: keyframe.property, + time_frame: assertFrameAligned(keyframe.time_us, fps), + value: keyframe.value, + interpolation: keyframe.interpolation, + } +} + +function itemToFrames(item: TimelineItem, fps: number): FrameItem { + if (item.item_type === 'caption_cue') { + return { + ...item, + start_frame: assertFrameAligned(item.start_us, fps), + end_frame: assertFrameAligned(item.end_us, fps), + } + } + if (item.item_type === 'text') { + return { + ...item, + timeline_start_frame: assertFrameAligned(item.timeline_start_us, fps), + timeline_end_frame: assertFrameAligned(item.timeline_end_us, fps), + keyframes: item.keyframes?.map((keyframe) => keyframeToFrames(keyframe, fps)), + } + } + return { + ...item, + timeline_start_frame: assertFrameAligned(item.timeline_start_us, fps), + timeline_end_frame: assertFrameAligned(item.timeline_end_us, fps), + source_start_frame: assertFrameAligned(item.source_start_us, fps), + source_end_frame: assertFrameAligned(item.source_end_us, fps), + fade_in_frame: + item.fade_in_us === undefined ? undefined : assertFrameAligned(item.fade_in_us, fps), + fade_out_frame: + item.fade_out_us === undefined ? undefined : assertFrameAligned(item.fade_out_us, fps), + transition_in: transitionToFrames(item.transition_in, fps), + transition_out: transitionToFrames(item.transition_out, fps), + keyframes: item.keyframes?.map((keyframe) => keyframeToFrames(keyframe, fps)), + } +} + +function propertiesToFrames( + properties: ItemPropertiesPatch, + fps: number, +): FrameItemPropertiesPatch { + return { + ...properties, + fade_in_frame: + properties.fade_in_us === undefined + ? undefined + : properties.fade_in_us === null + ? null + : assertFrameAligned(properties.fade_in_us, fps), + fade_out_frame: + properties.fade_out_us === undefined + ? undefined + : properties.fade_out_us === null + ? null + : assertFrameAligned(properties.fade_out_us, fps), + transition_in: transitionToFrames(properties.transition_in, fps), + transition_out: transitionToFrames(properties.transition_out, fps), + keyframes: properties.keyframes?.map((keyframe) => keyframeToFrames(keyframe, fps)), + } +} + +export function translateCommandToFrames(command: EditCommand, fps: number): FrameEditCommand { + switch (command.type) { + case 'add_clip': + return { ...command, item: itemToFrames(command.item, fps) as FrameClipItem } + case 'add_text': + return { ...command, item: itemToFrames(command.item, fps) as FrameTextItem } + case 'duplicate_item': + return { + ...command, + ...(command.timeline_start_us === undefined + ? {} + : { timeline_start_frame: assertFrameAligned(command.timeline_start_us, fps) }), + } + case 'remove_item': + return command + case 'move_item': + return { + ...command, + timeline_start_frame: assertFrameAligned(command.timeline_start_us, fps), + } + case 'trim_item': + return { + ...command, + timeline_frame: assertFrameAligned(command.timeline_us, fps), + source_frame: assertFrameAligned(command.source_us, fps), + } + case 'split_item': + return { + ...command, + at_timeline_frame: assertFrameAligned(command.at_timeline_us, fps), + at_source_frame: assertFrameAligned(command.at_source_us, fps), + } + case 'ripple_delete': + return { + ...command, + start_frame: assertFrameAligned(command.start_us, fps), + end_frame: assertFrameAligned(command.end_us, fps), + } + case 'upsert_caption_cues': + return { + ...command, + cues: command.cues.map((cue) => itemToFrames(cue, fps) as FrameCaptionCue), + } + case 'set_item_properties': + return { ...command, properties: propertiesToFrames(command.properties, fps) } + case 'add_track': + case 'remove_track': + case 'move_track': + case 'update_track': + case 'add_caption_track': + case 'remove_caption_track': + case 'update_caption_track': + case 'remove_caption_cues': + case 'set_caption_style': + case 'request_job': + return command + } +} + +export function translateCommandBatchToFrames( + batch: EditCommandBatch, + fps: number, +): FrameEditCommandBatch { + return { + ...batch, + fps, + commands: batch.commands.map((command) => translateCommandToFrames(command, fps)), + } +} From 212a18c2325122805f8d870979bb86f2128fce86 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Sat, 15 Aug 2026 23:35:23 -0700 Subject: [PATCH 03/19] fix(editor): harden fractional frame adapter paths --- src/features/editor/codepress/README.md | 12 +- src/features/editor/codepress/adapter.test.ts | 243 +++++++++++++++++- src/features/editor/codepress/adapter.ts | 8 +- src/features/editor/codepress/document.ts | 81 ++++-- src/features/editor/codepress/edit-engine.ts | 149 ++++++++--- src/features/editor/codepress/interfaces.ts | 5 +- src/features/editor/codepress/timing.ts | 44 +++- src/features/editor/codepress/translation.ts | 19 +- 8 files changed, 481 insertions(+), 80 deletions(-) diff --git a/src/features/editor/codepress/README.md b/src/features/editor/codepress/README.md index 8ea9f2157..0f0d8521c 100644 --- a/src/features/editor/codepress/README.md +++ b/src/features/editor/codepress/README.md @@ -20,6 +20,11 @@ revision and preconditions, applies it through the pure edit engine, records idempotent replays, and publishes the accepted controlled document. A failed command never replaces the document. +`request_job` remains part of the canonical command vocabulary, but the pure +engine rejects it as `unsupported_command` until a host explicitly owns the +`MediaJobClient` dispatch. It never reports a media job as applied while doing +nothing. + The existing `headless/` browser harness and its localhost `/v1` service are not imported here and remain development-only implementation seams. @@ -30,12 +35,17 @@ frames, so all mutation timestamps are required to equal the deterministic canonical integer-microsecond representation of a frame at the document FPS. `timing.ts` uses rational `BigInt` arithmetic for alignment and nearest-frame conversion; it does not depend on a browser clock or floating-point remainder. +The controlled document bridge converts each interval endpoint independently, +so valid fractional-rate intervals (including 30000/1001) preserve their frame +indices even when the integer-microsecond duration is not itself a canonical +frame timestamp. ## Ripple and captions `ripple_delete` operates on `[start_us, end_us)` in the selected tracks (or all tracks for `track_ids: null`). Downstream items shift left by the exact frame -delta. An item crossing both boundaries is split deterministically: the +delta, with each shifted endpoint re-encoded from its resulting frame index. +An item crossing both boundaries is split deterministically: the left-hand fragment keeps the original ID and the right-hand fragment receives a stable `:ripple-right` ID. Caption cues use the same interval semantics and remain ordinary caption-track items in the controlled document. diff --git a/src/features/editor/codepress/adapter.test.ts b/src/features/editor/codepress/adapter.test.ts index 7b3888d4f..8181387e9 100644 --- a/src/features/editor/codepress/adapter.test.ts +++ b/src/features/editor/codepress/adapter.test.ts @@ -24,6 +24,7 @@ import type { TimelineState, } from './contract' import type { ControlledEditorDocument } from './interfaces' +import type { FrameRateLike } from './timing' function readFixture(relativePath: string): T { return JSON.parse( @@ -111,7 +112,7 @@ function timeline(overrides: Partial = {}): TimelineState { } } -function documentFor(next: TimelineState, fps = 30): ControlledEditorDocument { +function documentFor(next: TimelineState, fps: FrameRateLike = 30): ControlledEditorDocument { return { timeline: next, fps, width: 1920, height: 1080 } } @@ -240,6 +241,103 @@ describe('controlled command adapter', () => { }) }) + it('round-trips nonzero-start clip, text, and caption endpoints at fractional FPS', () => { + const rates: readonly FrameRateLike[] = [ + 29.97, + { numerator: 30_000n, denominator: 1_001n, value: 30_000 / 1_001 }, + ] + for (const fps of rates) { + const frameDocument = { + timelineId: 'timeline-fractional', + revision: 2, + fps, + durationInFrames: 180, + media: [videoMedia], + tracks: [ + { + id: 'track-video', + kind: 'video' as const, + name: 'Video', + locked: false, + muted: false, + items: [ + { + type: 'video' as const, + id: 'clip-fractional', + trackId: 'track-video', + mediaId: 'media-video', + from: 1, + durationInFrames: 1, + sourceStart: 2, + sourceEnd: 3, + }, + ], + }, + { + id: 'track-overlay', + kind: 'overlay' as const, + name: 'Overlay', + locked: false, + muted: false, + items: [ + { + type: 'text' as const, + id: 'text-fractional', + trackId: 'track-overlay', + from: 2, + durationInFrames: 1, + text: 'Fractional text', + }, + ], + }, + { + id: 'track-captions', + kind: 'caption' as const, + name: 'Captions', + language: 'en', + locked: false, + muted: false, + items: [ + { + type: 'caption_cue' as const, + id: 'cue-fractional', + trackId: 'track-captions', + from: 3, + durationInFrames: 1, + text: 'Fractional cue', + }, + ], + }, + ], + width: 1920, + height: 1080, + } + const controlled = freeCutDocumentToControlledDocument(frameDocument) + const clipItem = controlled.timeline.tracks[0]?.items[0] + const textItem = controlled.timeline.tracks[1]?.items[0] + const cueItem = controlled.timeline.tracks[2]?.items[0] + expect(clipItem).toMatchObject({ + timeline_start_us: framesToMicroseconds(1, fps), + timeline_end_us: framesToMicroseconds(2, fps), + source_start_us: framesToMicroseconds(2, fps), + source_end_us: framesToMicroseconds(3, fps), + }) + expect(textItem).toMatchObject({ + timeline_start_us: framesToMicroseconds(2, fps), + timeline_end_us: framesToMicroseconds(3, fps), + }) + expect(cueItem).toMatchObject({ + start_us: framesToMicroseconds(3, fps), + end_us: framesToMicroseconds(4, fps), + }) + + const roundTrip = controlledDocumentToFreeCutDocument(controlled) + expect(roundTrip.tracks[0]?.items[0]).toMatchObject({ from: 1, durationInFrames: 1 }) + expect(roundTrip.tracks[1]?.items[0]).toMatchObject({ from: 2, durationInFrames: 1 }) + expect(roundTrip.tracks[2]?.items[0]).toMatchObject({ from: 3, durationInFrames: 1 }) + } + }) + it('applies the canonical core fixture atomically and reports normalized effects', () => { const fixture = readFixture('valid/core-edit-batch.json') const adapter = new CodePressCommandAdapter({ document: documentFor(fixture.timeline) }) @@ -387,6 +485,112 @@ describe('controlled command adapter', () => { expect(selected.timeline.duration_us).toBe(10_000_000) }) + it('re-encodes NTSC ripple endpoints independently at fractional FPS', () => { + const fps: FrameRateLike = { + numerator: 30_000n, + denominator: 1_001n, + value: 30_000 / 1_001, + } + const frame = (value: number) => framesToMicroseconds(value, fps) + const base = timeline({ + duration_us: frame(12), + tracks: [ + { + track_id: 'track-video', + kind: 'video', + name: 'Video', + locked: false, + muted: false, + items: [ + clip({ + item_id: 'before-ntsc', + timeline_start_us: frame(0), + timeline_end_us: frame(1), + source_start_us: frame(0), + source_end_us: frame(1), + }), + clip({ + item_id: 'after-ntsc', + timeline_start_us: frame(2), + timeline_end_us: frame(3), + source_start_us: frame(2), + source_end_us: frame(3), + }), + ], + }, + { + track_id: 'track-captions', + kind: 'caption', + name: 'Captions', + language: 'en', + locked: false, + muted: false, + items: [ + { + item_type: 'caption_cue', + cue_id: 'cue-after-ntsc', + track_id: 'track-captions', + start_us: frame(2), + end_us: frame(3), + text: 'After NTSC', + }, + ], + }, + ], + }) + const adapter = new CodePressCommandAdapter({ document: documentFor(base, fps) }) + const result = applyRequest(adapter, { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'ripple-ntsc', + idempotency_key: 'ripple-ntsc:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'ripple-ntsc-command', + type: 'ripple_delete', + start_us: frame(1), + end_us: frame(2), + track_ids: null, + }, + ], + }) + const videoAfter = result.timeline.tracks[0]?.items.find( + (item) => itemIdForTest(item) === 'after-ntsc', + ) + const captionAfter = result.timeline.tracks[1]?.items.find( + (item) => itemIdForTest(item) === 'cue-after-ntsc', + ) + expect(videoAfter).toMatchObject({ + timeline_start_us: frame(1), + timeline_end_us: frame(2), + }) + expect(captionAfter).toMatchObject({ start_us: frame(1), end_us: frame(2) }) + expect(result.timeline.duration_us).toBe(frame(11)) + expect(validateTimelineState(result.timeline).ok).toBe(true) + + const next = adapter.apply({ + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'move-after-ntsc', + idempotency_key: 'move-after-ntsc:1', + base_revision: 1, + preconditions: [], + commands: [ + { + command_id: 'move-after-ntsc-command', + type: 'move_item', + item_id: 'after-ntsc', + to_track_id: 'track-video', + timeline_start_us: frame(1), + index: 1, + }, + ], + }) + expect(next.status).toBe('applied') + }) + it('keeps the left fragment ID and derives a stable right fragment for a crossing item', () => { const crossing = new CodePressCommandAdapter({ document: documentFor( @@ -478,6 +682,43 @@ describe('controlled command adapter', () => { expect(adapter.getSnapshot().document.timeline.tracks[0]?.items).toHaveLength(1) }) + it('rejects request_job until a host dispatch boundary is implemented', () => { + let hostCalls = 0 + const adapter = new CodePressCommandAdapter({ + document: documentFor(timeline()), + hosts: { + mediaJobClient: { + request: () => { + hostCalls += 1 + return { job_id: 'job-should-not-run' } + }, + }, + }, + }) + const result = adapter.apply({ + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'request-job', + idempotency_key: 'request-job:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'request-job-command', + type: 'request_job', + job_type: 'thumbnail', + media_id: 'media-video', + }, + ], + }) + expect(result).toMatchObject({ + status: 'rejected', + error: { code: 'unsupported_command', retryable: false }, + }) + expect(hostCalls).toBe(0) + expect(adapter.getSnapshot().revision).toBe(0) + }) + it('returns explicit revision and idempotency conflicts', () => { const adapter = createCodePressCommandAdapter({ document: documentFor(timeline()) }) const request: EditCommandBatch = { diff --git a/src/features/editor/codepress/adapter.ts b/src/features/editor/codepress/adapter.ts index f3a2b8640..789615ee4 100644 --- a/src/features/editor/codepress/adapter.ts +++ b/src/features/editor/codepress/adapter.ts @@ -48,7 +48,13 @@ function stableSerialize(value: unknown): string { } function copyResult(value: T): T { - return JSON.parse(JSON.stringify(value)) as T + const clone = (entry: unknown): unknown => { + if (entry === null || (typeof entry !== 'object' && typeof entry !== 'bigint')) return entry + if (typeof entry === 'bigint') return entry + if (Array.isArray(entry)) return entry.map(clone) + return Object.fromEntries(Object.entries(entry).map(([key, child]) => [key, clone(child)])) + } + return clone(value) as T } function emptyActual(): Record { diff --git a/src/features/editor/codepress/document.ts b/src/features/editor/codepress/document.ts index 39dd254a6..79e6f2cea 100644 --- a/src/features/editor/codepress/document.ts +++ b/src/features/editor/codepress/document.ts @@ -7,7 +7,12 @@ import type { TimelineTrack, } from './contract' import type { ControlledEditorDocument } from './interfaces' -import { assertFrameAligned, framesToMicroseconds } from './timing' +import { + assertFrameAligned, + framesToMicroseconds, + normalizeFrameRate, + type FrameRateLike, +} from './timing' /** Minimal frame-native document shape expected from a FreeCut host. */ export interface FreeCutFrameClip { @@ -62,7 +67,7 @@ export interface FreeCutFrameTrack { export interface FreeCutFrameDocument { timelineId: string revision: number - fps: number + fps: FrameRateLike durationInFrames: number media: readonly MediaReference[] tracks: readonly FreeCutFrameTrack[] @@ -78,7 +83,10 @@ export class FreeCutDocumentConversionError extends Error { } } -function frameRange(item: FreeCutFrameItem, fps: number): { start_us: number; end_us: number } { +function frameRange( + item: FreeCutFrameItem, + fps: FrameRateLike, +): { start_us: number; end_us: number } { if ( !Number.isSafeInteger(item.from) || item.from < 0 || @@ -96,7 +104,7 @@ function frameRange(item: FreeCutFrameItem, fps: number): { start_us: number; en return { start_us, end_us } } -function toContractItem(item: FreeCutFrameItem, fps: number): TimelineItem { +function toContractItem(item: FreeCutFrameItem, fps: FrameRateLike): TimelineItem { const range = frameRange(item, fps) if (item.type === 'caption_cue') { return { @@ -193,7 +201,7 @@ function fromTransform( } } -function toTrack(track: FreeCutFrameTrack, fps: number): TimelineTrack { +function toTrack(track: FreeCutFrameTrack, fps: FrameRateLike): TimelineTrack { return { track_id: track.id, kind: track.kind, @@ -208,8 +216,13 @@ function toTrack(track: FreeCutFrameTrack, fps: number): TimelineTrack { export function freeCutDocumentToControlledDocument( input: FreeCutFrameDocument, ): ControlledEditorDocument { - if (!Number.isFinite(input.fps) || input.fps <= 0) - throw new FreeCutDocumentConversionError('fps must be a finite positive number') + try { + normalizeFrameRate(input.fps) + } catch (error) { + throw new FreeCutDocumentConversionError( + error instanceof Error ? error.message : 'fps must be a finite positive rate', + ) + } if (!Number.isSafeInteger(input.durationInFrames) || input.durationInFrames < 0) throw new FreeCutDocumentConversionError('durationInFrames must be a safe non-negative integer') const timeline: TimelineState = { @@ -230,40 +243,76 @@ export function freeCutDocumentToControlledDocument( } } -function fromContractItem(item: TimelineItem, fps: number): FreeCutFrameItem { +interface FrameBounds { + start: number + end: number +} + +function frameBounds( + start_us: number, + end_us: number, + fps: FrameRateLike, + label: string, +): FrameBounds { + const start = assertFrameAligned(start_us, fps) + const end = assertFrameAligned(end_us, fps) + if (end <= start) throw new FreeCutDocumentConversionError(`${label} has an empty frame range`) + return { start, end } +} + +function fromContractItem(item: TimelineItem, fps: FrameRateLike): FreeCutFrameItem { if (item.item_type === 'caption_cue') { + const range = frameBounds(item.start_us, item.end_us, fps, `Caption cue "${item.cue_id}"`) return { type: 'caption_cue', id: item.cue_id, trackId: item.track_id, - from: assertFrameAligned(item.start_us, fps), - durationInFrames: assertFrameAligned(item.end_us - item.start_us, fps), + from: range.start, + durationInFrames: range.end - range.start, text: item.text, ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), } } if (item.item_type === 'text') { + const range = frameBounds( + item.timeline_start_us, + item.timeline_end_us, + fps, + `Text item "${item.item_id}"`, + ) return { type: 'text', id: item.item_id, trackId: item.track_id, - from: assertFrameAligned(item.timeline_start_us, fps), - durationInFrames: assertFrameAligned(item.timeline_end_us - item.timeline_start_us, fps), + from: range.start, + durationInFrames: range.end - range.start, text: item.text, ...(item.style ? { style: { ...item.style } } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), ...(item.transform ? { transform: fromTransform(item.transform) } : {}), } } + const timelineRange = frameBounds( + item.timeline_start_us, + item.timeline_end_us, + fps, + `Clip "${item.item_id}"`, + ) + const sourceRange = frameBounds( + item.source_start_us, + item.source_end_us, + fps, + `Clip "${item.item_id}" source`, + ) return { type: item.media_kind, id: item.item_id, trackId: item.track_id, mediaId: item.media_id, - from: assertFrameAligned(item.timeline_start_us, fps), - durationInFrames: assertFrameAligned(item.timeline_end_us - item.timeline_start_us, fps), - sourceStart: assertFrameAligned(item.source_start_us, fps), - sourceEnd: assertFrameAligned(item.source_end_us, fps), + from: timelineRange.start, + durationInFrames: timelineRange.end - timelineRange.start, + sourceStart: sourceRange.start, + sourceEnd: sourceRange.end, ...(item.volume !== undefined ? { volume: item.volume } : {}), ...(item.speed !== undefined ? { speed: item.speed } : {}), ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), diff --git a/src/features/editor/codepress/edit-engine.ts b/src/features/editor/codepress/edit-engine.ts index dddd44d49..f3b63337d 100644 --- a/src/features/editor/codepress/edit-engine.ts +++ b/src/features/editor/codepress/edit-engine.ts @@ -1,4 +1,9 @@ -import { assertFrameAligned, framesToMicroseconds } from './timing' +import { + assertFrameAligned, + framesToMicroseconds, + normalizeFrameRate, + type FrameRateLike, +} from './timing' import type { AddCaptionTrackCommand, AddClipCommand, @@ -168,21 +173,55 @@ function setItemTrack(item: TimelineItem, track_id: TrackId): TimelineItem { return item.item_type === 'caption_cue' ? { ...item, track_id } : { ...item, track_id } } -function sourceAtTimeline(item: ClipItem, timeline_us: number): number { - const timelineDuration = item.timeline_end_us - item.timeline_start_us - const sourceDuration = item.source_end_us - item.source_start_us - if (timeline_us <= item.timeline_start_us) return item.source_start_us - if (timeline_us >= item.timeline_end_us) return item.source_end_us - const numerator = BigInt(timeline_us - item.timeline_start_us) * BigInt(sourceDuration) - const denominator = BigInt(timelineDuration) +function roundedRatio(numerator: bigint, denominator: bigint): bigint { const quotient = numerator / denominator const remainder = numerator % denominator - const rounded = remainder * 2n >= denominator ? quotient + 1n : quotient - return item.source_start_us + Number(rounded) + return remainder * 2n >= denominator ? quotient + 1n : quotient +} + +function sourceAtTimelineFrame(item: ClipItem, timelineFrame: number, fps: FrameRateLike): number { + const timelineStartFrame = assertFrameAligned(item.timeline_start_us, fps) + const timelineEndFrame = assertFrameAligned(item.timeline_end_us, fps) + const sourceStartFrame = assertFrameAligned(item.source_start_us, fps) + const sourceEndFrame = assertFrameAligned(item.source_end_us, fps) + const timelineDuration = timelineEndFrame - timelineStartFrame + const sourceDuration = sourceEndFrame - sourceStartFrame + if (timelineFrame <= timelineStartFrame) return item.source_start_us + if (timelineFrame >= timelineEndFrame) return item.source_end_us + const sourceOffset = roundedRatio( + BigInt(timelineFrame - timelineStartFrame) * BigInt(sourceDuration), + BigInt(timelineDuration), + ) + return framesToMicroseconds(sourceStartFrame + Number(sourceOffset), fps) +} + +function frameRangeFor(item: TimelineItem, fps: FrameRateLike): { start: number; end: number } { + return { + start: assertFrameAligned(itemStart(item), fps), + end: assertFrameAligned(itemEnd(item), fps), + } +} + +function setItemFramePosition( + item: TimelineItem, + startFrame: number, + endFrame: number, + fps: FrameRateLike, +): TimelineItem { + return setItemPosition( + item, + framesToMicroseconds(startFrame, fps), + framesToMicroseconds(endFrame, fps), + ) } -function shiftItem(item: TimelineItem, delta_us: number): TimelineItem { - return setItemPosition(item, itemStart(item) - delta_us, itemEnd(item) - delta_us) +function shiftItemByFrames( + item: TimelineItem, + deltaFrames: number, + fps: FrameRateLike, +): TimelineItem { + const range = frameRangeFor(item, fps) + return setItemFramePosition(item, range.start - deltaFrames, range.end - deltaFrames, fps) } function deriveFragmentId(originalId: string, usedIds: Set): string { @@ -198,16 +237,21 @@ function deriveFragmentId(originalId: string, usedIds: Set): string { ) } -function trimItemToRange(item: TimelineItem, start_us: number, end_us: number): TimelineItem { +function trimItemToFrameRange( + item: TimelineItem, + startFrame: number, + endFrame: number, + fps: FrameRateLike, +): TimelineItem { + const start_us = framesToMicroseconds(startFrame, fps) + const end_us = framesToMicroseconds(endFrame, fps) if (item.item_type === 'clip') { - const sourceStart = sourceAtTimeline(item, start_us) - const sourceEnd = sourceAtTimeline(item, end_us) return { ...item, timeline_start_us: start_us, timeline_end_us: end_us, - source_start_us: sourceStart, - source_end_us: sourceEnd, + source_start_us: sourceAtTimelineFrame(item, startFrame, fps), + source_end_us: sourceAtTimelineFrame(item, endFrame, fps), } } return setItemPosition(item, start_us, end_us) @@ -215,30 +259,35 @@ function trimItemToRange(item: TimelineItem, start_us: number, end_us: number): function expandRippleItem( item: TimelineItem, - start_us: number, - end_us: number, - delta_us: number, + startFrame: number, + endFrame: number, + deltaFrames: number, + fps: FrameRateLike, usedIds: Set, ): { items: TimelineItem[]; created: string[]; updated: string[]; deleted: string[] } { const id = itemId(item) - const start = itemStart(item) - const end = itemEnd(item) - if (end <= start_us) return { items: [item], created: [], updated: [], deleted: [] } - if (start >= end_us) - return { items: [shiftItem(item, delta_us)], created: [], updated: [id], deleted: [] } - - const hasLeft = start < start_us - const hasRight = end > end_us + const range = frameRangeFor(item, fps) + if (range.end <= startFrame) return { items: [item], created: [], updated: [], deleted: [] } + if (range.start >= endFrame) + return { + items: [shiftItemByFrames(item, deltaFrames, fps)], + created: [], + updated: [id], + deleted: [], + } + + const hasLeft = range.start < startFrame + const hasRight = range.end > endFrame if (!hasLeft && !hasRight) return { items: [], created: [], updated: [], deleted: [id] } const result: TimelineItem[] = [] const created: string[] = [] const updated = [id] const deleted: string[] = [] - if (hasLeft) result.push(trimItemToRange(item, start, start_us)) + if (hasLeft) result.push(trimItemToFrameRange(item, range.start, startFrame, fps)) if (hasRight) { - const right = trimItemToRange(item, end_us, end) - const shifted = shiftItem(right, delta_us) + const right = trimItemToFrameRange(item, endFrame, range.end, fps) + const shifted = shiftItemByFrames(right, deltaFrames, fps) if (hasLeft) { const fragmentId = deriveFragmentId(id, usedIds) usedIds.add(fragmentId) @@ -468,12 +517,12 @@ function applySplit( function applyRippleDelete( timeline: MutableTimeline, command: Extract, - fps: number, + fps: FrameRateLike, ): CommandEffect { const startFrame = assertFrameAligned(command.start_us, fps) const endFrame = assertFrameAligned(command.end_us, fps) const deltaFrames = endFrame - startFrame - const delta_us = framesToMicroseconds(deltaFrames, fps) + const previousDuration = timeline.duration_us const selected = command.track_ids === null ? null : new Set(command.track_ids) for (const trackId of selected ?? []) findTrack(timeline, trackId, command.command_id) const usedIds = new Set(allItems(timeline).map(itemId)) @@ -484,7 +533,7 @@ function applyRippleDelete( if (selected !== null && !selected.has(track.track_id)) continue const nextItems: TimelineItem[] = [] for (const item of track.items) { - const result = expandRippleItem(item, command.start_us, command.end_us, delta_us, usedIds) + const result = expandRippleItem(item, startFrame, endFrame, deltaFrames, fps, usedIds) nextItems.push(...result.items) created.push(...result.created) updated.push(...result.updated) @@ -492,9 +541,15 @@ function applyRippleDelete( } replaceTrackItems(timeline, trackIndex, nextItems) } - const durationDelta = selected === null ? -delta_us : 0 - if (selected === null) timeline.duration_us = Math.max(0, timeline.duration_us + durationDelta) + if (selected === null) { + const previousDurationFrames = assertFrameAligned(previousDuration, fps) + timeline.duration_us = framesToMicroseconds( + Math.max(0, previousDurationFrames - deltaFrames), + fps, + ) + } recomputeDuration(timeline) + const durationDelta = selected === null ? timeline.duration_us - previousDuration : 0 return { ...emptyEffect(durationDelta), created_item_ids: created, @@ -754,7 +809,11 @@ function applyCaptionStyle( return { ...emptyEffect(), updated_item_ids: updated } } -function applyCommand(timeline: MutableTimeline, command: EditCommand, fps: number): CommandEffect { +function applyCommand( + timeline: MutableTimeline, + command: EditCommand, + fps: FrameRateLike, +): CommandEffect { // All command time fields are public microseconds. Conversion is performed // before mutation so a non-frame-aligned batch can never partially apply. switch (command.type) { @@ -816,7 +875,11 @@ function applyCommand(timeline: MutableTimeline, command: EditCommand, fps: numb case 'set_caption_style': return applyCaptionStyle(timeline, command) case 'request_job': - return emptyEffect() + throw new EditEngineError( + 'unsupported_command', + 'request_job must be dispatched through a MediaJobClient host adapter', + command.command_id, + ) default: return assertNever(command) } @@ -830,8 +893,14 @@ function assertNever(value: never): never { } function validateAndPrepare(timeline: TimelineState, context: EditEngineContext): MutableTimeline { - if (!Number.isFinite(context.fps) || context.fps <= 0) - throw new EditEngineError('invalid_request', 'fps must be a finite positive number') + try { + normalizeFrameRate(context.fps) + } catch (error) { + throw new EditEngineError( + 'invalid_request', + error instanceof Error ? error.message : 'fps must be a finite positive rate', + ) + } const valid = validateTimelineState(timeline) if (!valid.ok) throw new EditEngineError('invalid_request', valid.errors[0]?.message ?? 'timeline is invalid') diff --git a/src/features/editor/codepress/interfaces.ts b/src/features/editor/codepress/interfaces.ts index d311373b5..e7f7ab4f6 100644 --- a/src/features/editor/codepress/interfaces.ts +++ b/src/features/editor/codepress/interfaces.ts @@ -7,11 +7,12 @@ import type { TimelineState, TimelineRevision, } from './contract' +import type { FrameRateLike } from './timing' /** Data the controlled editor owns while it is mounted. It is not persistence. */ export interface ControlledEditorDocument { timeline: TimelineState - fps: number + fps: FrameRateLike width: number height: number background_color?: string @@ -25,7 +26,7 @@ export interface ControlledEditorPort { } export interface EditEngineContext { - fps: number + fps: FrameRateLike } export interface EditEngineResult { diff --git a/src/features/editor/codepress/timing.ts b/src/features/editor/codepress/timing.ts index 681eec1de..696e663f0 100644 --- a/src/features/editor/codepress/timing.ts +++ b/src/features/editor/codepress/timing.ts @@ -14,17 +14,22 @@ export interface FrameRate { readonly value: number } +/** A numeric rate is convenient for ordinary documents; the rational form + * preserves exact NTSC-style rates such as 30000/1001. */ +export type FrameRateLike = number | FrameRate + export type TimingRounding = 'nearest' | 'floor' | 'ceil' export class FrameTimingError extends Error { readonly microseconds: number readonly fps: number - constructor(microseconds: number, fps: number) { - super(`${microseconds}µs is not aligned to an integer frame at ${fps}fps`) + constructor(microseconds: number, fps: FrameRateLike) { + const value = typeof fps === 'number' ? fps : fps.value + super(`${microseconds}µs is not aligned to an integer frame at ${value}fps`) this.name = 'FrameTimingError' this.microseconds = microseconds - this.fps = fps + this.fps = value } } @@ -55,9 +60,26 @@ function decimalToRational(value: number): { numerator: bigint; denominator: big return { numerator: numerator / divisor, denominator: denominator / divisor } } -export function normalizeFrameRate(fps: number): FrameRate { - const rational = decimalToRational(fps) - return { ...rational, value: fps } +export function normalizeFrameRate(fps: FrameRateLike): FrameRate { + if (typeof fps === 'number') { + const rational = decimalToRational(fps) + return { ...rational, value: fps } + } + if ( + typeof fps.numerator !== 'bigint' || + typeof fps.denominator !== 'bigint' || + fps.numerator <= 0n || + fps.denominator <= 0n || + !Number.isFinite(fps.value) || + fps.value <= 0 + ) + throw new RangeError('fps must be a finite positive rational rate') + const divisor = gcd(fps.numerator, fps.denominator) + return { + numerator: fps.numerator / divisor, + denominator: fps.denominator / divisor, + value: fps.value, + } } function assertMicroseconds(value: number): void { @@ -83,7 +105,7 @@ function divideRounded(numerator: bigint, denominator: bigint, rounding: TimingR /** Convert an integer microsecond timestamp to a frame, requiring alignment by default. */ export function microsecondsToFrames( microseconds: number, - fps: number | FrameRate, + fps: FrameRateLike, options: { rounding?: TimingRounding; requireAligned?: boolean } = {}, ): number { assertMicroseconds(microseconds) @@ -111,7 +133,7 @@ export function microsecondsToFrames( /** Convert an integer frame position to the nearest integer microsecond. */ export function framesToMicroseconds( frames: number, - fps: number | FrameRate, + fps: FrameRateLike, rounding: TimingRounding = 'nearest', ): number { assertFrames(frames) @@ -123,7 +145,7 @@ export function framesToMicroseconds( return result } -export function isFrameAligned(microseconds: number, fps: number | FrameRate): boolean { +export function isFrameAligned(microseconds: number, fps: FrameRateLike): boolean { try { microsecondsToFrames(microseconds, fps) return true @@ -133,7 +155,7 @@ export function isFrameAligned(microseconds: number, fps: number | FrameRate): b } } -export function assertFrameAligned(microseconds: number, fps: number | FrameRate): number { +export function assertFrameAligned(microseconds: number, fps: FrameRateLike): number { return microsecondsToFrames(microseconds, fps, { requireAligned: true }) } @@ -145,7 +167,7 @@ export interface FrameInterval { export function microsecondIntervalToFrames( start_us: number, end_us: number, - fps: number | FrameRate, + fps: FrameRateLike, ): FrameInterval { const start = assertFrameAligned(start_us, fps) const end = assertFrameAligned(end_us, fps) diff --git a/src/features/editor/codepress/translation.ts b/src/features/editor/codepress/translation.ts index 8a630308f..a4d45fada 100644 --- a/src/features/editor/codepress/translation.ts +++ b/src/features/editor/codepress/translation.ts @@ -1,4 +1,4 @@ -import { assertFrameAligned } from './timing' +import { assertFrameAligned, type FrameRateLike } from './timing' import type { CaptionCue, ClipItem, @@ -117,13 +117,13 @@ export type FrameEditCommand = | Extract export interface FrameEditCommandBatch extends Omit { - fps: number + fps: FrameRateLike commands: readonly FrameEditCommand[] } function transitionToFrames( transition: Transition | null | undefined, - fps: number, + fps: FrameRateLike, ): FrameTransition | null | undefined { if (transition === undefined) return undefined if (transition === null) return null @@ -133,7 +133,7 @@ function transitionToFrames( } } -function keyframeToFrames(keyframe: Keyframe, fps: number): FrameKeyframe { +function keyframeToFrames(keyframe: Keyframe, fps: FrameRateLike): FrameKeyframe { return { property: keyframe.property, time_frame: assertFrameAligned(keyframe.time_us, fps), @@ -142,7 +142,7 @@ function keyframeToFrames(keyframe: Keyframe, fps: number): FrameKeyframe { } } -function itemToFrames(item: TimelineItem, fps: number): FrameItem { +function itemToFrames(item: TimelineItem, fps: FrameRateLike): FrameItem { if (item.item_type === 'caption_cue') { return { ...item, @@ -176,7 +176,7 @@ function itemToFrames(item: TimelineItem, fps: number): FrameItem { function propertiesToFrames( properties: ItemPropertiesPatch, - fps: number, + fps: FrameRateLike, ): FrameItemPropertiesPatch { return { ...properties, @@ -198,7 +198,10 @@ function propertiesToFrames( } } -export function translateCommandToFrames(command: EditCommand, fps: number): FrameEditCommand { +export function translateCommandToFrames( + command: EditCommand, + fps: FrameRateLike, +): FrameEditCommand { switch (command.type) { case 'add_clip': return { ...command, item: itemToFrames(command.item, fps) as FrameClipItem } @@ -259,7 +262,7 @@ export function translateCommandToFrames(command: EditCommand, fps: number): Fra export function translateCommandBatchToFrames( batch: EditCommandBatch, - fps: number, + fps: FrameRateLike, ): FrameEditCommandBatch { return { ...batch, From 86722c597084df38bd61b0b5726395447062927c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Sun, 16 Aug 2026 00:02:45 -0700 Subject: [PATCH 04/19] fix(editor): close adapter boundary review gaps --- src/features/editor/codepress/README.md | 16 +- src/features/editor/codepress/adapter.test.ts | 294 ++++++++++++++++++ src/features/editor/codepress/adapter.ts | 96 +++--- src/features/editor/codepress/edit-engine.ts | 10 +- src/features/editor/codepress/translation.ts | 34 +- 5 files changed, 404 insertions(+), 46 deletions(-) diff --git a/src/features/editor/codepress/README.md b/src/features/editor/codepress/README.md index 0f0d8521c..c8f60251c 100644 --- a/src/features/editor/codepress/README.md +++ b/src/features/editor/codepress/README.md @@ -20,6 +20,12 @@ revision and preconditions, applies it through the pure edit engine, records idempotent replays, and publishes the accepted controlled document. A failed command never replaces the document. +The commit boundary is explicit: edit-engine failures return `rejected` before +the adapter commits its document and idempotency result. Once that internal +commit succeeds, the adapter returns `applied`; editor publication, +subscribers, and telemetry are best-effort observers whose synchronous or +asynchronous failures cannot change the committed result. + `request_job` remains part of the canonical command vocabulary, but the pure engine rejects it as `unsupported_command` until a host explicitly owns the `MediaJobClient` dispatch. It never reports a media job as applied while doing @@ -47,5 +53,11 @@ tracks for `track_ids: null`). Downstream items shift left by the exact frame delta, with each shifted endpoint re-encoded from its resulting frame index. An item crossing both boundaries is split deterministically: the left-hand fragment keeps the original ID and the right-hand fragment receives a -stable `:ripple-right` ID. Caption cues use the same interval semantics and -remain ordinary caption-track items in the controlled document. +stable `:ripple-right` ID. Fragment suffix allocation reserves its bounded +suffix space, so maximum-length source IDs still receive deterministic unique +fragments. Caption cues use the same interval semantics and remain ordinary +caption-track items in the controlled document. + +Frame translation constructs frame-native objects explicitly; legacy +microsecond endpoint and property fields are not retained alongside their +frame equivalents. diff --git a/src/features/editor/codepress/adapter.test.ts b/src/features/editor/codepress/adapter.test.ts index 8181387e9..2ec212568 100644 --- a/src/features/editor/codepress/adapter.test.ts +++ b/src/features/editor/codepress/adapter.test.ts @@ -13,6 +13,7 @@ import { isFrameAligned, isVideoCommandError, translateCommandBatchToFrames, + translateCommandToFrames, validateCommandBatch, validateTimelineState, } from './index' @@ -187,6 +188,139 @@ describe('deterministic microsecond/frame conversion', () => { const split = translated.commands[0] expect(split).toMatchObject({ at_timeline_frame: 120, at_source_frame: 120 }) }) + + it('constructs frame-native items and patches without legacy microsecond fields', () => { + const translatedClip = translateCommandToFrames( + { + command_id: 'add-clip', + type: 'add_clip', + track_id: 'track-video', + item: clip({ + fade_in_us: 1_000_000, + fade_out_us: 1_000_000, + transition_in: { transition_type: 'crossfade', duration_us: 1_000_000 }, + transition_out: { transition_type: 'dip_to_black', duration_us: 1_000_000 }, + keyframes: [ + { + property: 'opacity', + time_us: 0, + value: 1, + interpolation: 'linear', + }, + ], + }), + }, + 30, + ) + if (translatedClip.type !== 'add_clip') throw new Error('clip command did not translate') + expect(translatedClip.item).toMatchObject({ + timeline_start_frame: 0, + timeline_end_frame: 30, + source_start_frame: 0, + source_end_frame: 30, + fade_in_frame: 30, + fade_out_frame: 30, + transition_in: { duration_frame: 30 }, + transition_out: { duration_frame: 30 }, + keyframes: [{ time_frame: 0 }], + }) + for (const field of [ + 'timeline_start_us', + 'timeline_end_us', + 'source_start_us', + 'source_end_us', + 'fade_in_us', + 'fade_out_us', + ]) { + expect(translatedClip.item).not.toHaveProperty(field) + } + + const translatedText = translateCommandToFrames( + { + command_id: 'add-text', + type: 'add_text', + track_id: 'track-video', + item: text({ + keyframes: [ + { + property: 'opacity', + time_us: 0, + value: 1, + interpolation: 'linear', + }, + ], + }), + }, + 30, + ) + if (translatedText.type !== 'add_text') throw new Error('text command did not translate') + expect(translatedText.item).toMatchObject({ + timeline_start_frame: 0, + timeline_end_frame: 30, + keyframes: [{ time_frame: 0 }], + }) + expect(translatedText.item).not.toHaveProperty('timeline_start_us') + expect(translatedText.item).not.toHaveProperty('timeline_end_us') + + const translatedCue = translateCommandToFrames( + { + command_id: 'upsert-cue', + type: 'upsert_caption_cues', + track_id: 'track-captions', + cues: [ + { + item_type: 'caption_cue', + cue_id: 'cue-frame-native', + track_id: 'track-captions', + start_us: 0, + end_us: 1_000_000, + text: 'Cue', + }, + ], + }, + 30, + ) + if (translatedCue.type !== 'upsert_caption_cues') + throw new Error('caption command did not translate') + expect(translatedCue.cues[0]).toMatchObject({ start_frame: 0, end_frame: 30 }) + expect(translatedCue.cues[0]).not.toHaveProperty('start_us') + expect(translatedCue.cues[0]).not.toHaveProperty('end_us') + + const translatedProperties = translateCommandToFrames( + { + command_id: 'set-properties', + type: 'set_item_properties', + item_id: 'clip-a', + properties: { + fade_in_us: 1_000_000, + fade_out_us: null, + transition_in: { transition_type: 'crossfade', duration_us: 1_000_000 }, + transition_out: null, + keyframes: [ + { + property: 'opacity', + time_us: 0, + value: 1, + interpolation: 'linear', + }, + ], + }, + }, + 30, + ) + if (translatedProperties.type !== 'set_item_properties') + throw new Error('properties command did not translate') + expect(translatedProperties.properties).toMatchObject({ + fade_in_frame: 30, + fade_out_frame: null, + transition_in: { duration_frame: 30 }, + transition_out: null, + keyframes: [{ time_frame: 0 }], + }) + for (const field of ['fade_in_us', 'fade_out_us']) { + expect(translatedProperties.properties).not.toHaveProperty(field) + } + }) }) describe('controlled command adapter', () => { @@ -658,6 +792,71 @@ describe('controlled command adapter', () => { }) }) + it('allocates bounded ripple fragment IDs for max-length IDs and collisions', () => { + const maxId = 'x'.repeat(128) + const baseFragmentId = `${maxId.slice(0, 128 - ':ripple-right'.length)}:ripple-right` + const suffixFragmentId = `${maxId.slice(0, 128 - ':ripple-2'.length)}:ripple-2` + const base = timeline({ + duration_us: 8_000_000, + tracks: [ + { + track_id: 'track-video', + kind: 'video', + name: 'Video', + locked: false, + muted: false, + items: [ + clip({ + item_id: maxId, + timeline_start_us: 0, + timeline_end_us: 4_000_000, + source_start_us: 0, + source_end_us: 4_000_000, + }), + clip({ + item_id: baseFragmentId, + timeline_start_us: 5_000_000, + timeline_end_us: 6_000_000, + source_start_us: 5_000_000, + source_end_us: 6_000_000, + }), + ], + }, + { + track_id: 'track-captions', + kind: 'caption', + name: 'Captions', + language: 'en', + locked: false, + muted: false, + items: [], + }, + ], + }) + const adapter = new CodePressCommandAdapter({ document: documentFor(base) }) + const result = applyRequest(adapter, { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'ripple-max-id', + idempotency_key: 'ripple-max-id:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'ripple-max-id-command', + type: 'ripple_delete', + start_us: 1_000_000, + end_us: 2_000_000, + track_ids: ['track-video'], + }, + ], + }) + const ids = result.timeline.tracks[0]?.items.map(itemIdForTest) + expect(ids).toEqual([maxId, suffixFragmentId, baseFragmentId]) + expect(ids?.every((id) => id.length <= 128)).toBe(true) + expect(validateTimelineState(result.timeline).ok).toBe(true) + }) + it('does not mutate the controlled document when a later command fails', () => { const adapter = new CodePressCommandAdapter({ document: documentFor(timeline()) }) const result = adapter.apply({ @@ -682,6 +881,101 @@ describe('controlled command adapter', () => { expect(adapter.getSnapshot().document.timeline.tracks[0]?.items).toHaveLength(1) }) + it('commits before editor, subscriber, and synchronous telemetry failures', () => { + const initial = documentFor(timeline()) + const editor = { + getDocument: () => initial, + replaceDocument: () => { + throw new Error('editor observer failed') + }, + } + const adapter = new CodePressCommandAdapter({ + document: initial, + editor, + hosts: { + telemetryClient: { + emit: () => { + throw new Error('telemetry observer failed') + }, + }, + }, + }) + let subscriberCalls = 0 + adapter.subscribe(() => { + subscriberCalls += 1 + throw new Error('subscriber observer failed') + }) + adapter.subscribe(() => { + subscriberCalls += 1 + }) + const request: EditCommandBatch = { + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'observer-failures', + idempotency_key: 'observer-failures:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'move-observer-failures', + type: 'move_item', + item_id: 'clip-a', + to_track_id: 'track-video', + timeline_start_us: 1_000_000, + index: 0, + }, + ], + } + const result = adapter.apply(request) + expect(result.status).toBe('applied') + expect(adapter.getSnapshot().revision).toBe(1) + expect(subscriberCalls).toBe(2) + const replayed = adapter.apply(request) + expect(replayed.status).toBe('replayed') + }) + + it('handles rejected telemetry without changing the committed result', async () => { + let unhandled: unknown + const onUnhandled = (reason: unknown) => { + unhandled = reason + } + process.on('unhandledRejection', onUnhandled) + try { + const adapter = new CodePressCommandAdapter({ + document: documentFor(timeline()), + hosts: { + telemetryClient: { + emit: () => Promise.reject(new Error('async telemetry failed')), + }, + }, + }) + const result = adapter.apply({ + contract_version: 1, + timeline_id: 'timeline-test', + operation_id: 'async-telemetry-failure', + idempotency_key: 'async-telemetry-failure:1', + base_revision: 0, + preconditions: [], + commands: [ + { + command_id: 'move-async-telemetry', + type: 'move_item', + item_id: 'clip-a', + to_track_id: 'track-video', + timeline_start_us: 1_000_000, + index: 0, + }, + ], + }) + expect(result.status).toBe('applied') + expect(adapter.getSnapshot().revision).toBe(1) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(unhandled).toBeUndefined() + } finally { + process.off('unhandledRejection', onUnhandled) + } + }) + it('rejects request_job until a host dispatch boundary is implemented', () => { let hostCalls = 0 const adapter = new CodePressCommandAdapter({ diff --git a/src/features/editor/codepress/adapter.ts b/src/features/editor/codepress/adapter.ts index 789615ee4..0ec97cbdb 100644 --- a/src/features/editor/codepress/adapter.ts +++ b/src/features/editor/codepress/adapter.ts @@ -14,6 +14,7 @@ import type { ControlledEditorDocument, ControlledEditorPort, ControlledRenderer, + EditEngineResult, RenderedFrame, } from './interfaces' import { FrameTimingError, assertFrameAligned } from './timing' @@ -249,7 +250,6 @@ export class CodePressCommandAdapter implements ControlledEditorPort { } getDocument(): ControlledEditorDocument { - if (this.externalEditor) return copyResult(this.externalEditor.getDocument()) return copyResult(this.document) } @@ -258,8 +258,7 @@ export class CodePressCommandAdapter implements ControlledEditorPort { if (timelineError) throw new Error(timelineError.message) assertFrameAligned(document.timeline.duration_us, document.fps) this.document = copyResult(document) - this.externalEditor?.replaceDocument(copyResult(document)) - this.notify() + this.publishDocument(this.document) } subscribe(listener: (document: ControlledEditorDocument) => void): () => void { @@ -368,39 +367,11 @@ export class CodePressCommandAdapter implements ControlledEditorPort { } } + let engineResult: EditEngineResult try { - const engineResult = this.editEngine.apply(current.timeline, batch.commands, { + engineResult = this.editEngine.apply(current.timeline, batch.commands, { fps: current.fps, }) - const nextTimeline: TimelineState = { - ...engineResult.timeline, - revision: current.timeline.revision + 1, - } - const nextDocument = { ...current, timeline: nextTimeline } - const result: Exclude = { - status: 'applied', - timeline_id: batch.timeline_id, - operation_id: batch.operation_id, - idempotency_key: batch.idempotency_key, - base_revision: batch.base_revision, - previous_revision: current.timeline.revision, - resulting_revision: nextTimeline.revision, - timeline: nextTimeline, - commands: engineResult.commands, - rebase: { status: 'not_attempted', automatic: false }, - } - this.document = copyResult(nextDocument) - this.externalEditor?.replaceDocument(copyResult(nextDocument)) - this.operations.set(batch.idempotency_key, { payload, result: copyResult(result) }) - this.notify() - void this.hosts.telemetryClient?.emit({ - name: 'video.timeline_operation_applied', - timeline_id: batch.timeline_id, - operation_id: batch.operation_id, - revision: nextTimeline.revision, - attributes: { command_count: batch.commands.length }, - }) - return copyResult(result) } catch (caught) { const commandError = caught instanceof FrameTimingError @@ -439,6 +410,29 @@ export class CodePressCommandAdapter implements ControlledEditorPort { ) return rejected(batch, commandError) } + + const nextTimeline: TimelineState = { + ...engineResult.timeline, + revision: current.timeline.revision + 1, + } + const nextDocument = { ...current, timeline: nextTimeline } + const result: Exclude = { + status: 'applied', + timeline_id: batch.timeline_id, + operation_id: batch.operation_id, + idempotency_key: batch.idempotency_key, + base_revision: batch.base_revision, + previous_revision: current.timeline.revision, + resulting_revision: nextTimeline.revision, + timeline: nextTimeline, + commands: engineResult.commands, + rebase: { status: 'not_attempted', automatic: false }, + } + this.document = copyResult(nextDocument) + this.operations.set(batch.idempotency_key, { payload, result: copyResult(result) }) + this.publishDocument(nextDocument) + this.publishTelemetry(batch, nextTimeline.revision) + return copyResult(result) } async renderFrame(time_us: number): Promise { @@ -448,9 +442,39 @@ export class CodePressCommandAdapter implements ControlledEditorPort { return this.renderer.renderFrame({ document, frame, time_us }) } - private notify(): void { - const document = this.getDocument() - for (const listener of this.listeners) listener(document) + private publishDocument(document: ControlledEditorDocument): void { + try { + this.externalEditor?.replaceDocument(copyResult(document)) + } catch { + // Editor publication is an observer side effect; it cannot reject a committed edit. + } + this.notify(document) + } + + private publishTelemetry(batch: EditCommandBatch, revision: number): void { + try { + void Promise.resolve( + this.hosts.telemetryClient?.emit({ + name: 'video.timeline_operation_applied', + timeline_id: batch.timeline_id, + operation_id: batch.operation_id, + revision, + attributes: { command_count: batch.commands.length }, + }), + ).catch(() => undefined) + } catch { + // Telemetry is best effort; a host failure cannot change the committed result. + } + } + + private notify(document: ControlledEditorDocument = this.document): void { + for (const listener of this.listeners) { + try { + listener(copyResult(document)) + } catch { + // Subscribers are observers; one faulty listener must not affect the edit result. + } + } } } diff --git a/src/features/editor/codepress/edit-engine.ts b/src/features/editor/codepress/edit-engine.ts index f3b63337d..dc49a5dcd 100644 --- a/src/features/editor/codepress/edit-engine.ts +++ b/src/features/editor/codepress/edit-engine.ts @@ -23,7 +23,7 @@ import type { TimelineTrack, TrackId, } from './contract' -import { validateTimelineState } from './contract' +import { MAX_ID_LENGTH, validateTimelineState } from './contract' import type { ControlledEditEngine, EditEngineContext, EditEngineResult } from './interfaces' export class EditEngineError extends Error { @@ -225,11 +225,13 @@ function shiftItemByFrames( } function deriveFragmentId(originalId: string, usedIds: Set): string { - const base = `${originalId}:ripple-right`.slice(0, 128) + const candidate = (suffix: string): string => + `${originalId.slice(0, Math.max(0, MAX_ID_LENGTH - suffix.length))}${suffix}` + const base = candidate(':ripple-right') if (!usedIds.has(base)) return base for (let suffix = 2; suffix < 10_000; suffix += 1) { - const candidate = `${originalId}:ripple-${suffix}`.slice(0, 128) - if (!usedIds.has(candidate)) return candidate + const collisionSafe = candidate(`:ripple-${suffix}`) + if (!usedIds.has(collisionSafe)) return collisionSafe } throw new EditEngineError( 'invalid_request', diff --git a/src/features/editor/codepress/translation.ts b/src/features/editor/codepress/translation.ts index a4d45fada..d7f486b91 100644 --- a/src/features/editor/codepress/translation.ts +++ b/src/features/editor/codepress/translation.ts @@ -145,21 +145,41 @@ function keyframeToFrames(keyframe: Keyframe, fps: FrameRateLike): FrameKeyframe function itemToFrames(item: TimelineItem, fps: FrameRateLike): FrameItem { if (item.item_type === 'caption_cue') { return { - ...item, + item_type: 'caption_cue', + cue_id: item.cue_id, + track_id: item.track_id, + text: item.text, + ...(item.speaker !== undefined ? { speaker: item.speaker } : {}), + ...(item.style !== undefined ? { style: item.style } : {}), start_frame: assertFrameAligned(item.start_us, fps), end_frame: assertFrameAligned(item.end_us, fps), } } if (item.item_type === 'text') { return { - ...item, + item_type: 'text', + item_id: item.item_id, + track_id: item.track_id, + text: item.text, + ...(item.style !== undefined ? { style: item.style } : {}), + ...(item.transform !== undefined ? { transform: item.transform } : {}), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), timeline_start_frame: assertFrameAligned(item.timeline_start_us, fps), timeline_end_frame: assertFrameAligned(item.timeline_end_us, fps), keyframes: item.keyframes?.map((keyframe) => keyframeToFrames(keyframe, fps)), } } return { - ...item, + item_type: 'clip', + item_id: item.item_id, + track_id: item.track_id, + media_id: item.media_id, + media_kind: item.media_kind, + ...(item.transform !== undefined ? { transform: item.transform } : {}), + ...(item.opacity !== undefined ? { opacity: item.opacity } : {}), + ...(item.volume !== undefined ? { volume: item.volume } : {}), + ...(item.speed !== undefined ? { speed: item.speed } : {}), + ...(item.effects !== undefined ? { effects: item.effects } : {}), timeline_start_frame: assertFrameAligned(item.timeline_start_us, fps), timeline_end_frame: assertFrameAligned(item.timeline_end_us, fps), source_start_frame: assertFrameAligned(item.source_start_us, fps), @@ -179,7 +199,13 @@ function propertiesToFrames( fps: FrameRateLike, ): FrameItemPropertiesPatch { return { - ...properties, + ...(properties.transform !== undefined ? { transform: properties.transform } : {}), + ...(properties.opacity !== undefined ? { opacity: properties.opacity } : {}), + ...(properties.volume !== undefined ? { volume: properties.volume } : {}), + ...(properties.speed !== undefined ? { speed: properties.speed } : {}), + ...(properties.text !== undefined ? { text: properties.text } : {}), + ...(properties.text_style !== undefined ? { text_style: properties.text_style } : {}), + ...(properties.effects !== undefined ? { effects: properties.effects } : {}), fade_in_frame: properties.fade_in_us === undefined ? undefined From 856d60560b2f7b0bb6997be75a14c925221587a8 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Sun, 16 Aug 2026 22:41:50 -0700 Subject: [PATCH 05/19] feat(editor): add host-backed FreeCut surface --- src/features/editor/components/editor.tsx | 225 ++++++--- .../editor/components/media-sidebar.tsx | 107 ++-- .../mini-timeline/use-clip-thumbnails.ts | 12 +- .../editor/components/preview-area.tsx | 21 +- src/features/editor/components/toolbar.tsx | 113 +++-- .../components/unsaved-changes-dialog.tsx | 8 +- .../editor/components/whats-new-dialog.tsx | 9 +- .../editor/components/workspace-switcher.tsx | 9 +- .../editor/deps/media-library-contract.ts | 5 +- .../editor/hooks/use-editor-hotkeys.ts | 14 +- src/features/editor/host/context-provider.tsx | 12 + src/features/editor/host/context.ts | 33 ++ src/features/editor/host/contract.ts | 194 +++++++ src/features/editor/host/controller.test.ts | 378 ++++++++++++++ src/features/editor/host/controller.ts | 476 ++++++++++++++++++ src/features/editor/host/document.ts | 319 ++++++++++++ .../editor/host/editor-surface.test.tsx | 56 +++ src/features/editor/host/editor-surface.tsx | 100 ++++ src/features/editor/host/index.ts | 45 ++ src/features/editor/host/runtime.ts | 280 +++++++++++ .../media-library/components/media-card.tsx | 47 +- .../media-library/components/media-grid.tsx | 7 +- .../components/media-info-popover.tsx | 26 +- .../components/media-library.tsx | 88 ++-- .../components/use-media-library-deletion.ts | 16 +- .../components/use-media-library-drag-drop.ts | 40 +- .../media-library/deps/editor-contract.ts | 3 + src/features/media-library/deps/editor.ts | 3 + .../media-library/utils/media-resolver.ts | 46 ++ .../preview/utils/scrub-proxy-fallback.ts | 3 + .../clip-filmstrip/image-filmstrip.tsx | 8 +- .../components/clip-filmstrip/index.tsx | 12 +- .../components/clip-waveform/index.tsx | 8 +- .../timeline/components/timeline-header.tsx | 317 ++++++------ .../timeline-item/item-context-menu.tsx | 115 +++-- .../timeline/components/timeline-track.tsx | 5 +- src/features/timeline/components/timeline.tsx | 50 +- .../timeline/components/track-header.tsx | 23 +- src/features/timeline/deps/editor-contract.ts | 3 + src/features/timeline/deps/editor.ts | 3 + .../timeline/hooks/use-waveform-prefetch.ts | 5 +- src/shared/state/editor/store.ts | 1 + src/shared/state/editor/types.ts | 2 + src/types/storage.ts | 5 +- 44 files changed, 2782 insertions(+), 470 deletions(-) create mode 100644 src/features/editor/host/context-provider.tsx create mode 100644 src/features/editor/host/context.ts create mode 100644 src/features/editor/host/contract.ts create mode 100644 src/features/editor/host/controller.test.ts create mode 100644 src/features/editor/host/controller.ts create mode 100644 src/features/editor/host/document.ts create mode 100644 src/features/editor/host/editor-surface.test.tsx create mode 100644 src/features/editor/host/editor-surface.tsx create mode 100644 src/features/editor/host/index.ts create mode 100644 src/features/editor/host/runtime.ts create mode 100644 src/features/media-library/deps/editor-contract.ts create mode 100644 src/features/media-library/deps/editor.ts create mode 100644 src/features/timeline/deps/editor-contract.ts create mode 100644 src/features/timeline/deps/editor.ts diff --git a/src/features/editor/components/editor.tsx b/src/features/editor/components/editor.tsx index ac1a99133..6943acaf8 100644 --- a/src/features/editor/components/editor.tsx +++ b/src/features/editor/components/editor.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback, memo, lazy, Suspense } from 'react' +import type { ReactNode } from 'react' import { useRouter } from '@tanstack/react-router' import { useTranslation } from 'react-i18next' import { createLogger, createOperationId } from '@/shared/logging/logger' @@ -168,6 +169,11 @@ function preloadBundleExportDialog() { } /** Project metadata passed from route loader (timeline loaded separately via loadTimeline) */ +interface LoadedEditorHostRuntime { + mountStores(): void + unmountStores(): void +} + interface EditorProps { projectId: string project: { @@ -183,6 +189,9 @@ interface EditorProps { currentSchemaVersion: number requiresUpgrade: boolean } + hostRuntime?: LoadedEditorHostRuntime + onNavigateBack?: () => void + onRefreshMigration?: () => Promise } /** @@ -203,6 +212,7 @@ const startedUpgradeBackups = new Map>() */ export const Editor = memo(function Editor({ projectId, project, migration }: EditorProps) { const { t } = useTranslation() + const router = useRouter() const [upgradeApproved, setUpgradeApproved] = useState(!migration.requiresUpgrade) const backupName = formatProjectUpgradeBackupName( project.name, @@ -278,6 +288,18 @@ export const Editor = memo(function Editor({ projectId, project, migration }: Ed t, ]) + const onNavigateBack = useCallback(() => { + void router.navigate({ to: '/projects' }) + }, [router]) + const onRefreshMigration = useCallback( + () => + router.invalidate({ + filter: (match) => + match.routeId === EDITOR_PROJECT_ROUTE_ID && match.params.projectId === projectId, + }), + [projectId, router], + ) + if (!upgradeApproved) { return (
@@ -288,7 +310,15 @@ export const Editor = memo(function Editor({ projectId, project, migration }: Ed ) } - return + return ( + + ) }) const EditorDialogHost = memo(function EditorDialogHost({ projectId }: { projectId: string }) { @@ -363,6 +393,28 @@ const TimelineDialogHost = memo(function TimelineDialogHost() { ) }) +const LocalRouterBridge = memo(function LocalRouterBridge({ + projectId, + onReady, +}: { + projectId: string + onReady: (invalidate: () => Promise) => void +}) { + const router = useRouter() + const invalidate = useCallback( + () => + router.invalidate({ + filter: (match) => + match.routeId === EDITOR_PROJECT_ROUTE_ID && match.params.projectId === projectId, + }), + [projectId, router], + ) + + onReady(invalidate) + + return null +}) + const AutoSaveController = memo(function AutoSaveController({ onSave, }: { @@ -378,13 +430,37 @@ const TimelineShortcutsController = memo(function TimelineShortcutsController() return null }) +const HostDisabledRegion = memo(function HostDisabledRegion({ + disabled, + children, +}: { + disabled: boolean + children: ReactNode +}) { + if (!disabled) return <>{children} + return ( +
+ {children} +
+ ) +}) + +// fallow-ignore-next-line complexity export const LoadedEditor = memo(function LoadedEditor({ projectId, project, migration, + hostRuntime, + onNavigateBack, + onRefreshMigration, }: EditorProps) { const { t } = useTranslation() - const router = useRouter() const [exportDialogOpen, setExportDialogOpen] = useState(false) const [bundleExportDialogOpen, setBundleExportDialogOpen] = useState(false) const [renderQueueOpen, setRenderQueueOpen] = useState(false) @@ -402,6 +478,7 @@ export const LoadedEditor = memo(function LoadedEditor({ const workspace = useEditorStore((s) => s.workspace) const isMaskEditingActive = useMaskEditorStore((s) => s.isEditing) const hasRefreshedMigrationStateRef = useRef(false) + const localRefreshMigrationRef = useRef<(() => Promise) | null>(null) const timelinePanelRef = useRef(null) const previousWorkspaceRef = useRef(workspace) @@ -412,9 +489,13 @@ export const LoadedEditor = memo(function LoadedEditor({ hasRefreshedMigrationStateRef.current = false }, [projectId]) + const setLocalRefreshMigration = useCallback((invalidate: () => Promise) => { + localRefreshMigrationRef.current = invalidate + }, []) + useEffect(() => { - rememberLastEditorProjectId(projectId) - }, [projectId]) + if (!hostRuntime) rememberLastEditorProjectId(projectId) + }, [hostRuntime, projectId]) // Initialize transition chain subscription (pre-computes chains from timeline data) // This subscription recomputes chains when items/transitions change - deferred to idle @@ -432,6 +513,7 @@ export const LoadedEditor = memo(function LoadedEditor({ // Preload export dialogs during idle time so they open instantly. useEffect(() => { + if (hostRuntime) return const id = requestIdleCallback(() => { // Best-effort idle preloads: swallow rejections (offline chunk load, or // the dynamic import racing test-environment teardown). The real load @@ -440,11 +522,12 @@ export const LoadedEditor = memo(function LoadedEditor({ void preloadBundleExportDialog().catch(() => {}) }) return () => cancelIdleCallback(id) - }, []) + }, [hostRuntime]) // Prewarm effect preview thumbnails during idle time without making the // effects feature part of the initial editor route chunk. useEffect(() => { + if (hostRuntime) return const id = requestIdleCallback(() => { // Best-effort prewarm — ignore failures, including the import() promise // rejecting when it resolves after the test environment is torn down. @@ -453,10 +536,20 @@ export const LoadedEditor = memo(function LoadedEditor({ .catch(() => {}) }) return () => cancelIdleCallback(id) - }, []) + }, [hostRuntime]) // Initialize timeline from project data (or create default tracks for new projects). useEffect(() => { + if (hostRuntime) { + hostRuntime.mountStores() + return () => { + hostRuntime.unmountStores() + usePlaybackStore.getState().setPreviewFrame(null) + usePlaybackStore.getState().pause() + clearPreviewAudioCache() + } + } + const { setCurrentProject: setMediaProject, loadMediaItems } = useMediaLibraryStore.getState() const { setCurrentProject } = useProjectStore.getState() const playbackStore = usePlaybackStore.getState() @@ -505,10 +598,8 @@ export const LoadedEditor = memo(function LoadedEditor({ // Refresh the editor route metadata once the approved legacy project has // opened successfully so future reopens do not briefly show the upgrade prompt. - await router.invalidate({ - filter: (match) => - match.routeId === EDITOR_PROJECT_ROUTE_ID && match.params.projectId === projectId, - }) + const refreshMigration = onRefreshMigration ?? localRefreshMigrationRef.current + await refreshMigration?.() } catch (error) { logger.error('Failed to load timeline:', error) } @@ -534,7 +625,8 @@ export const LoadedEditor = memo(function LoadedEditor({ project.name, project.width, projectId, - router, + hostRuntime, + onRefreshMigration, ]) useEffect(() => { @@ -557,7 +649,7 @@ export const LoadedEditor = memo(function LoadedEditor({ const timelinePanel = timelinePanelRef.current if (!timelinePanel) return - saveWorkspaceTimelineSize(previousWorkspace, timelinePanel.getSize()) + if (!hostRuntime) saveWorkspaceTimelineSize(previousWorkspace, timelinePanel.getSize()) const targetSize = loadWorkspaceTimelineSize(workspace) ?? @@ -566,7 +658,7 @@ export const LoadedEditor = memo(function LoadedEditor({ timelinePanel.resize( Math.min(editorLayout.timelineMaxSize, Math.max(editorLayout.timelineMinSize, targetSize)), ) - }, [workspace, editorLayout]) + }, [editorLayout, hostRuntime, workspace]) useEffect(() => { const timelineState = useTimelineStore.getState() @@ -652,8 +744,9 @@ export const LoadedEditor = memo(function LoadedEditor({ // Enable keyboard shortcuts useEditorHotkeys({ - onSave: handleSave, - onExport: handleExport, + onSave: hostRuntime ? undefined : handleSave, + onExport: hostRuntime ? undefined : handleExport, + enableLocalUi: !hostRuntime, }) // Enable transition breakage notifications @@ -673,19 +766,23 @@ export const LoadedEditor = memo(function LoadedEditor({ role="application" aria-label={t('editor.editor.appLabel')} > - - + {!hostRuntime && } + {!hostRuntime && } + {!hostRuntime && ( + + )} {/* Top Toolbar */} @@ -757,7 +854,9 @@ export const LoadedEditor = memo(function LoadedEditor({ {!propertiesFullColumn && ( - + + + )} @@ -800,52 +899,56 @@ export const LoadedEditor = memo(function LoadedEditor({ {propertiesFullColumn && !hidesDefaultSidebars && ( - + + + )}
- - {/* Export Dialog */} - {exportDialogOpen && ( - setExportDialogOpen(false)} - onOpenRenderQueue={handleOpenRenderQueue} - /> - )} - - {/* Exports + render queue dialog */} - {renderQueueOpen && ( - setRenderQueueOpen(false)} - projectId={projectId} - /> - )} - - {/* Bundle Export Dialog */} - {bundleExportDialogOpen && ( - { - setBundleExportDialogOpen(false) - setBundleFileHandle(undefined) - }} - projectId={projectId} - onBeforeExport={handleSave} - fileHandle={bundleFileHandle} - /> - )} - + {!hostRuntime && ( + + {/* Export Dialog */} + {exportDialogOpen && ( + setExportDialogOpen(false)} + onOpenRenderQueue={handleOpenRenderQueue} + /> + )} + + {/* Exports + render queue dialog */} + {renderQueueOpen && ( + setRenderQueueOpen(false)} + projectId={projectId} + /> + )} + + {/* Bundle Export Dialog */} + {bundleExportDialogOpen && ( + { + setBundleExportDialogOpen(false) + setBundleFileHandle(undefined) + }} + projectId={projectId} + onBeforeExport={handleSave} + fileHandle={bundleFileHandle} + /> + )} + + )} {/* Restores/persists the per-project queue, and drains it serially. */} - - + {!hostRuntime && } + {!hostRuntime && } - - + {!hostRuntime && } + {!hostRuntime && } {/* Single global cursor-readout for IO (in/out) drags across all surfaces. */} diff --git a/src/features/editor/components/media-sidebar.tsx b/src/features/editor/components/media-sidebar.tsx index 5a02fbefe..ba6cfe1a7 100644 --- a/src/features/editor/components/media-sidebar.tsx +++ b/src/features/editor/components/media-sidebar.tsx @@ -60,6 +60,7 @@ import { EffectThumbnail, useGpuEffectPreviewData } from '@/features/editor/deps import { createLogger } from '@/shared/logging/logger' import { useSettingsStore } from '@/features/editor/deps/settings' import { resolveGeneratedLayerCanvasSize } from '../utils/generated-layer-canvas-size' +import { useEditorCapability, useEditorHostMode } from '../host/context' const LazyAiPanel = lazy(() => import('./ai-tab').then((m) => ({ default: m.AiTab }))) const LazyTranscriptEditorPanel = lazy(() => importTranscriptEditorPanel().then(({ TranscriptEditorPanel }) => ({ @@ -288,6 +289,8 @@ const ADD_TEXT_TEMPLATE_LABEL = 'Add Text' export const MediaSidebar = memo(function MediaSidebar() { const { t } = useTranslation() + const hostMode = useEditorHostMode() + const canAddTimeline = useEditorCapability('timeline.add') const editorDensity = useSettingsStore((s) => s.editorDensity) const editorLayout = getEditorLayout(editorDensity) // Use granular selectors - Zustand v5 best practice @@ -383,6 +386,7 @@ export const MediaSidebar = memo(function MediaSidebar() { // the same preset onto the canvas does (minus the cursor-driven position). const handleAddText = useCallback( (presetId?: (typeof TEXT_STYLE_PRESETS)[number]['id']) => { + if (!canAddTimeline) return // Read all needed state from stores directly to avoid subscriptions const { tracks, fps, addItemOnNewTrack } = useTimelineStore.getState() const { activeTrackId, selectItems, setActiveTrack } = useSelectionStore.getState() @@ -422,58 +426,65 @@ export const MediaSidebar = memo(function MediaSidebar() { setActiveTrack(newTrack.trackId) selectItems([textItem.id]) }, - [t], + [canAddTimeline, t], ) // Add shape item on its own new layer at the playhead, matching the canvas drop. - const handleAddShape = useCallback((shapeType: ShapeType, shapePreset?: 'solid' | 'gradient') => { - // Read all needed state from stores directly to avoid subscriptions - const { tracks, fps, addItemOnNewTrack } = useTimelineStore.getState() - const { activeTrackId, selectItems, setActiveTrack } = useSelectionStore.getState() - const currentProject = useProjectStore.getState().currentProject - const activeCompositionId = - useCompositionNavigationStore.getState().activeCompositionId - const activeComposition = activeCompositionId - ? useCompositionsStore.getState().getComposition(activeCompositionId) - : undefined - - const newTrack = createOverlayLayerTrack({ tracks, activeTrackId }) - - if (!newTrack) { - logger.warn('No available track for shape item') - return - } + const handleAddShape = useCallback( + (shapeType: ShapeType, shapePreset?: 'solid' | 'gradient') => { + if (hostMode || !canAddTimeline) return + // Read all needed state from stores directly to avoid subscriptions + const { tracks, fps, addItemOnNewTrack } = useTimelineStore.getState() + const { activeTrackId, selectItems, setActiveTrack } = useSelectionStore.getState() + const currentProject = useProjectStore.getState().currentProject + const activeCompositionId = useCompositionNavigationStore.getState().activeCompositionId + const activeComposition = activeCompositionId + ? useCompositionsStore.getState().getComposition(activeCompositionId) + : undefined - const { width: canvasWidth, height: canvasHeight } = resolveGeneratedLayerCanvasSize( - activeComposition, - currentProject?.metadata, - ) + const newTrack = createOverlayLayerTrack({ tracks, activeTrackId }) - const placement = { - trackId: newTrack.trackId, - from: Math.max(0, usePlaybackStore.getState().currentFrame), - durationInFrames: getDefaultGeneratedLayerDurationInFrames(fps), - canvasWidth, - canvasHeight, - shapeType, - } - const shapeItem: ShapeItem = - shapePreset === 'solid' - ? createDefaultSolidColorItem(placement) - : shapePreset === 'gradient' - ? createDefaultGradientItem(placement) - : createDefaultShapeItem(placement) - - addItemOnNewTrack(shapeItem, newTrack.tracks) - setActiveTrack(newTrack.trackId) - selectItems([shapeItem.id]) - }, []) + if (!newTrack) { + logger.warn('No available track for shape item') + return + } + + const { width: canvasWidth, height: canvasHeight } = resolveGeneratedLayerCanvasSize( + activeComposition, + currentProject?.metadata, + ) + + const placement = { + trackId: newTrack.trackId, + from: Math.max(0, usePlaybackStore.getState().currentFrame), + durationInFrames: getDefaultGeneratedLayerDurationInFrames(fps), + canvasWidth, + canvasHeight, + shapeType, + } + const shapeItem: ShapeItem = + shapePreset === 'solid' + ? createDefaultSolidColorItem(placement) + : shapePreset === 'gradient' + ? createDefaultGradientItem(placement) + : createDefaultShapeItem(placement) + + addItemOnNewTrack(shapeItem, newTrack.tracks) + setActiveTrack(newTrack.trackId) + selectItems([shapeItem.id]) + }, + [canAddTimeline, hostMode], + ) // Add adjustment layer to timeline at the best available position // Optionally with pre-applied effects and custom label - const handleAddAdjustmentLayer = useCallback((effects?: VisualEffect[], label?: string) => { - addAdjustmentLayer(effects, label) - }, []) + const handleAddAdjustmentLayer = useCallback( + (effects?: VisualEffect[], label?: string) => { + if (hostMode || !canAddTimeline) return + addAdjustmentLayer(effects, label) + }, + [canAddTimeline, hostMode], + ) // Create adjustment layer with preset effects const handleAddPreset = useCallback( @@ -488,6 +499,7 @@ export const MediaSidebar = memo(function MediaSidebar() { // Add a single GPU effect — to selected clips, or as adjustment layer if nothing selected const handleAddGpuEffect = useCallback( (gpuEffectId: string) => { + if (hostMode || !canAddTimeline) return const { selectedItemIds } = useSelectionStore.getState() const { items, addEffect } = useTimelineStore.getState() @@ -513,7 +525,7 @@ export const MediaSidebar = memo(function MediaSidebar() { ]) } }, - [handleAddAdjustmentLayer], + [canAddTimeline, handleAddAdjustmentLayer, hostMode], ) const { gpuCategories, triggerPreviews } = useGpuEffectPreviewData() @@ -544,6 +556,9 @@ export const MediaSidebar = memo(function MediaSidebar() { { id: 'transcript' as const, icon: Captions, label: t('transcript.tabLabel') }, { id: 'ai' as const, icon: WandSparkles, label: t('editor.mediaSidebar.ai') }, ] + const visibleCategories = hostMode + ? categories.filter(({ id }) => id === 'media' || (id === 'text' && canAddTimeline)) + : categories const shouldSuppressGeneratedItemClick = useCallback(() => { if (!suppressGeneratedItemClickRef.current) { @@ -620,7 +635,7 @@ export const MediaSidebar = memo(function MediaSidebar() { {/* Category Icons */}
- {categories.map(({ id, icon: Icon, label }) => ( + {visibleCategories.map(({ id, icon: Icon, label }) => (
{isPenModeActive ? ( @@ -687,7 +694,7 @@ export const PreviewArea = memo(function PreviewArea({ ) : (
- {previewChrome === 'edit' && ( + {previewChrome === 'edit' && !hostMode && (
diff --git a/src/features/editor/components/toolbar.tsx b/src/features/editor/components/toolbar.tsx index 5f19cfb1b..8a9538283 100644 --- a/src/features/editor/components/toolbar.tsx +++ b/src/features/editor/components/toolbar.tsx @@ -1,5 +1,4 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { useNavigate } from '@tanstack/react-router' +import { lazy, memo, Suspense, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { ArrowLeft, @@ -28,7 +27,6 @@ import { import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { Separator } from '@/components/ui/separator' import { LocalInferenceStatusPill } from './local-inference-status-pill' -import { ProjectDebugPanel } from './project-debug-panel' import { SettingsDialog } from './settings-dialog' import { ShortcutsDialog } from './shortcuts-dialog' import { UnsavedChangesDialog } from './unsaved-changes-dialog' @@ -41,8 +39,12 @@ import { LanguageSwitcher } from '@/shared/ui/language-switcher' import { useDebugStore } from '@/features/editor/stores/debug-store' import { useItemsStore, useTimelineStore } from '@/features/editor/deps/timeline-store' import { useMediaLibraryStore } from '@/features/editor/deps/media-library' +import { useEditorHostMode } from '../host/context' const SAVE_ANIMATION_MIN_MS = 1800 +const LazyProjectDebugPanel = lazy(() => + import('./project-debug-panel').then((module) => ({ default: module.ProjectDebugPanel })), +) const SaveDirtyIndicator = memo(function SaveDirtyIndicator() { const isDirty = useTimelineStore((state) => state.isDirty) @@ -68,6 +70,7 @@ interface ToolbarProps { fps: number } onSave?: () => Promise + onBack?: () => void onExport?: () => void onExportBundle?: () => void onOpenRenderQueue?: () => void @@ -78,14 +81,15 @@ interface ToolbarProps { export const Toolbar = memo(function Toolbar({ projectId, project, + onBack, onSave, onExport, onExportBundle, onOpenRenderQueue, renderQueueCount = 0, }: ToolbarProps) { - const navigate = useNavigate() const { t } = useTranslation() + const hostMode = useEditorHostMode() const [showUnsavedDialog, setShowUnsavedDialog] = useState(false) const [showShortcutsDialog, setShowShortcutsDialog] = useState(false) const [showSettingsDialog, setShowSettingsDialog] = useState(false) @@ -98,18 +102,15 @@ export const Toolbar = memo(function Toolbar({ const maxItemEndFrame = useItemsStore((state) => state.maxItemEndFrame) const mediaDependencyIds = useItemsStore((state) => state.mediaDependencyIds) const brokenMediaIds = useMediaLibraryStore((state) => state.brokenMediaIds) - const projectSummary = useMemo( - () => { - const projectMediaIds = new Set(mediaDependencyIds) - return { - durationSeconds: project.fps > 0 ? maxItemEndFrame / project.fps : 0, - clipCount: itemCount, - mediaCount: mediaDependencyIds.length, - brokenMediaCount: brokenMediaIds.filter((mediaId) => projectMediaIds.has(mediaId)).length, - } - }, - [brokenMediaIds, itemCount, maxItemEndFrame, mediaDependencyIds, project.fps], - ) + const projectSummary = useMemo(() => { + const projectMediaIds = new Set(mediaDependencyIds) + return { + durationSeconds: project.fps > 0 ? maxItemEndFrame / project.fps : 0, + clipCount: itemCount, + mediaCount: mediaDependencyIds.length, + brokenMediaCount: brokenMediaIds.filter((mediaId) => projectMediaIds.has(mediaId)).length, + } + }, [brokenMediaIds, itemCount, maxItemEndFrame, mediaDependencyIds, project.fps]) useEffect(() => { setHasUnseenWhatsNew(hasUnseenChangelog()) @@ -130,10 +131,10 @@ export const Toolbar = memo(function Toolbar({ const handleBackClick = () => { if (useTimelineStore.getState().isDirty) { - setShowUnsavedDialog(true) - } else { - navigate({ to: '/projects' }) + if (onSave && onBack) setShowUnsavedDialog(true) + return } + onBack?.() } const handleSave = async () => { @@ -178,6 +179,7 @@ export const Toolbar = memo(function Toolbar({ size="icon" className="h-8 w-8" onClick={handleBackClick} + disabled={!onBack} data-tooltip={t('toolbar.backToProjects')} data-tooltip-side="right" aria-label={t('toolbar.backToProjectsAria')} @@ -185,12 +187,15 @@ export const Toolbar = memo(function Toolbar({ - + {onSave && onBack && ( + + )} @@ -216,16 +221,18 @@ export const Toolbar = memo(function Toolbar({
- + {!hostMode && } - + {!hostMode && ( + + )}
- {import.meta.env.DEV && import.meta.env.VITE_SHOW_DEBUG_PANEL !== 'false' && ( + {!hostMode && import.meta.env.DEV && import.meta.env.VITE_SHOW_DEBUG_PANEL !== 'false' && ( )} @@ -292,6 +299,7 @@ export const Toolbar = memo(function Toolbar({ size="icon" className="h-7 w-7" onClick={() => setShowSettingsDialog(true)} + disabled={hostMode} data-tooltip={t('toolbar.settings')} data-tooltip-side="bottom" aria-label={t('toolbar.settings')} @@ -319,6 +327,7 @@ export const Toolbar = memo(function Toolbar({ size="sm" className="gap-1.5" onClick={handleSave} + disabled={!onSave} aria-label={t('toolbar.saveAria')} >
@@ -351,25 +360,31 @@ export const Toolbar = memo(function Toolbar({ )} - - - - - - - - - - {t('toolbar.downloadProjectZip')} - - - + {(onExport || onExportBundle) && ( + + + + + + + + + + {t('toolbar.downloadProjectZip')} + + + + )}
) @@ -444,7 +459,9 @@ function DebugPopover({ projectId }: { projectId: string }) { sideOffset={8} className="w-64 p-0 bg-zinc-900 border-zinc-700 text-zinc-100" > - + + + ) diff --git a/src/features/editor/components/unsaved-changes-dialog.tsx b/src/features/editor/components/unsaved-changes-dialog.tsx index e6119ca44..fe12e67d0 100644 --- a/src/features/editor/components/unsaved-changes-dialog.tsx +++ b/src/features/editor/components/unsaved-changes-dialog.tsx @@ -1,5 +1,4 @@ import { useState } from 'react' -import { useNavigate } from '@tanstack/react-router' import { Trans, useTranslation } from 'react-i18next' import { AlertDialog, @@ -21,6 +20,7 @@ interface UnsavedChangesDialogProps { open: boolean onOpenChange: (open: boolean) => void onSave: () => Promise + onNavigateBack: () => void projectName?: string } @@ -28,9 +28,9 @@ export function UnsavedChangesDialog({ open, onOpenChange, onSave, + onNavigateBack, projectName, }: UnsavedChangesDialogProps) { - const navigate = useNavigate() const { t } = useTranslation() const [isSaving, setIsSaving] = useState(false) @@ -39,7 +39,7 @@ export function UnsavedChangesDialog({ try { await onSave() onOpenChange(false) - navigate({ to: '/projects' }) + onNavigateBack() } catch (error) { logger.error('Failed to save project:', error) // Keep dialog open on error @@ -50,7 +50,7 @@ export function UnsavedChangesDialog({ const handleDiscard = () => { onOpenChange(false) - navigate({ to: '/projects' }) + onNavigateBack() } return ( diff --git a/src/features/editor/components/whats-new-dialog.tsx b/src/features/editor/components/whats-new-dialog.tsx index 0e28a81d1..5b76084bc 100644 --- a/src/features/editor/components/whats-new-dialog.tsx +++ b/src/features/editor/components/whats-new-dialog.tsx @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState, type ComponentType } from 'react' import { useTranslation } from 'react-i18next' -import { Link } from '@tanstack/react-router' import { Sparkles, Bug, Zap, ArrowRight } from 'lucide-react' import { i18n } from '@/i18n' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' @@ -134,13 +133,15 @@ export function WhatsNewDialog({ open, onOpenChange }: WhatsNewDialogProps) { ? t('editor.whatsNew.released', { version: latestReleaseVersion }) : t('editor.whatsNew.preRelease')} - {t('editor.whatsNew.fullChangelog')} - +
diff --git a/src/features/editor/components/workspace-switcher.tsx b/src/features/editor/components/workspace-switcher.tsx index 2f458ce07..ba388b9b7 100644 --- a/src/features/editor/components/workspace-switcher.tsx +++ b/src/features/editor/components/workspace-switcher.tsx @@ -5,6 +5,7 @@ import type { LucideIcon } from 'lucide-react' import { useEditorStore } from '@/shared/state/editor' import { cn } from '@/shared/ui/cn' import type { EditorWorkspaceId } from '@/config/editor-workspaces' +import { useEditorCapability } from '../host/context' const WORKSPACE_ITEMS: readonly { id: EditorWorkspaceId @@ -25,6 +26,12 @@ export const WorkspaceSwitcher = memo(function WorkspaceSwitcher() { const { t } = useTranslation() const workspace = useEditorStore((s) => s.workspace) const setWorkspace = useEditorStore((s) => s.setWorkspace) + const canUseColor = useEditorCapability('workspace.color') + const canUseMotion = useEditorCapability('workspace.motion') + const visibleWorkspaceItems = WORKSPACE_ITEMS.filter( + ({ id }) => + id === 'edit' || (id === 'color' && canUseColor) || (id === 'motion' && canUseMotion), + ) return (
- {WORKSPACE_ITEMS.map(({ id, icon: Icon, labelKey }) => { + {visibleWorkspaceItems.map(({ id, icon: Icon, labelKey }) => { const isActive = workspace === id return (
{/* Generate proxies for selection */} - {selectedProxyEligibleCount > 0 && ( + {canGenerateProxy && selectedProxyEligibleCount > 0 && ( - - - + {!hostMode && ( + + + + )}
@@ -1229,7 +1255,11 @@ export const MediaLibrary = memo(function MediaLibrary({ onMediaSelect }: MediaL {/* Loading / empty state when no groups to show */} {mediaGroups.length === 0 && ( - + )} diff --git a/src/features/media-library/components/use-media-library-deletion.ts b/src/features/media-library/components/use-media-library-deletion.ts index d92bb43ef..bbfdf082e 100644 --- a/src/features/media-library/components/use-media-library-deletion.ts +++ b/src/features/media-library/components/use-media-library-deletion.ts @@ -25,6 +25,7 @@ interface UseMediaLibraryDeletionParams { currentProjectId: string | null clearSelection: () => void deleteMediaBatch: (mediaIds: string[]) => Promise + enabled?: boolean } /** @@ -42,6 +43,7 @@ export function useMediaLibraryDeletion({ currentProjectId, clearSelection, deleteMediaBatch, + enabled = true, }: UseMediaLibraryDeletionParams) { const { t, i18n } = useTranslation() const isFocusedRef = useRef(false) @@ -74,7 +76,7 @@ export function useMediaLibraryDeletion({ useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // Only handle Delete key - if (event.key !== 'Delete') return + if (!enabled || event.key !== 'Delete') return // Don't trigger if media library is not focused if (!isFocusedRef.current) return @@ -105,7 +107,14 @@ export function useMediaLibraryDeletion({ return () => { document.removeEventListener('keydown', handleKeyDown) } - }, [isFocusedRef, selectedAssetCount, selectedCompositionIds, selectedMediaIds, showDeleteDialog]) + }, [ + enabled, + isFocusedRef, + selectedAssetCount, + selectedCompositionIds, + selectedMediaIds, + showDeleteDialog, + ]) const deleteSummary = useMemo(() => { const parts: string[] = [] @@ -152,7 +161,7 @@ export function useMediaLibraryDeletion({ affectedMediaImpact.totalReferenceCount + compoundClipDeleteImpact.totalReferenceCount const handleDeleteSelected = () => { - if (selectedAssetCount === 0) return + if (!enabled || selectedAssetCount === 0) return // Capture the IDs BEFORE opening dialog (selection may be cleared by click outside) setPendingDeletion({ mediaIds: [...selectedMediaIds], @@ -162,6 +171,7 @@ export function useMediaLibraryDeletion({ } const handleConfirmDelete = async () => { + if (!enabled) return setShowDeleteDialog(false) try { // First remove timeline items that reference selected library assets diff --git a/src/features/media-library/components/use-media-library-drag-drop.ts b/src/features/media-library/components/use-media-library-drag-drop.ts index c88365dfe..a8bed04c9 100644 --- a/src/features/media-library/components/use-media-library-drag-drop.ts +++ b/src/features/media-library/components/use-media-library-drag-drop.ts @@ -9,6 +9,7 @@ import type { MediaLibraryNotification } from '../types' interface UseMediaLibraryDragDropParams { showNotification: (notification: MediaLibraryNotification) => void importHandles: (handles: FileSystemFileHandle[]) => Promise + enabled?: boolean } /** @@ -20,25 +21,34 @@ interface UseMediaLibraryDragDropParams { export function useMediaLibraryDragDrop({ showNotification, importHandles, + enabled = true, }: UseMediaLibraryDragDropParams) { const { t } = useTranslation() const [isDragging, setIsDragging] = useState(false) const dragCounterRef = useRef(0) - const handleDragEnter = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - dragCounterRef.current++ - if (dragCounterRef.current === 1 && !e.dataTransfer.types.includes('application/json')) { - setIsDragging(true) - } - }, []) + const handleDragEnter = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!enabled) return + dragCounterRef.current++ + if (dragCounterRef.current === 1 && !e.dataTransfer.types.includes('application/json')) { + setIsDragging(true) + } + }, + [enabled], + ) - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - e.dataTransfer.dropEffect = 'copy' - }, []) + const handleDragOver = useCallback( + (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!enabled) return + e.dataTransfer.dropEffect = 'copy' + }, + [enabled], + ) const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault() @@ -51,9 +61,11 @@ export function useMediaLibraryDragDrop({ }, []) const handleDrop = useCallback( + // fallow-ignore-next-line complexity async (e: React.DragEvent) => { e.preventDefault() e.stopPropagation() + if (!enabled) return dragCounterRef.current = 0 setIsDragging(false) @@ -95,7 +107,7 @@ export function useMediaLibraryDragDrop({ await importHandles(entries.map((entry) => entry.handle)) } }, - [showNotification, importHandles, t], + [enabled, showNotification, importHandles, t], ) return { isDragging, handleDragEnter, handleDragOver, handleDragLeave, handleDrop } diff --git a/src/features/media-library/deps/editor-contract.ts b/src/features/media-library/deps/editor-contract.ts new file mode 100644 index 000000000..b65192e80 --- /dev/null +++ b/src/features/media-library/deps/editor-contract.ts @@ -0,0 +1,3 @@ +/** Adapter exports for the editor host capability boundary. */ + +export { useEditorCapability, useEditorHostMode } from '@/features/editor/host/context' diff --git a/src/features/media-library/deps/editor.ts b/src/features/media-library/deps/editor.ts new file mode 100644 index 000000000..d28d51db1 --- /dev/null +++ b/src/features/media-library/deps/editor.ts @@ -0,0 +1,3 @@ +/** Compatibility adapter that re-exports through editor-contract. */ + +export * from './editor-contract' diff --git a/src/features/media-library/utils/media-resolver.ts b/src/features/media-library/utils/media-resolver.ts index 779e962f2..a90c96756 100644 --- a/src/features/media-library/utils/media-resolver.ts +++ b/src/features/media-library/utils/media-resolver.ts @@ -14,6 +14,35 @@ const logger = createLogger('MediaResolver') */ const pendingRequests = new Map>() +type RuntimeMediaResolver = (mediaId: string) => Promise | string | null + +let runtimeMediaResolver: RuntimeMediaResolver | null = null + +/** + * Install a runtime-only media resolver for an embedded host. The resolver + * receives an opaque media id and returns a playback locator for this session; + * it is never written into a timeline item or project record. + */ +export function installRuntimeMediaResolver(resolver: RuntimeMediaResolver): () => void { + const previous = runtimeMediaResolver + const resolvedMediaIds = new Set() + const installedResolver: RuntimeMediaResolver = async (mediaId) => { + const source = await resolver(mediaId) + if (source) resolvedMediaIds.add(mediaId) + return source + } + runtimeMediaResolver = installedResolver + return () => { + if (runtimeMediaResolver === installedResolver) { + for (const mediaId of resolvedMediaIds) { + blobUrlManager.invalidate(mediaId) + } + resolvedMediaIds.clear() + runtimeMediaResolver = previous + } + } +} + /** * Resolves a mediaId to a blob URL for use in Composition Player * @@ -21,6 +50,21 @@ const pendingRequests = new Map>() * @returns Blob URL for the media, or empty string if not found */ export async function resolveMediaUrl(mediaId: string): Promise { + if (runtimeMediaResolver) { + const source = await runtimeMediaResolver(mediaId) + if (!source) return '' + + // The preview runtime uses this manager as a freshness signal. Registering + // an external locator keeps that signal in memory only; unlike acquire(), + // it never creates or persists a Blob URL. + const cached = blobUrlManager.get(mediaId) + if (cached !== source) { + if (cached) blobUrlManager.invalidate(mediaId) + blobUrlManager.registerUrl(mediaId, source) + } + return source + } + // Check centralized manager first - URLs persist until explicit release const cached = blobUrlManager.get(mediaId) if (cached) { @@ -116,6 +160,8 @@ export async function resolveMediaUrl(mediaId: string): Promise { * Returns null if no proxy exists (caller should fall back to full-res). */ export function resolveProxyUrl(mediaId: string): string | null { + if (runtimeMediaResolver) return null + const media = useMediaLibraryStore.getState().mediaById[mediaId] if (media) { const proxyKey = getSharedProxyKey(media) diff --git a/src/features/preview/utils/scrub-proxy-fallback.ts b/src/features/preview/utils/scrub-proxy-fallback.ts index f0439beca..c59805366 100644 --- a/src/features/preview/utils/scrub-proxy-fallback.ts +++ b/src/features/preview/utils/scrub-proxy-fallback.ts @@ -2,6 +2,7 @@ import { blobUrlManager } from '@/infrastructure/browser/blob-url-manager' import { getObjectUrlBlob } from '@/infrastructure/browser/object-url-registry' import { proxyService, useMediaLibraryStore } from '../deps/media-library-contract' import { importFilmstripCache } from '../deps/timeline-filmstrip' +import { useEditorStore } from '@/shared/state/editor' import { cacheActivePreviewFallbackBitmap, getCachedActivePreviewFallbackBitmap, @@ -19,6 +20,7 @@ function loadFilmstripModule() { } export function warmScrubProxyFallback(): void { + if (useEditorStore.getState().hostMode) return void loadFilmstripModule().then(({ filmstripCache }) => filmstripCache.prewarm()) } @@ -32,6 +34,7 @@ async function bitmapFromFrame(frame: { url: string; bitmap?: ImageBitmap }): Pr } export function scheduleScrubProxyFallback(src: string, timestamp: number): void { + if (useEditorStore.getState().hostMode) return if (getCachedActivePreviewFallbackBitmap(src, timestamp)) return const mediaId = blobUrlManager.getMediaIdByUrl(src) ?? proxyService.getMediaIdByProxyUrl(src) if (!mediaId) return diff --git a/src/features/timeline/components/clip-filmstrip/image-filmstrip.tsx b/src/features/timeline/components/clip-filmstrip/image-filmstrip.tsx index f23ff8850..7e0fe7bf7 100644 --- a/src/features/timeline/components/clip-filmstrip/image-filmstrip.tsx +++ b/src/features/timeline/components/clip-filmstrip/image-filmstrip.tsx @@ -4,6 +4,7 @@ import { useMediaBlobUrl } from '../../hooks/use-media-blob-url' import { resolveMediaUrl } from '@/features/timeline/deps/media-library-resolver' import { createLogger } from '@/shared/logging/logger' import { computeFilmstripRenderWindow } from './render-window' +import { useEditorStore } from '@/shared/state/editor' const logger = createLogger('ImageFilmstrip') @@ -112,6 +113,7 @@ export const ImageFilmstrip = memo(function ImageFilmstrip({ }: ImageFilmstripProps) { const containerRef = useRef(null) const [height, setHeight] = useState(0) + const hostMode = useEditorStore((state) => state.hostMode) // Measure container height useEffect(() => { @@ -133,7 +135,7 @@ export const ImageFilmstrip = memo(function ImageFilmstrip({ const { blobUrl, setBlobUrl, hasStartedLoadingRef, blobUrlVersion } = useMediaBlobUrl(mediaId) useEffect(() => { - if (!isVisible || !mediaId || hasStartedLoadingRef.current) return + if (hostMode || !isVisible || !mediaId || hasStartedLoadingRef.current) return hasStartedLoadingRef.current = true let mounted = true @@ -145,13 +147,13 @@ export const ImageFilmstrip = memo(function ImageFilmstrip({ return () => { mounted = false } - }, [mediaId, isVisible, blobUrlVersion, hasStartedLoadingRef, setBlobUrl]) + }, [blobUrlVersion, hasStartedLoadingRef, hostMode, isVisible, mediaId, setBlobUrl]) const { frames, durations, totalDuration } = useGifFrames({ mediaId, blobUrl, isVisible, - enabled: isAnimated && !!blobUrl, + enabled: !hostMode && isAnimated && !!blobUrl, format: animationFormat, }) diff --git a/src/features/timeline/components/clip-filmstrip/index.tsx b/src/features/timeline/components/clip-filmstrip/index.tsx index d195f7051..e29e03dfd 100644 --- a/src/features/timeline/components/clip-filmstrip/index.tsx +++ b/src/features/timeline/components/clip-filmstrip/index.tsx @@ -11,6 +11,7 @@ import { useMediaLibraryStore } from '@/features/timeline/deps/media-library-sto import { observeParentElementHeight } from '../measure-parent-height' import { useTimelineViewportStore } from '../../stores/timeline-viewport-store' import { CLIP_VISIBILITY_PREFETCH_MARGIN_PX } from '../../hooks/use-clip-visibility' +import { useEditorStore } from '@/shared/state/editor' const logger = createLogger('ClipFilmstrip') @@ -79,6 +80,7 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({ const containerRef = useRef(null) const [height, setHeight] = useState(0) const viewportWidth = useTimelineViewportStore((state) => state.viewportWidth) + const hostMode = useEditorStore((state) => state.hostMode) const { blobUrl, setBlobUrl, hasStartedLoadingRef, blobUrlVersion } = useMediaBlobUrl(mediaId) const refreshingFrameIndicesRef = useRef>(new Set()) const proxyStatus = useMediaLibraryStore((s) => s.proxyStatus.get(mediaId) ?? null) @@ -189,7 +191,7 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({ // Load blob URL lazily when visible, and retry after global invalidation. useEffect(() => { - if (!isVisible || !mediaId || proxyBlobUrl || hasStartedLoadingRef.current) { + if (hostMode || !isVisible || !mediaId || proxyBlobUrl || hasStartedLoadingRef.current) { return } hasStartedLoadingRef.current = true @@ -211,7 +213,7 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({ return () => { mounted = false } - }, [mediaId, isVisible, proxyBlobUrl, blobUrlVersion, hasStartedLoadingRef, setBlobUrl]) + }, [blobUrlVersion, hasStartedLoadingRef, hostMode, isVisible, mediaId, proxyBlobUrl, setBlobUrl]) // Use filmstrip hook. `enabled` no longer requires the source blob URL — // disk-cached frames can render before useMediaBlobUrl resolves. The @@ -221,7 +223,7 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({ blobUrl: filmstripSourceUrl, duration: sourceDuration, isVisible, - enabled: sourceDuration > 0, + enabled: !hostMode && sourceDuration > 0, priorityWindow, targetFrameCount, targetFrameIndices, @@ -252,7 +254,7 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({ const handleFrameSourceError = useCallback( (frameIndex: number) => { - if (!mediaId || refreshingFrameIndicesRef.current.has(frameIndex)) { + if (hostMode || !mediaId || refreshingFrameIndicesRef.current.has(frameIndex)) { return } @@ -266,7 +268,7 @@ export const ClipFilmstrip = memo(function ClipFilmstrip({ refreshingFrameIndicesRef.current.delete(frameIndex) }) }, - [mediaId], + [hostMode, mediaId], ) // Share one stable fallback frame per media item. Every clip instance then diff --git a/src/features/timeline/components/clip-waveform/index.tsx b/src/features/timeline/components/clip-waveform/index.tsx index 76d89fd01..2d57fb071 100644 --- a/src/features/timeline/components/clip-waveform/index.tsx +++ b/src/features/timeline/components/clip-waveform/index.tsx @@ -18,6 +18,7 @@ import { observeParentElementHeight } from '../measure-parent-height' import { useTimelineViewportStore } from '../../stores/timeline-viewport-store' import { useZoomStore } from '../../stores/zoom-store' import { CLIP_VISIBILITY_PREFETCH_MARGIN_PX } from '../../hooks/use-clip-visibility' +import { useEditorStore } from '@/shared/state/editor' const logger = createLogger('ClipWaveform') @@ -85,6 +86,7 @@ export const ClipWaveform = memo(function ClipWaveform({ const amplitudesBufferRef = useRef(new Float32Array(0)) const [height, setHeight] = useState(0) const viewportWidth = useTimelineViewportStore((state) => state.viewportWidth) + const hostMode = useEditorStore((state) => state.hostMode) const conformStartedRef = useRef(false) const { blobUrl, setBlobUrl, hasStartedLoadingRef, blobUrlVersion } = useMediaBlobUrl(mediaId) @@ -169,7 +171,7 @@ export const ClipWaveform = memo(function ClipWaveform({ // Load blob URL for the media when visible, including post-invalidation retries. useEffect(() => { // Skip if already started loading (prevents re-triggering on visibility changes) - if (hasStartedLoadingRef.current) { + if (hostMode || hasStartedLoadingRef.current) { return } @@ -236,7 +238,7 @@ export const ClipWaveform = memo(function ClipWaveform({ return () => { mounted = false } - }, [mediaId, isVisible, blobUrlVersion, hasStartedLoadingRef, setBlobUrl]) + }, [blobUrlVersion, hasStartedLoadingRef, hostMode, isVisible, mediaId, setBlobUrl]) // Use waveform hook. It can hydrate persisted waveforms before blobUrl is // available; blobUrl is only required when the cache has to generate. @@ -245,7 +247,7 @@ export const ClipWaveform = memo(function ClipWaveform({ mediaId, blobUrl, isVisible, - enabled: audioCodecSupported, + enabled: audioCodecSupported && !hostMode, deferDurationSec: sourceDuration, pixelsPerSecond, visibleSourceStartSec: visibleSourceWindow.start, diff --git a/src/features/timeline/components/timeline-header.tsx b/src/features/timeline/components/timeline-header.tsx index 5753a3473..586efa104 100644 --- a/src/features/timeline/components/timeline-header.tsx +++ b/src/features/timeline/components/timeline-header.tsx @@ -349,12 +349,7 @@ const TimelineZoomControls = memo(function TimelineZoomControls({ finishSliderZoomInteraction() } }, - [ - commitSliderZoom, - finishSliderZoomInteraction, - releaseSliderZoomGesture, - renderSliderPreview, - ], + [commitSliderZoom, finishSliderZoomInteraction, releaseSliderZoomGesture, renderSliderPreview], ) const controlledSliderValue = zoomToSlider(settledZoomLevel) @@ -444,6 +439,7 @@ const TimelineZoomControls = memo(function TimelineZoomControls({ * - In/Out points, Snap toggle * - Zoom controls */ +// fallow-ignore-next-line complexity export const TimelineHeader = memo(function TimelineHeader({ onZoomChange, onZoomIn, @@ -451,6 +447,7 @@ export const TimelineHeader = memo(function TimelineHeader({ onZoomToFit, }: TimelineHeaderProps) { const { t } = useTranslation() + const hostMode = useEditorStore((s) => s.hostMode) const hotkeys = useResolvedHotkeys() const snapEnabled = useTimelineStore((s) => s.snapEnabled) const toggleSnap = useTimelineStore((s) => s.toggleSnap) @@ -563,25 +560,27 @@ export const TimelineHeader = memo(function TimelineHeader({ - - - {SLIP_SLIDE_TOOLS_ENABLED ? ( + {!hostMode && ( + + )} + + {!hostMode && SLIP_SLIDE_TOOLS_ENABLED ? ( - - - + {!hostMode && ( +
+ + + +
+ )} - {/* In/Out Points */} -
- - - - - -
- - + {!hostMode && ( + <> + {/* In/Out Points */} +
+ + + + + +
+ + + + )} {/* Markers */} -
- - - - - -
+ {!hostMode && ( +
+ + + + + +
+ )} {/* Microphone voiceover */} - + {!hostMode && } @@ -811,7 +821,12 @@ export const TimelineHeader = memo(function TimelineHeader({ - + {!hostMode && ( + + )}
- addVideoTrackToTop()}> + addVideoTrackToTop()}> - appendAudioTrackToSection()}> + appendAudioTrackToSection()} + > {t('timeline.addTrack.audio')} @@ -998,7 +1018,11 @@ export const Timeline = memo(function Timeline({ duration }: TimelineProps) { size="icon" className="h-6 w-6" onClick={handleRemoveTracks} - disabled={tracks.length === 0 || (!activeTrackId && selectedTrackIds.length === 0)} + disabled={ + hostMode || + tracks.length === 0 || + (!activeTrackId && selectedTrackIds.length === 0) + } title={ tracks.length === 0 ? t('timeline.noTracksToRemove') @@ -1087,7 +1111,7 @@ export const Timeline = memo(function Timeline({ duration }: TimelineProps) { void @@ -43,6 +45,7 @@ function areTrackHeaderPropsEqual(prev: TrackHeaderProps, next: TrackHeaderProps prev.track === next.track && prev.isActive === next.isActive && prev.isSelected === next.isSelected && + prev.canAddTrack === next.canAddTrack && prev.canDeleteTrack === next.canDeleteTrack && prev.canDeleteEmptyTracks === next.canDeleteEmptyTracks ) @@ -58,10 +61,12 @@ function areTrackHeaderPropsEqual(prev: TrackHeaderProps, next: TrackHeaderProps * Right-click context menu for track actions. * Memoized to prevent re-renders when props haven't changed. */ +// fallow-ignore-next-line complexity export const TrackHeader = memo(function TrackHeader({ track, isActive, isSelected, + canAddTrack = true, canDeleteTrack, canDeleteEmptyTracks, onToggleLock, @@ -76,6 +81,7 @@ export const TrackHeader = memo(function TrackHeader({ onDeleteEmptyTracks, }: TrackHeaderProps) { const { t } = useTranslation() + const hostMode = useEditorStore((s) => s.hostMode) const itemCount = useItemsStore((s) => s.itemsByTrackId[track.id]?.length ?? 0) const syncLockEnabled = isTrackSyncLockActive(track) const trackDisabled = isTrackDisabled(track) @@ -108,7 +114,7 @@ export const TrackHeader = memo(function TrackHeader({ `} style={{ height: `${track.height}px` }} onClick={onSelect} - onMouseDown={handleDragStart} + onMouseDown={hostMode ? undefined : handleDragStart} >
@@ -128,6 +134,7 @@ export const TrackHeader = memo(function TrackHeader({ onToggleDisabled() }} onMouseDown={(e) => e.stopPropagation()} + disabled={hostMode} aria-label={ trackDisabled ? t('timeline.trackHeader.enableTrack') @@ -160,6 +167,7 @@ export const TrackHeader = memo(function TrackHeader({ onToggleSolo() }} onMouseDown={(e) => e.stopPropagation()} + disabled={hostMode} aria-label={ track.solo ? t('timeline.trackHeader.unsoloTrack') @@ -188,6 +196,7 @@ export const TrackHeader = memo(function TrackHeader({ onToggleLock() }} onMouseDown={(e) => e.stopPropagation()} + disabled={hostMode} aria-label={ track.locked ? t('timeline.trackHeader.unlockTrack') @@ -215,6 +224,7 @@ export const TrackHeader = memo(function TrackHeader({ onToggleSyncLock() }} onMouseDown={(e) => e.stopPropagation()} + disabled={hostMode} aria-label={ syncLockEnabled ? t('timeline.trackHeader.disableSyncLock') @@ -242,6 +252,7 @@ export const TrackHeader = memo(function TrackHeader({ onCloseGaps?.() }} onMouseDown={(e) => e.stopPropagation()} + disabled={hostMode} aria-label={t('timeline.trackHeader.closeAllGaps')} data-tooltip={t('timeline.trackHeader.closeAllGaps')} > @@ -262,22 +273,22 @@ export const TrackHeader = memo(function TrackHeader({ - + {t('timeline.trackHeader.closeAllGaps')} - + {t('timeline.trackHeader.addVideoTrack')} - + {t('timeline.trackHeader.addAudioTrack')} - + {t('timeline.trackHeader.deleteTrack')} - + {t('timeline.trackHeader.deleteEmptyTracks')} diff --git a/src/features/timeline/deps/editor-contract.ts b/src/features/timeline/deps/editor-contract.ts new file mode 100644 index 000000000..a96ca3678 --- /dev/null +++ b/src/features/timeline/deps/editor-contract.ts @@ -0,0 +1,3 @@ +/** Adapter exports for editor host capability state used by timeline UI. */ + +export { useEditorCapability } from '@/features/editor/host/context' diff --git a/src/features/timeline/deps/editor.ts b/src/features/timeline/deps/editor.ts new file mode 100644 index 000000000..d28d51db1 --- /dev/null +++ b/src/features/timeline/deps/editor.ts @@ -0,0 +1,3 @@ +/** Compatibility adapter that re-exports through editor-contract. */ + +export * from './editor-contract' diff --git a/src/features/timeline/hooks/use-waveform-prefetch.ts b/src/features/timeline/hooks/use-waveform-prefetch.ts index de70ea141..145b599f4 100644 --- a/src/features/timeline/hooks/use-waveform-prefetch.ts +++ b/src/features/timeline/hooks/use-waveform-prefetch.ts @@ -6,6 +6,7 @@ import { useItemsStore } from '../stores/items-store' import { useTimelineSettingsStore } from '../stores/timeline-settings-store' import { useTimelineViewportStore } from '../stores/timeline-viewport-store' import { useZoomStore } from '../stores/zoom-store' +import { useEditorStore } from '@/shared/state/editor' import { isPreviewWorkDeferred, schedulePreviewWork, @@ -46,6 +47,7 @@ function loadWaveformCache() { * - Defers work while drag/trim/zoom interactions are active */ export function useWaveformPrefetch() { + const hostMode = useEditorStore((state) => state.hostMode) const prevScrollLeftRef = useRef(useTimelineViewportStore.getState().scrollLeft) useEffect(() => { @@ -56,6 +58,7 @@ export function useWaveformPrefetch() { } const prefetchVisibleWaveforms = () => { + if (useEditorStore.getState().hostMode) return const { scrollLeft, viewportWidth } = useTimelineViewportStore.getState() const { pixelsPerSecond } = useZoomStore.getState() const { fps } = useTimelineSettingsStore.getState() @@ -141,5 +144,5 @@ export function useWaveformPrefetch() { unsubscribe() } } - }, []) + }, [hostMode]) } diff --git a/src/shared/state/editor/store.ts b/src/shared/state/editor/store.ts index 2d035b32a..88f552aa0 100644 --- a/src/shared/state/editor/store.ts +++ b/src/shared/state/editor/store.ts @@ -127,6 +127,7 @@ export const useEditorStore = create((set) => ({ keyframeEditorShortcutScopeActive: false, transcriptEditorShortcutScopeActive: false, workspace: initialWorkspace, + hostMode: false, activeTab: initialWorkspaceLayout.activeTab, clipInspectorTab: initialWorkspaceLayout.clipInspectorTab, sidebarWidth: loadSidebarWidth('editor:sidebarWidth', EDITOR_LAYOUT.leftSidebarDefaultWidth), diff --git a/src/shared/state/editor/types.ts b/src/shared/state/editor/types.ts index d86d80d28..f3242391a 100644 --- a/src/shared/state/editor/types.ts +++ b/src/shared/state/editor/types.ts @@ -16,6 +16,8 @@ export interface EditorState { /** True while the transcript editor owns Delete/Backspace (pointer or focus within). */ transcriptEditorShortcutScopeActive: boolean workspace: EditorWorkspaceId + /** True while the editor is mounted through an injected host surface. */ + hostMode: boolean activeTab: EditorSidebarTab clipInspectorTab: ClipInspectorTab sidebarWidth: number diff --git a/src/types/storage.ts b/src/types/storage.ts index 560b9bc06..c3a03fd18 100644 --- a/src/types/storage.ts +++ b/src/types/storage.ts @@ -12,8 +12,11 @@ * imported by older builds, and the repair sweep mirrors them * into the workspace folder. OPFS remains the store for * regenerable caches (proxies, waveforms, decoded audio). + * - 'host': Descriptive metadata supplied by an embedded host. The + * source is resolved at runtime and is never represented by a + * File System Access handle or an OPFS path. */ -export type MediaStorageType = 'handle' | 'workspace' | 'opfs' +export type MediaStorageType = 'handle' | 'workspace' | 'opfs' | 'host' /** * Provenance + usage terms for media imported from a third-party provider. From e5b6d199719faeb6a4400e1c1f899a0caff8d7cc Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Sun, 16 Aug 2026 23:56:50 -0700 Subject: [PATCH 06/19] fix(editor): guard host mode from audio skim persistence --- .../use-timeline-audio-skim-preview.test.tsx | 24 +++++++++++++++++++ .../hooks/use-timeline-audio-skim-preview.ts | 17 ++++++++++--- 2 files changed, 38 insertions(+), 3 deletions(-) create mode 100644 src/features/timeline/hooks/use-timeline-audio-skim-preview.test.tsx diff --git a/src/features/timeline/hooks/use-timeline-audio-skim-preview.test.tsx b/src/features/timeline/hooks/use-timeline-audio-skim-preview.test.tsx new file mode 100644 index 000000000..894445fb7 --- /dev/null +++ b/src/features/timeline/hooks/use-timeline-audio-skim-preview.test.tsx @@ -0,0 +1,24 @@ +// @vitest-environment jsdom + +import { renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vite-plus/test' +import { useEditorStore } from '@/shared/state/editor' +import { usePlaybackStore } from '@/shared/state/playback' +import { useTimelineAudioSkimPreview } from './use-timeline-audio-skim-preview' + +describe('useTimelineAudioSkimPreview', () => { + afterEach(() => { + useEditorStore.setState({ hostMode: false }) + vi.restoreAllMocks() + }) + + it('does not subscribe to playback audio skim in host mode', () => { + useEditorStore.setState({ hostMode: true }) + const subscribe = vi.spyOn(usePlaybackStore, 'subscribe') + + const { unmount } = renderHook(() => useTimelineAudioSkimPreview()) + + expect(subscribe).not.toHaveBeenCalled() + unmount() + }) +}) diff --git a/src/features/timeline/hooks/use-timeline-audio-skim-preview.ts b/src/features/timeline/hooks/use-timeline-audio-skim-preview.ts index 3b956254c..bbf1bce77 100644 --- a/src/features/timeline/hooks/use-timeline-audio-skim-preview.ts +++ b/src/features/timeline/hooks/use-timeline-audio-skim-preview.ts @@ -7,6 +7,7 @@ import { publishAudioSkimMeterLevel, } from '@/shared/state/audio-skim-meter' import { usePlaybackStore } from '@/shared/state/playback' +import { useEditorStore } from '@/shared/state/editor' import { getOrDecodeAudioSliceForPlayback, resolvePreviewAudioConformUrl, @@ -24,6 +25,7 @@ import { export function useTimelineAudioSkimPreview(): void { const audioSkimmingEnabled = useTimelineStore((s) => s.audioSkimmingEnabled) + const hostMode = useEditorStore((s) => s.hostMode) const requestIdRef = useRef(0) const rafRef = useRef(null) const pendingFrameRef = useRef(null) @@ -102,6 +104,10 @@ export function useTimelineAudioSkimPreview(): void { const skimPreviewFrame = useCallback( async (frame: number) => { + if (hostMode) { + stopAudioSkim() + return + } if (!useTimelineStore.getState().audioSkimmingEnabled) { stopAudioSkim() return @@ -224,12 +230,13 @@ export function useTimelineAudioSkimPreview(): void { } } }, - [resolveSkimUrl, stopAudioSkim], + [hostMode, resolveSkimUrl, stopAudioSkim], ) skimPreviewFrameRef.current = skimPreviewFrame const scheduleAudioSkim = useCallback( (frame: number) => { + if (hostMode) return pendingFrameRef.current = frame if (rafRef.current !== null) return @@ -241,7 +248,7 @@ export function useTimelineAudioSkimPreview(): void { latestAudioSkimRunnerRef.current?.schedule(nextFrame) }) }, - [], + [hostMode], ) useEffect(() => { @@ -250,6 +257,10 @@ export function useTimelineAudioSkimPreview(): void { }, [audioSkimmingEnabled, stopAudioSkim]) useEffect(() => { + if (hostMode) { + stopAudioSkim() + return + } return usePlaybackStore.subscribe((state, prev) => { if (state.isPlaying) { if (!prev.isPlaying || prev.previewFrame !== null) stopAudioSkim() @@ -262,7 +273,7 @@ export function useTimelineAudioSkimPreview(): void { if (state.previewFrameEpoch === prev.previewFrameEpoch) return scheduleAudioSkim(state.previewFrame) }) - }, [scheduleAudioSkim, stopAudioSkim]) + }, [hostMode, scheduleAudioSkim, stopAudioSkim]) useEffect(() => stopAudioSkim, [stopAudioSkim]) } From 050aa1d5c94b05ecd577b0cc6898606e60d9a7af Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 17 Aug 2026 06:45:29 -0700 Subject: [PATCH 07/19] feat(package): publish host-backed editor surface --- package.json | 15 +- packages/freecut-editor/LICENSE | 21 ++ packages/freecut-editor/README.md | 47 ++++ .../freecut-editor/consumer-smoke-style.d.ts | 1 + .../freecut-editor/consumer-smoke.setup.ts | 14 ++ .../freecut-editor/consumer-smoke.test.tsx | 95 +++++++ packages/freecut-editor/package.json | 41 +++ packages/freecut-editor/src/index.d.ts | 238 ++++++++++++++++++ packages/freecut-editor/src/index.ts | 28 +++ packages/freecut-editor/tsconfig.json | 17 ++ provenance/dependency-inventory.json | 2 +- provenance/freecut-baseline.json | 2 +- scripts/package-editor-surface.mjs | 201 +++++++++++++++ src/features/editor/host/context-provider.tsx | 9 +- src/features/editor/host/index.ts | 2 + vite.editor-package.config.ts | 122 +++++++++ vite.editor-package.test.config.ts | 25 ++ 17 files changed, 867 insertions(+), 13 deletions(-) create mode 100644 packages/freecut-editor/LICENSE create mode 100644 packages/freecut-editor/README.md create mode 100644 packages/freecut-editor/consumer-smoke-style.d.ts create mode 100644 packages/freecut-editor/consumer-smoke.setup.ts create mode 100644 packages/freecut-editor/consumer-smoke.test.tsx create mode 100644 packages/freecut-editor/package.json create mode 100644 packages/freecut-editor/src/index.d.ts create mode 100644 packages/freecut-editor/src/index.ts create mode 100644 packages/freecut-editor/tsconfig.json create mode 100644 scripts/package-editor-surface.mjs create mode 100644 vite.editor-package.config.ts create mode 100644 vite.editor-package.test.config.ts diff --git a/package.json b/package.json index 3fd29b7ca..401742c3c 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,16 @@ "dev:quiet": "vp dev --host --mode perf", "dev:compare": "node scripts/run-dev-and-perf.mjs", "build": "vp build", + "build:editor-surface": "vp build --config vite.editor-package.config.ts", + "test:editor-surface:consumer": "npm run build:editor-surface && vp test run --config vite.editor-package.test.config.ts packages/freecut-editor/consumer-smoke.test.tsx", "verify:provenance": "node scripts/package-reproducible.mjs --verify-only", "package:reproducible": "node scripts/package-reproducible.mjs", + "package:editor-surface": "node scripts/package-editor-surface.mjs", "build:perf": "vp build --mode perf", - "lint": "vp lint src headless vite.config.ts", - "lint:fix": "vp lint src headless vite.config.ts --fix", - "format": "vp fmt src headless vite.config.ts package.json .oxlintrc.json .oxfmtrc.json", - "format:check": "vp fmt src headless vite.config.ts package.json .oxlintrc.json .oxfmtrc.json --check", + "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", + "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", + "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", "check:boundaries": "node scripts/check-feature-boundaries.mjs", "check:deps-contracts": "node scripts/check-deps-contract-boundaries.mjs", "check:legacy-lib-imports": "node scripts/check-legacy-lib-imports.mjs", @@ -26,8 +29,8 @@ "check:edge-budgets": "node scripts/check-feature-edge-budgets.mjs", "report:feature-edges": "node scripts/report-feature-edges.mjs", "report:feature-edges:json": "node scripts/report-feature-edges.mjs --json", - "check": "vp check --no-fmt src headless vite.config.ts", - "check:fix": "vp check src headless vite.config.ts package.json .oxlintrc.json .oxfmtrc.json --fix", + "check": "vp check --no-fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "check:fix": "vp check src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --fix", "verify": "vp run check && vp run check:boundaries && vp run check:deps-contracts && vp run check:legacy-lib-imports && vp run check:deps-wrapper-health && vp run check:unused-exports && vp run check:unused-class-members && vp run check:changed-health && vp run check:edge-budgets && vp test run && vp build && npm run headless:test:portable", "preview": "vp preview", "preview:perf": "vp preview --host --strictPort --port 4173", diff --git a/packages/freecut-editor/LICENSE b/packages/freecut-editor/LICENSE new file mode 100644 index 000000000..887b52acb --- /dev/null +++ b/packages/freecut-editor/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FreeCut + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md new file mode 100644 index 000000000..9c0cc1aa8 --- /dev/null +++ b/packages/freecut-editor/README.md @@ -0,0 +1,47 @@ +# `@quantfive/freecut-editor-surface` + +This package is the versioned browser entry for FreeCut's host-backed editor +surface. It exports the real `FreeCutEditorSurface`, the `EditorHostProvider`, +and the typed host contract used to supply authoritative project state, media +resolution, and bounded edit submission. + +The package owns the editor UI and in-memory browser runtime only. The host +owns authentication, persistence, transport, media bytes, and short-lived +media resolution. It does not include the standalone FreeCut app, router +provider, workspace bootstrap, headless services, or local project storage +bootstrap. + +## Usage + +```tsx +import { + FreeCutEditorSurface, + type EditorHost, +} from '@quantfive/freecut-editor-surface' +import '@quantfive/freecut-editor-surface/style.css' + +export function HostEditor({ host }: { host: EditorHost }) { + return +} +``` + +The `EditorHost` contract carries opaque media locators and authoritative +snapshots. It never accepts filesystem paths, permanent URLs, provider keys, +or media bytes. Supported edits are submitted through `submitEdit`; rejected +or conflicting results return an authoritative snapshot to the surface. + +This package is built from a specific FreeCut commit. To create the local +consumer artifact from a clean checkout, run: + +```bash +npm ci --ignore-scripts +npm run package:editor-surface +``` + +The command writes a deterministic tarball to `artifacts/`. Registry +publication is intentionally separate from the build and requires the +publisher's npm credentials: + +```bash +npm publish artifacts/freecut-editor-surface-0.1.0.tgz --access public +``` diff --git a/packages/freecut-editor/consumer-smoke-style.d.ts b/packages/freecut-editor/consumer-smoke-style.d.ts new file mode 100644 index 000000000..8141b9430 --- /dev/null +++ b/packages/freecut-editor/consumer-smoke-style.d.ts @@ -0,0 +1 @@ +declare module '@quantfive/freecut-editor-surface/style.css' {} diff --git a/packages/freecut-editor/consumer-smoke.setup.ts b/packages/freecut-editor/consumer-smoke.setup.ts new file mode 100644 index 000000000..760be9d9b --- /dev/null +++ b/packages/freecut-editor/consumer-smoke.setup.ts @@ -0,0 +1,14 @@ +const storageState = new Map() +const storage: Storage = { + get length() { + return storageState.size + }, + clear: () => storageState.clear(), + getItem: (key) => storageState.get(key) ?? null, + key: (index) => Array.from(storageState.keys())[index] ?? null, + removeItem: (key) => storageState.delete(key), + setItem: (key, value) => storageState.set(key, String(value)), +} + +Object.defineProperty(window, 'localStorage', { configurable: true, value: storage }) +Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage }) diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx new file mode 100644 index 000000000..32884d98a --- /dev/null +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom + +import '@testing-library/jest-dom' +import '@quantfive/freecut-editor-surface/style.css' +import { render, screen, waitFor } from '@testing-library/react' +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test' +import { + FreeCutEditorSurface, + capabilityForCommand, + isHostCapabilityEnabled, + type EditorHost, + type EmbeddedEditorSnapshot, +} from '@quantfive/freecut-editor-surface' + +const snapshot: EmbeddedEditorSnapshot = { + project: { + id: 'consumer-smoke-project', + name: 'Consumer smoke project', + width: 1920, + height: 1080, + fps: 30, + }, + timeline: { + timelineId: 'consumer-smoke-timeline', + revision: 0, + fps: 30, + durationInFrames: 300, + media: [], + tracks: [], + width: 1920, + height: 1080, + }, + assets: [], +} + +function fakeHost(): EditorHost { + return { + capabilities: { + 'media.resolve': true, + 'timeline.add': false, + }, + load: vi.fn(() => snapshot), + resolveMedia: vi.fn(() => null), + submitEdit: vi.fn(() => { + throw new Error('consumer smoke does not submit an edit') + }), + } +} + +beforeAll(() => { + class TestResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + Object.assign(globalThis, { + ResizeObserver: TestResizeObserver, + requestIdleCallback: (callback: IdleRequestCallback) => + setTimeout(() => callback({ didTimeout: false, timeRemaining: () => 50 }), 0), + cancelIdleCallback: (id: number) => clearTimeout(id), + }) + Object.defineProperty(window, 'matchMedia', { + configurable: true, + value: () => ({ + matches: false, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, + }), + }) + HTMLElement.prototype.scrollIntoView = () => {} +}) + +describe('published FreeCut browser entry', () => { + it('imports the package entry, mounts the real editor surfaces, and keeps host capability gates bounded', async () => { + const host = fakeHost() + render() + + await waitFor( + () => { + expect(screen.getAllByRole('toolbar').length).toBeGreaterThanOrEqual(2) + expect(screen.getByRole('region', { name: 'Preview area' })).toBeInTheDocument() + expect(screen.getByText('Timeline')).toBeInTheDocument() + }, + { timeout: 10_000 }, + ) + + expect(screen.getByTestId('properties-clip-panel-host')).toBeInTheDocument() + expect(host.load).toHaveBeenCalledTimes(1) + expect(capabilityForCommand('move_item')).toBe('timeline.move') + expect(isHostCapabilityEnabled(host.capabilities, 'timeline.add')).toBe(false) + }) +}) diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json new file mode 100644 index 000000000..e03aa7db5 --- /dev/null +++ b/packages/freecut-editor/package.json @@ -0,0 +1,41 @@ +{ + "name": "@quantfive/freecut-editor-surface", + "version": "0.1.0", + "description": "The host-backed FreeCut browser editor surface.", + "license": "MIT", + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "type": "module", + "sideEffects": [ + "./dist/style.css" + ], + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./style.css": "./dist/style.css", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@testing-library/jest-dom": "6.9.1", + "@testing-library/react": "16.3.2", + "vite-plus": "0.2.4" + }, + "peerDependencies": { + "react": ">=19.2.0 <20", + "react-dom": ">=19.2.0 <20" + }, + "engines": { + "node": ">=22" + } +} diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts new file mode 100644 index 000000000..b093631c2 --- /dev/null +++ b/packages/freecut-editor/src/index.d.ts @@ -0,0 +1,238 @@ +import type { ComponentType, ReactNode } from 'react' + +export type EditorCapability = + | 'project.navigate' + | 'project.save' + | 'media.resolve' + | 'media.import' + | 'media.delete' + | 'media.proxy' + | 'media.transcription' + | 'media.relink' + | 'timeline.add' + | 'timeline.move' + | 'timeline.trim' + | 'timeline.split' + | 'timeline.remove' + | 'timeline.track' + | 'workspace.edit' + | 'workspace.color' + | 'workspace.motion' + | 'export.video' + | 'export.bundle' + | 'render.queue' + +export type EditorCapabilityMap = Readonly>> + +export type HostMediaKind = 'video' | 'audio' | 'image' | 'lottie' + +export interface MediaLocator { + mediaId: string + kind: HostMediaKind + variant?: 'source' | 'proxy' | 'thumbnail' +} + +export interface ResolvedMediaLocator { + source: string + audioSource?: string + expiresAt?: number +} + +export interface EmbeddedEditorProject { + id: string + name: string + width: number + height: number + fps: number + backgroundColor?: string +} + +export interface MediaReference { + media_id: string + media_kind: Exclude + content_hash: string + duration_us: number | null + availability: + | { + mode: 'local' + local: { device_id: string; root_id: string; file_id: string; root_generation: number } + } + | { mode: 'cloud'; cloud: { object_id: string } } + | { + mode: 'hybrid' + local: { device_id: string; root_id: string; file_id: string; root_generation: number } + cloud: { object_id: string } + } +} + +export type FrameRateLike = number | { numerator: bigint; denominator: bigint; value: number } + +export interface FreeCutFrameClip { + type: 'video' | 'audio' | 'image' + id: string + trackId: string + mediaId: string + from: number + durationInFrames: number + sourceStart?: number + sourceEnd?: number + volume?: number + speed?: number + opacity?: number + transform?: Record +} + +export interface FreeCutFrameText { + type: 'text' + id: string + trackId: string + from: number + durationInFrames: number + text: string + style?: Record + opacity?: number + transform?: Record +} + +export interface FreeCutFrameCaptionCue { + type: 'caption_cue' + id: string + trackId: string + from: number + durationInFrames: number + text: string + speaker?: string | null +} + +export type FreeCutFrameItem = FreeCutFrameClip | FreeCutFrameText | FreeCutFrameCaptionCue + +export interface FreeCutFrameTrack { + id: string + kind: 'video' | 'audio' | 'overlay' | 'caption' + name: string + language?: string + locked: boolean + muted: boolean + items: readonly FreeCutFrameItem[] +} + +export interface FreeCutFrameDocument { + timelineId: string + revision: number + fps: FrameRateLike + durationInFrames: number + media: readonly MediaReference[] + tracks: readonly FreeCutFrameTrack[] + width: number + height: number + backgroundColor?: string +} + +export interface EmbeddedEditorAsset { + id: string + kind: HostMediaKind + fileName: string + mimeType: string + durationSeconds: number + width: number + height: number + fps: number + fileSize?: number + contentHash?: string + thumbnailLocator?: MediaLocator +} + +export interface EmbeddedEditorSnapshot { + project: EmbeddedEditorProject + timeline: FreeCutFrameDocument + assets: readonly EmbeddedEditorAsset[] +} + +export interface HostNotice { + kind: 'info' | 'warning' | 'error' | 'unsupported' | 'conflict' + message: string + operationId?: string +} + +export interface HostAppliedEditResult { + status: 'applied' | 'replayed' + snapshot: EmbeddedEditorSnapshot + result: Record +} + +export interface HostConflictResult { + status: 'conflict' | 'rejected' + snapshot: EmbeddedEditorSnapshot + result: Record +} + +export type HostEditResult = HostAppliedEditResult | HostConflictResult + +export interface EditCommand { + type: string + command_id?: string + [field: string]: unknown +} + +export interface EditCommandBatch { + contract_version: 1 + timeline_id: string + operation_id: string + idempotency_key: string + base_revision: number + preconditions: readonly object[] + commands: readonly EditCommand[] +} + +export interface EditorHostNavigation { + back(): void +} + +export interface EditorHost { + readonly capabilities: EditorCapabilityMap + load(): Promise | EmbeddedEditorSnapshot + resolveMedia( + locator: MediaLocator, + ): Promise | ResolvedMediaLocator | null + submitEdit(batch: EditCommandBatch): Promise | HostEditResult + navigation?: EditorHostNavigation + notify?(notice: HostNotice): void +} + +export type LocalEditorHostOptions = Omit & { + capabilities?: EditorCapabilityMap +} + +export interface EditorHostContextValue { + mode: 'local' | 'host' + capabilities: EditorCapabilityMap + host?: EditorHost +} + +export interface EditorHostProviderProps { + value: EditorHostContextValue + children: ReactNode +} + +export interface FreeCutEditorSurfaceProps { + host: EditorHost +} + +export declare const FreeCutEditorSurface: ComponentType +export declare const EditorHostProvider: ComponentType +export declare const DEFAULT_HOST_CAPABILITIES: EditorCapabilityMap +export declare const SUPPORTED_HOST_COMMANDS: readonly [ + 'add_clip', + 'add_text', + 'move_item', + 'trim_item', + 'split_item', + 'remove_item', + 'add_track', +] +export declare function capabilityForCommand(command: string): EditorCapability | null +export declare function isHostCapabilityEnabled( + capabilities: EditorCapabilityMap, + capability: EditorCapability, +): boolean +export declare function createLocalEditorHost(options: LocalEditorHostOptions): EditorHost diff --git a/packages/freecut-editor/src/index.ts b/packages/freecut-editor/src/index.ts new file mode 100644 index 000000000..37bfa15f9 --- /dev/null +++ b/packages/freecut-editor/src/index.ts @@ -0,0 +1,28 @@ +export { FreeCutEditorSurface } from '@/features/editor/host/editor-surface' +export { EditorHostProvider } from '@/features/editor/host/context-provider' +export { + DEFAULT_HOST_CAPABILITIES, + SUPPORTED_HOST_COMMANDS, + capabilityForCommand, + createLocalEditorHost, + isHostCapabilityEnabled, +} from '@/features/editor/host/contract' +export type { EditorHostContextValue } from '@/features/editor/host/context' +export type { EditorHostProviderProps } from '@/features/editor/host/context-provider' +export type { + EditorCapability, + EditorCapabilityMap, + EditorHost, + EditorHostNavigation, + EmbeddedEditorAsset, + EmbeddedEditorProject, + EmbeddedEditorSnapshot, + HostAppliedEditResult, + HostConflictResult, + HostEditResult, + HostMediaKind, + HostNotice, + LocalEditorHostOptions, + MediaLocator, + ResolvedMediaLocator, +} from '@/features/editor/host/contract' diff --git a/packages/freecut-editor/tsconfig.json b/packages/freecut-editor/tsconfig.json new file mode 100644 index 000000000..b0ace0222 --- /dev/null +++ b/packages/freecut-editor/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "declaration": false, + "declarationMap": false, + "emitDeclarationOnly": false, + "noCheck": true, + "noEmit": true, + "rootDir": "../..", + "paths": { + "@/*": ["../../src/*"] + }, + "types": ["vite/client", "vite-plus/test/globals", "@webgpu/types"] + }, + "include": ["./src/index.ts"] +} diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 736277ee1..6c311254b 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "b1b55899c8b54d0bea585df254ed9934e4b74f30fbc362b0ce1bcf8f29aab05e", + "packageJsonSha256": "b0cce60842cfde27bae44c8729614fbdce43322404bfac4992e2790e4d1a11c7", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index 8059af585..d241cf558 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "b1b55899c8b54d0bea585df254ed9934e4b74f30fbc362b0ce1bcf8f29aab05e", + "packageJsonSha256": "b0cce60842cfde27bae44c8729614fbdce43322404bfac4992e2790e4d1a11c7", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", diff --git a/scripts/package-editor-surface.mjs b/scripts/package-editor-surface.mjs new file mode 100644 index 000000000..4a70a8b23 --- /dev/null +++ b/scripts/package-editor-surface.mjs @@ -0,0 +1,201 @@ +import crypto from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { execFileSync, spawnSync } from 'node:child_process' +import { once } from 'node:events' +import { fileURLToPath } from 'node:url' +import { createGzip } from 'node:zlib' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PACKAGE_ROOT = path.join(ROOT, 'packages/freecut-editor') +const DIST = path.join(PACKAGE_ROOT, 'dist') +const ARTIFACTS = path.join(ROOT, 'artifacts') +const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) +const artifactName = `freecut-editor-surface-${packageJson.version}.tgz` +const artifactPath = path.join(ARTIFACTS, artifactName) + +function fail(message) { + throw new Error(`[editor-surface-package] ${message}`) +} + +function assertCondition(condition, message) { + if (!condition) fail(message) +} + +function run(command, args) { + const result = spawnSync(command, args, { + cwd: ROOT, + env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, + stdio: 'inherit', + }) + assertCondition(result.status === 0, `${command} ${args.join(' ')} failed`) +} + +function sha256(filePath) { + return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex') +} + +function packageFiles() { + const files = [] + const visit = (directory) => { + for (const entry of fs.readdirSync(directory).sort()) { + const absolute = path.join(directory, entry) + const relative = path.relative(PACKAGE_ROOT, absolute).split(path.sep).join('/') + const stat = fs.lstatSync(absolute) + assertCondition(!stat.isSymbolicLink(), `symbolic link is not allowed: ${relative}`) + if (stat.isDirectory()) visit(absolute) + else if (stat.isFile()) files.push(relative) + else fail(`unsupported package input: ${relative}`) + } + } + visit(PACKAGE_ROOT) + return files +} + +function verifyBuildBoundary() { + for (const required of ['index.js', 'index.d.ts', 'style.css']) { + assertCondition(fs.existsSync(path.join(DIST, required)), `missing package output: dist/${required}`) + } + + const forbiddenImportPatterns = [ + /(?:^|["'])@\/features\/workspace-gate\//, + /(?:^|["'])@\/headless\//, + /(?:^|["'])\.\/src\/headless\//, + /(?:^|["'])@\/app\.tsx?/, + /(?:^|["'])@\/infrastructure\/storage\/workspace-fs\/bootstrap/, + /(?:^|["'])@tanstack\/react-router["']/, + ] + for (const file of packageFiles().filter( + (candidate) => candidate.startsWith('dist/') && candidate.endsWith('.js'), + )) { + const javascript = fs.readFileSync(path.join(PACKAGE_ROOT, file), 'utf8') + for (const pattern of forbiddenImportPatterns) { + assertCondition( + !pattern.test(javascript), + `forbidden consumer dependency in ${file}: ${pattern}`, + ) + } + assertCondition(!javascript.includes('@/'), `source alias leaked into ${file}`) + assertCondition(!javascript.includes('../../../src/'), `raw source-relative alias leaked into ${file}`) + assertCondition(!javascript.includes('/Users/'), `local filesystem path leaked into ${file}`) + assertCondition(!javascript.includes('file://'), `file URL leaked into ${file}`) + } +} + +function verifyPackageInputs() { + assertCondition(packageJson.name === '@quantfive/freecut-editor-surface', 'unexpected package name') + assertCondition(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(packageJson.version), 'package version must be semver') + assertCondition(packageJson.exports?.['./style.css'] === './dist/style.css', 'style export must be stable') + assertCondition(packageJson.peerDependencies?.react, 'React must remain a peer dependency') + assertCondition(packageJson.peerDependencies?.['react-dom'], 'React DOM must remain a peer dependency') + assertCondition(fs.existsSync(path.join(PACKAGE_ROOT, 'README.md')), 'package README is missing') + assertCondition(fs.existsSync(path.join(PACKAGE_ROOT, 'LICENSE')), 'package LICENSE is missing') +} + +// fallow-ignore-next-line complexity +function archiveEntries(directory, archivePrefix) { + const entries = [{ type: 'directory', name: `${archivePrefix}/`, sourcePath: directory }] + for (const entry of fs.readdirSync(directory).sort()) { + if (entry === '.gitkeep') continue + const sourcePath = path.join(directory, entry) + const archiveName = `${archivePrefix}/${entry}` + const stat = fs.lstatSync(sourcePath) + assertCondition(!stat.isSymbolicLink(), `symbolic link is not allowed: ${archiveName}`) + if (stat.isDirectory()) entries.push(...archiveEntries(sourcePath, archiveName)) + else if (stat.isFile()) entries.push({ type: 'file', name: archiveName, sourcePath }) + else fail(`unsupported package input: ${archiveName}`) + } + return entries +} + +function tarString(header, offset, length, value) { + const bytes = Buffer.from(value) + assertCondition(bytes.length <= length, `tar header field is too long: ${value}`) + bytes.copy(header, offset) +} + +function tarOctal(header, offset, length, value) { + tarString(header, offset, length, `${Math.floor(value).toString(8).padStart(length - 1, '0')}\0`) +} + +function tarHeader(entry) { + const header = Buffer.alloc(512) + tarString(header, 0, 100, entry.name) + tarOctal(header, 100, 8, entry.type === 'directory' ? 0o755 : 0o644) + tarOctal(header, 108, 8, 0) + tarOctal(header, 116, 8, 0) + tarOctal(header, 124, 12, entry.type === 'file' ? fs.statSync(entry.sourcePath).size : 0) + tarOctal(header, 136, 12, 0) + header.fill(0x20, 148, 156) + header[156] = entry.type === 'directory' ? 0x35 : 0x30 + tarString(header, 257, 6, 'ustar\0') + tarString(header, 263, 2, '00') + const checksum = header.reduce((total, byte) => total + byte, 0) + tarString(header, 148, 8, `${checksum.toString(8).padStart(6, '0')}\0 `) + return header +} + +async function writeChunk(stream, chunk) { + if (stream.write(chunk)) return + await once(stream, 'drain') +} + +async function pack() { + fs.mkdirSync(ARTIFACTS, { recursive: true }) + fs.rmSync(artifactPath, { force: true }) + const staticEntries = ['LICENSE', 'README.md', 'package.json'].map((file) => ({ + type: 'file', + name: `package/${file}`, + sourcePath: path.join(PACKAGE_ROOT, file), + })) + const entries = [ + { type: 'directory', name: 'package/', sourcePath: PACKAGE_ROOT }, + ...staticEntries, + ...archiveEntries(DIST, 'package/dist'), + ].sort((left, right) => left.name.localeCompare(right.name)) + + const gzip = createGzip({ level: 9, mtime: 0 }) + const output = fs.createWriteStream(artifactPath) + gzip.pipe(output) + for (const entry of entries) { + await writeChunk(gzip, tarHeader(entry)) + if (entry.type !== 'file') continue + const bytes = fs.readFileSync(entry.sourcePath) + await writeChunk(gzip, bytes) + const remainder = bytes.length % 512 + if (remainder !== 0) await writeChunk(gzip, Buffer.alloc(512 - remainder)) + } + await writeChunk(gzip, Buffer.alloc(1024)) + gzip.end() + await once(output, 'close') + return artifactPath +} + +function verifyTarball(filePath) { + const listing = execFileSync('tar', ['-tzf', filePath], { encoding: 'utf8' }) + const entries = listing.split('\n').filter(Boolean) + assertCondition(entries.includes('package/dist/index.js'), 'tarball is missing dist/index.js') + assertCondition(entries.includes('package/dist/index.d.ts'), 'tarball is missing dist/index.d.ts') + assertCondition(entries.includes('package/dist/style.css'), 'tarball is missing dist/style.css') + assertCondition(entries.includes('package/package.json'), 'tarball is missing package.json') + assertCondition(!entries.some((entry) => entry.includes('node_modules/')), 'tarball contains node_modules') + assertCondition(!entries.some((entry) => entry.includes('/src/')), 'tarball contains source files') +} + +async function main() { + verifyPackageInputs() + run(process.platform === 'win32' ? 'npm.cmd' : 'npm', ['run', 'build:editor-surface']) + verifyBuildBoundary() + const artifact = await pack() + verifyTarball(artifact) + console.log(`[editor-surface-package] artifact ${path.relative(ROOT, artifact)}`) + console.log(`[editor-surface-package] version ${packageJson.version}`) + console.log(`[editor-surface-package] sha256 ${sha256(artifact)}`) +} + +try { + await main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} diff --git a/src/features/editor/host/context-provider.tsx b/src/features/editor/host/context-provider.tsx index 44b22726d..1a7315517 100644 --- a/src/features/editor/host/context-provider.tsx +++ b/src/features/editor/host/context-provider.tsx @@ -1,12 +1,11 @@ import type { ReactNode } from 'react' import { EditorHostContext, type EditorHostContextValue } from './context' -export function EditorHostProvider({ - value, - children, -}: { +export interface EditorHostProviderProps { value: EditorHostContextValue children: ReactNode -}) { +} + +export function EditorHostProvider({ value, children }: EditorHostProviderProps) { return {children} } diff --git a/src/features/editor/host/index.ts b/src/features/editor/host/index.ts index 05c639b3b..d2cb0b20b 100644 --- a/src/features/editor/host/index.ts +++ b/src/features/editor/host/index.ts @@ -3,6 +3,8 @@ export { FreeCutEditorSurface } from './editor-surface' export { useEditorCapability, useEditorHostContext, useEditorHostMode } from './context' export { EditorHostProvider } from './context-provider' +export type { EditorHostContextValue } from './context' +export type { EditorHostProviderProps } from './context-provider' export { DEFAULT_HOST_CAPABILITIES, SUPPORTED_HOST_COMMANDS, diff --git a/vite.editor-package.config.ts b/vite.editor-package.config.ts new file mode 100644 index 000000000..1c5da402b --- /dev/null +++ b/vite.editor-package.config.ts @@ -0,0 +1,122 @@ +import { defineConfig, lazyPlugins } from 'vite-plus' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { + copyFileSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('.', import.meta.url)) +const outDir = fileURLToPath(new URL('./packages/freecut-editor/dist', import.meta.url)) + +const hostDisabledWorkerNames = [ + 'clip-worker-', + 'embeddings-worker-', + 'export-render.worker-', + 'frame-interpolation-worker-', + 'gemma-scene-worker-', + 'kokoro-', + 'lfm-scene-worker-', + 'parakeet.worker-', + 'ort.bundle.min-', + 'transformers.web-', + 'upscale-worker-', +] + +function statExists(filePath: string): boolean { + try { + statSync(filePath) + return true + } catch { + return false + } +} + +// fallow-ignore-next-line complexity +function removeHostDisabledWorkers() { + for (const directory of [path.join(outDir, 'assets'), path.join(outDir, 'chunks')]) { + if (!statExists(directory)) continue + for (const entry of readdirSync(directory)) { + if (!hostDisabledWorkerNames.some((name) => entry.startsWith(name))) continue + rmSync(path.join(directory, entry), { force: true }) + } + } +} + +function normalizeEditorPackageAssets() { + return { + name: 'freecut-editor-package-assets', + buildStart() { + rmSync(outDir, { recursive: true, force: true }) + }, + writeBundle() { + mkdirSync(outDir, { recursive: true }) + const cssFiles: string[] = [] + const visit = (directory: string) => { + for (const entry of readdirSync(directory).sort()) { + const absolute = path.join(directory, entry) + if (statSync(absolute).isDirectory()) visit(absolute) + else if (entry.endsWith('.css')) cssFiles.push(absolute) + } + } + visit(outDir) + if (cssFiles.length > 0) { + const stylePath = fileURLToPath( + new URL('./packages/freecut-editor/dist/style.css', import.meta.url), + ) + const css = cssFiles + .sort((left, right) => left.localeCompare(right)) + .map((filePath) => readFileSync(filePath, 'utf8')) + .join('\n') + writeFileSync(stylePath, css) + for (const filePath of cssFiles) { + if (filePath !== stylePath) rmSync(filePath, { force: true }) + } + } + removeHostDisabledWorkers() + copyFileSync( + fileURLToPath(new URL('./packages/freecut-editor/src/index.d.ts', import.meta.url)), + fileURLToPath(new URL('./packages/freecut-editor/dist/index.d.ts', import.meta.url)), + ) + }, + } +} + +export default defineConfig({ + plugins: lazyPlugins(() => [react(), tailwindcss(), normalizeEditorPackageAssets()]), + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['react', 'react-dom'], + }, + build: { + target: 'esnext', + outDir, + emptyOutDir: true, + sourcemap: false, + cssCodeSplit: false, + lib: { + entry: fileURLToPath(new URL('./packages/freecut-editor/src/index.ts', import.meta.url)), + formats: ['es'], + fileName: () => 'index.js', + }, + rollupOptions: { + external: [/^react(?:\/|$)/, /^react-dom(?:\/|$)/], + output: { + assetFileNames: 'assets/[name]-[hash][extname]', + chunkFileNames: 'chunks/[name]-[hash].js', + entryFileNames: 'index.js', + }, + }, + }, + root, + publicDir: false, +}) diff --git a/vite.editor-package.test.config.ts b/vite.editor-package.test.config.ts new file mode 100644 index 000000000..96c8c492a --- /dev/null +++ b/vite.editor-package.test.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, lazyPlugins } from 'vite-plus' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import { fileURLToPath } from 'node:url' + +export default defineConfig({ + plugins: lazyPlugins(() => [react(), tailwindcss()]), + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + dedupe: ['react', 'react-dom'], + }, + test: { + environment: 'jsdom', + environmentOptions: { + jsdom: { + url: 'http://localhost/', + }, + }, + globals: true, + include: ['packages/freecut-editor/consumer-smoke.test.tsx'], + setupFiles: ['packages/freecut-editor/consumer-smoke.setup.ts'], + }, +}) From 524f43d0dd8b8678374b7acb09325d296eb67b59 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 17 Aug 2026 08:01:32 -0700 Subject: [PATCH 08/19] chore(package): publish editor surface privately --- .github/workflows/publish-editor-surface.yml | 70 ++++++++++++++++++++ packages/freecut-editor/README.md | 35 ++++++++-- packages/freecut-editor/package.json | 7 +- scripts/package-editor-surface.mjs | 5 ++ 4 files changed, 112 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/publish-editor-surface.yml diff --git a/.github/workflows/publish-editor-surface.yml b/.github/workflows/publish-editor-surface.yml new file mode 100644 index 000000000..0eed92a73 --- /dev/null +++ b/.github/workflows/publish-editor-surface.yml @@ -0,0 +1,70 @@ +name: Publish FreeCut editor surface + +on: + workflow_dispatch: + push: + tags: + - 'freecut-editor-surface-v*' + +permissions: + contents: read + packages: write + +concurrency: + group: publish-freecut-editor-surface + cancel-in-progress: false + +jobs: + publish: + name: Publish @quantfive/freecut-editor-surface + # Manual runs are intentionally limited to staging; tag pushes are + # already constrained by the trigger below and must match the package + # version in the validation step. + if: github.event_name == 'push' || github.ref_name == 'staging' + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22.14.0' + cache: npm + registry-url: https://npm.pkg.github.com + + - name: Select pinned npm + run: npm install --global npm@11.8.0 + + - name: Install locked dependencies + run: npm ci --ignore-scripts + + - name: Verify provenance + run: npm run verify:provenance + + - name: Validate release tag + if: startsWith(github.ref, 'refs/tags/') + shell: bash + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#freecut-editor-surface-v}" + package_version="$(node -p "require('./packages/freecut-editor/package.json').version")" + if [[ "${GITHUB_REF_NAME}" != freecut-editor-surface-v* || "${tag_version}" != "${package_version}" ]]; then + echo "Release tag ${GITHUB_REF_NAME} must match package version ${package_version}." >&2 + exit 1 + fi + + - name: Build deterministic package artifact + run: npm run package:editor-surface + + - name: Publish private package to GitHub Packages + shell: bash + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + package_version="$(node -p "require('./packages/freecut-editor/package.json').version")" + artifact="artifacts/freecut-editor-surface-${package_version}.tgz" + test -f "${artifact}" + npm publish "${artifact}" --registry=https://npm.pkg.github.com diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 9c0cc1aa8..93aac5bb9 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -38,10 +38,37 @@ npm ci --ignore-scripts npm run package:editor-surface ``` -The command writes a deterministic tarball to `artifacts/`. Registry -publication is intentionally separate from the build and requires the -publisher's npm credentials: +The command writes a deterministic tarball to `artifacts/`. The package is +published to the `quantfive` GitHub Packages npm registry by the +manual/tagged workflow in `.github/workflows/publish-editor-surface.yml`. +The first GitHub Packages publication defaults to private; verify that +visibility remains private and grant `quantfive/codepress` read access under +**Manage Actions access** before CodePress installs it. + +For an authorized local publication, authenticate with a GitHub classic PAT +that has `write:packages` and run: + +```bash +NODE_AUTH_TOKEN="$GITHUB_CLASSIC_PAT" npm publish \ + artifacts/freecut-editor-surface-0.1.0.tgz \ + --registry=https://npm.pkg.github.com +``` + +Do not commit a token. The workflow uses its `GITHUB_TOKEN` with +`packages: write`; CodePress CI uses its `GITHUB_TOKEN` with `packages: read` +after the repository has been granted package access. + +CodePress should route the `@quantfive` scope to GitHub Packages and provide +`NODE_AUTH_TOKEN` in CI: + +```ini +@quantfive:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN} +``` + +It can then install the exact published version and keep it pinned in its +lockfile: ```bash -npm publish artifacts/freecut-editor-surface-0.1.0.tgz --access public +npm install @quantfive/freecut-editor-surface@0.1.0 ``` diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index e03aa7db5..b1641a6fc 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -3,6 +3,11 @@ "version": "0.1.0", "description": "The host-backed FreeCut browser editor surface.", "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/quantfive/freecut.git", + "directory": "packages/freecut-editor" + }, "files": [ "dist", "README.md", @@ -24,7 +29,7 @@ "./package.json": "./package.json" }, "publishConfig": { - "access": "public" + "registry": "https://npm.pkg.github.com" }, "devDependencies": { "@testing-library/jest-dom": "6.9.1", diff --git a/scripts/package-editor-surface.mjs b/scripts/package-editor-surface.mjs index 4a70a8b23..bd259c4e5 100644 --- a/scripts/package-editor-surface.mjs +++ b/scripts/package-editor-surface.mjs @@ -85,6 +85,11 @@ function verifyBuildBoundary() { function verifyPackageInputs() { assertCondition(packageJson.name === '@quantfive/freecut-editor-surface', 'unexpected package name') assertCondition(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(packageJson.version), 'package version must be semver') + assertCondition( + packageJson.publishConfig?.registry === 'https://npm.pkg.github.com', + 'package must target the private GitHub Packages npm registry', + ) + assertCondition(packageJson.publishConfig?.access !== 'public', 'package must not enable public npm access') assertCondition(packageJson.exports?.['./style.css'] === './dist/style.css', 'style export must be stable') assertCondition(packageJson.peerDependencies?.react, 'React must remain a peer dependency') assertCondition(packageJson.peerDependencies?.['react-dom'], 'React DOM must remain a peer dependency') From 60ac3e54b747b25492fba74c03510755ea7aba9c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 17 Aug 2026 08:03:59 -0700 Subject: [PATCH 09/19] fix(package): keep private publish guard below quality threshold --- scripts/package-editor-surface.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/package-editor-surface.mjs b/scripts/package-editor-surface.mjs index bd259c4e5..cb78143fd 100644 --- a/scripts/package-editor-surface.mjs +++ b/scripts/package-editor-surface.mjs @@ -11,6 +11,7 @@ const PACKAGE_ROOT = path.join(ROOT, 'packages/freecut-editor') const DIST = path.join(PACKAGE_ROOT, 'dist') const ARTIFACTS = path.join(ROOT, 'artifacts') const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) +const publishConfig = packageJson.publishConfig ?? {} const artifactName = `freecut-editor-surface-${packageJson.version}.tgz` const artifactPath = path.join(ARTIFACTS, artifactName) @@ -86,10 +87,10 @@ function verifyPackageInputs() { assertCondition(packageJson.name === '@quantfive/freecut-editor-surface', 'unexpected package name') assertCondition(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(packageJson.version), 'package version must be semver') assertCondition( - packageJson.publishConfig?.registry === 'https://npm.pkg.github.com', + publishConfig.registry === 'https://npm.pkg.github.com', 'package must target the private GitHub Packages npm registry', ) - assertCondition(packageJson.publishConfig?.access !== 'public', 'package must not enable public npm access') + assertCondition(publishConfig.access !== 'public', 'package must not enable public npm access') assertCondition(packageJson.exports?.['./style.css'] === './dist/style.css', 'style export must be stable') assertCondition(packageJson.peerDependencies?.react, 'React must remain a peer dependency') assertCondition(packageJson.peerDependencies?.['react-dom'], 'React DOM must remain a peer dependency') From fd487df20218fbf89e8f61f79a99b56a88524c99 Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 17 Aug 2026 09:29:28 -0700 Subject: [PATCH 10/19] fix(package): gate publish on packed consumer smoke --- .github/workflows/publish-editor-surface.yml | 9 + package.json | 10 +- provenance/dependency-inventory.json | 2 +- provenance/freecut-baseline.json | 2 +- scripts/test-editor-surface-consumer.mjs | 164 +++++++++++++++++++ vite.editor-package.test.config.ts | 19 ++- 6 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 scripts/test-editor-surface-consumer.mjs diff --git a/.github/workflows/publish-editor-surface.yml b/.github/workflows/publish-editor-surface.yml index 0eed92a73..3ebd71026 100644 --- a/.github/workflows/publish-editor-surface.yml +++ b/.github/workflows/publish-editor-surface.yml @@ -58,6 +58,15 @@ jobs: - name: Build deterministic package artifact run: npm run package:editor-surface + - name: Verify packed artifact as an installed consumer + shell: bash + run: | + set -euo pipefail + package_version="$(node -p "require('./packages/freecut-editor/package.json').version")" + artifact="artifacts/freecut-editor-surface-${package_version}.tgz" + test -f "${artifact}" + npm run test:editor-surface:consumer -- --artifact "${artifact}" + - name: Publish private package to GitHub Packages shell: bash env: diff --git a/package.json b/package.json index 401742c3c..3b28e0da1 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,15 @@ "dev:compare": "node scripts/run-dev-and-perf.mjs", "build": "vp build", "build:editor-surface": "vp build --config vite.editor-package.config.ts", - "test:editor-surface:consumer": "npm run build:editor-surface && vp test run --config vite.editor-package.test.config.ts packages/freecut-editor/consumer-smoke.test.tsx", + "test:editor-surface:consumer": "node scripts/test-editor-surface-consumer.mjs", "verify:provenance": "node scripts/package-reproducible.mjs --verify-only", "package:reproducible": "node scripts/package-reproducible.mjs", "package:editor-surface": "node scripts/package-editor-surface.mjs", "build:perf": "vp build --mode perf", - "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", - "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", - "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", - "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", + "lint": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "lint:fix": "vp lint src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts --fix", + "format": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json", + "format:check": "vp fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts scripts/package-editor-surface.mjs scripts/test-editor-surface-consumer.mjs headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --check", "check:boundaries": "node scripts/check-feature-boundaries.mjs", "check:deps-contracts": "node scripts/check-deps-contract-boundaries.mjs", "check:legacy-lib-imports": "node scripts/check-legacy-lib-imports.mjs", diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 6c311254b..7b5abca6b 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "b0cce60842cfde27bae44c8729614fbdce43322404bfac4992e2790e4d1a11c7", + "packageJsonSha256": "19ff9c18b9a52ed03dc8fb63a9cac34c26141c983a4250a490ec9faf52c9d8ce", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index d241cf558..deed724cf 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "b0cce60842cfde27bae44c8729614fbdce43322404bfac4992e2790e4d1a11c7", + "packageJsonSha256": "19ff9c18b9a52ed03dc8fb63a9cac34c26141c983a4250a490ec9faf52c9d8ce", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", diff --git a/scripts/test-editor-surface-consumer.mjs b/scripts/test-editor-surface-consumer.mjs new file mode 100644 index 000000000..265a9f32b --- /dev/null +++ b/scripts/test-editor-surface-consumer.mjs @@ -0,0 +1,164 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const PACKAGE_ROOT = path.join(ROOT, 'packages/freecut-editor') +const packageJson = JSON.parse(fs.readFileSync(path.join(PACKAGE_ROOT, 'package.json'), 'utf8')) +const rootPackageJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const artifactName = `freecut-editor-surface-${packageJson.version}.tgz` +const defaultArtifact = path.join(ROOT, 'artifacts', artifactName) +const fixtureFiles = [ + 'consumer-smoke.test.tsx', + 'consumer-smoke.setup.ts', + 'consumer-smoke-style.d.ts', + 'vite.editor-package.test.config.ts', +] + +function fail(message) { + throw new Error(`[editor-surface-consumer] ${message}`) +} + +function assertCondition(condition, message) { + if (!condition) fail(message) +} + +function run(command, args, cwd) { + const result = spawnSync(command, args, { + cwd, + env: { ...process.env, TZ: 'UTC', LC_ALL: 'C' }, + stdio: 'inherit', + }) + assertCondition(result.status === 0, `${command} ${args.join(' ')} failed`) +} + +function npmCommand() { + return process.platform === 'win32' ? 'npm.cmd' : 'npm' +} + +function vpPath(fixture) { + return path.join( + fixture, + 'node_modules', + '.bin', + process.platform === 'win32' ? 'vp.cmd' : 'vp', + ) +} + +function readArg(name) { + const index = process.argv.indexOf(name) + if (index < 0) return null + const value = process.argv[index + 1] + assertCondition(value && !value.startsWith('--'), `${name} requires a value`) + return value +} + +function dependencySpec(name) { + const version = rootPackageJson.devDependencies?.[name] ?? rootPackageJson.dependencies?.[name] + assertCondition(version, `missing pinned fixture dependency: ${name}`) + return `${name}@${version}` +} + +function createFixture() { + const fixture = fs.mkdtempSync(path.join(os.tmpdir(), 'freecut-editor-consumer-')) + fs.writeFileSync( + path.join(fixture, 'package.json'), + `${JSON.stringify( + { + name: 'freecut-editor-consumer-smoke-fixture', + private: true, + type: 'module', + version: '0.0.0', + }, + null, + 2, + )}\n`, + ) + for (const file of fixtureFiles) { + const source = file === 'vite.editor-package.test.config.ts' ? path.join(ROOT, file) : path.join(PACKAGE_ROOT, file) + const target = file === 'vite.editor-package.test.config.ts' ? 'vite.config.ts' : file + fs.copyFileSync(source, path.join(fixture, target)) + } + return fixture +} + +function fixtureDependencies() { + return [ + '@testing-library/dom', + '@testing-library/jest-dom', + '@testing-library/react', + '@vitejs/plugin-react', + 'jsdom', + 'react', + 'react-dom', + 'vite-plus', + ].map(dependencySpec) +} + +function verifyInstalledPackage(fixture) { + const installedPackage = path.join( + fixture, + 'node_modules', + '@quantfive', + 'freecut-editor-surface', + 'package.json', + ) + assertCondition(fs.existsSync(installedPackage), 'packed package was not installed in fixture') + const installedPackageJson = JSON.parse(fs.readFileSync(installedPackage, 'utf8')) + assertCondition( + installedPackageJson.name === packageJson.name, + `installed package name mismatch: ${installedPackageJson.name}`, + ) + assertCondition( + installedPackageJson.version === packageJson.version, + `installed package version mismatch: ${installedPackageJson.version}`, + ) +} + +function resolveArtifact() { + const requested = readArg('--artifact') + if (requested) { + const artifact = path.resolve(ROOT, requested) + assertCondition(fs.existsSync(artifact), `artifact does not exist: ${artifact}`) + return artifact + } + + run(npmCommand(), ['run', 'package:editor-surface'], ROOT) + assertCondition(fs.existsSync(defaultArtifact), `package command did not create ${defaultArtifact}`) + return defaultArtifact +} + +// fallow-ignore-next-line complexity +function main() { + const artifact = resolveArtifact() + const fixture = createFixture() + try { + run( + npmCommand(), + [ + 'install', + '--ignore-scripts', + '--no-package-lock', + '--no-save', + artifact, + ...fixtureDependencies(), + ], + fixture, + ) + verifyInstalledPackage(fixture) + run(vpPath(fixture), ['test', 'run', '--config', path.join(fixture, 'vite.config.ts')], fixture) + console.log(`[editor-surface-consumer] installed and tested ${packageJson.name}@${packageJson.version}`) + console.log(`[editor-surface-consumer] fixture ${fixture}`) + } finally { + fs.rmSync(fixture, { recursive: true, force: true }) + } +} + +try { + main() +} catch (error) { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 +} diff --git a/vite.editor-package.test.config.ts b/vite.editor-package.test.config.ts index 96c8c492a..4b18aec01 100644 --- a/vite.editor-package.test.config.ts +++ b/vite.editor-package.test.config.ts @@ -1,16 +1,14 @@ import { defineConfig, lazyPlugins } from 'vite-plus' import react from '@vitejs/plugin-react' -import tailwindcss from '@tailwindcss/vite' -import { fileURLToPath } from 'node:url' export default defineConfig({ - plugins: lazyPlugins(() => [react(), tailwindcss()]), + plugins: lazyPlugins(() => [react()]), resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, dedupe: ['react', 'react-dom'], }, + ssr: { + noExternal: ['@quantfive/freecut-editor-surface'], + }, test: { environment: 'jsdom', environmentOptions: { @@ -19,7 +17,12 @@ export default defineConfig({ }, }, globals: true, - include: ['packages/freecut-editor/consumer-smoke.test.tsx'], - setupFiles: ['packages/freecut-editor/consumer-smoke.setup.ts'], + include: ['consumer-smoke.test.tsx'], + setupFiles: ['consumer-smoke.setup.ts'], + server: { + deps: { + inline: ['@quantfive/freecut-editor-surface'], + }, + }, }, }) From 4979b4889effd28eb6c680579f24cb9f8f8b979c Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 17 Aug 2026 09:53:08 -0700 Subject: [PATCH 11/19] fix(package): keep consumer smoke outside root check --- package.json | 4 ++-- provenance/dependency-inventory.json | 2 +- provenance/freecut-baseline.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3b28e0da1..5e5d5cdb4 100644 --- a/package.json +++ b/package.json @@ -29,8 +29,8 @@ "check:edge-budgets": "node scripts/check-feature-edge-budgets.mjs", "report:feature-edges": "node scripts/report-feature-edges.mjs", "report:feature-edges:json": "node scripts/report-feature-edges.mjs --json", - "check": "vp check --no-fmt src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", - "check:fix": "vp check src packages/freecut-editor/src packages/freecut-editor/consumer-smoke.test.tsx packages/freecut-editor/consumer-smoke.setup.ts packages/freecut-editor/consumer-smoke-style.d.ts headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --fix", + "check": "vp check --no-fmt src packages/freecut-editor/src headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts", + "check:fix": "vp check src packages/freecut-editor/src headless vite.config.ts vite.editor-package.config.ts vite.editor-package.test.config.ts package.json packages/freecut-editor/package.json .oxlintrc.json .oxfmtrc.json --fix", "verify": "vp run check && vp run check:boundaries && vp run check:deps-contracts && vp run check:legacy-lib-imports && vp run check:deps-wrapper-health && vp run check:unused-exports && vp run check:unused-class-members && vp run check:changed-health && vp run check:edge-budgets && vp test run && vp build && npm run headless:test:portable", "preview": "vp preview", "preview:perf": "vp preview --host --strictPort --port 4173", diff --git a/provenance/dependency-inventory.json b/provenance/dependency-inventory.json index 7b5abca6b..a9efc1877 100644 --- a/provenance/dependency-inventory.json +++ b/provenance/dependency-inventory.json @@ -3,7 +3,7 @@ "generatedFrom": "package.json", "packageName": "freecut", "packageVersion": "0.0.0", - "packageJsonSha256": "19ff9c18b9a52ed03dc8fb63a9cac34c26141c983a4250a490ec9faf52c9d8ce", + "packageJsonSha256": "331cb27e2fa2e50dbf9759e625e6d2b9c97db2205ee5870c8157ffbb2b14f971", "lockfile": { "path": "package-lock.json", "lockfileVersion": 3, diff --git a/provenance/freecut-baseline.json b/provenance/freecut-baseline.json index deed724cf..5346dde91 100644 --- a/provenance/freecut-baseline.json +++ b/provenance/freecut-baseline.json @@ -34,7 +34,7 @@ ], "dependencies": { "packageJson": "package.json", - "packageJsonSha256": "19ff9c18b9a52ed03dc8fb63a9cac34c26141c983a4250a490ec9faf52c9d8ce", + "packageJsonSha256": "331cb27e2fa2e50dbf9759e625e6d2b9c97db2205ee5870c8157ffbb2b14f971", "lockfile": "package-lock.json", "lockfileVersion": 3, "lockfileSha256": "b4a86741ce7891da1f63df01b6fdd4ed507e8887d5097c6a0f93fc2ea6f3420e", From 8077bbd055bbdd93c18e827ad604d46f0ba67b7e Mon Sep 17 00:00:00 2001 From: Patrick Lu Date: Mon, 17 Aug 2026 16:52:09 -0700 Subject: [PATCH 12/19] Add caption commands and FreeCut caption UI (#3) * add caption command and editor ui * fix(editor): mount caption editor in production shell * feat(editor): route captions through host surface * fix(editor): preserve caption background opacity --- packages/freecut-editor/README.md | 13 +- .../freecut-editor/consumer-smoke.test.tsx | 2 + packages/freecut-editor/package.json | 2 +- packages/freecut-editor/src/index.d.ts | 19 + src/features/editor/codepress/README.md | 16 + .../editor/codepress/caption-commands.test.ts | 304 +++++++ .../editor/codepress/caption-commands.ts | 109 +++ .../editor/codepress/caption-editor-view.tsx | 833 ++++++++++++++++++ .../editor/codepress/caption-editor.test.tsx | 128 +++ .../editor/codepress/caption-editor.tsx | 474 ++++++++++ .../codepress/caption-validation.test.ts | 141 +++ .../editor/codepress/caption-validation.ts | 118 +++ src/features/editor/codepress/contract.ts | 97 +- src/features/editor/codepress/document.ts | 7 + src/features/editor/codepress/index.ts | 3 + src/features/editor/codepress/translation.ts | 51 +- src/features/editor/components/editor.tsx | 30 +- .../properties-sidebar/index.test.tsx | 60 ++ .../components/properties-sidebar/index.tsx | 90 +- .../host/caption-editor-context.test.tsx | 129 +++ .../editor/host/caption-editor-context.tsx | 104 +++ src/features/editor/host/contract.ts | 17 + src/features/editor/host/controller.test.ts | 64 +- src/features/editor/host/document.ts | 65 +- src/features/editor/host/editor-surface.tsx | 33 +- src/types/text.ts | 2 + 26 files changed, 2814 insertions(+), 97 deletions(-) create mode 100644 src/features/editor/codepress/caption-commands.test.ts create mode 100644 src/features/editor/codepress/caption-commands.ts create mode 100644 src/features/editor/codepress/caption-editor-view.tsx create mode 100644 src/features/editor/codepress/caption-editor.test.tsx create mode 100644 src/features/editor/codepress/caption-editor.tsx create mode 100644 src/features/editor/codepress/caption-validation.test.ts create mode 100644 src/features/editor/codepress/caption-validation.ts create mode 100644 src/features/editor/host/caption-editor-context.test.tsx create mode 100644 src/features/editor/host/caption-editor-context.tsx diff --git a/packages/freecut-editor/README.md b/packages/freecut-editor/README.md index 93aac5bb9..73529c066 100644 --- a/packages/freecut-editor/README.md +++ b/packages/freecut-editor/README.md @@ -14,10 +14,7 @@ bootstrap. ## Usage ```tsx -import { - FreeCutEditorSurface, - type EditorHost, -} from '@quantfive/freecut-editor-surface' +import { FreeCutEditorSurface, type EditorHost } from '@quantfive/freecut-editor-surface' import '@quantfive/freecut-editor-surface/style.css' export function HostEditor({ host }: { host: EditorHost }) { @@ -28,7 +25,9 @@ export function HostEditor({ host }: { host: EditorHost }) { The `EditorHost` contract carries opaque media locators and authoritative snapshots. It never accepts filesystem paths, permanent URLs, provider keys, or media bytes. Supported edits are submitted through `submitEdit`; rejected -or conflicting results return an authoritative snapshot to the surface. +or conflicting results return an authoritative snapshot to the surface. The +0.2.0 surface adds host-backed caption tracks, bounded cues, caption styles, +and display toggles to that same command path. This package is built from a specific FreeCut commit. To create the local consumer artifact from a clean checkout, run: @@ -50,7 +49,7 @@ that has `write:packages` and run: ```bash NODE_AUTH_TOKEN="$GITHUB_CLASSIC_PAT" npm publish \ - artifacts/freecut-editor-surface-0.1.0.tgz \ + artifacts/freecut-editor-surface-0.2.0.tgz \ --registry=https://npm.pkg.github.com ``` @@ -70,5 +69,5 @@ It can then install the exact published version and keep it pinned in its lockfile: ```bash -npm install @quantfive/freecut-editor-surface@0.1.0 +npm install @quantfive/freecut-editor-surface@0.2.0 ``` diff --git a/packages/freecut-editor/consumer-smoke.test.tsx b/packages/freecut-editor/consumer-smoke.test.tsx index 32884d98a..ee07086cd 100644 --- a/packages/freecut-editor/consumer-smoke.test.tsx +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -88,8 +88,10 @@ describe('published FreeCut browser entry', () => { ) expect(screen.getByTestId('properties-clip-panel-host')).toBeInTheDocument() + expect(await screen.findByTestId('caption-editor')).toBeInTheDocument() expect(host.load).toHaveBeenCalledTimes(1) expect(capabilityForCommand('move_item')).toBe('timeline.move') + expect(capabilityForCommand('set_caption_style')).toBe('timeline.caption') expect(isHostCapabilityEnabled(host.capabilities, 'timeline.add')).toBe(false) }) }) diff --git a/packages/freecut-editor/package.json b/packages/freecut-editor/package.json index b1641a6fc..250e6d7a0 100644 --- a/packages/freecut-editor/package.json +++ b/packages/freecut-editor/package.json @@ -1,6 +1,6 @@ { "name": "@quantfive/freecut-editor-surface", - "version": "0.1.0", + "version": "0.2.0", "description": "The host-backed FreeCut browser editor surface.", "license": "MIT", "repository": { diff --git a/packages/freecut-editor/src/index.d.ts b/packages/freecut-editor/src/index.d.ts index b093631c2..f2c5107b2 100644 --- a/packages/freecut-editor/src/index.d.ts +++ b/packages/freecut-editor/src/index.d.ts @@ -15,6 +15,7 @@ export type EditorCapability = | 'timeline.split' | 'timeline.remove' | 'timeline.track' + | 'timeline.caption' | 'workspace.edit' | 'workspace.color' | 'workspace.motion' @@ -67,6 +68,15 @@ export interface MediaReference { export type FrameRateLike = number | { numerator: bigint; denominator: bigint; value: number } +export interface CaptionStyle { + font_family?: string + font_size?: number + color?: string + background_color?: string + background_opacity?: number + alignment?: 'left' | 'center' | 'right' +} + export interface FreeCutFrameClip { type: 'video' | 'audio' | 'image' id: string @@ -102,6 +112,7 @@ export interface FreeCutFrameCaptionCue { durationInFrames: number text: string speaker?: string | null + style?: CaptionStyle } export type FreeCutFrameItem = FreeCutFrameClip | FreeCutFrameText | FreeCutFrameCaptionCue @@ -113,6 +124,7 @@ export interface FreeCutFrameTrack { language?: string locked: boolean muted: boolean + defaultStyle?: CaptionStyle | null items: readonly FreeCutFrameItem[] } @@ -229,6 +241,13 @@ export declare const SUPPORTED_HOST_COMMANDS: readonly [ 'split_item', 'remove_item', 'add_track', + 'update_track', + 'add_caption_track', + 'remove_caption_track', + 'update_caption_track', + 'upsert_caption_cues', + 'remove_caption_cues', + 'set_caption_style', ] export declare function capabilityForCommand(command: string): EditorCapability | null export declare function isHostCapabilityEnabled( diff --git a/src/features/editor/codepress/README.md b/src/features/editor/codepress/README.md index c8f60251c..492ffabcb 100644 --- a/src/features/editor/codepress/README.md +++ b/src/features/editor/codepress/README.md @@ -31,6 +31,22 @@ engine rejects it as `unsupported_command` until a host explicitly owns the `MediaJobClient` dispatch. It never reports a media job as applied while doing nothing. +## Caption UI + +`CaptionEditor` is the FreeCut-side controlled caption surface mounted by the +merged host-backed editor surface. It reads the runtime controller's +authoritative frame-native `FreeCutFrameDocument`, but every mutation is +translated back into the canonical integer-microsecond command contract before +it reaches `EditorHost.submitEdit`. Track display toggles use the canonical +track mute field; cue text/timing, track defaults, cue-specific styles, and +track/cue removal all carry the host controller's revision and precondition +checks. The standalone adapter remains a deterministic unit-test port; the +production caption panel does not create a project or synthetic duration. + +The UI rejects empty, out-of-range, overlapping, duplicate, and over-budget cue +sets before submitting them. A rejected revision or idempotency result is shown +as an accessible error and is never automatically rebased. + The existing `headless/` browser harness and its localhost `/v1` service are not imported here and remain development-only implementation seams. diff --git a/src/features/editor/codepress/caption-commands.test.ts b/src/features/editor/codepress/caption-commands.test.ts new file mode 100644 index 000000000..fe017b2eb --- /dev/null +++ b/src/features/editor/codepress/caption-commands.test.ts @@ -0,0 +1,304 @@ +import { describe, expect, it } from 'vite-plus/test' + +import { CodePressCommandAdapter } from './adapter' +import { + applyCaptionCommands, + captionCuePrecondition, + createCaptionCommandBatch, +} from './caption-commands' +import { framesToMicroseconds, type FrameRateLike } from './timing' +import type { ControlledEditorDocument } from './interfaces' + +const ntsc: FrameRateLike = { + numerator: 30_000n, + denominator: 1_001n, + value: 30_000 / 1_001, +} + +function documentWithCaptionTrack(): ControlledEditorDocument { + return { + fps: ntsc, + width: 1920, + height: 1080, + timeline: { + contract_version: 1, + schema_version: 1, + timeline_id: 'timeline-captions', + revision: 0, + duration_us: framesToMicroseconds(180, ntsc), + media: [], + tracks: [ + { + track_id: 'captions-en', + kind: 'caption', + name: 'English', + language: 'en', + locked: false, + muted: false, + items: [], + }, + ], + }, + } +} + +function frameCue(start: number, end: number, text = 'Hello') { + return { + type: 'caption_cue' as const, + id: 'cue-1', + trackId: 'captions-en', + from: start, + durationInFrames: end - start, + text, + } +} + +describe('caption command bridge', () => { + it('translates frame-native cue timing without leaking frame or legacy fields', () => { + const adapter = new CodePressCommandAdapter({ document: documentWithCaptionTrack() }) + const batch = createCaptionCommandBatch( + adapter, + [ + { + command_id: 'upsert-cue', + type: 'upsert_caption_cues', + track_id: 'captions-en', + cues: [ + { + item_type: 'caption_cue', + cue_id: 'cue-1', + track_id: 'captions-en', + start_frame: 3, + end_frame: 31, + text: 'NTSC cue', + style: { font_size: 40, alignment: 'center' }, + }, + ], + }, + ], + ntsc, + { operationId: 'caption-op-1' }, + ) + + const command = batch.commands[0] + expect(command).toMatchObject({ type: 'upsert_caption_cues' }) + if (command?.type !== 'upsert_caption_cues') return + expect(command.cues[0]).toMatchObject({ + start_us: framesToMicroseconds(3, ntsc), + end_us: framesToMicroseconds(31, ntsc), + }) + expect(command.cues[0]).not.toHaveProperty('start_frame') + expect(command.cues[0]).not.toHaveProperty('end_frame') + + const result = adapter.apply(batch) + expect(result.status).toBe('applied') + const cue = adapter.getSnapshot().document.timeline.tracks[0]?.items[0] + expect(cue).toMatchObject({ + item_type: 'caption_cue', + start_us: framesToMicroseconds(3, ntsc), + end_us: framesToMicroseconds(31, ntsc), + }) + }) + + it('applies add/update/remove track and cue commands plus styles atomically', () => { + const adapter = new CodePressCommandAdapter({ + document: { + ...documentWithCaptionTrack(), + timeline: { ...documentWithCaptionTrack().timeline, tracks: [] }, + }, + }) + const addTrack = applyCaptionCommands( + adapter, + [ + { + command_id: 'add-track', + type: 'add_caption_track', + track_id: 'captions-en', + name: 'English', + language: 'en', + index: 0, + }, + ], + ntsc, + { operationId: 'caption-add-track' }, + ) + expect(addTrack.status).toBe('applied') + + const cue = frameCue(10, 40) + const addCue = applyCaptionCommands( + adapter, + [ + { + command_id: 'add-cue', + type: 'upsert_caption_cues', + track_id: 'captions-en', + cues: [ + { + item_type: 'caption_cue', + cue_id: cue.id, + track_id: cue.trackId, + start_frame: cue.from, + end_frame: cue.from + cue.durationInFrames, + text: cue.text, + }, + ], + }, + ], + ntsc, + { + operationId: 'caption-add-cue', + preconditions: [{ type: 'track_exists', track_id: 'captions-en' }], + }, + ) + expect(addCue.status).toBe('applied') + + const updatedCue = { ...cue, text: 'Updated cue', from: 12, durationInFrames: 30 } + const updateCue = applyCaptionCommands( + adapter, + [ + { + command_id: 'update-cue', + type: 'upsert_caption_cues', + track_id: 'captions-en', + cues: [ + { + item_type: 'caption_cue', + cue_id: updatedCue.id, + track_id: updatedCue.trackId, + start_frame: updatedCue.from, + end_frame: updatedCue.from + updatedCue.durationInFrames, + text: updatedCue.text, + }, + ], + }, + ], + ntsc, + { + operationId: 'caption-update-cue', + preconditions: [captionCuePrecondition(cue, ntsc)], + }, + ) + expect(updateCue.status).toBe('applied') + + const style = applyCaptionCommands( + adapter, + [ + { + command_id: 'style-cue', + type: 'set_caption_style', + track_id: 'captions-en', + cue_ids: ['cue-1'], + style: { font_size: 48, color: '#ff0', alignment: 'center' }, + }, + ], + ntsc, + { + operationId: 'caption-style-cue', + preconditions: [captionCuePrecondition(updatedCue, ntsc)], + }, + ) + expect(style.status).toBe('applied') + expect(adapter.getSnapshot().document.timeline.tracks[0]?.items[0]).toMatchObject({ + text: 'Updated cue', + style: { font_size: 48, color: '#ff0' }, + }) + + const trackStyle = applyCaptionCommands( + adapter, + [ + { + command_id: 'style-track', + type: 'set_caption_style', + track_id: 'captions-en', + cue_ids: null, + style: { font_family: 'Inter', background_opacity: 0.6 }, + }, + ], + ntsc, + { operationId: 'caption-style-track' }, + ) + expect(trackStyle.status).toBe('applied') + expect(adapter.getSnapshot().document.timeline.tracks[0]?.default_style).toMatchObject({ + font_family: 'Inter', + background_opacity: 0.6, + }) + + const removedCue = applyCaptionCommands( + adapter, + [ + { + command_id: 'remove-cue', + type: 'remove_caption_cues', + track_id: 'captions-en', + cue_ids: ['cue-1'], + }, + ], + ntsc, + { operationId: 'caption-remove-cue' }, + ) + expect(removedCue.status).toBe('applied') + expect(adapter.getSnapshot().document.timeline.tracks[0]?.items).toHaveLength(0) + + const removedTrack = applyCaptionCommands( + adapter, + [ + { + command_id: 'remove-track', + type: 'remove_caption_track', + track_id: 'captions-en', + }, + ], + ntsc, + { operationId: 'caption-remove-track' }, + ) + expect(removedTrack.status).toBe('applied') + expect(adapter.getSnapshot().document.timeline.tracks).toHaveLength(0) + }) + + it('preserves idempotency replay and reports revision/idempotency conflicts', () => { + const adapter = new CodePressCommandAdapter({ document: documentWithCaptionTrack() }) + const batch = createCaptionCommandBatch( + adapter, + [ + { + command_id: 'cue-1', + type: 'upsert_caption_cues', + track_id: 'captions-en', + cues: [ + { + item_type: 'caption_cue', + cue_id: 'cue-1', + track_id: 'captions-en', + start_frame: 1, + end_frame: 20, + text: 'Replay me', + }, + ], + }, + ], + ntsc, + { operationId: 'caption-idempotent', idempotencyKey: 'caption-idempotent-key' }, + ) + expect(adapter.apply(batch).status).toBe('applied') + expect(adapter.apply(batch).status).toBe('replayed') + + const idempotencyConflict = adapter.apply({ + ...batch, + operation_id: 'different-operation', + commands: [{ ...batch.commands[0]!, command_id: 'different-command' }], + }) + expect(idempotencyConflict).toMatchObject({ + status: 'rejected', + error: { code: 'idempotency_conflict' }, + }) + + const stale = adapter.apply({ + ...batch, + operation_id: 'stale-operation', + idempotency_key: 'stale-key', + base_revision: 0, + commands: [{ ...batch.commands[0]!, command_id: 'stale-command', cues: [] }], + }) + expect(stale).toMatchObject({ status: 'rejected', error: { code: 'revision_conflict' } }) + }) +}) diff --git a/src/features/editor/codepress/caption-commands.ts b/src/features/editor/codepress/caption-commands.ts new file mode 100644 index 000000000..7bf16819c --- /dev/null +++ b/src/features/editor/codepress/caption-commands.ts @@ -0,0 +1,109 @@ +import { + VIDEO_COMMAND_CONTRACT_VERSION, + type EditApplyResult, + type EditCommandBatch, + type Precondition, + type TimelineRevision, +} from './contract' +import type { AdapterSnapshot } from './adapter' +import type { ControlledEditorDocument } from './interfaces' +import type { FreeCutFrameCaptionCue } from './document' +import { + translateFrameCaptionCommandToCommand, + type CaptionCommand, + type FrameCaptionCommand, + type FrameCaptionCue, +} from './translation' +import { framesToMicroseconds, type FrameRateLike } from './timing' + +export interface CaptionDocumentPort { + getSnapshot(): AdapterSnapshot + subscribe(listener: (document: ControlledEditorDocument) => void): () => void +} + +export interface CaptionCommandPort extends CaptionDocumentPort { + apply(input: unknown): EditApplyResult +} + +export type CaptionCommandSubmitter = ( + batch: EditCommandBatch, +) => EditApplyResult | Promise + +export interface CaptionBatchOptions { + operationId?: string + idempotencyKey?: string + baseRevision?: TimelineRevision + preconditions?: readonly Precondition[] +} + +export function makeCaptionOperationId(prefix = 'caption-operation'): string { + const suffix = + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2) + return `${prefix}-${suffix}` +} + +export function frameCueToCommandCue(cue: FreeCutFrameCaptionCue): FrameCaptionCue { + return { + item_type: 'caption_cue', + cue_id: cue.id, + track_id: cue.trackId, + start_frame: cue.from, + end_frame: cue.from + cue.durationInFrames, + text: cue.text, + ...(cue.speaker !== undefined ? { speaker: cue.speaker } : {}), + ...(cue.style !== undefined ? { style: { ...cue.style } } : {}), + } +} + +export function captionCuePrecondition( + cue: FrameCaptionCue | FreeCutFrameCaptionCue, + fps: FrameRateLike, +): Precondition { + const normalized = 'cue_id' in cue ? cue : frameCueToCommandCue(cue) + return { + type: 'caption_cue_at', + track_id: normalized.track_id, + cue_id: normalized.cue_id, + start_us: framesToMicroseconds(normalized.start_frame, fps), + end_us: framesToMicroseconds(normalized.end_frame, fps), + text: normalized.text, + } +} + +export function createCaptionCommandBatch( + adapter: CaptionDocumentPort, + frameCommands: readonly FrameCaptionCommand[], + fps: FrameRateLike, + options: CaptionBatchOptions = {}, +): EditCommandBatch { + const snapshot = adapter.getSnapshot() + const operationId = options.operationId ?? makeCaptionOperationId() + return { + contract_version: VIDEO_COMMAND_CONTRACT_VERSION, + timeline_id: snapshot.document.timeline.timeline_id, + operation_id: operationId, + idempotency_key: options.idempotencyKey ?? operationId, + base_revision: options.baseRevision ?? snapshot.revision, + preconditions: options.preconditions ?? [], + commands: frameCommands.map((command) => + translateFrameCaptionCommandToCommand(command, fps), + ) as readonly CaptionCommand[], + } +} + +export function applyCaptionCommands( + adapter: CaptionCommandPort, + frameCommands: readonly FrameCaptionCommand[], + fps: FrameRateLike, + options: CaptionBatchOptions = {}, +): EditApplyResult { + return adapter.apply(createCaptionCommandBatch(adapter, frameCommands, fps, options)) +} + +export function isCaptionApplySuccess( + result: EditApplyResult, +): result is Extract { + return result.status === 'applied' || result.status === 'replayed' +} diff --git a/src/features/editor/codepress/caption-editor-view.tsx b/src/features/editor/codepress/caption-editor-view.tsx new file mode 100644 index 000000000..f7d4b6c73 --- /dev/null +++ b/src/features/editor/codepress/caption-editor-view.tsx @@ -0,0 +1,833 @@ +import type { CSSProperties } from 'react' +import { Captions, Check, Eye, EyeOff, Pencil, Plus, RotateCcw, Trash2, X } from 'lucide-react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Switch } from '@/components/ui/switch' +import { Textarea } from '@/components/ui/textarea' +import { cn } from '@/shared/ui/cn' + +import { captionStyleOrDefault } from './caption-validation' +import type { CaptionStyle } from './contract' +import type { FreeCutFrameCaptionCue, FreeCutFrameDocument, FreeCutFrameTrack } from './document' +import type { FrameRateLike } from './timing' + +export interface CaptionCueDraft { + start_frame: number + end_frame: number + text: string + speaker: string +} + +export interface CaptionTrackDraft { + name: string + language: string +} + +interface CaptionEditorViewProps { + adapterError: string | null + activeCue?: FreeCutFrameCaptionCue + activeTrack?: FreeCutFrameTrack + activeTrackId: string | null + announcement: string + busy: boolean + className?: string + currentFrame: number + cueDrafts: Record + document: FreeCutFrameDocument | null + editingCueId: string | null + editingTrackDraft: CaptionTrackDraft | null + error: string | null + fps: FrameRateLike + loading: boolean + onAddCue: () => void + onAddTrack: () => void + onApplyStyle: () => void + onBeginEditCue: (cue: FreeCutFrameCaptionCue) => void + onCancelEditCue: (cueId: string) => void + onCueDraftChange: (cueId: string, draft: CaptionCueDraft) => void + onRemoveCue: (trackId: string, cue: FreeCutFrameCaptionCue) => void + onRemoveTrack: () => void + onRetry?: () => void + onSaveCue: (trackId: string, cue: FreeCutFrameCaptionCue) => void + onSaveTrack: () => void + onSeek?: (frame: number) => void + onSelectTrack: (trackId: string) => void + onStyleChange: (patch: Partial) => void + onStyleTargetChange: (cueId: string | null) => void + onToggleDisplay: () => void + onTrackDraftChange: (patch: Partial) => void + styleDraft: CaptionStyle + styleTargetCueId: string | null + tracks: readonly FreeCutFrameTrack[] +} + +interface CaptionStyleControlsProps { + activeTrack: FreeCutFrameTrack + busy: boolean + onApplyStyle: () => void + onStyleChange: (patch: Partial) => void + onStyleTargetChange: (cueId: string | null) => void + styleDraft: CaptionStyle + styleTargetCueId: string | null +} + +interface CaptionPreviewProps { + activeCue?: FreeCutFrameCaptionCue + activeTrack: FreeCutFrameTrack + currentFrame: number + fps: FrameRateLike +} + +function frameRateValue(fps: FrameRateLike): number { + return typeof fps === 'number' ? fps : fps.value +} + +function formatFrame(frame: number, fps: FrameRateLike): string { + const seconds = frame / frameRateValue(fps) + return `${seconds.toFixed(2)}s · frame ${frame}` +} + +function CaptionStyleControls({ + activeTrack, + busy, + onApplyStyle, + onStyleChange, + onStyleTargetChange, + styleDraft, + styleTargetCueId, +}: CaptionStyleControlsProps) { + const cues = activeTrack.items.filter( + (item): item is FreeCutFrameCaptionCue => item.type === 'caption_cue', + ) + + return ( +
+
+
+

Caption style

+

+ Apply a default or cue-specific style through the command contract. +

+
+ +
+
+
+ + onStyleChange({ font_family: event.target.value })} + className="mt-1 h-8 text-xs" + maxLength={128} + /> +
+
+ + onStyleChange({ font_size: Number(event.target.value) })} + className="mt-1 h-8 text-xs" + /> +
+
+ + onStyleChange({ color: event.target.value })} + className="mt-1 h-8 text-xs" + maxLength={128} + /> +
+
+ + +
+
+ +
+ ) +} + +function CaptionPreview({ activeCue, activeTrack, currentFrame, fps }: CaptionPreviewProps) { + const previewStyle = captionStyleOrDefault(activeCue?.style ?? activeTrack.defaultStyle) + + return ( +
+

+ Preview · {formatFrame(currentFrame, fps)} +

+
+ {activeTrack.muted ? ( +

Captions hidden

+ ) : activeCue ? ( +

+ {activeCue.text} +

+ ) : ( +

No caption at this frame

+ )} +
+
+ ) +} + +interface CaptionCueRowProps { + busy: boolean + cue: FreeCutFrameCaptionCue + draft: CaptionCueDraft + durationInFrames: number + fps: FrameRateLike + index: number + isEditing: boolean + onBeginEdit: () => void + onCancelEdit: () => void + onDraftChange: (draft: CaptionCueDraft) => void + onRemove: () => void + onSave: () => void + onSeek?: () => void +} + +function CaptionCueRow({ + busy, + cue, + draft, + durationInFrames, + fps, + index, + isEditing, + onBeginEdit, + onCancelEdit, + onDraftChange, + onRemove, + onSave, + onSeek, +}: CaptionCueRowProps) { + return ( +
  • +
    + +
    + + +
    +
    + {isEditing ? ( +
    +
    +
    + + + onDraftChange({ ...draft, start_frame: Number(event.target.value) }) + } + className="mt-1 h-8 text-xs" + /> +
    +
    + + + onDraftChange({ ...draft, end_frame: Number(event.target.value) }) + } + className="mt-1 h-8 text-xs" + /> +
    +
    +
    + +