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/.github/workflows/publish-editor-surface.yml b/.github/workflows/publish-editor-surface.yml new file mode 100644 index 000000000..7b3a4406a --- /dev/null +++ b/.github/workflows/publish-editor-surface.yml @@ -0,0 +1,92 @@ +name: Publish FreeCut editor surface + +on: + workflow_dispatch: + push: + branches: + - codepress-main + tags: + - 'freecut-editor-surface-v*' + +permissions: + contents: read + # npm trusted publishing (OIDC): the job exchanges the GitHub Actions + # identity token for a short-lived npm credential, so there is no NPM_TOKEN + # to store or rotate. Requires a trusted publisher configured on npmjs.com + # for this repository and this workflow filename. + id-token: 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' + # A merge that does not bump the version is a no-op, not a failure: npm + # rejects republishing an existing version, and the branch trigger fires + # on every qualifying merge. + 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 + registry-url: https://registry.npmjs.org + + - 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: 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 to the public npm registry + 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}" + if npm view "@quantfive/freecut-editor-surface@${package_version}" version >/dev/null 2>&1; then + echo "::notice::@quantfive/freecut-editor-surface@${package_version} is already published; nothing to do." + exit 0 + fi + npm publish "./${artifact}" --provenance --access public 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/.github/workflows/sync-upstream.yml b/.github/workflows/sync-upstream.yml new file mode 100644 index 000000000..2d266ebdd --- /dev/null +++ b/.github/workflows/sync-upstream.yml @@ -0,0 +1,38 @@ +# Keep `main` a clean mirror of upstream walterlow/freecut. +# +# Nothing CodePress-owned is ever committed to `main`. Our work lives on the +# `codepress-main` integration branch, and upstream reaches it through a reviewed +# PR (`main` -> `codepress-main`) rather than by landing here directly. That keeps +# `gh repo sync` a fast-forward, so this job never has to resolve a conflict +# unattended. +name: sync-upstream + +on: + schedule: + - cron: "0 8 * * 1" # Mondays, 08:00 UTC + workflow_dispatch: + +permissions: + contents: write + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Fast-forward main from upstream + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh repo sync ${{ github.repository }} --source walterlow/freecut --branch main + + # Surface the delta so upstream drift is visible without anyone going + # looking for it. This opens nothing and merges nothing by itself. + - name: Report how far codepress-main trails main + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + behind="$(gh api "repos/${{ github.repository }}/compare/codepress-main...main" --jq '.ahead_by')" + if [ "${behind:-0}" -gt 0 ]; then + echo "::warning::codepress-main is $behind commit(s) behind upstream main. Open a PR from main into codepress-main to integrate." + else + echo "codepress-main is up to date with upstream main." + fi 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..5e5d5cdb4 100644 --- a/package.json +++ b/package.json @@ -8,11 +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": "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 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 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", @@ -24,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 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/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..686a51203 --- /dev/null +++ b/packages/freecut-editor/README.md @@ -0,0 +1,74 @@ +# `@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. The +0.3.0 surface adds an optional host-backed transcript consumer. Hosts opt into the +transcript tab by providing `EditorHost.transcript` and explicitly enabling +`media.transcription`. The port returns a compact status receipt and bounded +microsecond sections, and previews source-bound caption commands with +`willMutateTimeline: false`; only an explicit user action submits that returned +batch through `submitEdit`. Transcript IDs, asset IDs, source hashes, cursors, +and structured errors are opaque browser data—authentication, transport, +provider details, URLs, paths, and media bytes remain host-owned. + +The same 0.3.0 surface retains the host-backed caption tracks, bounded cues, +caption styles, and display toggles from 0.2.0. + +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/`, which the +workflow in `.github/workflows/publish-editor-surface.yml` then publishes. + +The package is published to the **public npm registry**. It is MIT licensed +and built from a public repository, so there is no credential to distribute +and no per-consumer access to grant. + +Releases publish from CI using npm trusted publishing (OIDC): the workflow +exchanges its GitHub Actions identity token for a short-lived npm credential. +There is no `NPM_TOKEN` secret in this repository, and none should be added. +The trusted publisher is configured on npmjs.com against this repository and +`.github/workflows/publish-editor-surface.yml`; changing that filename breaks +publishing until the publisher entry is updated to match. + +Consumers need no registry configuration at all: + +```bash +npm install @quantfive/freecut-editor-surface +``` + +It can then install the exact published version and keep it pinned in its +lockfile: + +```bash +npm install @quantfive/freecut-editor-surface@0.3.0 +``` 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..ee07086cd --- /dev/null +++ b/packages/freecut-editor/consumer-smoke.test.tsx @@ -0,0 +1,97 @@ +// @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(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 new file mode 100644 index 000000000..5cabd7076 --- /dev/null +++ b/packages/freecut-editor/package.json @@ -0,0 +1,47 @@ +{ + "name": "@quantfive/freecut-editor-surface", + "version": "0.3.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", + "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": { + "registry": "https://registry.npmjs.org", + "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..1ca774a12 --- /dev/null +++ b/packages/freecut-editor/src/index.d.ts @@ -0,0 +1,391 @@ +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' + | 'timeline.caption' + | '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 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 + 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 + style?: CaptionStyle +} + +export type FreeCutFrameItem = FreeCutFrameClip | FreeCutFrameText | FreeCutFrameCaptionCue + +export interface FreeCutFrameTrack { + id: string + kind: 'video' | 'audio' | 'overlay' | 'caption' + name: string + language?: string + locked: boolean + muted: boolean + defaultStyle?: CaptionStyle | null + 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 declare const MAX_TRANSCRIPT_SELECTIONS: number +export declare const MAX_TRANSCRIPT_SECTION_PAGE_SIZE: number +export declare const MAX_TRANSCRIPT_SECTION_TEXT_BYTES: number +export declare const MAX_TRANSCRIPT_COMMAND_TEXT_BYTES: number +export declare const MAX_TRANSCRIPT_DURATION_US: number +export declare const MAX_TRANSCRIPT_CURSOR_LENGTH: number +export declare const MAX_TRANSCRIPT_QUERY_LENGTH: number + +export type HostTranscriptStatus = + | 'pending' + | 'running' + | 'succeeded' + | 'failed' + | 'stale' + | 'purged' + +export interface HostTranscriptError { + code: string + message: string + retryable: boolean + details?: Readonly> +} + +export interface HostTranscriptStatusReceipt { + transcriptId: string + assetId: string | null + sourceAssetHash: string + status: HostTranscriptStatus + language?: string | null + durationUs: number | null + sectionCount: number + error?: HostTranscriptError | null +} + +export interface HostTranscriptSection { + id: string + transcriptId: string + ordinal: number + startUs: number + endUs: number + text: string + speaker?: string | null +} + +export interface HostTranscriptSectionsRequest { + transcriptId: string + cursor?: string | null + limit?: number + startUs?: number + endUs?: number +} + +export interface HostTranscriptSectionsPage { + transcriptId: string + sections: readonly HostTranscriptSection[] + nextCursor?: string | null + hasMore: boolean +} + +export interface HostTranscriptSearchRequest { + transcriptId: string + query: string + cursor?: string | null + limit?: number +} + +export interface HostTranscriptSearchPage { + transcriptId: string + query: string + sections: readonly HostTranscriptSection[] + nextCursor?: string | null + hasMore: boolean +} + +export interface HostTranscriptRange { + startUs: number + endUs: number + text?: string +} + +export type HostTranscriptCommandAction = 'cut' | 'captions' | 'ripple_cut' | 'caption' + +export interface HostTranscriptCommandPreviewRequest { + transcriptId: string + assetId: string + sourceAssetHash: string + operationId: string + idempotencyKey: string + baseRevision: number + action: HostTranscriptCommandAction + timestampCapability: 'section' | 'word' | 'frame' + sectionIds?: readonly string[] + ranges?: readonly HostTranscriptRange[] + captionTrackId?: string + captionTrackName?: string + captionLanguage?: string | null + preconditions?: readonly object[] +} + +export interface HostTranscriptCommandPreview { + status: 'preview' | 'replayed' + receiptId: string + transcriptId: string + assetId: string + sourceAssetHash: string + timestampCapability: 'section' + timelineId: string + operationId: string + idempotencyKey: string + baseRevision: number + commandBatch: EditCommandBatch + preview: Readonly<{ + action?: string + sectionCount?: number + captionCount?: number + willMutateTimeline: false + [key: string]: unknown + }> +} + +export interface EditorTranscriptPort { + getStatus(): Promise | HostTranscriptStatusReceipt | null + getSections( + request: HostTranscriptSectionsRequest, + ): Promise | HostTranscriptSectionsPage + search?( + request: HostTranscriptSearchRequest, + ): Promise | HostTranscriptSearchPage + previewCommands( + request: HostTranscriptCommandPreviewRequest, + ): Promise | HostTranscriptCommandPreview +} + +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 + transcript?: EditorTranscriptPort + 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', + '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( + 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..ac374e68d --- /dev/null +++ b/packages/freecut-editor/src/index.ts @@ -0,0 +1,48 @@ +export { FreeCutEditorSurface } from '@/features/editor/host/editor-surface' +export { EditorHostProvider } from '@/features/editor/host/context-provider' +export { + DEFAULT_HOST_CAPABILITIES, + MAX_TRANSCRIPT_COMMAND_TEXT_BYTES, + MAX_TRANSCRIPT_CURSOR_LENGTH, + MAX_TRANSCRIPT_DURATION_US, + MAX_TRANSCRIPT_QUERY_LENGTH, + MAX_TRANSCRIPT_SECTION_PAGE_SIZE, + MAX_TRANSCRIPT_SECTION_TEXT_BYTES, + MAX_TRANSCRIPT_SELECTIONS, + 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, + EditorTranscriptPort, + HostAppliedEditResult, + HostConflictResult, + HostEditResult, + HostMediaKind, + HostNotice, + HostTranscriptCommandAction, + HostTranscriptCommandPreview, + HostTranscriptCommandPreviewRequest, + HostTranscriptError, + HostTranscriptRange, + HostTranscriptSearchPage, + HostTranscriptSearchRequest, + HostTranscriptSection, + HostTranscriptSectionsPage, + HostTranscriptSectionsRequest, + HostTranscriptStatus, + HostTranscriptStatusReceipt, + 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/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..a9efc1877 --- /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": "331cb27e2fa2e50dbf9759e625e6d2b9c97db2205ee5870c8157ffbb2b14f971", + "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..5346dde91 --- /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": "331cb27e2fa2e50dbf9759e625e6d2b9c97db2205ee5870c8157ffbb2b14f971", + "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-editor-surface.mjs b/scripts/package-editor-surface.mjs new file mode 100644 index 000000000..3a03bec16 --- /dev/null +++ b/scripts/package-editor-surface.mjs @@ -0,0 +1,207 @@ +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 publishConfig = packageJson.publishConfig ?? {} +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( + publishConfig.registry === 'https://registry.npmjs.org', + 'package must target the public npm registry', + ) + assertCondition(publishConfig.access === 'public', 'package must publish with 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') + 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/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 +} 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/src/features/editor/codepress/README.md b/src/features/editor/codepress/README.md new file mode 100644 index 000000000..492ffabcb --- /dev/null +++ b/src/features/editor/codepress/README.md @@ -0,0 +1,79 @@ +# 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 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 +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. + +## 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. +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, 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. 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 new file mode 100644 index 000000000..2ec212568 --- /dev/null +++ b/src/features/editor/codepress/adapter.test.ts @@ -0,0 +1,1093 @@ +// @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, + translateCommandToFrames, + validateCommandBatch, + validateTimelineState, +} from './index' +import type { + ClipItem, + EditCommandBatch, + MediaReference, + TextItem, + TimelineState, +} from './contract' +import type { ControlledEditorDocument } from './interfaces' +import type { FrameRateLike } from './timing' + +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: FrameRateLike = 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 }) + }) + + 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', () => { + 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('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) }) + 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('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( + 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('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({ + 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('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({ + 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 = { + 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..0ec97cbdb --- /dev/null +++ b/src/features/editor/codepress/adapter.ts @@ -0,0 +1,485 @@ +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, + EditEngineResult, + 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 { + 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 { + 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 { + 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.publishDocument(this.document) + } + + 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 }, + ), + ) + } + } + + let engineResult: EditEngineResult + try { + engineResult = this.editEngine.apply(current.timeline, batch.commands, { + fps: current.fps, + }) + } 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) + } + + 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 { + 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 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. + } + } + } +} + +export function createCodePressCommandAdapter( + options: CodePressCommandAdapterOptions, +): CodePressCommandAdapter { + return new CodePressCommandAdapter(options) +} 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" + /> +
    +
    +
    + +