diff --git a/.gitattributes b/.gitattributes
index e2cb66f19..8c572715c 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -4,6 +4,9 @@ packages/producer/tests/*/output/output.mp4 filter=lfs diff=lfs merge=lfs -text
# Source video clips for regression test fixtures (HDR samples, etc.)
packages/producer/tests/*/src/*.mp4 filter=lfs diff=lfs merge=lfs -text
+# Studio E2E parity fixture videos
+packages/studio/e2e/fixtures/**/*.mp4 filter=lfs diff=lfs merge=lfs -text
+
# Source image assets for regression test fixtures (HDR PNGs, screenshot fixtures, etc.)
packages/producer/tests/*/src/*.png filter=lfs diff=lfs merge=lfs -text
diff --git a/.github/workflows/studio-parity.yml b/.github/workflows/studio-parity.yml
new file mode 100644
index 000000000..6a22630a4
--- /dev/null
+++ b/.github/workflows/studio-parity.yml
@@ -0,0 +1,212 @@
+name: studio-parity
+
+permissions:
+ contents: write
+ pull-requests: write
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+
+concurrency:
+ group: studio-parity-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ changes:
+ name: Detect changes
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ outputs:
+ affected: ${{ steps.filter.outputs.affected }}
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ fetch-depth: 0
+ - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
+ id: filter
+ with:
+ token: ""
+ filters: |
+ affected:
+ - "packages/studio/**"
+ - "packages/core/**"
+ - "packages/player/**"
+ - "packages/producer/**"
+ - "packages/cli/**"
+ - "packages/engine/**"
+ - ".github/workflows/studio-parity.yml"
+
+ parity:
+ name: Preview/render parity
+ needs: changes
+ if: needs.changes.outputs.affected == 'true'
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+ with:
+ lfs: true
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
+ with:
+ node-version: 22
+
+ - name: Install dependencies
+ run: bun install --frozen-lockfile
+
+ - name: Build all packages
+ run: bun run build
+
+ - name: Build hyperframes runtime
+ run: bun run --cwd packages/core build:hyperframes-runtime
+
+ - name: Install ffmpeg
+ run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends ffmpeg
+
+ - name: Install Playwright browsers
+ run: bunx playwright install chromium --with-deps
+
+ - name: Export Playwright Chromium path for renderer
+ run: |
+ CHROME=$(bun --cwd packages/studio -e "import { chromium } from '@playwright/test'; console.log(chromium.executablePath())")
+ echo "HYPERFRAMES_BROWSER_PATH=${CHROME}" >> "$GITHUB_ENV"
+
+ - name: Run studio parity E2E
+ run: bun run --cwd packages/studio test:e2e
+
+ - name: Upload screenshots and diffs
+ if: always()
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: studio-parity-screenshots
+ path: packages/studio/e2e/.debug/
+ if-no-files-found: ignore
+ retention-days: 14
+
+ - name: Publish diff thumbnails to parity-screens branch
+ if: github.event_name == 'pull_request' && always()
+ env:
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ set -e
+ DEBUG_DIR="packages/studio/e2e/.debug"
+ WORK_DIR=$(mktemp -d)
+
+ # Resize preview, render, and diff images to 480x270 thumbnails
+ for t in 0 1 2; do
+ for kind in preview render diff; do
+ src="${DEBUG_DIR}/${kind}_t${t}.png"
+ if [ -f "${src}" ]; then
+ ffmpeg -hide_banner -loglevel error -y -i "${src}" \
+ -vf scale=480:270 "${WORK_DIR}/${kind}_t${t}.png"
+ fi
+ done
+ done
+
+ # Skip if no images were produced
+ ls "${WORK_DIR}"/*.png 2>/dev/null || exit 0
+
+ # Push as orphan commit to parity-screens/pr-{N} branch
+ git -C "${WORK_DIR}" init
+ git -C "${WORK_DIR}" config user.email "github-actions[bot]@users.noreply.github.com"
+ git -C "${WORK_DIR}" config user.name "github-actions[bot]"
+ git -C "${WORK_DIR}" add .
+ git -C "${WORK_DIR}" commit -m "parity screenshots PR #${PR_NUMBER} run ${{ github.run_id }}"
+ git -C "${WORK_DIR}" push --force \
+ "https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git" \
+ "HEAD:refs/heads/parity-screens/pr-${PR_NUMBER}"
+
+ - name: Post parity results as PR comment
+ if: github.event_name == 'pull_request' && always()
+ uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
+ with:
+ script: |
+ const { readFileSync } = await import('fs');
+
+ let scores = {};
+ try {
+ scores = JSON.parse(readFileSync('packages/studio/e2e/.debug/yavg.json', 'utf8'));
+ } catch {}
+
+ const threshold = 2.0;
+ const passed = Object.values(scores).every(v => v <= threshold);
+ const header = passed ? '✅ Studio parity passed' : '❌ Studio parity failed';
+
+ const rows = [0, 1, 2].map(t => {
+ const yavg = scores[`t${t}`];
+ if (yavg == null) return `| t=${t}s | — | — |`;
+ const pct = ((yavg / 255) * 100).toFixed(2);
+ const status = yavg <= threshold ? '✅' : '❌';
+ return `| t=${t}s | ${yavg.toFixed(2)} / 255 (${pct}%) | ${status} |`;
+ }).join('\n');
+
+ const artifactUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+ const branch = `parity-screens/pr-${context.issue.number}`;
+ const rawBase = `https://raw.githubusercontent.com/${context.repo.owner}/${context.repo.repo}/${branch}`;
+
+ const diffRow = [0, 1, 2]
+ .map(t => ``)
+ .join(' | ');
+
+ const body = [
+ `## ${header}`,
+ '',
+ '| Frame | YAVG diff | Status |',
+ '|-------|-----------|--------|',
+ rows,
+ '',
+ `Threshold: ${threshold} / 255 (≈ 0.78%). [Download all images](${artifactUrl}).`,
+ '',
+ 'Diff images (luma difference, preview ↔ render)
',
+ '',
+ `| t=0s | t=1s | t=2s |`,
+ `|------|------|------|`,
+ `| ${diffRow} |`,
+ '',
+ ' ',
+ ].join('\n');
+
+ await github.rest.issues.createComment({
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ issue_number: context.issue.number,
+ body,
+ });
+
+ gate:
+ name: Studio parity gate
+ needs: [changes, parity]
+ if: always()
+ runs-on: ubuntu-latest
+ steps:
+ - name: Check result
+ env:
+ FILTER: ${{ needs.changes.outputs.affected }}
+ RESULT: ${{ needs.parity.result }}
+ run: |
+ {
+ echo "## Studio parity gate"
+ echo ""
+ echo "- paths-filter \`affected\` matched: \`${FILTER}\`"
+ echo "- parity result: \`${RESULT}\`"
+ echo ""
+ } >> "$GITHUB_STEP_SUMMARY"
+
+ if [ "${FILTER}" != "true" ]; then
+ echo "::notice title=Studio parity::SKIPPED — no affected changes"
+ echo "**Status:** SKIPPED" >> "$GITHUB_STEP_SUMMARY"
+ exit 0
+ fi
+
+ if [ "${RESULT}" != "success" ]; then
+ echo "**Status:** FAILED" >> "$GITHUB_STEP_SUMMARY"
+ echo "Studio parity check failed"
+ exit 1
+ fi
+
+ echo "**Status:** PASSED" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.gitignore b/.gitignore
index eaf23805f..353ced9fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,6 +30,9 @@ docs/images/
coverage/
.debug/
+# E2E test fixture runtime state (generated by Studio during test runs)
+packages/studio/e2e/fixtures/**/.hyperframes/
+
# Producer regression test failures (generated debugging artifacts)
packages/producer/tests/*/failures/
packages/producer/tests/parity/fixtures/hyperframe.runtime.iife.js
diff --git a/bun.lock b/bun.lock
index 086091b54..a92547e00 100644
--- a/bun.lock
+++ b/bun.lock
@@ -21,7 +21,7 @@
},
"packages/cli": {
"name": "@hyperframes/cli",
- "version": "0.6.2",
+ "version": "0.6.4",
"bin": {
"hyperframes": "./dist/cli.js",
},
@@ -64,7 +64,7 @@
},
"packages/core": {
"name": "@hyperframes/core",
- "version": "0.6.2",
+ "version": "0.6.4",
"dependencies": {
"@chenglou/pretext": "^0.0.5",
"postcss": "^8.5.8",
@@ -91,7 +91,7 @@
},
"packages/engine": {
"name": "@hyperframes/engine",
- "version": "0.6.2",
+ "version": "0.6.4",
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
@@ -109,7 +109,7 @@
},
"packages/player": {
"name": "@hyperframes/player",
- "version": "0.6.2",
+ "version": "0.6.4",
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
@@ -121,7 +121,7 @@
},
"packages/producer": {
"name": "@hyperframes/producer",
- "version": "0.6.2",
+ "version": "0.6.4",
"dependencies": {
"@fontsource/archivo-black": "^5.2.8",
"@fontsource/eb-garamond": "^5.2.7",
@@ -160,7 +160,7 @@
},
"packages/shader-transitions": {
"name": "@hyperframes/shader-transitions",
- "version": "0.6.2",
+ "version": "0.6.4",
"dependencies": {
"html2canvas": "^1.4.1",
},
@@ -172,7 +172,7 @@
},
"packages/studio": {
"name": "@hyperframes/studio",
- "version": "0.6.2",
+ "version": "0.6.4",
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
"@codemirror/commands": "^6.10.3",
@@ -192,6 +192,7 @@
},
"devDependencies": {
"@hyperframes/producer": "workspace:*",
+ "@playwright/test": "^1.52.0",
"@types/react": "19",
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
@@ -712,6 +713,8 @@
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
+ "@playwright/test": ["@playwright/test@1.60.0", "", { "dependencies": { "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" } }, "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag=="],
+
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
@@ -1412,6 +1415,10 @@
"pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
+ "playwright": ["playwright@1.60.0", "", { "dependencies": { "playwright-core": "1.60.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA=="],
+
+ "playwright-core": ["playwright-core@1.60.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA=="],
+
"postcss": ["postcss@8.5.14", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
@@ -1722,6 +1729,8 @@
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+ "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
+
"proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts
index b58207112..a863d2546 100644
--- a/packages/cli/src/server/studioServer.ts
+++ b/packages/cli/src/server/studioServer.ts
@@ -221,6 +221,10 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
return cachedProjectSignature;
},
+ onProjectFileWrite() {
+ cachedProjectSignature = null;
+ },
+
async lint(html: string, opts?: { filePath?: string }) {
const { lintHyperframeHtml } = await import("@hyperframes/core/lint");
return lintHyperframeHtml(html, opts);
diff --git a/packages/core/src/studio-api/routes/files.ts b/packages/core/src/studio-api/routes/files.ts
index e801d5b59..ea0f48a96 100644
--- a/packages/core/src/studio-api/routes/files.ts
+++ b/packages/core/src/studio-api/routes/files.ts
@@ -151,6 +151,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
ensureDir(res.absPath);
const body = await c.req.text();
writeFileSync(res.absPath, body, "utf-8");
+ adapter.onProjectFileWrite?.(res.filePath);
return c.json({ ok: true });
});
@@ -168,6 +169,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
ensureDir(res.absPath);
const body = await c.req.text().catch(() => "");
writeFileSync(res.absPath, body, "utf-8");
+ adapter.onProjectFileWrite?.(res.filePath);
return c.json({ ok: true, path: res.filePath }, 201);
});
@@ -184,6 +186,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
} else {
unlinkSync(res.absPath);
}
+ adapter.onProjectFileWrite?.(res.filePath);
return c.json({ ok: true });
});
@@ -222,6 +225,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
}
writeFileSync(absPath, patchedContent, "utf-8");
+ adapter.onProjectFileWrite?.(filePath);
return c.json({ ok: true, changed: true, content: patchedContent });
});
@@ -249,6 +253,7 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void {
// Update references to the old path across all project files
const updatedFiles = updateReferences(res.project.dir, res.filePath, body.newPath);
+ adapter.onProjectFileWrite?.(body.newPath);
return c.json({ ok: true, path: body.newPath, updatedReferences: updatedFiles });
});
diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts
index a8cf5293d..edf8def4d 100644
--- a/packages/core/src/studio-api/routes/preview.ts
+++ b/packages/core/src/studio-api/routes/preview.ts
@@ -11,6 +11,10 @@ import {
createStudioMotionRenderBodyScript,
STUDIO_MOTION_PATH,
} from "../helpers/studioMotionRenderScript.js";
+import {
+ createStudioManualEditsRenderBodyScript,
+ STUDIO_MANUAL_EDITS_PATH,
+} from "../helpers/manualEditsRenderScript.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0";
@@ -33,6 +37,29 @@ function injectProjectSignature(html: string, signature: string): string {
return `${tag}\n${html}`;
}
+function readStudioManualEditManifestContent(projectDir: string): string {
+ const manifestPath = join(projectDir, STUDIO_MANUAL_EDITS_PATH);
+ if (!existsSync(manifestPath)) return "";
+ try {
+ return readFileSync(manifestPath, "utf-8");
+ } catch {
+ return "";
+ }
+}
+
+function injectStudioManualEditsScript(
+ html: string,
+ projectDir: string,
+ activeCompositionPath: string,
+): string {
+ const manifestContent = readStudioManualEditManifestContent(projectDir);
+ const script = createStudioManualEditsRenderBodyScript(manifestContent, {
+ activeCompositionPath,
+ });
+ if (!script) return html;
+ return injectScriptsIntoHtml(html, [], [script], false);
+}
+
function readStudioMotionManifestContent(projectDir: string): string {
const manifestPath = join(projectDir, STUDIO_MOTION_PATH);
if (!existsSync(manifestPath)) return "";
@@ -118,7 +145,11 @@ function injectStudioPreviewAugmentations(
activeCompositionPath: string,
): string {
return injectStudioMotionScript(
- injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
+ injectStudioManualEditsScript(
+ injectProjectSignature(html, resolveProjectSignature(adapter, projectDir)),
+ projectDir,
+ activeCompositionPath,
+ ),
projectDir,
activeCompositionPath,
);
diff --git a/packages/core/src/studio-api/types.ts b/packages/core/src/studio-api/types.ts
index 40e1c4460..f151c9d0d 100644
--- a/packages/core/src/studio-api/types.ts
+++ b/packages/core/src/studio-api/types.ts
@@ -105,4 +105,7 @@ export interface StudioApiAdapter {
/** Optional: resolve session ID to project (multi-project mode). */
resolveSession?: (sessionId: string) => Promise<{ projectId: string; title: string } | null>;
+
+ /** Optional: called after any project file is written via the files API. */
+ onProjectFileWrite?: (filePath: string) => void;
}
diff --git a/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4 b/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4
new file mode 100644
index 000000000..f39579627
--- /dev/null
+++ b/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:5c602708e766b60ff43a983858f2b79a2c975909de96c30a3901923a96b171a6
+size 4500
diff --git a/packages/studio/e2e/fixtures/parity-project/assets/photo.jpg b/packages/studio/e2e/fixtures/parity-project/assets/photo.jpg
new file mode 100644
index 000000000..43fa7cc93
Binary files /dev/null and b/packages/studio/e2e/fixtures/parity-project/assets/photo.jpg differ
diff --git a/packages/studio/e2e/fixtures/parity-project/index.html b/packages/studio/e2e/fixtures/parity-project/index.html
new file mode 100644
index 000000000..df5b7cec7
--- /dev/null
+++ b/packages/studio/e2e/fixtures/parity-project/index.html
@@ -0,0 +1,81 @@
+
+
+
+
+ Parity Project
+
+
+
+
+
+
+
Hello Parity
+

