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 => `![diff t=${t}s](${rawBase}/diff_t${t}.png)`) + .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 + + + + + +
+

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); + + // ── 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"),