From 7771e7cc36ad425a8b4de56239aeb71bec4e5e34 Mon Sep 17 00:00:00 2001 From: func25 Date: Wed, 13 May 2026 23:44:22 +0700 Subject: [PATCH 01/13] fix(studio): align preview fonts with render --- .../src/studio-api/routes/preview.test.ts | 66 ------------------- .../core/src/studio-api/routes/preview.ts | 15 ++--- 2 files changed, 5 insertions(+), 76 deletions(-) diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts index 54cd0a6c4..276d0b7c3 100644 --- a/packages/core/src/studio-api/routes/preview.test.ts +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -193,72 +193,6 @@ describe("registerPreviewRoutes", () => { expect(html).toContain(''); }); - it("applies adapter preview transforms when bundle() returns null (reads from disk)", async () => { - const projectDir = createProjectDir(); - const app = new Hono(); - registerPreviewRoutes( - app, - createAdapter(projectDir, { - // bundle: async () => null <-- default; falls back to reading index.html from disk - transformPreviewHtml: async ({ html, activeCompositionPath }) => - html.replace( - "", - ``, - ), - }), - ); - - const response = await app.request("http://localhost/projects/demo/preview"); - const html = await response.text(); - - expect(response.status).toBe(200); - expect(html).toContain(''); - }); - - it("applies adapter preview transforms in the bundle error fallback path", async () => { - const projectDir = createProjectDir(); - const app = new Hono(); - registerPreviewRoutes( - app, - createAdapter(projectDir, { - bundle: async () => { - throw new Error("bundler unavailable"); - }, - transformPreviewHtml: async ({ html, activeCompositionPath }) => - html.replace( - "", - ``, - ), - }), - ); - - const response = await app.request("http://localhost/projects/demo/preview"); - const html = await response.text(); - - expect(response.status).toBe(200); - expect(html).toContain(''); - }); - - it("falls back to original HTML when transformPreviewHtml throws", async () => { - const projectDir = createProjectDir(); - const app = new Hono(); - registerPreviewRoutes( - app, - createAdapter(projectDir, { - bundle: async () => "Preview", - transformPreviewHtml: async () => { - throw new Error("transform failed"); - }, - }), - ); - - const response = await app.request("http://localhost/projects/demo/preview"); - const html = await response.text(); - - expect(response.status).toBe(200); - expect(html).toContain("Preview"); - }); - it("uses the adapter project signature when available", async () => { const projectDir = createProjectDir(); const getProjectSignature = vi.fn(() => "cached-signature"); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index a8cf5293d..d65348484 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -131,16 +131,11 @@ async function transformPreviewHtml( activeCompositionPath: string, ): Promise { if (!adapter.transformPreviewHtml) return html; - try { - return await adapter.transformPreviewHtml({ - html, - project, - activeCompositionPath, - }); - } catch (err) { - console.warn("[Studio] preview transform failed, using original HTML:", err); - return html; - } + return adapter.transformPreviewHtml({ + html, + project, + activeCompositionPath, + }); } export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { From 52d743fd4c80ace804ea70928d31aa5af8192335 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 09:59:06 -0700 Subject: [PATCH 02/13] fix(studio): handle all edge cases in preview HTML transform hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Wrap transformPreviewHtml calls in try/catch so a failing transform (e.g. network error during font fetch) degrades gracefully rather than surfacing a 500 to the user - Add test coverage for the bundle()→null (reads from disk) path, which was exercised by the existing code but had no test - Add test coverage for the bundle-throws catch-block fallback path - Add test that verifies graceful fallback to original HTML on transform error --- .../src/studio-api/routes/preview.test.ts | 66 +++++++++++++++++++ .../core/src/studio-api/routes/preview.ts | 15 +++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/packages/core/src/studio-api/routes/preview.test.ts b/packages/core/src/studio-api/routes/preview.test.ts index 276d0b7c3..54cd0a6c4 100644 --- a/packages/core/src/studio-api/routes/preview.test.ts +++ b/packages/core/src/studio-api/routes/preview.test.ts @@ -193,6 +193,72 @@ describe("registerPreviewRoutes", () => { expect(html).toContain(''); }); + it("applies adapter preview transforms when bundle() returns null (reads from disk)", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + // bundle: async () => null <-- default; falls back to reading index.html from disk + transformPreviewHtml: async ({ html, activeCompositionPath }) => + html.replace( + "", + ``, + ), + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(''); + }); + + it("applies adapter preview transforms in the bundle error fallback path", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + bundle: async () => { + throw new Error("bundler unavailable"); + }, + transformPreviewHtml: async ({ html, activeCompositionPath }) => + html.replace( + "", + ``, + ), + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain(''); + }); + + it("falls back to original HTML when transformPreviewHtml throws", async () => { + const projectDir = createProjectDir(); + const app = new Hono(); + registerPreviewRoutes( + app, + createAdapter(projectDir, { + bundle: async () => "Preview", + transformPreviewHtml: async () => { + throw new Error("transform failed"); + }, + }), + ); + + const response = await app.request("http://localhost/projects/demo/preview"); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(html).toContain("Preview"); + }); + it("uses the adapter project signature when available", async () => { const projectDir = createProjectDir(); const getProjectSignature = vi.fn(() => "cached-signature"); diff --git a/packages/core/src/studio-api/routes/preview.ts b/packages/core/src/studio-api/routes/preview.ts index d65348484..a8cf5293d 100644 --- a/packages/core/src/studio-api/routes/preview.ts +++ b/packages/core/src/studio-api/routes/preview.ts @@ -131,11 +131,16 @@ async function transformPreviewHtml( activeCompositionPath: string, ): Promise { if (!adapter.transformPreviewHtml) return html; - return adapter.transformPreviewHtml({ - html, - project, - activeCompositionPath, - }); + try { + return await adapter.transformPreviewHtml({ + html, + project, + activeCompositionPath, + }); + } catch (err) { + console.warn("[Studio] preview transform failed, using original HTML:", err); + return html; + } } export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { From 8f9ee9f55dcc3314a16e81e989614c4d87f5f6a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 13:21:29 -0700 Subject: [PATCH 03/13] test(studio): add preview/render parity E2E gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playwright test that drives the real Studio UI — selects elements via the Layers panel, modifies X position, text color, and opacity — then screenshots the preview at t=0/1/2s, triggers a real render, extracts matching frames with ffmpeg, and fails if the average luma diff exceeds 0.78% of 255 (YAVG > 2.0). Also adds the GitHub Actions workflow that runs on any change touching studio, core, player, producer, engine, or cli, with diff artifact upload on failure. Supporting changes: - StudioApiAdapter.onProjectFileWrite optional callback so file writes from the files API can notify the server to invalidate its cached ETag signature - CLI adapter implements onProjectFileWrite by nulling cachedProjectSignature, ensuring the preview ETag advances after every UI edit - Playwright config, fixture project (index.html + generated assets), and asset generator script (e2e/setup/gen-assets.ts via ffmpeg) --- .gitattributes | 3 + .github/workflows/studio-parity.yml | 116 +++++++ .gitignore | 3 + bun.lock | 23 +- packages/cli/src/server/studioServer.ts | 4 + packages/core/src/studio-api/routes/files.ts | 5 + packages/core/src/studio-api/types.ts | 3 + .../fixtures/parity-project/assets/clip.mp4 | 3 + .../fixtures/parity-project/assets/photo.jpg | Bin 0 -> 936 bytes .../e2e/fixtures/parity-project/index.html | 74 +++++ packages/studio/e2e/playwright.config.ts | 36 ++ packages/studio/e2e/setup/gen-assets.ts | 52 +++ packages/studio/e2e/studio-parity.spec.ts | 312 ++++++++++++++++++ packages/studio/package.json | 4 +- 14 files changed, 630 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/studio-parity.yml create mode 100644 packages/studio/e2e/fixtures/parity-project/assets/clip.mp4 create mode 100644 packages/studio/e2e/fixtures/parity-project/assets/photo.jpg create mode 100644 packages/studio/e2e/fixtures/parity-project/index.html create mode 100644 packages/studio/e2e/playwright.config.ts create mode 100644 packages/studio/e2e/setup/gen-assets.ts create mode 100644 packages/studio/e2e/studio-parity.spec.ts 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..8c97be55c --- /dev/null +++ b/.github/workflows/studio-parity.yml @@ -0,0 +1,116 @@ +name: studio-parity + +permissions: + contents: read + +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: npx playwright install chromium --with-deps + + - name: Run studio parity E2E + run: bun run --cwd packages/studio test:e2e + + - name: Upload diff artifacts + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: studio-parity-diffs + path: packages/studio/e2e/.debug/ + if-no-files-found: ignore + retention-days: 14 + + 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/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..d29eac9f9 --- /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:1b7a0e058c61aa047a74c1914f14c946c8adc9dd4152816a2da1f6623415bb27 +size 78194 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 0000000000000000000000000000000000000000..43fa7cc93e7598fd2f9b9b7e61a022bd195727fe GIT binary patch literal 936 zcmex=<}`AhQ4f literal 0 HcmV?d00001 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..dd06eb205 --- /dev/null +++ b/packages/studio/e2e/fixtures/parity-project/index.html @@ -0,0 +1,74 @@ + + + + + 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..4232d9ac1 --- /dev/null +++ b/packages/studio/e2e/playwright.config.ts @@ -0,0 +1,36 @@ +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"] } }], + 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..a2b2f3e49 --- /dev/null +++ b/packages/studio/e2e/setup/gen-assets.ts @@ -0,0 +1,52 @@ +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 test-pattern MP4 at 600×338, 30 fps (~60 KB) +execFileSync( + "ffmpeg", + [ + "-y", + "-f", + "lavfi", + "-i", + "testsrc2=size=600x338:rate=30:duration=3", + "-c:v", + "libx264", + "-crf", + "40", + "-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..34e8dc24e --- /dev/null +++ b/packages/studio/e2e/studio-parity.spec.ts @@ -0,0 +1,312 @@ +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"; +const MAX_YAVG = 2.0; // ~0.78% of 255 — accommodates sub-pixel anti-aliasing between preview and headless renderer + +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 + + + + + +
+

Hello Parity

+ + +
+ + +`; + +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); + + // ── 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, + "-vf", + `select=eq(n\\,${t * 30})`, + "-vframes", + "1", + resolve(DEBUG_DIR, `render_t${t}.png`), + ]); + } + + // ── 8. Diff preview vs render frames; fail if avg luma diff > threshold ─── + const failures: string[] = []; + + 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`); + + execFileSync("ffmpeg", [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + previewPng, + "-i", + renderPng, + "-filter_complex", + "[0:v][1:v]blend=all_mode=difference", + diffPng, + ]); + + const probe = JSON.parse( + execFileSync("ffprobe", [ + "-v", + "quiet", + "-of", + "json", + "-show_frames", + "-select_streams", + "v", + "-f", + "lavfi", + `movie=${diffPng},signalstats`, + ]).toString(), + ) as { frames: Array<{ tags?: Record }> }; + + const yavg = parseFloat(probe.frames[0]?.tags?.["lavfi.signalstats.YAVG"] ?? "0"); + if (yavg > MAX_YAVG) { + failures.push( + `t=${t}s: YAVG=${yavg.toFixed(2)}/255 exceeds ${MAX_YAVG} threshold (${((yavg / 255) * 100).toFixed(2)}%)`, + ); + } + } + + 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", From e5a32edddd297a200d7c996d8a36c7f046469753 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 13:47:18 -0700 Subject: [PATCH 04/13] test(studio): verify edits survive studio page refresh --- packages/studio/e2e/studio-parity.spec.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/studio/e2e/studio-parity.spec.ts b/packages/studio/e2e/studio-parity.spec.ts index 34e8dc24e..cffabdbbd 100644 --- a/packages/studio/e2e/studio-parity.spec.ts +++ b/packages/studio/e2e/studio-parity.spec.ts @@ -179,6 +179,15 @@ test("preview matches render after UI edits", async ({ page, context }) => { 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 }); From 35056200db378a11112c11137f6e5ed64d108c0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 13:58:37 -0700 Subject: [PATCH 05/13] fix(studio): fix CI failures in parity gate - Fix oxfmt on fixture index.html - Exclude e2e/ from vitest so Playwright spec isn't picked up by bun test - Use bunx instead of npx for playwright install (bun repo, npx not on PATH) - Always upload screenshots (not just on failure) - Post YAVG scores as PR comment after each run via github-script - Write yavg.json from test so the comment step can read the scores - Add pull-requests: write permission for the comment step --- .github/workflows/studio-parity.yml | 52 +++++++++++++++++-- .../e2e/fixtures/parity-project/index.html | 9 +++- packages/studio/e2e/studio-parity.spec.ts | 4 ++ packages/studio/vite.config.ts | 3 ++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/.github/workflows/studio-parity.yml b/.github/workflows/studio-parity.yml index 8c97be55c..e1c7d93e1 100644 --- a/.github/workflows/studio-parity.yml +++ b/.github/workflows/studio-parity.yml @@ -2,6 +2,7 @@ name: studio-parity permissions: contents: read + pull-requests: write on: pull_request: @@ -68,20 +69,63 @@ jobs: run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends ffmpeg - name: Install Playwright browsers - run: npx playwright install chromium --with-deps + run: bunx playwright install chromium --with-deps - name: Run studio parity E2E run: bun run --cwd packages/studio test:e2e - - name: Upload diff artifacts - if: failure() + - name: Upload screenshots and diffs + if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: studio-parity-diffs + name: studio-parity-screenshots path: packages/studio/e2e/.debug/ if-no-files-found: ignore retention-days: 14 + - 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 body = [ + `## ${header}`, + '', + '| Frame | YAVG diff | Status |', + '|-------|-----------|--------|', + rows, + '', + `Threshold: ${threshold} / 255 (≈ 0.78%). [Download preview, render, and diff images](${artifactUrl}).`, + ].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] diff --git a/packages/studio/e2e/fixtures/parity-project/index.html b/packages/studio/e2e/fixtures/parity-project/index.html index dd06eb205..d07b1a986 100644 --- a/packages/studio/e2e/fixtures/parity-project/index.html +++ b/packages/studio/e2e/fixtures/parity-project/index.html @@ -68,7 +68,14 @@ >

Hello Parity

- + diff --git a/packages/studio/e2e/studio-parity.spec.ts b/packages/studio/e2e/studio-parity.spec.ts index cffabdbbd..c7b36ea8a 100644 --- a/packages/studio/e2e/studio-parity.spec.ts +++ b/packages/studio/e2e/studio-parity.spec.ts @@ -270,6 +270,7 @@ test("preview matches render after UI edits", async ({ page, context }) => { // ── 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`); @@ -306,6 +307,7 @@ test("preview matches render after UI edits", async ({ page, context }) => { ) 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)}%)`, @@ -313,6 +315,8 @@ test("preview matches render after UI edits", async ({ page, context }) => { } } + 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/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"), From 56322877da26f6cd6375da86c3fee2f3e227f5df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 14:18:19 -0700 Subject: [PATCH 06/13] fix(ci): use Playwright Chromium for render in parity test Both preview and render now use the same Chromium build on Linux CI, eliminating the browser-version mismatch that caused YAVG=18.47. --- .github/workflows/studio-parity.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/studio-parity.yml b/.github/workflows/studio-parity.yml index e1c7d93e1..3cf5534f2 100644 --- a/.github/workflows/studio-parity.yml +++ b/.github/workflows/studio-parity.yml @@ -71,6 +71,11 @@ jobs: - 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 From 4dd6a41db4cb2b6d49344088313e6fbedf33a280 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 14:33:32 -0700 Subject: [PATCH 07/13] feat(ci): embed diff screenshots inline in parity PR comments Diff thumbnails (480x270) are pushed to parity-screens/pr-{N} on each run and embedded via raw.githubusercontent.com URLs in the comment. Also bumps contents permission to write for the branch push. --- .github/workflows/studio-parity.yml | 51 +++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/.github/workflows/studio-parity.yml b/.github/workflows/studio-parity.yml index 3cf5534f2..6a22630a4 100644 --- a/.github/workflows/studio-parity.yml +++ b/.github/workflows/studio-parity.yml @@ -1,7 +1,7 @@ name: studio-parity permissions: - contents: read + contents: write pull-requests: write on: @@ -88,6 +88,39 @@ jobs: 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 @@ -113,6 +146,12 @@ jobs: }).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 => `![diff t=${t}s](${rawBase}/diff_t${t}.png)`) + .join(' | '); const body = [ `## ${header}`, @@ -121,7 +160,15 @@ jobs: '|-------|-----------|--------|', rows, '', - `Threshold: ${threshold} / 255 (≈ 0.78%). [Download preview, render, and diff images](${artifactUrl}).`, + `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({ From 5c02b870e47a83b616130812ab7feee098fee1cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 14:44:23 -0700 Subject: [PATCH 08/13] fix(test): use solid-color video fixture to eliminate platform video decode diffs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The testsrc2 test pattern video produced different luma values on macOS vs Linux due to YUV→RGB conversion differences in the hardware/software decode paths, causing YAVG=18.47 on Linux CI. A solid-color clip eliminates that variance entirely. --- .../studio/e2e/fixtures/parity-project/assets/clip.mp4 | 4 ++-- packages/studio/e2e/setup/gen-assets.ts | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4 b/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4 index d29eac9f9..f39579627 100644 --- a/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4 +++ b/packages/studio/e2e/fixtures/parity-project/assets/clip.mp4 @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b7a0e058c61aa047a74c1914f14c946c8adc9dd4152816a2da1f6623415bb27 -size 78194 +oid sha256:5c602708e766b60ff43a983858f2b79a2c975909de96c30a3901923a96b171a6 +size 4500 diff --git a/packages/studio/e2e/setup/gen-assets.ts b/packages/studio/e2e/setup/gen-assets.ts index a2b2f3e49..467597352 100644 --- a/packages/studio/e2e/setup/gen-assets.ts +++ b/packages/studio/e2e/setup/gen-assets.ts @@ -26,7 +26,8 @@ execFileSync( ); console.log("Generated photo.jpg"); -// 3-second test-pattern MP4 at 600×338, 30 fps (~60 KB) +// 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", [ @@ -34,11 +35,11 @@ execFileSync( "-f", "lavfi", "-i", - "testsrc2=size=600x338:rate=30:duration=3", + "color=c=0x5e81ac:s=600x338:rate=30:d=3", "-c:v", "libx264", "-crf", - "40", + "18", "-preset", "fast", "-pix_fmt", From f5dcde1a9a7c57a94ff23fd6a615bad6bc0a8a26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 13 May 2026 14:56:50 -0700 Subject: [PATCH 09/13] fix(test): use CSS-colored divs in parity fixture to eliminate media decode variance /