wip(media): editor e2e and player fixes (unverified)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
This commit is contained in:
Claude 2026-10-03 19:29:59 +00:00
parent 844f7fe7c1
commit bab398cb52
No known key found for this signature in database
9 changed files with 830 additions and 2 deletions

View File

@ -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<string, unknown> = {}) {
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<string, unknown>) {
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<string, unknown>[] = [
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<string, unknown>[] = [
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<string, unknown> }> = [];
/** 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<string, (input: Record<string, unknown>) => 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<string, unknown> };
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: "" }));
},
};
}

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Media editor test</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/e2e/fixtures/media-editor.tsx"></script>
</body>
</html>

View File

@ -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<MediaView>(initialView);
const props = { navigate: setView, onClose: () => undefined };
switch (view.name) {
case "project":
return <ProjectView {...props} projectId={view.projectId} />;
case "editor":
return <EditorView {...props} projectId={view.projectId} planId={view.planId} />;
case "review":
return <ReviewView {...props} projectId={view.projectId} renderId={view.renderId} />;
case "brandKit":
return <BrandKitView {...props} brandKitId={view.brandKitId} />;
default:
return <div data-testid="media-other-view" data-view={view.name} />;
}
}
createRoot(document.getElementById("root")!).render(
<I18nProvider i18n={i18n}>
<main className="min-h-screen bg-background text-foreground">
<Harness />
</main>
</I18nProvider>,
);

View File

@ -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<Array<{ kit: { name: string; intro?: { templateId: string } } }>>(
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");
});

View File

@ -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<typeof edl>;
};
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");
});

View File

@ -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");
});

View File

@ -157,7 +157,11 @@ export function SourcePlayer({
}, [edl]); }, [edl]);
return ( return (
<div className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted"> <div
className="relative aspect-video w-full overflow-hidden rounded-lg bg-muted"
data-testid="media-source-player"
data-position-ms={outputMs}
>
{/* biome-ignore lint/a11y/useMediaCaption: source footage has no caption track. */} {/* biome-ignore lint/a11y/useMediaCaption: source footage has no caption track. */}
<video <video
ref={video} ref={video}

View File

@ -226,6 +226,7 @@ function ClipBlock({
<div <div
data-testid="media-clip" data-testid="media-clip"
data-clip-id={id} data-clip-id={id}
data-invalid={invalid || undefined}
className={cn( className={cn(
"absolute inset-y-1 flex items-stretch overflow-hidden rounded-md border bg-primary/15 text-ui-caption text-foreground", "absolute inset-y-1 flex items-stretch overflow-hidden rounded-md border bg-primary/15 text-ui-caption text-foreground",
selected ? "border-primary ring-2 ring-ring/50" : "border-primary/30", selected ? "border-primary ring-2 ring-ring/50" : "border-primary/30",

View File

@ -95,7 +95,7 @@ export function EditorView({
return () => { return () => {
live = false; live = false;
}; };
}, [projectId, planId, t]); }, [projectId, planId]);
const change = useCallback((next: Edl) => { const change = useCallback((next: Edl) => {
setEdl(next); setEdl(next);