+
+
+
+
diff --git a/packages/studio/e2e/playwright.config.ts b/packages/studio/e2e/playwright.config.ts
new file mode 100644
index 000000000..14424518f
--- /dev/null
+++ b/packages/studio/e2e/playwright.config.ts
@@ -0,0 +1,44 @@
+import { defineConfig, devices } from "@playwright/test";
+import { existsSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+
+const cliDist = resolve(__dirname, "../../cli/dist/cli.js");
+const cliSrc = resolve(__dirname, "../../cli/src/cli.ts");
+const cliEntry = existsSync(cliDist) ? cliDist : cliSrc;
+const fixtureDir = resolve(__dirname, "fixtures/parity-project");
+
+export default defineConfig({
+ testDir: ".",
+ outputDir: ".debug",
+ timeout: 180_000,
+ expect: { timeout: 15_000 },
+ workers: 1,
+ reporter: [["list"], ["html", { open: "never", outputFolder: ".playwright-report" }]],
+ use: {
+ baseURL: "http://localhost:4200",
+ trace: "retain-on-failure",
+ screenshot: "only-on-failure",
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ launchOptions: { args: ["--force-color-profile=srgb", "--font-render-hinting=none"] },
+ },
+ },
+ ],
+ webServer: {
+ command: cliEntry.endsWith(".js")
+ ? `node ${cliEntry} preview --port 4200 ${fixtureDir}`
+ : `bun ${cliEntry} preview --port 4200 ${fixtureDir}`,
+ port: 4200,
+ reuseExistingServer: !process.env.CI,
+ timeout: 60_000,
+ stdout: "pipe",
+ stderr: "pipe",
+ },
+});
diff --git a/packages/studio/e2e/setup/gen-assets.ts b/packages/studio/e2e/setup/gen-assets.ts
new file mode 100644
index 000000000..467597352
--- /dev/null
+++ b/packages/studio/e2e/setup/gen-assets.ts
@@ -0,0 +1,53 @@
+import { execFileSync } from "node:child_process";
+import { mkdirSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const assetsDir = resolve(__dirname, "../fixtures/parity-project/assets");
+mkdirSync(assetsDir, { recursive: true });
+
+// Solid blue 400×300 JPEG (~4 KB)
+execFileSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=0x3b82f6:s=400x300:d=1",
+ "-vframes",
+ "1",
+ "-q:v",
+ "10",
+ `${assetsDir}/photo.jpg`,
+ ],
+ { stdio: "inherit" },
+);
+console.log("Generated photo.jpg");
+
+// 3-second solid-color MP4 at 600×338, 30 fps — solid color is platform-agnostic
+// (testsrc2 caused cross-platform YUV→RGB conversion differences)
+execFileSync(
+ "ffmpeg",
+ [
+ "-y",
+ "-f",
+ "lavfi",
+ "-i",
+ "color=c=0x5e81ac:s=600x338:rate=30:d=3",
+ "-c:v",
+ "libx264",
+ "-crf",
+ "18",
+ "-preset",
+ "fast",
+ "-pix_fmt",
+ "yuv420p",
+ "-t",
+ "3",
+ `${assetsDir}/clip.mp4`,
+ ],
+ { stdio: "inherit" },
+);
+console.log("Generated clip.mp4");
diff --git a/packages/studio/e2e/studio-parity.spec.ts b/packages/studio/e2e/studio-parity.spec.ts
new file mode 100644
index 000000000..0af64f057
--- /dev/null
+++ b/packages/studio/e2e/studio-parity.spec.ts
@@ -0,0 +1,335 @@
+import { expect, test, type Page } from "@playwright/test";
+import { execFileSync } from "node:child_process";
+import { mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const DEBUG_DIR = resolve(__dirname, ".debug");
+const FIXTURE_DIR = resolve(__dirname, "fixtures/parity-project");
+const PROJECT_ID = "parity-project";
+// Diff is computed at 480×270 (1/4 linear scale) to average out H.264 quantization and sub-pixel
+// font-rendering noise. At that scale ~1.2% of 255 leaves comfortable room for the codec's inherent
+// limited-range chroma rounding while still catching macro-level position / color / opacity errors.
+const MAX_YAVG = 3.0;
+
+function ensureDebugDir(): void {
+ mkdirSync(DEBUG_DIR, { recursive: true });
+}
+
+// Pristine source HTML — restored before each run so style edits from previous
+// runs (which are patched directly into index.html) don't silently no-op.
+const PRISTINE_INDEX_HTML = `
+
+
+
+ Parity Project
+
+
+
+
+
+
+
+
+`;
+
+function resetFixture(): void {
+ // Remove Studio runtime artifacts so the test always starts from a clean state.
+ rmSync(resolve(FIXTURE_DIR, ".hyperframes"), { recursive: true, force: true });
+ // Restore index.html to its pristine state. Style commits (color changes, etc.)
+ // patch the source HTML directly — not just .hyperframes/ — so without this
+ // the color patch becomes a no-op on the second run (same value → ETag stays).
+ writeFileSync(resolve(FIXTURE_DIR, "index.html"), PRISTINE_INDEX_HTML, "utf-8");
+}
+
+async function fetchETag(page: Page): Promise {
+ const res = await page.request.head(`/api/projects/${PROJECT_ID}/preview`);
+ return res.headers()["etag"] ?? "";
+}
+
+async function waitForETagChange(page: Page, initial: string): Promise {
+ await expect(async () => {
+ expect(await fetchETag(page)).not.toBe(initial);
+ }).toPass({ timeout: 10_000, intervals: [200] });
+}
+
+test("preview matches render after UI edits", async ({ page, context }) => {
+ ensureDebugDir();
+ resetFixture();
+
+ // ── 1. Load studio and wait for the composition canvas ───────────────────
+ await page.goto("http://localhost:4200");
+ // The canvas overlay appears once the editor has loaded the project
+ const canvas = page.locator('[aria-label="Composition canvas"]');
+ await canvas.waitFor({ state: "visible", timeout: 30_000 });
+
+ // ── 2. Select #title via Layers panel and shift X by 120px ───────────────
+ // Inspector button opens the right panel on the Design tab
+ await page.locator('[aria-label="Inspector"]').click();
+ // Switch to Layers tab so we can select by element
+ await page.getByRole("button", { name: "Layers", exact: true }).click();
+ // Click the "Title" layer item — stays on Layers tab per Studio behaviour
+ // Layer buttons include a tag badge ("P") so accessible name is "P Title", not "Title"
+ await page
+ .getByRole("button", { name: /\bTitle\b/ })
+ .first()
+ .click();
+ // Now switch to Design tab to access Layout / color controls
+ await page.getByRole("button", { name: "Design", exact: true }).click();
+ // Wait until the Layout section (which contains the X field) is visible
+ const layoutSection = page
+ .locator("section")
+ .filter({ has: page.locator("h3", { hasText: "Layout" }) });
+ await layoutSection.waitFor({ state: "visible", timeout: 5_000 });
+ // The first text input in the Layout section is X (manual offset)
+ const xInput = layoutSection.locator("input[type=text]").first();
+ const etag0 = await fetchETag(page);
+ await xInput.fill("120");
+ await xInput.press("Enter");
+ await waitForETagChange(page, etag0);
+
+ // ── 3. Change #title text color to #00bcd4 ────────────────────────────────
+ const etag1 = await fetchETag(page);
+ await page.locator('[aria-label="Pick text color color"]').click();
+ const hexInput = page.locator("label").filter({ hasText: /^Hex$/ }).locator("input");
+ await hexInput.waitFor({ state: "visible" });
+ await hexInput.fill("00BCD4");
+ await hexInput.press("Tab");
+ await page.keyboard.press("Escape");
+ await waitForETagChange(page, etag1);
+
+ // ── 4. Change #clip opacity to 80% ───────────────────────────────────────
+ const etag2 = await fetchETag(page);
+ // Switch to Layers tab to select #clip
+ await page.getByRole("button", { name: "Layers", exact: true }).click();
+ // Same badge prefix issue: accessible name is "Vi Clip" not "Clip"
+ await page
+ .getByRole("button", { name: /\bClip\b/ })
+ .first()
+ .click();
+ // Switch to Design tab to access the Transparency section
+ await page.getByRole("button", { name: "Design", exact: true }).click();
+ await page.getByText("Transparency", { exact: true }).click();
+ // Scope the slider to the Transparency section to avoid matching the timeline seek slider.
+ // fill() properly triggers React's onChange on the controlled range input.
+ const transparencySection = page
+ .locator("section")
+ .filter({ has: page.locator("h3", { hasText: "Transparency" }) });
+ const opacitySlider = transparencySection.locator("input[type=range]");
+ await opacitySlider.waitFor({ state: "visible" });
+ await opacitySlider.fill("80");
+ await waitForETagChange(page, etag2);
+
+ // ── 4.5. Reload studio — verify edits survive a page refresh ─────────────
+ // Catches the class of bug where bootstrap re-applies an empty in-memory
+ // manifest to the preview or clobbers source files on load, reversing edits
+ // that were correctly persisted to disk (studio-manual-edits.json / index.html).
+ const etagAfterEdits = await fetchETag(page);
+ await page.reload();
+ await canvas.waitFor({ state: "visible", timeout: 30_000 });
+ expect(await fetchETag(page)).toBe(etagAfterEdits);
+
+ // ── 5. Screenshot preview at t=0, t=1, t=2 ───────────────────────────────
+ const previewPage = await context.newPage();
+ await previewPage.setViewportSize({ width: 1920, height: 1080 });
+ await previewPage.goto(`http://localhost:4200/api/projects/${PROJECT_ID}/preview`);
+ await previewPage.waitForLoadState("networkidle");
+ await previewPage.waitForFunction(
+ () => typeof (window as Record).__player === "object",
+ { timeout: 15_000 },
+ );
+
+ for (const t of [0, 1, 2]) {
+ await previewPage.evaluate(
+ (time: number) => (window as Record).__player?.seek?.(time),
+ t,
+ );
+ await previewPage.evaluate(() => new Promise((r) => requestAnimationFrame(() => r())));
+ await previewPage.waitForTimeout(50);
+ await previewPage.screenshot({
+ path: resolve(DEBUG_DIR, `preview_t${t}.png`),
+ clip: { x: 0, y: 0, width: 1920, height: 1080 },
+ });
+ }
+ await previewPage.close();
+
+ // ── 6. Trigger render via API, wait for completion via SSE ───────────────
+ const renderRes = await page.request.post(`/api/projects/${PROJECT_ID}/render`, {
+ data: { fps: 30, quality: "draft", format: "mp4" },
+ });
+ expect(renderRes.ok()).toBeTruthy();
+ const { jobId } = (await renderRes.json()) as { jobId: string };
+
+ await page.evaluate(
+ (id: string) =>
+ new Promise((resolve, reject) => {
+ const timeout = setTimeout(() => {
+ es.close();
+ reject(new Error("Render timed out after 120s"));
+ }, 120_000);
+ const es = new EventSource(`/api/render/${id}/progress`);
+ es.addEventListener("progress", (ev) => {
+ const d = JSON.parse((ev as MessageEvent).data) as {
+ status: string;
+ error?: string;
+ };
+ if (d.status === "complete") {
+ clearTimeout(timeout);
+ es.close();
+ resolve();
+ } else if (d.status === "failed") {
+ clearTimeout(timeout);
+ es.close();
+ reject(new Error(`Render failed: ${d.error}`));
+ }
+ });
+ }),
+ jobId,
+ );
+
+ // ── 7. Download rendered video and extract frames with ffmpeg ─────────────
+ const dlRes = await page.request.get(`/api/render/${jobId}/download`);
+ expect(dlRes.ok()).toBeTruthy();
+ const renderVideoPath = resolve(DEBUG_DIR, "render.mp4");
+ writeFileSync(renderVideoPath, Buffer.from(await dlRes.body()));
+
+ for (const t of [0, 1, 2]) {
+ execFileSync("ffmpeg", [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-y",
+ "-i",
+ renderVideoPath,
+ // scale=in_range=tv:out_range=pc: expand limited-range H.264 to full-range so PNG
+ // values match Playwright screenshots (Ubuntu ffmpeg 6.1.1 doesn't auto-expand).
+ "-vf",
+ `select=eq(n\\,${t * 30}),scale=in_range=tv:out_range=pc`,
+ "-vframes",
+ "1",
+ resolve(DEBUG_DIR, `render_t${t}.png`),
+ ]);
+ }
+
+ // ── 8. Diff preview vs render frames; fail if avg luma diff > threshold ───
+ const failures: string[] = [];
+ const yavgScores: Record = {};
+
+ for (const t of [0, 1, 2]) {
+ const previewPng = resolve(DEBUG_DIR, `preview_t${t}.png`);
+ const renderPng = resolve(DEBUG_DIR, `render_t${t}.png`);
+ const diffPng = resolve(DEBUG_DIR, `diff_t${t}.png`);
+
+ // Scale both images to 480×270 before diffing to average out H.264 quantization
+ // noise. Measuring YAVG on the full 1920×1080 diff exaggerates sparse sub-pixel
+ // differences by 8× vs a downscaled comparison. format=rgb24 prevents the lavfi
+ // movie= filter from converting the PNG to limited-range yuv420p (which would add
+ // a ~16-unit Y offset on Linux ffmpeg builds, inflating every YAVG reading by ~16).
+ execFileSync("ffmpeg", [
+ "-hide_banner",
+ "-loglevel",
+ "error",
+ "-y",
+ "-i",
+ previewPng,
+ "-i",
+ renderPng,
+ "-filter_complex",
+ "[0:v]scale=480:270[p];[1:v]scale=480:270[r];[p][r]blend=all_mode=difference",
+ diffPng,
+ ]);
+
+ const probe = JSON.parse(
+ execFileSync("ffprobe", [
+ "-v",
+ "quiet",
+ "-of",
+ "json",
+ "-show_frames",
+ "-select_streams",
+ "v",
+ "-f",
+ "lavfi",
+ `movie=${diffPng},format=rgb24,signalstats`,
+ ]).toString(),
+ ) as { frames: Array<{ tags?: Record }> };
+
+ const yavg = parseFloat(probe.frames[0]?.tags?.["lavfi.signalstats.YAVG"] ?? "0");
+ yavgScores[`t${t}`] = yavg;
+ if (yavg > MAX_YAVG) {
+ failures.push(
+ `t=${t}s: YAVG=${yavg.toFixed(2)}/255 exceeds ${MAX_YAVG} threshold (${((yavg / 255) * 100).toFixed(2)}%)`,
+ );
+ }
+ }
+
+ writeFileSync(resolve(DEBUG_DIR, "yavg.json"), JSON.stringify(yavgScores), "utf-8");
+
+ if (failures.length > 0) {
+ throw new Error(
+ `Preview/render mismatch detected:\n${failures.join("\n")}\nSee diff images in: ${DEBUG_DIR}`,
+ );
+ }
+});
diff --git a/packages/studio/package.json b/packages/studio/package.json
index aa89fafbb..335d434bf 100644
--- a/packages/studio/package.json
+++ b/packages/studio/package.json
@@ -23,7 +23,8 @@
"build": "vite build",
"typecheck": "tsc --noEmit",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "test:e2e": "playwright test --config e2e/playwright.config.ts"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.1",
@@ -44,6 +45,7 @@
},
"devDependencies": {
"@hyperframes/producer": "workspace:*",
+ "@playwright/test": "^1.52.0",
"@types/react": "19",
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
diff --git a/packages/studio/vite.config.ts b/packages/studio/vite.config.ts
index 4fe4b0b5b..4be80ebf2 100644
--- a/packages/studio/vite.config.ts
+++ b/packages/studio/vite.config.ts
@@ -166,6 +166,9 @@ function devProjectApi(): Plugin {
export default defineConfig({
plugins: [react(), devProjectApi()],
+ test: {
+ exclude: ["e2e/**", "node_modules/**"],
+ },
resolve: {
alias: {
"@hyperframes/player": resolve(__dirname, "../player/src/hyperframes-player.ts"),