Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
433 lines
12 KiB
TypeScript
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: "" }));
|
|
},
|
|
};
|
|
}
|