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:
parent
844f7fe7c1
commit
bab398cb52
432
apps/web/e2e/fixtures/media-editor-data.ts
Normal file
432
apps/web/e2e/fixtures/media-editor-data.ts
Normal 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: "" }));
|
||||
},
|
||||
};
|
||||
}
|
||||
12
apps/web/e2e/fixtures/media-editor.html
Normal file
12
apps/web/e2e/fixtures/media-editor.html
Normal 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>
|
||||
58
apps/web/e2e/fixtures/media-editor.tsx
Normal file
58
apps/web/e2e/fixtures/media-editor.tsx
Normal 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>,
|
||||
);
|
||||
60
apps/web/e2e/media-brand-kit.spec.ts
Normal file
60
apps/web/e2e/media-brand-kit.spec.ts
Normal 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");
|
||||
});
|
||||
144
apps/web/e2e/media-project.spec.ts
Normal file
144
apps/web/e2e/media-project.spec.ts
Normal 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");
|
||||
});
|
||||
117
apps/web/e2e/media-review.spec.ts
Normal file
117
apps/web/e2e/media-review.spec.ts
Normal 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");
|
||||
});
|
||||
@ -157,7 +157,11 @@ export function SourcePlayer({
|
||||
}, [edl]);
|
||||
|
||||
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. */}
|
||||
<video
|
||||
ref={video}
|
||||
|
||||
@ -226,6 +226,7 @@ function ClipBlock({
|
||||
<div
|
||||
data-testid="media-clip"
|
||||
data-clip-id={id}
|
||||
data-invalid={invalid || undefined}
|
||||
className={cn(
|
||||
"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",
|
||||
|
||||
@ -95,7 +95,7 @@ export function EditorView({
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [projectId, planId, t]);
|
||||
}, [projectId, planId]);
|
||||
|
||||
const change = useCallback((next: Edl) => {
|
||||
setEdl(next);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user