evOo-local/apps/web/e2e/fixtures/media-editor-data.ts
Claude bab398cb52
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
2026-10-03 19:29:59 +00:00

433 lines
12 KiB
TypeScript

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