From bab398cb529acbe40a2718e0ca98d5bfa73448d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:29:59 +0000 Subject: [PATCH] wip(media): editor e2e and player fixes (unverified) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w --- apps/web/e2e/fixtures/media-editor-data.ts | 432 ++++++++++++++++++ apps/web/e2e/fixtures/media-editor.html | 12 + apps/web/e2e/fixtures/media-editor.tsx | 58 +++ apps/web/e2e/media-brand-kit.spec.ts | 60 +++ apps/web/e2e/media-project.spec.ts | 144 ++++++ apps/web/e2e/media-review.spec.ts | 117 +++++ .../src/components/media/editor/Player.tsx | 6 +- .../src/components/media/editor/Timeline.tsx | 1 + apps/web/src/pages/media/EditorView.tsx | 2 +- 9 files changed, 830 insertions(+), 2 deletions(-) create mode 100644 apps/web/e2e/fixtures/media-editor-data.ts create mode 100644 apps/web/e2e/fixtures/media-editor.html create mode 100644 apps/web/e2e/fixtures/media-editor.tsx create mode 100644 apps/web/e2e/media-brand-kit.spec.ts create mode 100644 apps/web/e2e/media-project.spec.ts create mode 100644 apps/web/e2e/media-review.spec.ts diff --git a/apps/web/e2e/fixtures/media-editor-data.ts b/apps/web/e2e/fixtures/media-editor-data.ts new file mode 100644 index 0000000..6006ecf --- /dev/null +++ b/apps/web/e2e/fixtures/media-editor-data.ts @@ -0,0 +1,432 @@ +import type { Page, Request } from "@playwright/test"; + +/* + * Scripted Media Studio backend for the project and review specs. No RPC can + * seed an emulator library into a fresh space, so these specs answer the media + * procedures they use with synthetic, schema-shaped data and record every call. + */ + +export const PROJECT_ID = "proj-e2e"; +export const SHORTS = "youtube-shorts"; +export const PAID = "example-paid-video"; +export const TIKTOK = "tiktok"; + +const at = (minute: number) => new Date(Date.UTC(2026, 9, 1, 10, minute)).toISOString(); + +function clip(id: string, assetId: string, inMs: number, outMs: number, atMs: number) { + return { + id, + assetId, + inMs, + outMs, + atMs, + speed: 1, + reframe: "auto", + stabilize: false, + muted: false, + }; +} + +export function edl(profileId: string, width: number, height: number) { + return { + edlVersion: 1, + output: { + profileId, + width, + height, + fps: 30, + durationTargetMs: 12_000, + durationHardMinMs: 3_000, + durationHardMaxMs: 60_000, + }, + tracks: { + video: [ + clip("clip-1", "asset-1", 0, 4_000, 0), + clip("clip-2", "asset-2", 0, 4_000, 4_000), + clip("clip-3", "asset-3", 1_000, 4_000, 8_000), + ], + audio: [], + }, + transitions: [], + captions: { mode: "burned", maxCharsPerLine: 32, wordHighlight: false, position: "lower" }, + overlays: [{ id: "text-1", kind: "text", atMs: 500, durationMs: 2_000, text: "Wait for it" }], + redactions: { + faces: [ + { + id: "face-1", + target: { personId: "person-a" }, + mode: "partial_eyes", + paddingPct: 20, + featherPct: 10, + minSizePct: 2, + }, + ], + marks: [{ markId: "mark-1", treatment: "cover" }], + regions: [], + }, + metadata: { title: "Teaser", tags: [], aiAltered: false, aiAlterations: [] }, + }; +} + +function plan(id: string, profileId: string, version: number, extra: Record = {}) { + const [width, height] = profileId === PAID ? [1920, 1080] : [1080, 1920]; + return { + id, + projectId: PROJECT_ID, + profileId, + version, + edl: edl(profileId, width, height), + createdBy: "user", + rationale: null, + createdAt: at(version), + ...extra, + }; +} + +const report = (profileId: string, issues: unknown[], passed: boolean, autoFixRound = 0) => ({ + profileId, + passed, + checkedAt: at(30), + sampledFrames: 48, + issues, + autoFixRound, +}); + +function render(id: string, extra: Record) { + return { + id, + planId: "plan-shorts-1", + projectId: PROJECT_ID, + profileId: SHORTS, + quality: "draft", + status: "done", + progress: 1, + outputs: [ + { kind: "video", artifactId: null, fileName: "video.mp4", sizeBytes: 1_000_000 }, + { kind: "poster", artifactId: "art-poster", fileName: "poster.jpg", sizeBytes: 20_000 }, + ], + gateReport: null, + ffprobe: null, + error: null, + createdAt: at(20), + updatedAt: at(21), + ...extra, + }; +} + +export const BLOCKED_ISSUES = [ + { + check: "anonymization", + severity: "block", + code: "mask_coverage", + message: "A face of Alex is not fully covered.", + atMs: 5_000, + endMs: 5_600, + personId: "person-a", + }, + { + check: "content", + severity: "block", + code: "classifier_threshold", + message: "Explicit nudity above the profile limit.", + atMs: 2_000, + category: "explicit_nudity", + score: 0.9, + }, + { + check: "consent", + severity: "block", + code: "consent_missing", + message: "Sam has no recorded consent.", + atMs: 9_000, + personId: "person-b", + }, + { + check: "anonymization", + severity: "warn", + code: "mark_residual", + message: "The tattoo is faintly visible.", + atMs: 10_000, + endMs: 10_400, + markId: "mark-1", + }, + { + check: "text", + severity: "warn", + code: "hashtags", + message: "Fewer hashtags would read better.", + }, +]; + +/** Mutable backend state the mock answers from; specs read `calls` to assert requests. */ +export function mediaBackend() { + const plans: Record[] = [ + plan("plan-shorts-1", SHORTS, 1), + plan("plan-paid-1", PAID, 1), + plan("plan-paid-2", PAID, 2, { + createdBy: "agent", + rationale: "Auto-fix 1/3 after the gate blocked (mask_coverage): widened the face mask", + }), + ]; + const renders: Record[] = [ + render("render-shorts-draft", { gateReport: report(SHORTS, [], true) }), + render("render-paid-draft", { + planId: "plan-paid-1", + profileId: PAID, + status: "blocked", + outputs: [], + gateReport: report(PAID, BLOCKED_ISSUES, false, 1), + }), + ]; + const people = [ + { + id: "person-a", + displayName: "Alex", + isSelf: false, + confirmed: true, + anonymizeByDefault: "partial_eyes", + consentRecordedAt: at(1), + consentArtifactId: null, + ageVerifiedAt: at(1), + ageVerificationMethod: "platform_verified", + faceTrackCount: 3, + coverThumbAssetId: null, + }, + { + id: "person-b", + displayName: "Sam", + isSelf: false, + confirmed: true, + anonymizeByDefault: "blur", + consentRecordedAt: null, + consentArtifactId: null, + ageVerifiedAt: null, + ageVerificationMethod: "none", + faceTrackCount: 1, + coverThumbAssetId: null, + }, + ]; + const marks = [ + { + id: "mark-1", + kind: "tattoo", + label: null, + personId: "person-a", + assetId: "asset-2", + prompt: { tMs: 1_000, point: { x: 0.4, y: 0.6 } }, + treatment: "inpaint", + qualityScore: 0.42, + suggestedFallback: "cover", + createdAt: at(2), + }, + ]; + const calls: Array<{ procedure: string; input: Record }> = []; + /** Submissions answered with these errors first, then accepted. */ + const submitErrors: unknown[][] = []; + let renderCounter = 0; + let pollsUntilDone = 0; + + const project = { + id: PROJECT_ID, + title: "Harbor teaser", + goal: "teaser", + status: "review", + brandKitId: null, + collectionIds: [], + profileIds: [SHORTS, PAID, TIKTOK], + createdAt: at(0), + updatedAt: at(0), + }; + + function advanceRenders() { + for (const item of renders) { + if (item.status !== "queued" && item.status !== "rendering") continue; + if (pollsUntilDone > 0) { + pollsUntilDone -= 1; + item.status = "rendering"; + item.progress = 0.5; + continue; + } + item.status = "done"; + item.progress = 1; + item.outputs = [ + { kind: "video", artifactId: null, fileName: "video.mp4", sizeBytes: 2_000_000 }, + ]; + item.gateReport = report(String(item.profileId), [], true); + } + } + + const profiles = [ + [SHORTS, "YouTube Shorts", "free_social", false], + [PAID, "Example paid video", "paid_video", false], + [TIKTOK, "TikTok", "free_social", true], + ].map(([id, name, kind, unverified]) => ({ + id, + name, + kind, + origin: "builtin", + unverified, + stale: false, + aspectRatios: id === PAID ? ["16:9"] : ["9:16"], + hardMinMs: 1_000, + hardMaxMs: 60_000, + recommendedMs: [15_000], + })); + + const handlers: Record) => unknown> = { + capabilities: () => ({ + configured: true, + operations: {}, + accelerator: "cpu", + hardwareEncoders: [], + }), + "profiles/list": () => profiles, + "projects/get": () => { + advanceRenders(); + return { project, plans, renders }; + }, + "projects/autoPlan": () => { + const created = plan("plan-tiktok-1", TIKTOK, 1, { createdBy: "agent" }); + plans.push(created); + return [created]; + }, + "assets/get": (input) => ({ + asset: { + id: input.assetId, + sourceId: "source-1", + externalId: `${String(input.assetId)}.mp4`, + kind: "video", + storage: "referenced", + capturedAt: at(0), + durationMs: 6_000, + width: 1920, + height: 1080, + fps: 30, + gpsLat: null, + gpsLon: null, + deviceModel: null, + albumNames: [], + isFavorite: false, + ingestState: "analyzed", + hasProxy: true, + hasThumbnail: true, + qualityScore: 0.8, + createdAt: at(0), + }, + segments: [], + faceTracks: [], + transcript: null, + }), + "plans/submit": (input) => { + const errors = submitErrors.shift(); + if (errors) return { ok: false, errors }; + const base = plans.find((item) => item.profileId === (input.edl as { output: { profileId: string } }).output.profileId); + const version = plans.filter((item) => item.profileId === base?.profileId).length + 1; + const created = { + ...plan(`plan-saved-${version}`, String(base?.profileId), version), + edl: input.edl, + }; + plans.push(created); + return { ok: true, plan: created }; + }, + "renders/start": (input) => { + const source = plans.find((item) => item.id === input.planId); + renderCounter += 1; + const created = render(`render-new-${renderCounter}`, { + planId: input.planId, + profileId: source?.profileId, + quality: input.quality, + status: "queued", + progress: 0, + outputs: [], + createdAt: at(40 + renderCounter), + }); + renders.push(created); + return created; + }, + "renders/get": (input) => { + advanceRenders(); + return renders.find((item) => item.id === input.renderId); + }, + "renders/thumbnails": () => + ["a", "b", "c"].map((letter) => ({ + kind: "thumbnail", + artifactId: `art-thumb-${letter}`, + fileName: `thumb-${letter}.png`, + sizeBytes: 10_000, + })), + "marketing/copy": (input) => ({ + profileId: SHORTS, + language: input.language, + title: "Harbor at dawn", + hook: "You have never seen the harbor like this", + caption: "Three minutes before sunrise.", + hashtags: ["#harbor", "#sunrise"], + issues: [], + }), + "people/list": () => people, + "people/update": (input) => { + const person = people.find((item) => item.id === input.personId); + if (person && typeof input.anonymizeByDefault === "string") { + person.anonymizeByDefault = input.anonymizeByDefault; + } + return person; + }, + "marks/list": () => marks, + "marks/update": (input) => { + const mark = marks.find((item) => item.id === input.markId); + if (mark && typeof input.treatment === "string") mark.treatment = input.treatment; + return mark; + }, + "marks/create": (input) => { + const created = { + id: `mark-${marks.length + 1}`, + kind: input.kind, + label: null, + personId: null, + assetId: input.assetId, + prompt: input.prompt, + treatment: input.treatment, + qualityScore: 0.9, + suggestedFallback: null, + createdAt: at(50), + }; + marks.push(created as (typeof marks)[number]); + return created; + }, + }; + + return { + calls, + submitErrors, + /** Let a started render report `rendering` for this many polls before it finishes. */ + holdRenders(polls: number) { + pollsUntilDone = polls; + }, + async install(page: Page) { + await page.route("**/rpc/media/**", async (route) => { + const request: Request = route.request(); + const procedure = new URL(request.url()).pathname.replace(/^.*\/rpc\/media\//, ""); + const handler = handlers[procedure]; + const body = (request.postDataJSON() ?? {}) as { json?: Record }; + const input = body.json ?? {}; + calls.push({ procedure, input }); + if (!handler) { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: JSON.stringify({ json: { message: `unmocked ${procedure}` } }), + }); + return; + } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ json: handler(input) }), + }); + }); + // Byte routes: no synthetic media files are needed for these flows. + await page.route("**/api/media/**", (route) => route.fulfill({ status: 404, body: "" })); + }, + }; +} diff --git a/apps/web/e2e/fixtures/media-editor.html b/apps/web/e2e/fixtures/media-editor.html new file mode 100644 index 0000000..3930c6b --- /dev/null +++ b/apps/web/e2e/fixtures/media-editor.html @@ -0,0 +1,12 @@ + + + + + + Media editor test + + +
+ + + diff --git a/apps/web/e2e/fixtures/media-editor.tsx b/apps/web/e2e/fixtures/media-editor.tsx new file mode 100644 index 0000000..e7e38a5 --- /dev/null +++ b/apps/web/e2e/fixtures/media-editor.tsx @@ -0,0 +1,58 @@ +import { setupI18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { useState } from "react"; +import { createRoot } from "react-dom/client"; +import { BrandKitView } from "../../src/pages/media/BrandKitView"; +import { EditorView } from "../../src/pages/media/EditorView"; +import type { MediaView } from "../../src/pages/media/nav"; +import { ProjectView } from "../../src/pages/media/ProjectView"; +import { ReviewView } from "../../src/pages/media/ReviewView"; +import "../../src/styles.css"; + +/* + * Mounts the Media Studio editor-side views on their own, starting at the view + * named in the query string, so specs reach them without the library flow. + * Views the editor side does not own render as a marker the spec can assert. + */ + +const params = new URLSearchParams(location.search); +const i18n = setupI18n({ locale: "en", messages: { en: {} } }); + +function initialView(): MediaView { + const projectId = params.get("projectId") ?? ""; + switch (params.get("view")) { + case "editor": + return { name: "editor", projectId, planId: params.get("planId") ?? "" }; + case "review": + return { name: "review", projectId, renderId: params.get("renderId") ?? "" }; + case "brandKit": + return { name: "brandKit", brandKitId: params.get("brandKitId") ?? undefined }; + default: + return { name: "project", projectId }; + } +} + +function Harness() { + const [view, setView] = useState(initialView); + const props = { navigate: setView, onClose: () => undefined }; + switch (view.name) { + case "project": + return ; + case "editor": + return ; + case "review": + return ; + case "brandKit": + return ; + default: + return
; + } +} + +createRoot(document.getElementById("root")!).render( + +
+ +
+
, +); diff --git a/apps/web/e2e/media-brand-kit.spec.ts b/apps/web/e2e/media-brand-kit.spec.ts new file mode 100644 index 0000000..e41f79c --- /dev/null +++ b/apps/web/e2e/media-brand-kit.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, rpc, signup } from "./helpers"; + +test.setTimeout(180_000); + +test("brand kit editor saves a kit and the intro generator offers three variants", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `media-brand-${stamp}@rakazo.test`, "password12", "Brand Kit"); + await completeOnboarding(page); + // The scripted runtime runs Media Studio on the inference emulator. + const capabilities = await rpc<{ configured: boolean }>(page, "media/capabilities", {}); + expect(capabilities.configured).toBe(true); + + await page.goto("/e2e/fixtures/media-editor.html?view=brandKit"); + const editor = page.getByTestId("media-brand-kit"); + await expect(editor).toBeVisible(); + // A new kit has nothing to generate an intro from until it is saved. + await expect(editor.getByTestId("media-intro-generator")).toHaveCount(0); + + await editor.getByLabel("Name").fill("Studio Nord"); + await editor.getByLabel("Tone").fill("calm, precise"); + await editor.getByLabel("Primary").fill("#1f4e79"); + await editor.getByRole("switch", { name: "Watermark" }).click(); + await editor.getByLabel("Position").selectOption("top_right"); + await editor.getByRole("button", { name: "Caption style" }).click(); + await editor.getByLabel("Look").selectOption("karaoke"); + await editor.getByRole("button", { name: "Save" }).click(); + + const generator = editor.getByTestId("media-intro-generator"); + await expect(generator).toBeVisible(); + await expect(editor.getByRole("button", { name: "Save" })).toBeDisabled(); + await expect(editor.getByRole("heading", { name: "Studio Nord" })).toBeVisible(); + await captureScreenshot(page, testInfo, "media-brand-kit"); + + await generator.getByRole("button", { name: "Suggest" }).click(); + const variants = generator.getByTestId("media-intro-variant"); + await expect(variants).toHaveCount(3, { timeout: 120_000 }); + await captureScreenshot(page, testInfo, "media-brand-kit-intros"); + + await variants.first().getByRole("button", { name: "Use" }).click(); + await expect(variants.first().getByText("In use")).toBeVisible(); + await expect(variants.nth(1).getByRole("button", { name: "Use" })).toBeVisible(); + + // The intro is stored on the kit. + const kits = await rpc>( + page, + "media/brandKits/list", + {}, + ); + const saved = kits.find((record) => record.kit.name === "Studio Nord"); + expect(saved?.kit.intro?.templateId).toBeTruthy(); + + // Back lists the kits. + await editor.getByRole("button", { name: "Back" }).click(); + const list = page.getByTestId("media-brand-kits"); + await expect(list.getByText("Studio Nord")).toBeVisible(); + await captureScreenshot(page, testInfo, "media-brand-kit-list"); +}); diff --git a/apps/web/e2e/media-project.spec.ts b/apps/web/e2e/media-project.spec.ts new file mode 100644 index 0000000..294ea3e --- /dev/null +++ b/apps/web/e2e/media-project.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from "@playwright/test"; +import type { edl } from "./fixtures/media-editor-data"; +import { mediaBackend, PAID, PROJECT_ID, SHORTS, TIKTOK } from "./fixtures/media-editor-data"; +import { captureScreenshot } from "./helpers"; + +const projectUrl = `/e2e/fixtures/media-editor.html?view=project&projectId=${PROJECT_ID}`; + +test("project variants plan, approve into a final render, and publish", async ({ + page, +}, testInfo) => { + const backend = mediaBackend(); + await backend.install(page); + await page.goto(projectUrl); + + const project = page.getByTestId("media-project"); + await expect(project.getByRole("heading", { name: "Harbor teaser" })).toBeVisible(); + const shorts = page.locator(`[data-testid="media-variant"][data-profile="${SHORTS}"]`); + const paid = page.locator(`[data-testid="media-variant"][data-profile="${PAID}"]`); + const tiktok = page.locator(`[data-testid="media-variant"][data-profile="${TIKTOK}"]`); + await expect(shorts.getByTestId("media-gate-status")).toHaveAttribute("data-status", "pass"); + await expect(paid.getByTestId("media-gate-status")).toHaveAttribute("data-status", "blocked"); + await expect(paid.getByText("Auto-fix 1/3")).toBeVisible(); + await expect(tiktok.getByText("No plan yet")).toBeVisible(); + await expect(tiktok.getByText("Unverified")).toBeVisible(); + // A blocked draft cannot be approved. + await expect(paid.getByRole("button", { name: "Approve" })).toHaveCount(0); + await captureScreenshot(page, testInfo, "media-project-variants"); + + // Auto-plan fills the profile without a plan. + await project.getByRole("button", { name: "Plan" }).click(); + await expect(tiktok.getByRole("button", { name: "Edit" })).toBeVisible(); + await expect(project.getByRole("button", { name: "Plan" })).toHaveCount(0); + + // Approving a passed draft starts the final render; the card polls until it is done. + backend.holdRenders(2); + await shorts.getByRole("button", { name: "Approve" }).click(); + await expect(shorts.getByText(/Rendering \d+%/)).toBeVisible(); + const start = backend.calls.find((call) => call.procedure === "renders/start"); + expect(start?.input).toEqual({ planId: "plan-shorts-1", quality: "final" }); + await expect(shorts.getByTestId("media-publish")).toBeVisible({ timeout: 15_000 }); + await expect(shorts.getByRole("button", { name: "Download" })).toBeVisible(); + // Polling stops once nothing renders. + const polls = backend.calls.filter((call) => call.procedure === "projects/get").length; + await page.waitForTimeout(2_500); + expect(backend.calls.filter((call) => call.procedure === "projects/get").length).toBe(polls); + + // Thumbnails, copy, and the bundle step appear in order. + const publish = shorts.getByTestId("media-publish"); + await publish.getByRole("button", { name: "Thumbnails" }).click(); + await expect(publish.getByRole("button", { name: /thumb-/ })).toHaveCount(3); + await expect(publish.getByRole("button", { name: "thumb-a" })).toHaveAttribute( + "aria-pressed", + "true", + ); + await publish.getByRole("button", { name: "thumb-b" }).click(); + await publish.getByRole("button", { name: "Write copy" }).click(); + await expect(publish.getByRole("textbox", { name: "Title" })).toHaveValue("Harbor at dawn"); + await expect(publish.getByRole("textbox", { name: "Hashtags" })).toHaveValue( + "#harbor #sunrise", + ); + await expect(publish.getByRole("button", { name: "Bundle" })).toBeVisible(); + await captureScreenshot(page, testInfo, "media-project-publish"); +}); + +test("the editor trims and moves clips, edits captions and overlays, and shows EDL errors", async ({ + page, +}, testInfo) => { + const backend = mediaBackend(); + await backend.install(page); + backend.submitErrors.push([ + { + code: "overlap", + path: "tracks.video[1].atMs", + message: "clip-2 overlaps clip-3 on the video track", + }, + { code: "duration_above_max", path: "output", message: "duration is above the limit" }, + ]); + await page.goto(projectUrl); + + const shorts = page.locator(`[data-testid="media-variant"][data-profile="${SHORTS}"]`); + await shorts.getByRole("button", { name: "Edit" }).click(); + const editor = page.getByTestId("media-editor"); + await expect(editor).toBeVisible(); + const clips = editor.getByTestId("media-clip"); + await expect(clips).toHaveCount(3); + await expect(editor.getByRole("button", { name: "Save" })).toBeDisabled(); + + // Trim the first clip's end to the right with the pointer. + const trimEnd = clips.first().getByTestId("media-trim-end"); + const handle = await trimEnd.boundingBox(); + if (!handle) throw new Error("trim handle not laid out"); + await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2); + await page.mouse.down(); + await page.mouse.move(handle.x + 40, handle.y + handle.height / 2, { steps: 5 }); + await page.mouse.move(handle.x + 60, handle.y + handle.height / 2, { steps: 5 }); + await page.mouse.up(); + + // Move the third clip later. + const third = clips.nth(2).getByRole("button", { name: "Clip clip-3" }); + const box = await third.boundingBox(); + if (!box) throw new Error("clip not laid out"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 30, box.y + box.height / 2, { steps: 5 }); + await page.mouse.move(box.x + box.width / 2 + 50, box.y + box.height / 2, { steps: 5 }); + await page.mouse.up(); + + // Captions: switch to the subtitle file and highlight words. + await editor.getByRole("button", { name: "Captions", exact: true }).click(); + const inspector = editor.getByTestId("media-inspector"); + await inspector.getByLabel("Captions").selectOption("both"); + await inspector.getByRole("switch", { name: "Highlight words" }).click(); + + // A new text overlay at the playhead. + await editor.getByRole("button", { name: "Text", exact: true }).click(); + await inspector.getByLabel("Text").fill("Link in bio"); + + await editor.getByRole("button", { name: "Save" }).click(); + const errors = editor.getByTestId("media-edl-errors"); + await expect(errors.getByText("clip-2 overlaps clip-3 on the video track")).toBeVisible(); + await expect(errors.getByText("duration is above the limit")).toBeVisible(); + await expect(clips.nth(1)).toHaveAttribute("data-invalid", "true"); + await captureScreenshot(page, testInfo, "media-editor-errors"); + + const submitted = backend.calls.find((call) => call.procedure === "plans/submit")?.input as { + projectId: string; + edl: ReturnType; + }; + expect(submitted.projectId).toBe(PROJECT_ID); + const [first, , moved] = submitted.edl.tracks.video; + expect(first?.outMs).toBeGreaterThan(4_000); + expect(first?.outMs).toBeLessThanOrEqual(6_000); + expect(moved?.atMs).toBeGreaterThan(8_000); + expect(submitted.edl.captions).toMatchObject({ mode: "both", wordHighlight: true }); + expect(submitted.edl.overlays.at(-1)).toMatchObject({ kind: "text", text: "Link in bio" }); + + // Selecting an error selects the clip; the next save is accepted as a new version. + await errors.getByText("clip-2 overlaps clip-3 on the video track").click(); + await expect(inspector.getByRole("spinbutton", { name: "In", exact: true })).toBeVisible(); + await editor.getByRole("button", { name: "Save" }).click(); + await expect(editor.getByRole("heading", { name: `${SHORTS} ยท v2` })).toBeVisible(); + await expect(errors).toHaveCount(0); + await captureScreenshot(page, testInfo, "media-editor"); +}); diff --git a/apps/web/e2e/media-review.spec.ts b/apps/web/e2e/media-review.spec.ts new file mode 100644 index 0000000..b251e32 --- /dev/null +++ b/apps/web/e2e/media-review.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "@playwright/test"; +import { mediaBackend, PROJECT_ID } from "./fixtures/media-editor-data"; +import { captureScreenshot } from "./helpers"; + +const reviewUrl = (renderId: string) => + `/e2e/fixtures/media-editor.html?view=review&projectId=${PROJECT_ID}&renderId=${renderId}`; + +test("review lists gate blockers that jump the player and shows coverage and auto-fix", async ({ + page, +}, testInfo) => { + const backend = mediaBackend(); + await backend.install(page); + await page.goto(reviewUrl("render-paid-draft")); + + const review = page.getByTestId("media-review"); + await expect(review.getByTestId("media-gate-status")).toHaveAttribute("data-status", "blocked"); + await expect(review.getByTestId("media-autofix")).toContainText("Auto-fix 1/3"); + await expect(review.getByTestId("media-autofix")).toContainText("widened the face mask"); + + // Issues in timeline order, untimed last. + const issues = review.getByTestId("media-gate-issue"); + await expect(issues).toHaveCount(5); + await expect(issues.nth(0)).toContainText("0:02.0"); + await expect(issues.nth(0)).toContainText("Explicit nudity above the profile limit."); + await expect(issues.nth(1)).toContainText("0:05.0"); + await expect(issues.nth(4)).toContainText("Fewer hashtags would read better."); + await expect(issues.nth(4).getByRole("button")).toBeDisabled(); + + // A blocked render has no output; the plan plays from the sources. + const player = review.getByTestId("media-source-player"); + await expect(review.getByRole("button", { name: "After" })).toHaveCount(0); + await issues.nth(1).getByRole("button").click(); + await expect(player).toHaveAttribute("data-position-ms", "5000"); + await issues.nth(2).getByRole("button").click(); + await expect(player).toHaveAttribute("data-position-ms", "9000"); + + // Coverage per person and mark; a segment seeks too. + const coverage = review.getByTestId("media-coverage"); + await expect(coverage.getByText("Alex")).toBeVisible(); + await expect(coverage.getByText("Sam")).toBeVisible(); + await expect(coverage.getByText("Tattoo")).toBeVisible(); + await coverage.getByRole("button", { name: /0:10\.0/ }).click(); + await expect(player).toHaveAttribute("data-position-ms", "10000"); + await captureScreenshot(page, testInfo, "media-review"); +}); + +test("review edits people, marks, and masks, then re-renders the draft", async ({ + page, +}, testInfo) => { + const backend = mediaBackend(); + await backend.install(page); + await page.goto(reviewUrl("render-paid-draft")); + const review = page.getByTestId("media-review"); + await expect(review.getByTestId("media-gate-issue")).toHaveCount(5); + + // Per person: anonymization mode; consent only where the gate asks for it. + const people = review.getByTestId("media-review-person"); + await expect(people).toHaveCount(2); + await review.getByLabel("Anonymization for Alex").selectOption("pixelate"); + await expect + .poll(() => backend.calls.find((call) => call.procedure === "people/update")?.input) + .toEqual({ personId: "person-a", anonymizeByDefault: "pixelate" }); + await expect(people.nth(0).getByRole("button", { name: "Record consent" })).toHaveCount(0); + await expect(people.nth(1).getByRole("button", { name: "Record consent" })).toBeVisible(); + + // Per mark: a low quality score offers the suggested fallback. + const mark = review.getByTestId("media-review-mark"); + await expect(mark).toContainText("42%"); + await mark.getByRole("button", { name: "Use Cover" }).click(); + await expect(mark.getByLabel("Treatment")).toHaveValue("cover"); + await expect(mark.getByRole("button", { name: "Use Cover" })).toHaveCount(0); + + // Mark a spot on the frame. + await review.getByRole("button", { name: "Mark", exact: true }).click(); + await review.getByRole("button", { name: "Mark this spot" }).click({ position: { x: 20, y: 20 } }); + await expect + .poll(() => backend.calls.find((call) => call.procedure === "marks/create")?.input) + .toMatchObject({ kind: "custom", assetId: "asset-1", treatment: "blur" }); + + // Draw a mask and drag it. + await review.getByRole("button", { name: "Mask", exact: true }).click(); + const mask = review.getByTestId("media-mask"); + await expect(mask).toBeVisible(); + const box = await mask.boundingBox(); + if (!box) throw new Error("mask not laid out"); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2 + 10, { steps: 4 }); + await page.mouse.up(); + await captureScreenshot(page, testInfo, "media-review-mask"); + + await review.getByRole("button", { name: "Save" }).click(); + await expect(review.getByRole("button", { name: "Draft" })).toBeVisible(); + const submitted = backend.calls.find((call) => call.procedure === "plans/submit")?.input as { + edl: { + redactions: { + regions: Array<{ clipId: string; keyframes: Array<{ box: { x: number } }> }>; + }; + }; + }; + const region = submitted.edl.redactions.regions[0]; + expect(region?.clipId).toBe("clip-1"); + expect(region?.keyframes[0]?.box.x).toBeGreaterThan(0.4); + + // Re-rendering opens the new render, which polls while it runs. + backend.holdRenders(1); + await review.getByRole("button", { name: "Draft" }).click(); + await expect(review.getByText(/Rendering \d+%/)).toBeVisible(); + await expect(review.getByTestId("media-gate-status")).toHaveAttribute("data-status", "pass", { + timeout: 15_000, + }); + + // Consent is recorded on the people screen, never here. + await page.goto(reviewUrl("render-paid-draft")); + await page.getByRole("button", { name: "Record consent" }).click(); + await expect(page.getByTestId("media-other-view")).toHaveAttribute("data-view", "people"); +}); diff --git a/apps/web/src/components/media/editor/Player.tsx b/apps/web/src/components/media/editor/Player.tsx index 82c3756..79d6b28 100644 --- a/apps/web/src/components/media/editor/Player.tsx +++ b/apps/web/src/components/media/editor/Player.tsx @@ -157,7 +157,11 @@ export function SourcePlayer({ }, [edl]); return ( -
+
{/* biome-ignore lint/a11y/useMediaCaption: source footage has no caption track. */}