Compare commits
2 Commits
884d8903ca
...
bab398cb52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bab398cb52 | ||
|
|
844f7fe7c1 |
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");
|
||||||
|
});
|
||||||
214
apps/web/src/components/media/editor/Player.tsx
Normal file
214
apps/web/src/components/media/editor/Player.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import type { Edl } from "@rakazo/contracts";
|
||||||
|
import { cn } from "@rakazo/ui-web";
|
||||||
|
import { useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||||
|
import { assetFileUrl, useMediaObjectUrl } from "./media-files";
|
||||||
|
import { clipLengthMs, clipOutputStartMs, edlDurationMs, locateOutputTime } from "./timeline";
|
||||||
|
|
||||||
|
export interface PlayerHandle {
|
||||||
|
/** Jump to an output timestamp in ms. */
|
||||||
|
seek(ms: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the source player currently shows, in source coordinates. */
|
||||||
|
export interface SourceFrame {
|
||||||
|
outputMs: number;
|
||||||
|
clipId: string;
|
||||||
|
assetId: string;
|
||||||
|
sourceMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays a finished render. Reports the playhead in output ms. */
|
||||||
|
export function RenderVideo({
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
handleRef,
|
||||||
|
onTime,
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
src: string | null;
|
||||||
|
poster?: string | null;
|
||||||
|
handleRef?: React.Ref<PlayerHandle>;
|
||||||
|
onTime?: (ms: number) => void;
|
||||||
|
className?: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const video = useRef<HTMLVideoElement>(null);
|
||||||
|
const pending = useRef<number | null>(null);
|
||||||
|
useImperativeHandle(handleRef, () => ({
|
||||||
|
seek(ms) {
|
||||||
|
const element = video.current;
|
||||||
|
if (!element || element.readyState < 1) {
|
||||||
|
pending.current = ms;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
element.currentTime = ms / 1000;
|
||||||
|
onTime?.(ms);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useMediaCaption: renders carry burned-in or sidecar captions.
|
||||||
|
<video
|
||||||
|
ref={video}
|
||||||
|
aria-label={label}
|
||||||
|
className={cn("aspect-video w-full rounded-lg bg-muted object-contain", className)}
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="metadata"
|
||||||
|
poster={poster ?? undefined}
|
||||||
|
src={src ?? undefined}
|
||||||
|
onLoadedMetadata={(event) => {
|
||||||
|
if (pending.current === null) return;
|
||||||
|
event.currentTarget.currentTime = pending.current / 1000;
|
||||||
|
pending.current = null;
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(event) => onTime?.(Math.round(event.currentTarget.currentTime * 1000))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a plan's timeline from the source proxies (the unredacted "before"
|
||||||
|
* view, and the only view of a render the gate blocked). Clips play in order;
|
||||||
|
* gaps and the intro show nothing.
|
||||||
|
*/
|
||||||
|
export function SourcePlayer({
|
||||||
|
edl,
|
||||||
|
handleRef,
|
||||||
|
onFrame,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
edl: Edl;
|
||||||
|
handleRef?: React.Ref<PlayerHandle>;
|
||||||
|
onFrame?: (frame: SourceFrame | null) => void;
|
||||||
|
label: string;
|
||||||
|
/** Overlays positioned over the video frame (masks, mark prompts). */
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [outputMs, setOutputMs] = useState(() => clipOutputStartMs(edl, firstClip(edl)));
|
||||||
|
const located = locateOutputTime(edl, outputMs);
|
||||||
|
const assetId = located?.clip.assetId ?? null;
|
||||||
|
const src = useMediaObjectUrl(assetId ? assetFileUrl(assetId, "proxy") : null);
|
||||||
|
const video = useRef<HTMLVideoElement>(null);
|
||||||
|
const playing = useRef(false);
|
||||||
|
const [ratio, setRatio] = useState(16 / 9);
|
||||||
|
|
||||||
|
const report = useCallback(
|
||||||
|
(ms: number) => {
|
||||||
|
const at = locateOutputTime(edl, ms);
|
||||||
|
onFrame?.(
|
||||||
|
at
|
||||||
|
? { outputMs: ms, clipId: at.clip.id, assetId: at.clip.assetId, sourceMs: at.sourceMs }
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[edl, onFrame],
|
||||||
|
);
|
||||||
|
|
||||||
|
const seek = useCallback(
|
||||||
|
(ms: number) => {
|
||||||
|
const clamped = Math.max(0, Math.min(ms, edlDurationMs(edl)));
|
||||||
|
setOutputMs(clamped);
|
||||||
|
const at = locateOutputTime(edl, clamped);
|
||||||
|
const element = video.current;
|
||||||
|
if (at && element && at.clip.assetId === assetId && element.readyState >= 1) {
|
||||||
|
element.currentTime = at.sourceMs / 1000;
|
||||||
|
}
|
||||||
|
report(clamped);
|
||||||
|
},
|
||||||
|
[edl, assetId, report],
|
||||||
|
);
|
||||||
|
useImperativeHandle(handleRef, () => ({ seek }), [seek]);
|
||||||
|
|
||||||
|
// A new clip's proxy loaded: start at the mapped source time.
|
||||||
|
const onLoaded = (element: HTMLVideoElement) => {
|
||||||
|
if (element.videoWidth > 0 && element.videoHeight > 0) {
|
||||||
|
setRatio(element.videoWidth / element.videoHeight);
|
||||||
|
}
|
||||||
|
if (located) element.currentTime = located.sourceMs / 1000;
|
||||||
|
if (playing.current) void element.play().catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTimeUpdate = (element: HTMLVideoElement) => {
|
||||||
|
if (!located) return;
|
||||||
|
const { clip } = located;
|
||||||
|
const sourceMs = element.currentTime * 1000;
|
||||||
|
const clipEnd = clipOutputStartMs(edl, clip) + clipLengthMs(clip);
|
||||||
|
if (sourceMs >= clip.outMs) {
|
||||||
|
const next = nextClipStart(edl, clipEnd);
|
||||||
|
if (next === null) {
|
||||||
|
element.pause();
|
||||||
|
playing.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setOutputMs(next);
|
||||||
|
report(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ms = clipOutputStartMs(edl, clip) + Math.round((sourceMs - clip.inMs) / clip.speed);
|
||||||
|
setOutputMs(ms);
|
||||||
|
report(ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Report once per plan; playback reports through onTimeUpdate.
|
||||||
|
useEffect(() => {
|
||||||
|
report(outputMs);
|
||||||
|
}, [edl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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}
|
||||||
|
aria-label={label}
|
||||||
|
className="absolute inset-0 h-full w-full object-contain"
|
||||||
|
controls
|
||||||
|
playsInline
|
||||||
|
preload="auto"
|
||||||
|
src={src ?? undefined}
|
||||||
|
onLoadedMetadata={(event) => onLoaded(event.currentTarget)}
|
||||||
|
onPlay={() => {
|
||||||
|
playing.current = true;
|
||||||
|
}}
|
||||||
|
onPause={() => {
|
||||||
|
playing.current = false;
|
||||||
|
}}
|
||||||
|
onTimeUpdate={(event) => onTimeUpdate(event.currentTarget)}
|
||||||
|
/>
|
||||||
|
{children ? (
|
||||||
|
// Children get the displayed frame, so normalized boxes map onto the picture.
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div
|
||||||
|
data-frame
|
||||||
|
className="relative"
|
||||||
|
style={{
|
||||||
|
aspectRatio: ratio,
|
||||||
|
...(ratio >= 16 / 9 ? { width: "100%" } : { height: "100%" }),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstClip(edl: Edl) {
|
||||||
|
return [...edl.tracks.video].sort((a, b) => a.atMs - b.atMs)[0] ?? edl.tracks.video[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output start of the first clip beginning at or after `ms`, or null. */
|
||||||
|
function nextClipStart(edl: Edl, ms: number): number | null {
|
||||||
|
let best: number | null = null;
|
||||||
|
for (const clip of edl.tracks.video) {
|
||||||
|
const start = clipOutputStartMs(edl, clip);
|
||||||
|
if (start >= ms && (best === null || start < best)) best = start;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
191
apps/web/src/components/media/editor/PublishPanel.tsx
Normal file
191
apps/web/src/components/media/editor/PublishPanel.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type {
|
||||||
|
MarketingCopy,
|
||||||
|
MediaPublication,
|
||||||
|
MediaRender,
|
||||||
|
MediaRenderOutput,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { Button, cn, Input, Textarea } from "@rakazo/ui-web";
|
||||||
|
import { Download } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { rpc } from "../../../lib/rpc";
|
||||||
|
import { LoadingState } from "../../ai/primitives";
|
||||||
|
import { ErrorLine, errorMessage } from "./chrome";
|
||||||
|
import {
|
||||||
|
bundleUrl,
|
||||||
|
downloadMediaFile,
|
||||||
|
mediaArtifactUrl,
|
||||||
|
renderFileUrl,
|
||||||
|
useMediaObjectUrl,
|
||||||
|
} from "./media-files";
|
||||||
|
import { thumbnailVariant } from "./renders";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnails, copy, and the publication bundle for a final render that passed
|
||||||
|
* its gate. Each step appears once the one before it has produced something.
|
||||||
|
*/
|
||||||
|
export function PublishPanel({ render }: { render: MediaRender }) {
|
||||||
|
const { t, i18n } = useLingui();
|
||||||
|
const [thumbs, setThumbs] = useState<MediaRenderOutput[]>(() =>
|
||||||
|
render.outputs.filter((output) => output.kind === "thumbnail"),
|
||||||
|
);
|
||||||
|
const [chosen, setChosen] = useState<string | null>(null);
|
||||||
|
const [copy, setCopy] = useState<MarketingCopy | null>(null);
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [caption, setCaption] = useState("");
|
||||||
|
const [tags, setTags] = useState("");
|
||||||
|
const [publication, setPublication] = useState<MediaPublication | null>(null);
|
||||||
|
const [busy, setBusy] = useState<"thumbs" | "copy" | "bundle" | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function run(step: "thumbs" | "copy" | "bundle", work: () => Promise<void>) {
|
||||||
|
setBusy(step);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeThumbs = () =>
|
||||||
|
run("thumbs", async () => {
|
||||||
|
const outputs = await rpc.media.renders.thumbnails({ renderId: render.id, count: 3 });
|
||||||
|
setThumbs(outputs);
|
||||||
|
setChosen(outputs[0] ? thumbnailVariant(outputs[0]) : null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const makeCopy = () =>
|
||||||
|
run("copy", async () => {
|
||||||
|
const next = await rpc.media.marketing.copy({
|
||||||
|
renderId: render.id,
|
||||||
|
language: i18n.locale.slice(0, 2) || "en",
|
||||||
|
});
|
||||||
|
setCopy(next);
|
||||||
|
setTitle(next.title);
|
||||||
|
setCaption([next.hook, next.caption].filter(Boolean).join("\n\n"));
|
||||||
|
setTags(next.hashtags.join(" "));
|
||||||
|
});
|
||||||
|
|
||||||
|
const download = (bundled: MediaPublication) =>
|
||||||
|
downloadMediaFile(
|
||||||
|
bundled.bundleArtifactId ? mediaArtifactUrl(bundled.bundleArtifactId) : bundleUrl(bundled.id),
|
||||||
|
`bundle-${bundled.id}.zip`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const makeBundle = () =>
|
||||||
|
run("bundle", async () => {
|
||||||
|
if (publication) {
|
||||||
|
await download(publication);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const created = await rpc.media.publications.create({
|
||||||
|
renderId: render.id,
|
||||||
|
title: title.trim() || null,
|
||||||
|
caption: caption.trim() || null,
|
||||||
|
tags: tags
|
||||||
|
.split(/\s+/)
|
||||||
|
.map((tag) => tag.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
thumbnailVariant: chosen,
|
||||||
|
});
|
||||||
|
const bundled = await rpc.media.publications.bundle({ publicationId: created.id });
|
||||||
|
setPublication(bundled);
|
||||||
|
await download(bundled);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3" data-testid="media-publish">
|
||||||
|
{thumbs.length === 0 ? (
|
||||||
|
<Button variant="outline" size="sm" disabled={busy !== null} onClick={makeThumbs}>
|
||||||
|
{busy === "thumbs" ? <LoadingState label={t`Thumbnails`} /> : <Trans>Thumbnails</Trans>}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{thumbs.map((output) => {
|
||||||
|
const variant = thumbnailVariant(output);
|
||||||
|
return (
|
||||||
|
<Thumbnail
|
||||||
|
key={output.fileName}
|
||||||
|
src={renderFileUrl(render.id, output.fileName)}
|
||||||
|
selected={chosen === variant}
|
||||||
|
label={variant}
|
||||||
|
onSelect={() => setChosen(variant)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{copy ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Input aria-label={t`Title`} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||||
|
<Textarea
|
||||||
|
aria-label={t`Caption`}
|
||||||
|
rows={4}
|
||||||
|
value={caption}
|
||||||
|
onChange={(e) => setCaption(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input aria-label={t`Hashtags`} value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||||
|
{copy.issues.map((issue) => (
|
||||||
|
<p
|
||||||
|
key={`${issue.code}-${issue.message}`}
|
||||||
|
className={cn(
|
||||||
|
"text-ui-body-sm",
|
||||||
|
issue.severity === "block" ? "text-destructive" : "text-warning",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{issue.message}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button variant="outline" size="sm" disabled={busy !== null} onClick={makeCopy}>
|
||||||
|
{busy === "copy" ? <LoadingState label={t`Copy`} /> : <Trans>Write copy</Trans>}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" disabled={busy !== null} onClick={makeBundle}>
|
||||||
|
{busy === "bundle" ? (
|
||||||
|
<LoadingState label={t`Bundle`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download />
|
||||||
|
{publication ? <Trans>Download again</Trans> : <Trans>Bundle</Trans>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Thumbnail({
|
||||||
|
src,
|
||||||
|
selected,
|
||||||
|
label,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
selected: boolean;
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const url = useMediaObjectUrl(src);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"aspect-video overflow-hidden rounded-md border bg-muted",
|
||||||
|
selected ? "border-primary ring-2 ring-ring/50" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{url ? <img src={url} alt="" className="h-full w-full object-cover" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
321
apps/web/src/components/media/editor/Timeline.tsx
Normal file
321
apps/web/src/components/media/editor/Timeline.tsx
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
import type { DragEndEvent, DragMoveEvent } from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useDraggable,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import type { Edl } from "@rakazo/contracts";
|
||||||
|
import { cn } from "@rakazo/ui-web";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { DragId } from "./timeline";
|
||||||
|
import {
|
||||||
|
applyDrag,
|
||||||
|
clipLengthMs,
|
||||||
|
edlDurationMs,
|
||||||
|
formatMs,
|
||||||
|
introMs,
|
||||||
|
mainProgramMs,
|
||||||
|
} from "./timeline";
|
||||||
|
|
||||||
|
export type TimelineSelection =
|
||||||
|
| { kind: "clip"; id: string }
|
||||||
|
| { kind: "overlay"; id: string }
|
||||||
|
| { kind: "captions" }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tracks of a plan: video clips (move, trim), overlays (move), audio, and
|
||||||
|
* captions. Positions are output time; the intro occupies the start.
|
||||||
|
*/
|
||||||
|
export function Timeline({
|
||||||
|
edl,
|
||||||
|
assetDurations,
|
||||||
|
selection,
|
||||||
|
invalid,
|
||||||
|
playheadMs,
|
||||||
|
onSelect,
|
||||||
|
onSeek,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
edl: Edl;
|
||||||
|
assetDurations: ReadonlyMap<string, number | null>;
|
||||||
|
selection: TimelineSelection;
|
||||||
|
/** Clip and overlay ids the last save rejected. */
|
||||||
|
invalid: ReadonlySet<string>;
|
||||||
|
playheadMs: number;
|
||||||
|
onSelect: (selection: TimelineSelection) => void;
|
||||||
|
onSeek: (ms: number) => void;
|
||||||
|
onChange: (edl: Edl) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const container = useRef<HTMLDivElement>(null);
|
||||||
|
const [width, setWidth] = useState(800);
|
||||||
|
const [drag, setDrag] = useState<{ id: DragId; deltaMs: number } | null>(null);
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 3 } }),
|
||||||
|
useSensor(KeyboardSensor),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = container.current;
|
||||||
|
if (!element) return;
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry) setWidth(entry.contentRect.width);
|
||||||
|
});
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const shown = drag ? applyDrag(edl, drag.id, drag.deltaMs, assetDurations) : edl;
|
||||||
|
const totalMs = Math.max(5000, Math.round(edlDurationMs(edl) * 1.15));
|
||||||
|
const pxPerMs = Math.max(0.005, width / totalMs);
|
||||||
|
const offset = introMs(shown);
|
||||||
|
const x = (ms: number) => ms * pxPerMs;
|
||||||
|
|
||||||
|
const toMs = (event: DragMoveEvent | DragEndEvent) => Math.round(event.delta.x / pxPerMs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragMove={(event) => setDrag({ id: event.active.id as DragId, deltaMs: toMs(event) })}
|
||||||
|
onDragCancel={() => setDrag(null)}
|
||||||
|
onDragEnd={(event) => {
|
||||||
|
setDrag(null);
|
||||||
|
const deltaMs = toMs(event);
|
||||||
|
if (deltaMs !== 0) {
|
||||||
|
onChange(applyDrag(edl, event.active.id as DragId, deltaMs, assetDurations));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={container}
|
||||||
|
className="relative flex select-none flex-col gap-1 overflow-hidden rounded-lg border border-border bg-card p-2"
|
||||||
|
data-testid="media-timeline"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t`Timeline position`}
|
||||||
|
className="relative h-5 w-full cursor-pointer text-left text-ui-caption text-muted-foreground"
|
||||||
|
onClick={(event) => {
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
onSeek(Math.round((event.clientX - box.left) / pxPerMs));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{ticks(totalMs).map((ms) => (
|
||||||
|
<span key={ms} className="absolute top-0" style={{ left: x(ms) }}>
|
||||||
|
{formatMs(ms)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Track label={t`Video`}>
|
||||||
|
{shown.intro ? (
|
||||||
|
<div
|
||||||
|
className="absolute inset-y-0 rounded-md bg-muted"
|
||||||
|
style={{ left: 0, width: x(shown.intro.durationMs) }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{shown.tracks.video.map((clip) => (
|
||||||
|
<ClipBlock
|
||||||
|
key={clip.id}
|
||||||
|
id={clip.id}
|
||||||
|
left={x(offset + clip.atMs)}
|
||||||
|
width={x(clipLengthMs(clip))}
|
||||||
|
selected={selection?.kind === "clip" && selection.id === clip.id}
|
||||||
|
invalid={invalid.has(clip.id)}
|
||||||
|
label={t`Clip ${clip.id}`}
|
||||||
|
onSelect={() => onSelect({ kind: "clip", id: clip.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Track>
|
||||||
|
|
||||||
|
<Track label={t`Overlays`}>
|
||||||
|
{shown.overlays.map((overlay) => (
|
||||||
|
<OverlayBlock
|
||||||
|
key={overlay.id}
|
||||||
|
id={overlay.id}
|
||||||
|
left={x(offset + overlay.atMs)}
|
||||||
|
width={x(overlay.durationMs)}
|
||||||
|
text={overlay.text ?? overlay.kind}
|
||||||
|
selected={selection?.kind === "overlay" && selection.id === overlay.id}
|
||||||
|
invalid={invalid.has(overlay.id)}
|
||||||
|
onSelect={() => onSelect({ kind: "overlay", id: overlay.id })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Track>
|
||||||
|
|
||||||
|
{shown.tracks.audio.length > 0 ? (
|
||||||
|
<Track label={t`Audio`}>
|
||||||
|
{shown.tracks.audio.map((clip) => (
|
||||||
|
<div
|
||||||
|
key={clip.id}
|
||||||
|
className="absolute inset-y-1 rounded-md bg-secondary"
|
||||||
|
style={{
|
||||||
|
left: x(offset + clip.atMs),
|
||||||
|
width: x(Math.max(0, clip.outMs - clip.inMs)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Track>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Track label={t`Captions`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selection?.kind === "captions"}
|
||||||
|
onClick={() => onSelect({ kind: "captions" })}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-1 rounded-md border text-left text-ui-caption",
|
||||||
|
shown.captions && shown.captions.mode !== "none"
|
||||||
|
? "border-border bg-accent px-2 text-accent-foreground"
|
||||||
|
: "border-dashed border-border px-2 text-muted-foreground",
|
||||||
|
selection?.kind === "captions" && "ring-2 ring-ring",
|
||||||
|
)}
|
||||||
|
style={{ left: x(offset), width: Math.max(x(mainProgramMs(shown)), 48) }}
|
||||||
|
>
|
||||||
|
{t`Captions`}
|
||||||
|
</button>
|
||||||
|
</Track>
|
||||||
|
|
||||||
|
<div
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none absolute top-2 bottom-2 w-px bg-primary"
|
||||||
|
style={{ left: 8 + x(playheadMs) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Track({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
// biome-ignore lint/a11y/useSemanticElements: a timeline track is a labelled group of blocks.
|
||||||
|
<div role="group" aria-label={label} className="relative h-10 rounded-md bg-muted/40">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClipBlock({
|
||||||
|
id,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
selected,
|
||||||
|
invalid,
|
||||||
|
label,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
selected: boolean;
|
||||||
|
invalid: boolean;
|
||||||
|
label: string;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const move = useDraggable({ id: `clip:${id}` });
|
||||||
|
const start = useDraggable({ id: `trim-start:${id}` });
|
||||||
|
const end = useDraggable({ id: `trim-end:${id}` });
|
||||||
|
const handle = "h-full w-2 shrink-0 cursor-ew-resize bg-primary/40 focus-visible:bg-primary";
|
||||||
|
return (
|
||||||
|
<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",
|
||||||
|
invalid && "border-destructive ring-2 ring-destructive/40",
|
||||||
|
move.isDragging && "opacity-80",
|
||||||
|
)}
|
||||||
|
style={{ left, width: Math.max(width, 8) }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={start.setNodeRef}
|
||||||
|
{...start.attributes}
|
||||||
|
{...start.listeners}
|
||||||
|
aria-label={t`Trim start`}
|
||||||
|
data-testid="media-trim-start"
|
||||||
|
className={handle}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={move.setNodeRef}
|
||||||
|
{...move.attributes}
|
||||||
|
{...move.listeners}
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1 cursor-grab truncate px-1 text-left",
|
||||||
|
move.isDragging && "cursor-grabbing",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{id}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={end.setNodeRef}
|
||||||
|
{...end.attributes}
|
||||||
|
{...end.listeners}
|
||||||
|
aria-label={t`Trim end`}
|
||||||
|
data-testid="media-trim-end"
|
||||||
|
className={handle}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OverlayBlock({
|
||||||
|
id,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
text,
|
||||||
|
selected,
|
||||||
|
invalid,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
left: number;
|
||||||
|
width: number;
|
||||||
|
text: string;
|
||||||
|
selected: boolean;
|
||||||
|
invalid: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
const move = useDraggable({ id: `overlay:${id}` });
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
ref={move.setNodeRef}
|
||||||
|
{...move.attributes}
|
||||||
|
{...move.listeners}
|
||||||
|
aria-label={text}
|
||||||
|
aria-pressed={selected}
|
||||||
|
data-testid="media-overlay"
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-1 cursor-grab truncate rounded-md border bg-secondary px-1 text-left text-ui-caption text-secondary-foreground",
|
||||||
|
selected ? "border-primary ring-2 ring-ring/50" : "border-border",
|
||||||
|
invalid && "border-destructive ring-2 ring-destructive/40",
|
||||||
|
)}
|
||||||
|
style={{ left, width: Math.max(width, 8) }}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ruler marks every 1, 5, 10, or 30 seconds, whichever keeps about eight labels. */
|
||||||
|
function ticks(totalMs: number): number[] {
|
||||||
|
const step = [1000, 5000, 10_000, 30_000, 60_000].find((ms) => totalMs / ms <= 10) ?? 120_000;
|
||||||
|
const out: number[] = [];
|
||||||
|
for (let ms = 0; ms < totalMs; ms += step) out.push(ms);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
60
apps/web/src/components/media/editor/chrome.tsx
Normal file
60
apps/web/src/components/media/editor/chrome.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import { Badge, Button, cn } from "@rakazo/ui-web";
|
||||||
|
import { ChevronLeft } from "lucide-react";
|
||||||
|
import type { GateStatus } from "./renders";
|
||||||
|
|
||||||
|
/** Header row of an editor-side view: back, title, and the view's actions. */
|
||||||
|
export function ViewHeader({
|
||||||
|
title,
|
||||||
|
onBack,
|
||||||
|
backLabel,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: React.ReactNode;
|
||||||
|
onBack: () => void;
|
||||||
|
backLabel: string;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<header className="flex min-h-control-lg items-center gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" aria-label={backLabel} onClick={onBack}>
|
||||||
|
<ChevronLeft />
|
||||||
|
</Button>
|
||||||
|
<h2 className="min-w-0 flex-1 truncate text-ui-title font-semibold">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gate outcome as a small status badge. */
|
||||||
|
export function GateBadge({ status }: { status: GateStatus }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const label = { pass: t`Passed`, warn: t`Warnings`, blocked: t`Blocked` }[status];
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
data-testid="media-gate-status"
|
||||||
|
data-status={status}
|
||||||
|
variant={status === "blocked" ? "destructive" : "outline"}
|
||||||
|
className={cn(
|
||||||
|
status === "pass" && "border-success/40 text-success",
|
||||||
|
status === "warn" && "border-warning/40 text-warning",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A request error in one line, under the control that caused it. */
|
||||||
|
export function ErrorLine({ error }: { error: string | null }) {
|
||||||
|
if (!error) return null;
|
||||||
|
return (
|
||||||
|
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error && error.message ? error.message : String(error);
|
||||||
|
}
|
||||||
147
apps/web/src/components/media/editor/fields.tsx
Normal file
147
apps/web/src/components/media/editor/fields.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { Field, FieldLabel, Input, NativeSelect, NativeSelectOption, Switch } from "@rakazo/ui-web";
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compact labelled inputs for the inspector panels. Times are edited in
|
||||||
|
* seconds and stored in milliseconds.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function SecondsField({
|
||||||
|
label,
|
||||||
|
ms,
|
||||||
|
min = 0,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
ms: number;
|
||||||
|
min?: number;
|
||||||
|
onChange: (ms: number) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
const [text, setText] = useState(() => (ms / 1000).toFixed(1));
|
||||||
|
useEffect(() => setText((ms / 1000).toFixed(1)), [ms]);
|
||||||
|
return (
|
||||||
|
<Field className="gap-1">
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step={0.1}
|
||||||
|
min={min / 1000}
|
||||||
|
value={text}
|
||||||
|
onChange={(event) => setText(event.target.value)}
|
||||||
|
onBlur={() => {
|
||||||
|
const seconds = Number(text);
|
||||||
|
if (Number.isFinite(seconds) && seconds * 1000 >= min)
|
||||||
|
onChange(Math.round(seconds * 1000));
|
||||||
|
else setText((ms / 1000).toFixed(1));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumberField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
step = 1,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
step?: number;
|
||||||
|
onChange: (value: number) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<Field className="gap-1">
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={step}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
if (Number.isFinite(next)) onChange(Math.min(max, Math.max(min, next)));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectField<T extends string>({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: T;
|
||||||
|
options: ReadonlyArray<{ value: T; label: string }>;
|
||||||
|
onChange: (value: T) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<Field className="gap-1">
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<NativeSelect id={id} value={value} onChange={(event) => onChange(event.target.value as T)}>
|
||||||
|
{options.map((option) => (
|
||||||
|
<NativeSelectOption key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
maxLength,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
maxLength?: number;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<Field className="gap-1">
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
value={value}
|
||||||
|
maxLength={maxLength}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToggleField({
|
||||||
|
label,
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<Field orientation="horizontal" className="items-center gap-2">
|
||||||
|
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/web/src/components/media/editor/media-files.ts
Normal file
93
apps/web/src/components/media/editor/media-files.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import type { MediaRender } from "@rakazo/contracts";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { withSpaceHeaders } from "../../../lib/rpc";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Media byte routes need the selected space header, which `<video src>` and
|
||||||
|
* `<img src>` cannot send. Files are fetched with the header and shown through
|
||||||
|
* object URLs; renders and previews are short, so whole-file fetches are fine.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function renderFileUrl(renderId: string, fileName: string, download = false): string {
|
||||||
|
const base = `/api/media/renders/${encodeURIComponent(renderId)}/files/${encodeURIComponent(fileName)}`;
|
||||||
|
return download ? `${base}?download=1` : base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assetFileUrl(assetId: string, kind: "thumb" | "proxy"): string {
|
||||||
|
return `/api/media/assets/${encodeURIComponent(assetId)}/${kind}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbnails, intro previews, and other media outputs stored as space artifacts. */
|
||||||
|
export function mediaArtifactUrl(artifactId: string): string {
|
||||||
|
return `/api/media/artifacts/${encodeURIComponent(artifactId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bundleUrl(publicationId: string): string {
|
||||||
|
return `/api/media/bundles/${encodeURIComponent(publicationId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The streamable video of a render, when it has one. */
|
||||||
|
export function renderVideoUrl(render: Pick<MediaRender, "id" | "outputs">): string | null {
|
||||||
|
const video = render.outputs.find((output) => output.kind === "video");
|
||||||
|
return video ? renderFileUrl(render.id, video.fileName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPosterUrl(render: Pick<MediaRender, "id" | "outputs">): string | null {
|
||||||
|
const poster = render.outputs.find((output) => output.kind === "poster");
|
||||||
|
return poster ? renderFileUrl(render.id, poster.fileName) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An object URL for a media byte route, or null while loading or when missing. */
|
||||||
|
export function useMediaObjectUrl(url: string | null): string | null {
|
||||||
|
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
setObjectUrl(null);
|
||||||
|
if (!url) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
let created: string | null = null;
|
||||||
|
void fetch(url, {
|
||||||
|
headers: withSpaceHeaders(),
|
||||||
|
credentials: "include",
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (!response.ok) return;
|
||||||
|
const blob = await response.blob();
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
created = URL.createObjectURL(blob);
|
||||||
|
setObjectUrl(created);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
if (created) URL.revokeObjectURL(created);
|
||||||
|
};
|
||||||
|
}, [url]);
|
||||||
|
return objectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Download a media byte route as a file. */
|
||||||
|
export async function downloadMediaFile(url: string, fileName: string): Promise<void> {
|
||||||
|
const response = await fetch(url, { headers: withSpaceHeaders(), credentials: "include" });
|
||||||
|
if (!response.ok) throw new Error(`Download failed (${response.status})`);
|
||||||
|
const href = URL.createObjectURL(await response.blob());
|
||||||
|
const anchor = document.createElement("a");
|
||||||
|
anchor.href = href;
|
||||||
|
anchor.download = fileName;
|
||||||
|
anchor.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(href), 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run `tick` every `intervalMs` while `active`; stops as soon as work is idle.
|
||||||
|
* The latest `tick` is always used, so callers need not memoize it.
|
||||||
|
*/
|
||||||
|
export function usePolling(tick: () => unknown, active: boolean, intervalMs: number): void {
|
||||||
|
const latest = useRef(tick);
|
||||||
|
latest.current = tick;
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const timer = window.setInterval(() => void latest.current(), intervalMs);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, [active, intervalMs]);
|
||||||
|
}
|
||||||
135
apps/web/src/components/media/editor/renders.test.ts
Normal file
135
apps/web/src/components/media/editor/renders.test.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import type { GateIssue, MediaEditPlan, MediaRender } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
gateStatus,
|
||||||
|
projectVariants,
|
||||||
|
reviewedPlan,
|
||||||
|
sortedIssues,
|
||||||
|
thumbnailVariant,
|
||||||
|
} from "./renders";
|
||||||
|
|
||||||
|
function render(patch: Partial<MediaRender>): MediaRender {
|
||||||
|
return {
|
||||||
|
id: "r1",
|
||||||
|
planId: "p1",
|
||||||
|
projectId: "proj",
|
||||||
|
profileId: "youtube-shorts",
|
||||||
|
quality: "draft",
|
||||||
|
status: "done",
|
||||||
|
progress: 1,
|
||||||
|
outputs: [],
|
||||||
|
gateReport: null,
|
||||||
|
ffprobe: null,
|
||||||
|
error: null,
|
||||||
|
createdAt: "2026-10-01T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-10-01T10:00:00.000Z",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function plan(patch: Partial<MediaEditPlan>): MediaEditPlan {
|
||||||
|
return {
|
||||||
|
id: "p1",
|
||||||
|
projectId: "proj",
|
||||||
|
profileId: "youtube-shorts",
|
||||||
|
version: 1,
|
||||||
|
edl: {} as MediaEditPlan["edl"],
|
||||||
|
createdBy: "user",
|
||||||
|
rationale: null,
|
||||||
|
createdAt: "2026-10-01T09:00:00.000Z",
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = (patch: Partial<GateIssue>): GateIssue => ({
|
||||||
|
check: "content",
|
||||||
|
severity: "block",
|
||||||
|
code: "x",
|
||||||
|
message: "m",
|
||||||
|
...patch,
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = (issues: GateIssue[], passed: boolean, autoFixRound = 0) => ({
|
||||||
|
profileId: "youtube-shorts",
|
||||||
|
passed,
|
||||||
|
checkedAt: "2026-10-01T10:00:00.000Z",
|
||||||
|
sampledFrames: 10,
|
||||||
|
issues,
|
||||||
|
autoFixRound,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("gateStatus", () => {
|
||||||
|
it("is null while running or without a report", () => {
|
||||||
|
expect(gateStatus(render({ status: "rendering" }))).toBeNull();
|
||||||
|
expect(gateStatus(render({ gateReport: null }))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("separates pass, warnings, and blocked", () => {
|
||||||
|
expect(gateStatus(render({ gateReport: report([], true) }))).toBe("pass");
|
||||||
|
expect(gateStatus(render({ gateReport: report([issue({ severity: "warn" })], true) }))).toBe(
|
||||||
|
"warn",
|
||||||
|
);
|
||||||
|
expect(gateStatus(render({ status: "blocked", gateReport: report([issue({})], false) }))).toBe(
|
||||||
|
"blocked",
|
||||||
|
);
|
||||||
|
// A failed report never reads as passed, whatever the issue list says.
|
||||||
|
expect(gateStatus(render({ gateReport: report([], false) }))).toBe("blocked");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortedIssues", () => {
|
||||||
|
it("orders by time, untimed last, blockers first at equal time", () => {
|
||||||
|
const sorted = sortedIssues([
|
||||||
|
issue({ code: "untimed" }),
|
||||||
|
issue({ code: "late", atMs: 5000 }),
|
||||||
|
issue({ code: "warn", atMs: 1000, severity: "warn" }),
|
||||||
|
issue({ code: "block", atMs: 1000 }),
|
||||||
|
]);
|
||||||
|
expect(sorted.map((item) => item.code)).toEqual(["block", "warn", "late", "untimed"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("projectVariants", () => {
|
||||||
|
it("keeps one card per profile with the newest plan and renders", () => {
|
||||||
|
const plans = [
|
||||||
|
plan({ id: "p1", version: 1 }),
|
||||||
|
plan({ id: "p2", version: 2 }),
|
||||||
|
plan({ id: "q1", profileId: "tiktok" }),
|
||||||
|
];
|
||||||
|
const renders = [
|
||||||
|
render({ id: "d1", createdAt: "2026-10-01T10:00:00.000Z" }),
|
||||||
|
render({ id: "d2", createdAt: "2026-10-01T11:00:00.000Z" }),
|
||||||
|
render({ id: "f1", quality: "final" }),
|
||||||
|
];
|
||||||
|
const variants = projectVariants(["youtube-shorts", "instagram-reels"], plans, renders);
|
||||||
|
expect(variants.map((variant) => variant.profileId)).toEqual([
|
||||||
|
"youtube-shorts",
|
||||||
|
"instagram-reels",
|
||||||
|
"tiktok",
|
||||||
|
]);
|
||||||
|
expect(variants[0]?.plan?.id).toBe("p2");
|
||||||
|
expect(variants[0]?.draft?.id).toBe("d2");
|
||||||
|
expect(variants[0]?.final?.id).toBe("f1");
|
||||||
|
expect(variants[1]?.plan).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("reviewedPlan", () => {
|
||||||
|
it("follows auto-fix versions created by the gate", () => {
|
||||||
|
const plans = [
|
||||||
|
plan({ id: "p1", version: 1 }),
|
||||||
|
plan({ id: "p2", version: 2, createdBy: "agent" }),
|
||||||
|
plan({ id: "p3", version: 3, createdBy: "agent" }),
|
||||||
|
];
|
||||||
|
expect(reviewedPlan(render({ planId: "p1" }), plans)?.id).toBe("p1");
|
||||||
|
expect(
|
||||||
|
reviewedPlan(render({ planId: "p1", gateReport: report([], false, 1) }), plans)?.id,
|
||||||
|
).toBe("p2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("thumbnailVariant", () => {
|
||||||
|
it("strips the extension", () => {
|
||||||
|
expect(thumbnailVariant({ fileName: "thumb-b.png" })).toBe("thumb-b");
|
||||||
|
});
|
||||||
|
});
|
||||||
108
apps/web/src/components/media/editor/renders.ts
Normal file
108
apps/web/src/components/media/editor/renders.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import type {
|
||||||
|
GateIssue,
|
||||||
|
MediaEditPlan,
|
||||||
|
MediaRender,
|
||||||
|
MediaRenderOutput,
|
||||||
|
MediaRenderQuality,
|
||||||
|
MediaRenderStatus,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
|
||||||
|
/** Renders still moving; clients poll while any is in one of these states. */
|
||||||
|
const ACTIVE: ReadonlySet<MediaRenderStatus> = new Set(["queued", "rendering", "gating"]);
|
||||||
|
|
||||||
|
/** How often to re-read a render (or project) while work is in progress. */
|
||||||
|
export const RENDER_POLL_MS = 2000;
|
||||||
|
|
||||||
|
export function isRenderActive(render: Pick<MediaRender, "status">): boolean {
|
||||||
|
return ACTIVE.has(render.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GateStatus = "pass" | "warn" | "blocked";
|
||||||
|
|
||||||
|
/** Gate outcome of a finished render, or null while there is no report yet. */
|
||||||
|
export function gateStatus(render: Pick<MediaRender, "status" | "gateReport">): GateStatus | null {
|
||||||
|
const report = render.gateReport;
|
||||||
|
if (render.status === "blocked") return "blocked";
|
||||||
|
if (!report || isRenderActive(render)) return null;
|
||||||
|
if (!report.passed || report.issues.some((issue) => issue.severity === "block")) {
|
||||||
|
return "blocked";
|
||||||
|
}
|
||||||
|
return report.issues.length > 0 ? "warn" : "pass";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gate issues in timeline order (untimed ones last), blockers before warnings at equal times. */
|
||||||
|
export function sortedIssues(issues: GateIssue[]): GateIssue[] {
|
||||||
|
const at = (issue: GateIssue) => issue.atMs ?? Number.POSITIVE_INFINITY;
|
||||||
|
return [...issues].sort((a, b) => {
|
||||||
|
if (at(a) !== at(b)) return at(a) < at(b) ? -1 : 1;
|
||||||
|
if (a.severity !== b.severity) return a.severity === "block" ? -1 : 1;
|
||||||
|
return a.code.localeCompare(b.code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function newest<T extends { createdAt: string }>(items: T[]): T | null {
|
||||||
|
let best: T | null = null;
|
||||||
|
for (const item of items) if (!best || item.createdAt > best.createdAt) best = item;
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Variant {
|
||||||
|
profileId: string;
|
||||||
|
/** Newest plan version for the profile. */
|
||||||
|
plan: MediaEditPlan | null;
|
||||||
|
/** Newest render of the profile in each quality. */
|
||||||
|
draft: MediaRender | null;
|
||||||
|
final: MediaRender | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One variant per project profile: its newest plan and newest draft and final renders. */
|
||||||
|
export function projectVariants(
|
||||||
|
profileIds: string[],
|
||||||
|
plans: MediaEditPlan[],
|
||||||
|
renders: MediaRender[],
|
||||||
|
): Variant[] {
|
||||||
|
const ids = [...profileIds];
|
||||||
|
for (const plan of plans) if (!ids.includes(plan.profileId)) ids.push(plan.profileId);
|
||||||
|
return ids.map((profileId) => {
|
||||||
|
const own = plans.filter((plan) => plan.profileId === profileId);
|
||||||
|
let plan: MediaEditPlan | null = null;
|
||||||
|
for (const candidate of own) if (!plan || candidate.version > plan.version) plan = candidate;
|
||||||
|
const ofQuality = (quality: MediaRenderQuality) =>
|
||||||
|
newest(
|
||||||
|
renders.filter((render) => render.profileId === profileId && render.quality === quality),
|
||||||
|
);
|
||||||
|
return { profileId, plan, draft: ofQuality("draft"), final: ofQuality("final") };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan whose timeline a render's gate report refers to: the started plan,
|
||||||
|
* or the newest auto-fix version of it when the gate repaired the plan.
|
||||||
|
*/
|
||||||
|
export function reviewedPlan(render: MediaRender, plans: MediaEditPlan[]): MediaEditPlan | null {
|
||||||
|
const started = plans.find((plan) => plan.id === render.planId) ?? null;
|
||||||
|
const rounds = render.gateReport?.autoFixRound ?? 0;
|
||||||
|
if (!started || rounds === 0) return started;
|
||||||
|
let best = started;
|
||||||
|
for (const plan of plans) {
|
||||||
|
if (
|
||||||
|
plan.profileId === started.profileId &&
|
||||||
|
plan.version > best.version &&
|
||||||
|
plan.version <= started.version + rounds &&
|
||||||
|
plan.createdBy === "agent"
|
||||||
|
) {
|
||||||
|
best = plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when anything in a project still renders. */
|
||||||
|
export function anyRenderActive(renders: MediaRender[]): boolean {
|
||||||
|
return renders.some(isRenderActive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Thumbnail variant name from its output file (`thumb-a.png` → `thumb-a`). */
|
||||||
|
export function thumbnailVariant(output: Pick<MediaRenderOutput, "fileName">): string {
|
||||||
|
return output.fileName.replace(/\.[a-z0-9]+$/i, "");
|
||||||
|
}
|
||||||
162
apps/web/src/components/media/editor/review-parts.tsx
Normal file
162
apps/web/src/components/media/editor/review-parts.tsx
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import type { GateIssue } from "@rakazo/contracts";
|
||||||
|
import { cn } from "@rakazo/ui-web";
|
||||||
|
import { useRef } from "react";
|
||||||
|
import type { MaskBox } from "./timeline";
|
||||||
|
import { clampBox, formatMs } from "./timeline";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A draggable, resizable mask box over the source frame. Coordinates are
|
||||||
|
* normalized to the frame; the parent must be the displayed frame itself.
|
||||||
|
*/
|
||||||
|
export function MaskBoxEditor({
|
||||||
|
box,
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
box: MaskBox;
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: () => void;
|
||||||
|
onChange: (box: MaskBox) => void;
|
||||||
|
}) {
|
||||||
|
const gesture = useRef<{
|
||||||
|
mode: "move" | "resize";
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
start: MaskBox;
|
||||||
|
frame: DOMRect;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const begin = (mode: "move" | "resize") => (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
const frame = event.currentTarget.closest("[data-frame]")?.getBoundingClientRect();
|
||||||
|
if (!frame) return;
|
||||||
|
onSelect();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
gesture.current = { mode, x: event.clientX, y: event.clientY, start: box, frame };
|
||||||
|
};
|
||||||
|
const move = (event: React.PointerEvent<HTMLElement>) => {
|
||||||
|
const active = gesture.current;
|
||||||
|
if (!active) return;
|
||||||
|
const dx = (event.clientX - active.x) / active.frame.width;
|
||||||
|
const dy = (event.clientY - active.y) / active.frame.height;
|
||||||
|
const { start } = active;
|
||||||
|
onChange(
|
||||||
|
clampBox(
|
||||||
|
active.mode === "move"
|
||||||
|
? { ...start, x: start.x + dx, y: start.y + dy }
|
||||||
|
: { ...start, w: start.w + dx, h: start.h + dy },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const end = () => {
|
||||||
|
gesture.current = null;
|
||||||
|
};
|
||||||
|
const nudge = (event: React.KeyboardEvent) => {
|
||||||
|
const step = event.shiftKey ? 0.05 : 0.01;
|
||||||
|
const delta: Record<string, [number, number]> = {
|
||||||
|
ArrowLeft: [-step, 0],
|
||||||
|
ArrowRight: [step, 0],
|
||||||
|
ArrowUp: [0, -step],
|
||||||
|
ArrowDown: [0, step],
|
||||||
|
};
|
||||||
|
const [dx, dy] = delta[event.key] ?? [0, 0];
|
||||||
|
if (dx === 0 && dy === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
onChange(clampBox({ ...box, x: box.x + dx, y: box.y + dy }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
aria-pressed={selected}
|
||||||
|
data-testid="media-mask"
|
||||||
|
onPointerDown={begin("move")}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={end}
|
||||||
|
onPointerCancel={end}
|
||||||
|
onKeyDown={nudge}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto absolute cursor-move touch-none rounded-sm border-2 bg-foreground/20 backdrop-blur-sm",
|
||||||
|
selected ? "border-primary" : "border-background",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
left: `${box.x * 100}%`,
|
||||||
|
top: `${box.y * 100}%`,
|
||||||
|
width: `${box.w * 100}%`,
|
||||||
|
height: `${box.h * 100}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
onPointerDown={begin("resize")}
|
||||||
|
onPointerMove={move}
|
||||||
|
onPointerUp={end}
|
||||||
|
onPointerCancel={end}
|
||||||
|
className="absolute -right-1.5 -bottom-1.5 size-3 cursor-nwse-resize rounded-sm border border-background bg-primary"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoverageRow {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
issues: GateIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per person and per mark: a bar over the program length, tinted where the
|
||||||
|
* gate found the protection incomplete. A clean row means full coverage.
|
||||||
|
*/
|
||||||
|
export function CoverageHeatmap({
|
||||||
|
rows,
|
||||||
|
durationMs,
|
||||||
|
onSeek,
|
||||||
|
}: {
|
||||||
|
rows: CoverageRow[];
|
||||||
|
durationMs: number;
|
||||||
|
onSeek: (ms: number) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
if (rows.length === 0 || durationMs <= 0) return null;
|
||||||
|
const pct = (ms: number) => `${Math.min(100, Math.max(0, (ms / durationMs) * 100))}%`;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5" data-testid="media-coverage">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.key} className="grid grid-cols-[8rem_1fr] items-center gap-2">
|
||||||
|
<span className="truncate text-ui-body-sm">{row.label}</span>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative h-3 rounded-full",
|
||||||
|
row.issues.length === 0 ? "bg-success/30" : "bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.issues.map((issue, index) => {
|
||||||
|
const at = issue.atMs ?? 0;
|
||||||
|
const end = Math.max(issue.endMs ?? at, at + durationMs * 0.01);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${issue.code}-${at}-${index}`}
|
||||||
|
type="button"
|
||||||
|
aria-label={t`${formatMs(at)}: ${issue.message}`}
|
||||||
|
onClick={() => onSeek(at)}
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-y-0 rounded-full",
|
||||||
|
issue.severity === "block" ? "bg-destructive" : "bg-warning",
|
||||||
|
)}
|
||||||
|
style={{ left: pct(at), width: `calc(${pct(end)} - ${pct(at)})` }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
152
apps/web/src/components/media/editor/timeline.test.ts
Normal file
152
apps/web/src/components/media/editor/timeline.test.ts
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import type { Edl } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
addRegion,
|
||||||
|
applyDrag,
|
||||||
|
clampBox,
|
||||||
|
edlDurationMs,
|
||||||
|
errorTarget,
|
||||||
|
formatMs,
|
||||||
|
freeId,
|
||||||
|
locateOutputTime,
|
||||||
|
moveClip,
|
||||||
|
regionBoxAt,
|
||||||
|
setRegionKeyframe,
|
||||||
|
trimClip,
|
||||||
|
} from "./timeline";
|
||||||
|
|
||||||
|
function edl(): Edl {
|
||||||
|
return {
|
||||||
|
edlVersion: 1,
|
||||||
|
output: {
|
||||||
|
profileId: "example-short-vertical",
|
||||||
|
width: 1080,
|
||||||
|
height: 1920,
|
||||||
|
fps: 30,
|
||||||
|
durationTargetMs: 8000,
|
||||||
|
durationHardMinMs: 1000,
|
||||||
|
durationHardMaxMs: 60000,
|
||||||
|
},
|
||||||
|
tracks: {
|
||||||
|
video: [
|
||||||
|
{
|
||||||
|
id: "a",
|
||||||
|
assetId: "asset-1",
|
||||||
|
inMs: 1000,
|
||||||
|
outMs: 4000,
|
||||||
|
atMs: 0,
|
||||||
|
speed: 1,
|
||||||
|
reframe: "auto",
|
||||||
|
stabilize: false,
|
||||||
|
muted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "b",
|
||||||
|
assetId: "asset-2",
|
||||||
|
inMs: 0,
|
||||||
|
outMs: 4000,
|
||||||
|
atMs: 3000,
|
||||||
|
speed: 2,
|
||||||
|
reframe: "none",
|
||||||
|
stabilize: false,
|
||||||
|
muted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
audio: [],
|
||||||
|
},
|
||||||
|
transitions: [],
|
||||||
|
overlays: [{ id: "text-1", kind: "text", atMs: 500, durationMs: 1000, text: "Hi" }],
|
||||||
|
intro: { templateId: "intro-minimal", params: {}, durationMs: 1500 },
|
||||||
|
redactions: { faces: [], marks: [], regions: [] },
|
||||||
|
metadata: { tags: [], aiAltered: false, aiAlterations: [] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("timeline math", () => {
|
||||||
|
it("counts intro, clips at their speed, and outro", () => {
|
||||||
|
// intro 1500 + clip b ends at 3000 + 4000/2
|
||||||
|
expect(edlDurationMs(edl())).toBe(6500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps output time to the clip and source time, intro to nothing", () => {
|
||||||
|
expect(locateOutputTime(edl(), 1000)).toBeNull();
|
||||||
|
const first = locateOutputTime(edl(), 1500 + 500);
|
||||||
|
expect(first?.clip.id).toBe("a");
|
||||||
|
expect(first?.sourceMs).toBe(1500);
|
||||||
|
const second = locateOutputTime(edl(), 1500 + 4000);
|
||||||
|
expect(second?.clip.id).toBe("b");
|
||||||
|
expect(second?.sourceMs).toBe(2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves clips but never before zero", () => {
|
||||||
|
expect(moveClip(edl(), "b", 500).tracks.video[1]?.atMs).toBe(3500);
|
||||||
|
expect(moveClip(edl(), "a", -900).tracks.video[0]?.atMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims the end inside the asset and keeps a minimum length", () => {
|
||||||
|
expect(trimClip(edl(), "a", "end", 5000, 4500).tracks.video[0]?.outMs).toBe(4500);
|
||||||
|
expect(trimClip(edl(), "a", "end", -10_000, 4500).tracks.video[0]?.outMs).toBe(1100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims the start without moving the clip end on the timeline", () => {
|
||||||
|
const trimmed = trimClip(edl(), "b", "start", 500, 4000).tracks.video[1];
|
||||||
|
// 500 ms of output at speed 2 is 1000 ms of source.
|
||||||
|
expect(trimmed?.inMs).toBe(1000);
|
||||||
|
expect(trimmed?.atMs).toBe(3500);
|
||||||
|
// Cannot pull the start before the asset begins.
|
||||||
|
const extended = trimClip(edl(), "a", "start", -5000, 4000).tracks.video[0];
|
||||||
|
expect(extended?.inMs).toBe(1000);
|
||||||
|
expect(extended?.atMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies drags by handle id", () => {
|
||||||
|
const durations = new Map([
|
||||||
|
["asset-1", 4500],
|
||||||
|
["asset-2", 4000],
|
||||||
|
]);
|
||||||
|
expect(applyDrag(edl(), "trim-end:a", 300, durations).tracks.video[0]?.outMs).toBe(4300);
|
||||||
|
expect(applyDrag(edl(), "overlay:text-1", 250, durations).overlays[0]?.atMs).toBe(750);
|
||||||
|
expect(applyDrag(edl(), "clip:a", 100, durations).tracks.video[0]?.atMs).toBe(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("points EDL errors at the clip or overlay they name", () => {
|
||||||
|
expect(errorTarget(edl(), { path: "tracks.video[1].outMs" })).toEqual({
|
||||||
|
kind: "clip",
|
||||||
|
id: "b",
|
||||||
|
});
|
||||||
|
expect(errorTarget(edl(), { path: "overlays[0].box" })).toEqual({
|
||||||
|
kind: "overlay",
|
||||||
|
id: "text-1",
|
||||||
|
});
|
||||||
|
expect(errorTarget(edl(), { path: "output" })).toBeNull();
|
||||||
|
expect(errorTarget(edl(), { path: "tracks.video[9]" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats timestamps", () => {
|
||||||
|
expect(formatMs(0)).toBe("0:00.0");
|
||||||
|
expect(formatMs(65_300)).toBe("1:05.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds free ids", () => {
|
||||||
|
expect(freeId(["mask-1", "mask-2"], "mask")).toBe("mask-3");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("masks", () => {
|
||||||
|
it("adds a region and keyframes it in source time", () => {
|
||||||
|
const added = addRegion(edl(), "a", 2000);
|
||||||
|
expect(added.id).toBe("mask-1");
|
||||||
|
const moved = setRegionKeyframe(added.edl, added.id, 3000, { x: 0.9, y: 0.1, w: 0.3, h: 0.2 });
|
||||||
|
const region = moved.redactions.regions[0]!;
|
||||||
|
expect(region.keyframes.map((keyframe) => keyframe.tMs)).toEqual([2000, 3000]);
|
||||||
|
// Clamped into the frame.
|
||||||
|
expect(region.keyframes[1]?.box.x).toBeCloseTo(0.7);
|
||||||
|
expect(regionBoxAt(region, 2500)).toEqual(region.keyframes[0]?.box);
|
||||||
|
expect(regionBoxAt(region, 3500)).toEqual(region.keyframes[1]?.box);
|
||||||
|
expect(regionBoxAt(region, 0)).toEqual(region.keyframes[0]?.box);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clamps boxes to a minimum size inside the frame", () => {
|
||||||
|
expect(clampBox({ x: -1, y: 2, w: 0, h: 5 })).toEqual({ x: 0, y: 0, w: 0.02, h: 1 });
|
||||||
|
});
|
||||||
|
});
|
||||||
271
apps/web/src/components/media/editor/timeline.ts
Normal file
271
apps/web/src/components/media/editor/timeline.ts
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
import type { Edl, EdlError, EdlOverlay, EdlVideoClip } from "@rakazo/contracts";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Timeline math for the editor and review player. Mirrors the timing helpers
|
||||||
|
* in `@rakazo/media` (`clipLengthMs`, `edlDurationMs`, `locateOutputTime`),
|
||||||
|
* which the web app cannot import yet; the server validates every plan anyway.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Shortest clip the editor lets a trim produce. */
|
||||||
|
export const MIN_CLIP_MS = 100;
|
||||||
|
|
||||||
|
export function clipLengthMs(clip: Pick<EdlVideoClip, "inMs" | "outMs" | "speed">): number {
|
||||||
|
return Math.max(0, Math.round((clip.outMs - clip.inMs) / clip.speed));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function introMs(edl: Edl): number {
|
||||||
|
return edl.intro?.durationMs ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mainProgramMs(edl: Edl): number {
|
||||||
|
let end = 0;
|
||||||
|
for (const clip of edl.tracks.video) end = Math.max(end, clip.atMs + clipLengthMs(clip));
|
||||||
|
return end;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function edlDurationMs(edl: Edl): number {
|
||||||
|
return introMs(edl) + mainProgramMs(edl) + (edl.outro?.durationMs ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The clip and source time an output timestamp shows; the incoming clip wins during a blend. */
|
||||||
|
export function locateOutputTime(
|
||||||
|
edl: Edl,
|
||||||
|
outputMs: number,
|
||||||
|
): { clip: EdlVideoClip; sourceMs: number } | null {
|
||||||
|
const t = outputMs - introMs(edl);
|
||||||
|
if (t < 0) return null;
|
||||||
|
const visible = edl.tracks.video
|
||||||
|
.filter((clip) => t >= clip.atMs && t < clip.atMs + clipLengthMs(clip))
|
||||||
|
.sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id));
|
||||||
|
const clip = visible.at(-1);
|
||||||
|
if (!clip) return null;
|
||||||
|
return {
|
||||||
|
clip,
|
||||||
|
sourceMs: Math.min(clip.outMs, Math.round(clip.inMs + (t - clip.atMs) * clip.speed)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output timestamp where a clip starts (intro included). */
|
||||||
|
export function clipOutputStartMs(edl: Edl, clip: EdlVideoClip): number {
|
||||||
|
return introMs(edl) + clip.atMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceClip(edl: Edl, clipId: string, next: (clip: EdlVideoClip) => EdlVideoClip): Edl {
|
||||||
|
return {
|
||||||
|
...edl,
|
||||||
|
tracks: {
|
||||||
|
...edl.tracks,
|
||||||
|
video: edl.tracks.video.map((clip) => (clip.id === clipId ? next(clip) : clip)),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move a clip on the output timeline. */
|
||||||
|
export function moveClip(edl: Edl, clipId: string, deltaMs: number): Edl {
|
||||||
|
return replaceClip(edl, clipId, (clip) => ({
|
||||||
|
...clip,
|
||||||
|
atMs: Math.max(0, Math.round(clip.atMs + deltaMs)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trim a clip edge by an output-time delta. The start edge keeps the clip's
|
||||||
|
* end in place on the timeline; both edges stay inside the source asset.
|
||||||
|
*/
|
||||||
|
export function trimClip(
|
||||||
|
edl: Edl,
|
||||||
|
clipId: string,
|
||||||
|
edge: "start" | "end",
|
||||||
|
deltaMs: number,
|
||||||
|
assetDurationMs: number | null,
|
||||||
|
): Edl {
|
||||||
|
return replaceClip(edl, clipId, (clip) => {
|
||||||
|
const sourceDelta = Math.round(deltaMs * clip.speed);
|
||||||
|
const minSource = Math.round(MIN_CLIP_MS * clip.speed);
|
||||||
|
if (edge === "end") {
|
||||||
|
const max = assetDurationMs ?? Number.POSITIVE_INFINITY;
|
||||||
|
const outMs = Math.min(max, Math.max(clip.inMs + minSource, clip.outMs + sourceDelta));
|
||||||
|
return { ...clip, outMs };
|
||||||
|
}
|
||||||
|
// Never start before the asset, the timeline, or past the end edge.
|
||||||
|
const lowest = Math.max(0, clip.inMs - Math.round(clip.atMs * clip.speed));
|
||||||
|
const inMs = Math.min(clip.outMs - minSource, Math.max(lowest, clip.inMs + sourceDelta));
|
||||||
|
const atMs = Math.max(0, clip.atMs + Math.round((inMs - clip.inMs) / clip.speed));
|
||||||
|
return { ...clip, inMs, atMs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move an overlay on the output timeline. */
|
||||||
|
export function moveOverlay(edl: Edl, overlayId: string, deltaMs: number): Edl {
|
||||||
|
return {
|
||||||
|
...edl,
|
||||||
|
overlays: edl.overlays.map((overlay) =>
|
||||||
|
overlay.id === overlayId
|
||||||
|
? { ...overlay, atMs: Math.max(0, Math.round(overlay.atMs + deltaMs)) }
|
||||||
|
: overlay,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOverlay(edl: Edl, overlayId: string, patch: Partial<EdlOverlay>): Edl {
|
||||||
|
return {
|
||||||
|
...edl,
|
||||||
|
overlays: edl.overlays.map((overlay) =>
|
||||||
|
overlay.id === overlayId ? { ...overlay, ...patch } : overlay,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh `prefix-n` id in `[a-z0-9_-]` that `taken` does not contain. */
|
||||||
|
export function freeId(taken: Iterable<string>, prefix: string): string {
|
||||||
|
const used = new Set(taken);
|
||||||
|
for (let index = 1; ; index += 1) {
|
||||||
|
const id = `${prefix}-${index}`;
|
||||||
|
if (!used.has(id)) return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh id that no clip, overlay, or redaction of the plan uses yet. */
|
||||||
|
export function nextId(edl: Edl, prefix: string): string {
|
||||||
|
return freeId(
|
||||||
|
[
|
||||||
|
...edl.tracks.video.map((clip) => clip.id),
|
||||||
|
...edl.tracks.audio.map((clip) => clip.id),
|
||||||
|
...edl.overlays.map((overlay) => overlay.id),
|
||||||
|
...edl.redactions.faces.map((face) => face.id),
|
||||||
|
...edl.redactions.regions.map((region) => region.id),
|
||||||
|
],
|
||||||
|
prefix,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which timeline item an EDL error points at, from its machine-readable path
|
||||||
|
* (`tracks.video[2].outMs`, `overlays[0].box`). Null for plan-wide errors.
|
||||||
|
*/
|
||||||
|
export function errorTarget(
|
||||||
|
edl: Edl,
|
||||||
|
error: Pick<EdlError, "path">,
|
||||||
|
): { kind: "clip" | "overlay"; id: string } | null {
|
||||||
|
const clip = /^tracks\.video\[(\d+)\]/.exec(error.path);
|
||||||
|
if (clip) {
|
||||||
|
const id = edl.tracks.video[Number(clip[1])]?.id;
|
||||||
|
return id ? { kind: "clip", id } : null;
|
||||||
|
}
|
||||||
|
const overlay = /^overlays\[(\d+)\]/.exec(error.path);
|
||||||
|
if (overlay) {
|
||||||
|
const id = edl.overlays[Number(overlay[1])]?.id;
|
||||||
|
return id ? { kind: "overlay", id } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Masks drawn in review: region redactions keyed to a clip, keyframes in source time. */
|
||||||
|
export type RegionRedaction = Edl["redactions"]["regions"][number];
|
||||||
|
export type MaskBox = RegionRedaction["keyframes"][number]["box"];
|
||||||
|
|
||||||
|
/** The box a region shows at a source time: the last keyframe at or before it, else the first. */
|
||||||
|
export function regionBoxAt(region: RegionRedaction, sourceMs: number): MaskBox {
|
||||||
|
const sorted = [...region.keyframes].sort((a, b) => a.tMs - b.tMs);
|
||||||
|
let box = sorted[0]?.box ?? { x: 0, y: 0, w: 0, h: 0 };
|
||||||
|
for (const keyframe of sorted) if (keyframe.tMs <= sourceMs) box = keyframe.box;
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clamp a box into the frame with a minimum size. */
|
||||||
|
export function clampBox(box: MaskBox): MaskBox {
|
||||||
|
const w = Math.min(1, Math.max(0.02, box.w));
|
||||||
|
const h = Math.min(1, Math.max(0.02, box.h));
|
||||||
|
return {
|
||||||
|
x: Math.min(1 - w, Math.max(0, box.x)),
|
||||||
|
y: Math.min(1 - h, Math.max(0, box.y)),
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a mask to a clip with one keyframe at `sourceMs`. Returns the plan and the new id. */
|
||||||
|
export function addRegion(edl: Edl, clipId: string, sourceMs: number): { edl: Edl; id: string } {
|
||||||
|
const id = nextId(edl, "mask");
|
||||||
|
const region: RegionRedaction = {
|
||||||
|
id,
|
||||||
|
clipId,
|
||||||
|
treatment: "blur",
|
||||||
|
keyframes: [{ tMs: Math.round(sourceMs), box: { x: 0.4, y: 0.35, w: 0.2, h: 0.3 } }],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
edl: {
|
||||||
|
...edl,
|
||||||
|
redactions: { ...edl.redactions, regions: [...edl.redactions.regions, region] },
|
||||||
|
},
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set a region's box at `sourceMs`, replacing a keyframe at that exact time or adding one. */
|
||||||
|
export function setRegionKeyframe(edl: Edl, regionId: string, sourceMs: number, box: MaskBox): Edl {
|
||||||
|
const tMs = Math.round(sourceMs);
|
||||||
|
const clamped = clampBox(box);
|
||||||
|
return {
|
||||||
|
...edl,
|
||||||
|
redactions: {
|
||||||
|
...edl.redactions,
|
||||||
|
regions: edl.redactions.regions.map((region) => {
|
||||||
|
if (region.id !== regionId) return region;
|
||||||
|
const others = region.keyframes.filter((keyframe) => keyframe.tMs !== tMs);
|
||||||
|
const keyframes = [...others, { tMs, box: clamped }].sort((a, b) => a.tMs - b.tMs);
|
||||||
|
return { ...region, keyframes };
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRegion(edl: Edl, regionId: string): Edl {
|
||||||
|
return {
|
||||||
|
...edl,
|
||||||
|
redactions: {
|
||||||
|
...edl.redactions,
|
||||||
|
regions: edl.redactions.regions.filter((region) => region.id !== regionId),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DragId =
|
||||||
|
| `clip:${string}`
|
||||||
|
| `trim-start:${string}`
|
||||||
|
| `trim-end:${string}`
|
||||||
|
| `overlay:${string}`;
|
||||||
|
|
||||||
|
/** Apply an in-flight drag (pixels converted to ms) to the plan. */
|
||||||
|
export function applyDrag(
|
||||||
|
edl: Edl,
|
||||||
|
id: DragId,
|
||||||
|
deltaMs: number,
|
||||||
|
assetDurations: ReadonlyMap<string, number | null>,
|
||||||
|
): Edl {
|
||||||
|
const [kind, target] = id.split(/:(.*)/s, 2) as [string, string];
|
||||||
|
const assetDuration = (clipId: string) => {
|
||||||
|
const clip = edl.tracks.video.find((candidate) => candidate.id === clipId);
|
||||||
|
return clip ? (assetDurations.get(clip.assetId) ?? null) : null;
|
||||||
|
};
|
||||||
|
switch (kind) {
|
||||||
|
case "clip":
|
||||||
|
return moveClip(edl, target, deltaMs);
|
||||||
|
case "trim-start":
|
||||||
|
return trimClip(edl, target, "start", deltaMs, assetDuration(target));
|
||||||
|
case "trim-end":
|
||||||
|
return trimClip(edl, target, "end", deltaMs, assetDuration(target));
|
||||||
|
case "overlay":
|
||||||
|
return moveOverlay(edl, target, deltaMs);
|
||||||
|
default:
|
||||||
|
return edl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `1:05.3` style timestamp. */
|
||||||
|
export function formatMs(ms: number): string {
|
||||||
|
const tenths = Math.max(0, Math.round(ms / 100));
|
||||||
|
const minutes = Math.floor(tenths / 600);
|
||||||
|
const seconds = (tenths % 600) / 10;
|
||||||
|
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
|
||||||
|
}
|
||||||
@ -1,6 +1,587 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type {
|
||||||
|
BrandKit,
|
||||||
|
BrandKitRecord,
|
||||||
|
CaptionStylePreset,
|
||||||
|
IntroVariant,
|
||||||
|
MotionTemplateInfo,
|
||||||
|
Watermark,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { Button, Card, CardContent, Field, FieldLabel, Input, Skeleton } from "@rakazo/ui-web";
|
||||||
|
import { Check, Plus, Save, Sparkles, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useId, useState } from "react";
|
||||||
|
import { LoadingState, SuccessPop } from "../../components/ai/primitives";
|
||||||
|
import { ErrorLine, errorMessage, ViewHeader } from "../../components/media/editor/chrome";
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
SelectField,
|
||||||
|
TextField,
|
||||||
|
ToggleField,
|
||||||
|
} from "../../components/media/editor/fields";
|
||||||
|
import { mediaArtifactUrl, useMediaObjectUrl } from "../../components/media/editor/media-files";
|
||||||
|
import { formatMs, freeId } from "../../components/media/editor/timeline";
|
||||||
|
import { rpc } from "../../lib/rpc";
|
||||||
import type { MediaViewProps } from "./nav";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-editor slice replaces this view. */
|
/** Starting point for a new kit. Brand colors are render data, not UI colors. */
|
||||||
export function BrandKitView(_props: MediaViewProps & Record<string, unknown>) {
|
function emptyKit(name: string): BrandKit {
|
||||||
return null;
|
return {
|
||||||
|
name,
|
||||||
|
colors: {
|
||||||
|
primary: "#111111",
|
||||||
|
secondary: "#555555",
|
||||||
|
accent: "#999999",
|
||||||
|
text: "#ffffff",
|
||||||
|
background: "#000000",
|
||||||
|
},
|
||||||
|
fonts: [],
|
||||||
|
logoArtifactIds: [],
|
||||||
|
captionPresets: [],
|
||||||
|
musicBeds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brand kits: a list without an id, the kit editor and intro generator with one. */
|
||||||
|
export function BrandKitView({ navigate, brandKitId }: MediaViewProps & { brandKitId?: string }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [records, setRecords] = useState<BrandKitRecord[] | null>(null);
|
||||||
|
const [templates, setTemplates] = useState<MotionTemplateInfo[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRecords(await rpc.media.brandKits.list({}));
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
void rpc.media.brandKits
|
||||||
|
.templates({})
|
||||||
|
.then(setTemplates)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (!records) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
{error ? null : <Skeleton className="h-48 w-full" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = brandKitId ? records.find((candidate) => candidate.id === brandKitId) : undefined;
|
||||||
|
if (brandKitId && record) {
|
||||||
|
return (
|
||||||
|
<KitEditor
|
||||||
|
key={record.id}
|
||||||
|
record={record}
|
||||||
|
templates={templates}
|
||||||
|
onBack={() => navigate({ name: "brandKit" })}
|
||||||
|
onSaved={(saved) => setRecords((current) => upsertRecord(current ?? [], saved))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brandKitId === "new" || records.length === 0) {
|
||||||
|
return (
|
||||||
|
<KitEditor
|
||||||
|
record={null}
|
||||||
|
templates={templates}
|
||||||
|
onBack={() => navigate({ name: "library" })}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setRecords((current) => upsertRecord(current ?? [], saved));
|
||||||
|
navigate({ name: "brandKit", brandKitId: saved.id });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4" data-testid="media-brand-kits">
|
||||||
|
<ViewHeader
|
||||||
|
title={t`Brand kits`}
|
||||||
|
backLabel={t`Back to library`}
|
||||||
|
onBack={() => navigate({ name: "library" })}
|
||||||
|
>
|
||||||
|
<Button size="sm" onClick={() => navigate({ name: "brandKit", brandKitId: "new" })}>
|
||||||
|
<Plus />
|
||||||
|
<Trans>New</Trans>
|
||||||
|
</Button>
|
||||||
|
</ViewHeader>
|
||||||
|
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(14rem,1fr))]">
|
||||||
|
{records.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className="text-left"
|
||||||
|
onClick={() => navigate({ name: "brandKit", brandKitId: item.id })}
|
||||||
|
>
|
||||||
|
<Card className="transition-colors hover:bg-muted/50">
|
||||||
|
<CardContent className="flex items-center gap-3">
|
||||||
|
<Swatches colors={item.kit.colors} />
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ui-body font-medium">
|
||||||
|
{item.kit.name}
|
||||||
|
</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertRecord(records: BrandKitRecord[], saved: BrandKitRecord): BrandKitRecord[] {
|
||||||
|
return records.some((item) => item.id === saved.id)
|
||||||
|
? records.map((item) => (item.id === saved.id ? saved : item))
|
||||||
|
: [...records, saved];
|
||||||
|
}
|
||||||
|
|
||||||
|
function Swatches({ colors }: { colors: BrandKit["colors"] }) {
|
||||||
|
return (
|
||||||
|
<span aria-hidden className="flex -space-x-1">
|
||||||
|
{[colors.primary, colors.secondary, colors.accent].map((color, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="size-5 rounded-full border border-border"
|
||||||
|
style={{ backgroundColor: color }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function KitEditor({
|
||||||
|
record,
|
||||||
|
templates,
|
||||||
|
onBack,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
record: BrandKitRecord | null;
|
||||||
|
templates: MotionTemplateInfo[];
|
||||||
|
onBack: () => void;
|
||||||
|
onSaved: (record: BrandKitRecord) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [kit, setKit] = useState<BrandKit>(() => record?.kit ?? emptyKit(t`Brand kit`));
|
||||||
|
const [dirty, setDirty] = useState(record === null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const patch = (next: Partial<BrandKit>) => {
|
||||||
|
setKit((current) => ({ ...current, ...next }));
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function save(next: BrandKit = kit) {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await rpc.media.brandKits.upsert({
|
||||||
|
...(record ? { id: record.id } : {}),
|
||||||
|
kit: next,
|
||||||
|
});
|
||||||
|
setKit(result.record.kit);
|
||||||
|
setWarnings(result.warnings);
|
||||||
|
setDirty(false);
|
||||||
|
onSaved(result.record);
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const colorLabels: Record<keyof BrandKit["colors"], string> = {
|
||||||
|
primary: t`Primary`,
|
||||||
|
secondary: t`Secondary`,
|
||||||
|
accent: t`Accent`,
|
||||||
|
text: t`Text`,
|
||||||
|
background: t`Background`,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-4" data-testid="media-brand-kit">
|
||||||
|
<ViewHeader title={kit.name} backLabel={t`Back`} onBack={onBack}>
|
||||||
|
<Button size="sm" disabled={!dirty || saving} onClick={() => void save()}>
|
||||||
|
{saving ? (
|
||||||
|
<LoadingState label={t`Saving`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save />
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</ViewHeader>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
{warnings.map((warning) => (
|
||||||
|
<p key={warning} className="text-ui-body-sm text-warning">
|
||||||
|
{warning}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<section className="grid max-w-2xl grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<TextField
|
||||||
|
label={t`Name`}
|
||||||
|
value={kit.name}
|
||||||
|
maxLength={60}
|
||||||
|
onChange={(name) => patch({ name })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t`Tone`}
|
||||||
|
value={kit.tone ?? ""}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(tone) => patch({ tone: tone || undefined })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t`Audience`}
|
||||||
|
value={kit.audience ?? ""}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(audience) => patch({ audience: audience || undefined })}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-wrap gap-4">
|
||||||
|
{(Object.keys(colorLabels) as Array<keyof BrandKit["colors"]>).map((key) => (
|
||||||
|
<ColorField
|
||||||
|
key={key}
|
||||||
|
label={colorLabels[key]}
|
||||||
|
value={kit.colors[key]}
|
||||||
|
onChange={(value) => patch({ colors: { ...kit.colors, [key]: value } })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<WatermarkFields watermark={kit.watermark} onChange={(watermark) => patch({ watermark })} />
|
||||||
|
|
||||||
|
<CaptionPresets
|
||||||
|
presets={kit.captionPresets}
|
||||||
|
colors={kit.colors}
|
||||||
|
onChange={(captionPresets) => patch({ captionPresets })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{kit.fonts.length + kit.musicBeds.length > 0 ? (
|
||||||
|
<ul className="flex flex-col gap-1 text-ui-body-sm text-muted-foreground">
|
||||||
|
{kit.fonts.map((font) => (
|
||||||
|
<li key={font.artifactId}>
|
||||||
|
{font.family}
|
||||||
|
{font.license ? ` · ${font.license}` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{kit.musicBeds.map((bed) => (
|
||||||
|
<li key={bed.artifactId}>
|
||||||
|
{bed.title} · {bed.license}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{record ? (
|
||||||
|
<IntroGenerator
|
||||||
|
kitId={record.id}
|
||||||
|
kit={kit}
|
||||||
|
templates={templates}
|
||||||
|
disabled={dirty || saving}
|
||||||
|
onUse={(variant) => void save({ ...kit, intro: variant.template })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ColorField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId();
|
||||||
|
return (
|
||||||
|
<Field className="w-24 gap-1">
|
||||||
|
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="color"
|
||||||
|
className="h-control-md p-1"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WatermarkFields({
|
||||||
|
watermark,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
watermark: Watermark | undefined;
|
||||||
|
onChange: (watermark: Watermark | undefined) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
return (
|
||||||
|
<section className="flex max-w-2xl flex-col gap-3">
|
||||||
|
<ToggleField
|
||||||
|
label={t`Watermark`}
|
||||||
|
checked={watermark !== undefined}
|
||||||
|
onChange={(on) =>
|
||||||
|
onChange(on ? { position: "bottom_right", opacity: 0.6, scale: 0.12 } : undefined)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{watermark ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<SelectField
|
||||||
|
label={t`Position`}
|
||||||
|
value={watermark.position}
|
||||||
|
options={[
|
||||||
|
{ value: "top_left", label: t`Top left` },
|
||||||
|
{ value: "top_right", label: t`Top right` },
|
||||||
|
{ value: "bottom_left", label: t`Bottom left` },
|
||||||
|
{ value: "bottom_right", label: t`Bottom right` },
|
||||||
|
{ value: "center", label: t`Center` },
|
||||||
|
]}
|
||||||
|
onChange={(position) => onChange({ ...watermark, position })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label={t`Opacity`}
|
||||||
|
value={watermark.opacity}
|
||||||
|
min={0}
|
||||||
|
max={1}
|
||||||
|
step={0.05}
|
||||||
|
onChange={(opacity) => onChange({ ...watermark, opacity })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label={t`Size`}
|
||||||
|
value={watermark.scale}
|
||||||
|
min={0.02}
|
||||||
|
max={0.5}
|
||||||
|
step={0.01}
|
||||||
|
onChange={(scale) => onChange({ ...watermark, scale })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CaptionPresets({
|
||||||
|
presets,
|
||||||
|
colors,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
presets: CaptionStylePreset[];
|
||||||
|
colors: BrandKit["colors"];
|
||||||
|
onChange: (presets: CaptionStylePreset[]) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const update = (id: string, next: Partial<CaptionStylePreset>) =>
|
||||||
|
onChange(presets.map((preset) => (preset.id === id ? { ...preset, ...next } : preset)));
|
||||||
|
return (
|
||||||
|
<section className="flex max-w-2xl flex-col gap-3" data-testid="media-caption-presets">
|
||||||
|
{presets.map((preset) => (
|
||||||
|
<div key={preset.id} className="grid grid-cols-2 items-end gap-3 sm:grid-cols-5">
|
||||||
|
<TextField
|
||||||
|
label={t`Caption style`}
|
||||||
|
value={preset.name}
|
||||||
|
maxLength={40}
|
||||||
|
onChange={(name) => update(preset.id, { name })}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label={t`Look`}
|
||||||
|
value={preset.template}
|
||||||
|
options={[
|
||||||
|
{ value: "clean", label: t`Clean` },
|
||||||
|
{ value: "karaoke", label: t`Karaoke` },
|
||||||
|
{ value: "bold_social", label: t`Bold` },
|
||||||
|
]}
|
||||||
|
onChange={(template) => update(preset.id, { template })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t`Font`}
|
||||||
|
value={preset.fontFamily}
|
||||||
|
maxLength={80}
|
||||||
|
onChange={(fontFamily) => update(preset.id, { fontFamily })}
|
||||||
|
/>
|
||||||
|
<ColorField
|
||||||
|
label={t`Color`}
|
||||||
|
value={preset.textColor}
|
||||||
|
onChange={(textColor) => update(preset.id, { textColor })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t`Remove ${preset.name}`}
|
||||||
|
onClick={() => onChange(presets.filter((item) => item.id !== preset.id))}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="self-start"
|
||||||
|
disabled={presets.length >= 12}
|
||||||
|
onClick={() =>
|
||||||
|
onChange([
|
||||||
|
...presets,
|
||||||
|
{
|
||||||
|
id: freeId(
|
||||||
|
presets.map((preset) => preset.id),
|
||||||
|
"caption",
|
||||||
|
),
|
||||||
|
name: t`Captions`,
|
||||||
|
template: "clean",
|
||||||
|
fontFamily: "Inter",
|
||||||
|
sizePct: 5,
|
||||||
|
textColor: colors.text,
|
||||||
|
highlightColor: colors.accent,
|
||||||
|
uppercase: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
<Trans>Caption style</Trans>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntroGenerator({
|
||||||
|
kitId,
|
||||||
|
kit,
|
||||||
|
templates,
|
||||||
|
disabled,
|
||||||
|
onUse,
|
||||||
|
}: {
|
||||||
|
kitId: string;
|
||||||
|
kit: BrandKit;
|
||||||
|
templates: MotionTemplateInfo[];
|
||||||
|
disabled: boolean;
|
||||||
|
onUse: (variant: IntroVariant) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [variants, setVariants] = useState<IntroVariant[]>([]);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const templateName = (id: string) => templates.find((item) => item.id === id)?.name ?? id;
|
||||||
|
|
||||||
|
async function suggest() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setVariants(await rpc.media.brandKits.introVariants({ id: kitId, count: 3 }));
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inUse = (variant: IntroVariant) =>
|
||||||
|
kit.intro?.templateId === variant.template.templateId &&
|
||||||
|
kit.intro.durationMs === variant.template.durationMs &&
|
||||||
|
JSON.stringify(kit.intro.params) === JSON.stringify(variant.template.params);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3" data-testid="media-intro-generator">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h3 className="text-ui-body font-semibold">
|
||||||
|
<Trans>Intro</Trans>
|
||||||
|
</h3>
|
||||||
|
{kit.intro ? (
|
||||||
|
<span className="text-ui-body-sm text-muted-foreground">
|
||||||
|
{templateName(kit.intro.templateId)} · {formatMs(kit.intro.durationMs)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<Button variant="outline" size="sm" disabled={busy || disabled} onClick={suggest}>
|
||||||
|
{busy ? (
|
||||||
|
<LoadingState label={t`Rendering previews`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles />
|
||||||
|
{variants.length > 0 ? <Trans>Suggest again</Trans> : <Trans>Suggest</Trans>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
{variants.length > 0 ? (
|
||||||
|
<div className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(14rem,1fr))]">
|
||||||
|
{variants.map((variant) => (
|
||||||
|
<IntroCard
|
||||||
|
key={variant.id}
|
||||||
|
variant={variant}
|
||||||
|
name={templateName(variant.template.templateId)}
|
||||||
|
selected={inUse(variant)}
|
||||||
|
disabled={disabled}
|
||||||
|
onUse={() => onUse(variant)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IntroCard({
|
||||||
|
variant,
|
||||||
|
name,
|
||||||
|
selected,
|
||||||
|
disabled,
|
||||||
|
onUse,
|
||||||
|
}: {
|
||||||
|
variant: IntroVariant;
|
||||||
|
name: string;
|
||||||
|
selected: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onUse: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const preview = useMediaObjectUrl(
|
||||||
|
variant.previewArtifactId ? mediaArtifactUrl(variant.previewArtifactId) : null,
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Card data-testid="media-intro-variant">
|
||||||
|
<CardContent className="flex flex-col gap-2">
|
||||||
|
{preview ? (
|
||||||
|
<video
|
||||||
|
aria-label={t`${name} preview`}
|
||||||
|
className="aspect-video w-full rounded-md bg-muted object-contain"
|
||||||
|
src={preview}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="aspect-video w-full rounded-md bg-muted" />
|
||||||
|
)}
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ui-body font-medium">{name}</span>
|
||||||
|
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
||||||
|
{formatMs(variant.template.durationMs)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-ui-body-sm text-muted-foreground">{variant.rationale}</p>
|
||||||
|
{selected ? (
|
||||||
|
<SuccessPop label={t`In use`} />
|
||||||
|
) : (
|
||||||
|
<Button size="sm" variant="outline" disabled={disabled} onClick={onUse}>
|
||||||
|
<Check />
|
||||||
|
<Trans>Use</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,450 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type {
|
||||||
|
CaptionStylePreset,
|
||||||
|
Edl,
|
||||||
|
EdlCaptions,
|
||||||
|
EdlError,
|
||||||
|
EdlOverlay,
|
||||||
|
MediaEditPlan,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { Button, Skeleton } from "@rakazo/ui-web";
|
||||||
|
import { Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { LoadingState } from "../../components/ai/primitives";
|
||||||
|
import { ErrorLine, errorMessage, ViewHeader } from "../../components/media/editor/chrome";
|
||||||
|
import {
|
||||||
|
NumberField,
|
||||||
|
SecondsField,
|
||||||
|
SelectField,
|
||||||
|
TextField,
|
||||||
|
ToggleField,
|
||||||
|
} from "../../components/media/editor/fields";
|
||||||
|
import type { PlayerHandle } from "../../components/media/editor/Player";
|
||||||
|
import { SourcePlayer } from "../../components/media/editor/Player";
|
||||||
|
import type { TimelineSelection } from "../../components/media/editor/Timeline";
|
||||||
|
import { Timeline } from "../../components/media/editor/Timeline";
|
||||||
|
import {
|
||||||
|
errorTarget,
|
||||||
|
formatMs,
|
||||||
|
mainProgramMs,
|
||||||
|
nextId,
|
||||||
|
updateOverlay,
|
||||||
|
} from "../../components/media/editor/timeline";
|
||||||
|
import { rpc } from "../../lib/rpc";
|
||||||
import type { MediaViewProps } from "./nav";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-editor slice replaces this view. */
|
interface Loaded {
|
||||||
export function EditorView(_props: MediaViewProps & Record<string, unknown>) {
|
plan: MediaEditPlan;
|
||||||
return null;
|
assetDurations: Map<string, number | null>;
|
||||||
|
captionPresets: CaptionStylePreset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Timeline editor for one plan version. Saving submits a new version. */
|
||||||
|
export function EditorView({
|
||||||
|
navigate,
|
||||||
|
projectId,
|
||||||
|
planId,
|
||||||
|
}: MediaViewProps & { projectId: string; planId: string }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||||
|
const [edl, setEdl] = useState<Edl | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [selection, setSelection] = useState<TimelineSelection>(null);
|
||||||
|
const [errors, setErrors] = useState<EdlError[]>([]);
|
||||||
|
const [playheadMs, setPlayheadMs] = useState(0);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const player = useRef<PlayerHandle>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
setLoaded(null);
|
||||||
|
setEdl(null);
|
||||||
|
setDirty(false);
|
||||||
|
setErrors([]);
|
||||||
|
setSelection(null);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const { project, plans } = await rpc.media.projects.get({ projectId });
|
||||||
|
const plan = plans.find((candidate) => candidate.id === planId);
|
||||||
|
if (!plan) throw new Error(t`This plan no longer exists.`);
|
||||||
|
const assetIds = [...new Set(plan.edl.tracks.video.map((clip) => clip.assetId))];
|
||||||
|
const [assets, kits] = await Promise.all([
|
||||||
|
Promise.all(
|
||||||
|
assetIds.map((assetId) =>
|
||||||
|
rpc.media.assets
|
||||||
|
.get({ assetId })
|
||||||
|
.then(({ asset }) => [assetId, asset.durationMs] as const)
|
||||||
|
.catch(() => [assetId, null] as const),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
project.brandKitId ? rpc.media.brandKits.list({}) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
if (!live) return;
|
||||||
|
const kit = kits.find((record) => record.id === project.brandKitId);
|
||||||
|
setLoaded({
|
||||||
|
plan,
|
||||||
|
assetDurations: new Map(assets),
|
||||||
|
captionPresets: kit?.kit.captionPresets ?? [],
|
||||||
|
});
|
||||||
|
setEdl(plan.edl);
|
||||||
|
} catch (cause) {
|
||||||
|
if (live) setLoadError(errorMessage(cause));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [projectId, planId]);
|
||||||
|
|
||||||
|
const change = useCallback((next: Edl) => {
|
||||||
|
setEdl(next);
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const invalid = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
if (!edl) return ids;
|
||||||
|
for (const error of errors) {
|
||||||
|
const target = errorTarget(edl, error);
|
||||||
|
if (target) ids.add(target.id);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [edl, errors]);
|
||||||
|
|
||||||
|
if (!loaded || !edl) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<ErrorLine error={loadError} />
|
||||||
|
{loadError ? null : <Skeleton className="h-64 w-full" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!edl) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const result = await rpc.media.plans.submit({ projectId, edl, rationale: null });
|
||||||
|
if (result.ok) {
|
||||||
|
setErrors([]);
|
||||||
|
setDirty(false);
|
||||||
|
navigate({ name: "editor", projectId, planId: result.plan.id });
|
||||||
|
} else {
|
||||||
|
setErrors(result.errors);
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
setErrors([{ code: "schema", path: "", message: errorMessage(cause) }]);
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addOverlay() {
|
||||||
|
if (!edl) return;
|
||||||
|
const id = nextId(edl, "text");
|
||||||
|
const atMs = Math.max(0, Math.min(playheadMs, mainProgramMs(edl)));
|
||||||
|
const overlay: EdlOverlay = { id, kind: "text", atMs, durationMs: 2000, text: "" };
|
||||||
|
change({ ...edl, overlays: [...edl.overlays, overlay] });
|
||||||
|
setSelection({ kind: "overlay", id });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4" data-testid="media-editor">
|
||||||
|
<ViewHeader
|
||||||
|
title={`${loaded.plan.profileId} · v${loaded.plan.version}`}
|
||||||
|
backLabel={t`Back to project`}
|
||||||
|
onBack={() => navigate({ name: "project", projectId })}
|
||||||
|
>
|
||||||
|
<Button size="sm" disabled={!dirty || saving} onClick={save}>
|
||||||
|
{saving ? (
|
||||||
|
<LoadingState label={t`Saving`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save />
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</ViewHeader>
|
||||||
|
|
||||||
|
<div className="mx-auto w-full max-w-2xl">
|
||||||
|
<SourcePlayer
|
||||||
|
edl={edl}
|
||||||
|
handleRef={player}
|
||||||
|
label={t`Plan preview`}
|
||||||
|
onFrame={(frame) => {
|
||||||
|
if (frame) setPlayheadMs(frame.outputMs);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="flex-1 font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
||||||
|
{formatMs(playheadMs)}
|
||||||
|
</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={addOverlay}>
|
||||||
|
<Plus />
|
||||||
|
<Trans>Text</Trans>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Timeline
|
||||||
|
edl={edl}
|
||||||
|
assetDurations={loaded.assetDurations}
|
||||||
|
selection={selection}
|
||||||
|
invalid={invalid}
|
||||||
|
playheadMs={playheadMs}
|
||||||
|
onSelect={setSelection}
|
||||||
|
onSeek={(ms) => {
|
||||||
|
setPlayheadMs(ms);
|
||||||
|
player.current?.seek(ms);
|
||||||
|
}}
|
||||||
|
onChange={change}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{errors.length > 0 ? (
|
||||||
|
<ul role="alert" className="flex flex-col gap-1" data-testid="media-edl-errors">
|
||||||
|
{errors.map((error) => {
|
||||||
|
const target = errorTarget(edl, error);
|
||||||
|
return (
|
||||||
|
<li key={`${error.code}-${error.path}-${error.message}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!target}
|
||||||
|
onClick={() => target && setSelection(target)}
|
||||||
|
className="text-left text-ui-body-sm text-destructive enabled:hover:underline"
|
||||||
|
>
|
||||||
|
{error.message}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Inspector
|
||||||
|
edl={edl}
|
||||||
|
selection={selection}
|
||||||
|
assetDurations={loaded.assetDurations}
|
||||||
|
captionPresets={loaded.captionPresets}
|
||||||
|
onChange={change}
|
||||||
|
onClear={() => setSelection(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inspector({
|
||||||
|
edl,
|
||||||
|
selection,
|
||||||
|
assetDurations,
|
||||||
|
captionPresets,
|
||||||
|
onChange,
|
||||||
|
onClear,
|
||||||
|
}: {
|
||||||
|
edl: Edl;
|
||||||
|
selection: TimelineSelection;
|
||||||
|
assetDurations: ReadonlyMap<string, number | null>;
|
||||||
|
captionPresets: CaptionStylePreset[];
|
||||||
|
onChange: (edl: Edl) => void;
|
||||||
|
onClear: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
if (!selection) return null;
|
||||||
|
|
||||||
|
if (selection.kind === "clip") {
|
||||||
|
const clip = edl.tracks.video.find((candidate) => candidate.id === selection.id);
|
||||||
|
if (!clip) return null;
|
||||||
|
const max = assetDurations.get(clip.assetId) ?? Number.POSITIVE_INFINITY;
|
||||||
|
const patch = (next: Partial<typeof clip>) =>
|
||||||
|
onChange({
|
||||||
|
...edl,
|
||||||
|
tracks: {
|
||||||
|
...edl.tracks,
|
||||||
|
video: edl.tracks.video.map((candidate) =>
|
||||||
|
candidate.id === clip.id ? { ...candidate, ...next } : candidate,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="media-inspector">
|
||||||
|
<SecondsField label={t`Start`} ms={clip.atMs} onChange={(atMs) => patch({ atMs })} />
|
||||||
|
<SecondsField
|
||||||
|
label={t`In`}
|
||||||
|
ms={clip.inMs}
|
||||||
|
onChange={(inMs) => patch({ inMs: Math.min(inMs, clip.outMs - 100) })}
|
||||||
|
/>
|
||||||
|
<SecondsField
|
||||||
|
label={t`Out`}
|
||||||
|
ms={clip.outMs}
|
||||||
|
onChange={(outMs) => patch({ outMs: Math.min(max, Math.max(outMs, clip.inMs + 100)) })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label={t`Speed`}
|
||||||
|
value={clip.speed}
|
||||||
|
min={0.25}
|
||||||
|
max={4}
|
||||||
|
step={0.25}
|
||||||
|
onChange={(speed) => patch({ speed })}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
label={t`Framing`}
|
||||||
|
value={Array.isArray(clip.reframe) ? "keyframes" : clip.reframe}
|
||||||
|
options={[
|
||||||
|
{ value: "auto", label: t`Follow subject` },
|
||||||
|
{ value: "none", label: t`Center` },
|
||||||
|
...(Array.isArray(clip.reframe)
|
||||||
|
? [{ value: "keyframes" as const, label: t`Keyframes` }]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
|
onChange={(value) => {
|
||||||
|
if (value !== "keyframes") patch({ reframe: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ToggleField label={t`Mute`} checked={clip.muted} onChange={(muted) => patch({ muted })} />
|
||||||
|
{edl.tracks.video.length > 1 ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="self-end"
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
...edl,
|
||||||
|
tracks: {
|
||||||
|
...edl.tracks,
|
||||||
|
video: edl.tracks.video.filter((candidate) => candidate.id !== clip.id),
|
||||||
|
},
|
||||||
|
transitions: edl.transitions.filter(
|
||||||
|
(transition) =>
|
||||||
|
transition.fromClipId !== clip.id && transition.toClipId !== clip.id,
|
||||||
|
),
|
||||||
|
redactions: {
|
||||||
|
...edl.redactions,
|
||||||
|
regions: edl.redactions.regions.filter((region) => region.clipId !== clip.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selection.kind === "overlay") {
|
||||||
|
const overlay = edl.overlays.find((candidate) => candidate.id === selection.id);
|
||||||
|
if (!overlay) return null;
|
||||||
|
const patch = (next: Partial<EdlOverlay>) => onChange(updateOverlay(edl, overlay.id, next));
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="media-inspector">
|
||||||
|
<SelectField
|
||||||
|
label={t`Kind`}
|
||||||
|
value={overlay.kind}
|
||||||
|
options={[
|
||||||
|
{ value: "text", label: t`Text` },
|
||||||
|
{ value: "lower_third", label: t`Lower third` },
|
||||||
|
{ value: "logo", label: t`Logo` },
|
||||||
|
{ value: "watermark", label: t`Watermark` },
|
||||||
|
{ value: "cta_end_card", label: t`End card` },
|
||||||
|
{ value: "progress_bar", label: t`Progress bar` },
|
||||||
|
...(overlay.kind === "motion" ? [{ value: "motion" as const, label: t`Motion` }] : []),
|
||||||
|
]}
|
||||||
|
onChange={(kind) => patch({ kind })}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label={t`Text`}
|
||||||
|
value={overlay.text ?? ""}
|
||||||
|
maxLength={200}
|
||||||
|
onChange={(text) => patch({ text })}
|
||||||
|
/>
|
||||||
|
<SecondsField label={t`Start`} ms={overlay.atMs} onChange={(atMs) => patch({ atMs })} />
|
||||||
|
<SecondsField
|
||||||
|
label={t`Duration`}
|
||||||
|
ms={overlay.durationMs}
|
||||||
|
min={100}
|
||||||
|
onChange={(durationMs) => patch({ durationMs })}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="self-end"
|
||||||
|
onClick={() => {
|
||||||
|
onChange({
|
||||||
|
...edl,
|
||||||
|
overlays: edl.overlays.filter((candidate) => candidate.id !== overlay.id),
|
||||||
|
});
|
||||||
|
onClear();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<Trans>Remove</Trans>
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const captions: EdlCaptions = edl.captions ?? {
|
||||||
|
mode: "none",
|
||||||
|
maxCharsPerLine: 32,
|
||||||
|
wordHighlight: false,
|
||||||
|
position: "lower",
|
||||||
|
};
|
||||||
|
const patch = (next: Partial<EdlCaptions>) =>
|
||||||
|
onChange({ ...edl, captions: { ...captions, ...next } });
|
||||||
|
return (
|
||||||
|
<section className="grid grid-cols-2 gap-3 sm:grid-cols-4" data-testid="media-inspector">
|
||||||
|
<SelectField
|
||||||
|
label={t`Captions`}
|
||||||
|
value={captions.mode}
|
||||||
|
options={[
|
||||||
|
{ value: "burned", label: t`In video` },
|
||||||
|
{ value: "sidecar", label: t`Subtitle file` },
|
||||||
|
{ value: "both", label: t`Both` },
|
||||||
|
{ value: "none", label: t`Off` },
|
||||||
|
]}
|
||||||
|
onChange={(mode) => patch({ mode })}
|
||||||
|
/>
|
||||||
|
{captions.mode === "none" ? null : (
|
||||||
|
<>
|
||||||
|
<SelectField
|
||||||
|
label={t`Position`}
|
||||||
|
value={captions.position}
|
||||||
|
options={[
|
||||||
|
{ value: "upper", label: t`Top` },
|
||||||
|
{ value: "middle", label: t`Middle` },
|
||||||
|
{ value: "lower", label: t`Bottom` },
|
||||||
|
]}
|
||||||
|
onChange={(position) => patch({ position })}
|
||||||
|
/>
|
||||||
|
<NumberField
|
||||||
|
label={t`Characters per line`}
|
||||||
|
value={captions.maxCharsPerLine}
|
||||||
|
min={10}
|
||||||
|
max={60}
|
||||||
|
onChange={(maxCharsPerLine) => patch({ maxCharsPerLine })}
|
||||||
|
/>
|
||||||
|
{captionPresets.length > 0 ? (
|
||||||
|
<SelectField
|
||||||
|
label={t`Style`}
|
||||||
|
value={captions.presetId ?? ""}
|
||||||
|
options={[
|
||||||
|
{ value: "", label: t`Default` },
|
||||||
|
...captionPresets.map((preset) => ({ value: preset.id, label: preset.name })),
|
||||||
|
]}
|
||||||
|
onChange={(presetId) => patch({ presetId: presetId || undefined })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ToggleField
|
||||||
|
label={t`Highlight words`}
|
||||||
|
checked={captions.wordHighlight}
|
||||||
|
onChange={(wordHighlight) => patch({ wordHighlight })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,292 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type {
|
||||||
|
MediaEditPlan,
|
||||||
|
MediaProject,
|
||||||
|
MediaRender,
|
||||||
|
PlatformProfileSummary,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { Button, Card, CardContent, CardHeader, CardTitle, Skeleton } from "@rakazo/ui-web";
|
||||||
|
import { Download, Palette, Pencil, Play, ShieldCheck, Sparkles, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { LoadingState } from "../../components/ai/primitives";
|
||||||
|
import {
|
||||||
|
ErrorLine,
|
||||||
|
errorMessage,
|
||||||
|
GateBadge,
|
||||||
|
ViewHeader,
|
||||||
|
} from "../../components/media/editor/chrome";
|
||||||
|
import {
|
||||||
|
downloadMediaFile,
|
||||||
|
renderPosterUrl,
|
||||||
|
renderVideoUrl,
|
||||||
|
useMediaObjectUrl,
|
||||||
|
usePolling,
|
||||||
|
} from "../../components/media/editor/media-files";
|
||||||
|
import { RenderVideo } from "../../components/media/editor/Player";
|
||||||
|
import { PublishPanel } from "../../components/media/editor/PublishPanel";
|
||||||
|
import type { Variant } from "../../components/media/editor/renders";
|
||||||
|
import {
|
||||||
|
anyRenderActive,
|
||||||
|
gateStatus,
|
||||||
|
isRenderActive,
|
||||||
|
projectVariants,
|
||||||
|
RENDER_POLL_MS,
|
||||||
|
} from "../../components/media/editor/renders";
|
||||||
|
import { edlDurationMs, formatMs } from "../../components/media/editor/timeline";
|
||||||
|
import { rpc } from "../../lib/rpc";
|
||||||
import type { MediaViewProps } from "./nav";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-editor slice replaces this view. */
|
interface ProjectData {
|
||||||
export function ProjectView(_props: MediaViewProps & Record<string, unknown>) {
|
project: MediaProject;
|
||||||
return null;
|
plans: MediaEditPlan[];
|
||||||
|
renders: MediaRender[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A project's variants, one card per platform profile. */
|
||||||
|
export function ProjectView({ navigate, projectId }: MediaViewProps & { projectId: string }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [data, setData] = useState<ProjectData | null>(null);
|
||||||
|
const [profiles, setProfiles] = useState<Map<string, PlatformProfileSummary>>(new Map());
|
||||||
|
const [planning, setPlanning] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setData(await rpc.media.projects.get({ projectId }));
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
}
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
void rpc.media.profiles
|
||||||
|
.list({})
|
||||||
|
.then((list) => setProfiles(new Map(list.map((profile) => [profile.id, profile]))))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
usePolling(load, data ? anyRenderActive(data.renders) : false, RENDER_POLL_MS);
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
{error ? null : <Skeleton className="h-64 w-full" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = projectVariants(data.project.profileIds, data.plans, data.renders);
|
||||||
|
const unplanned = variants.some((variant) => !variant.plan);
|
||||||
|
|
||||||
|
async function autoPlan() {
|
||||||
|
setPlanning(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await rpc.media.projects.autoPlan({ projectId });
|
||||||
|
await load();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setPlanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4" data-testid="media-project">
|
||||||
|
<ViewHeader
|
||||||
|
title={data.project.title}
|
||||||
|
backLabel={t`Back to library`}
|
||||||
|
onBack={() => navigate({ name: "library" })}
|
||||||
|
>
|
||||||
|
{data.project.brandKitId ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t`Brand kit`}
|
||||||
|
onClick={() =>
|
||||||
|
navigate({ name: "brandKit", brandKitId: data.project.brandKitId ?? undefined })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Palette />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{unplanned ? (
|
||||||
|
<Button size="sm" disabled={planning} onClick={autoPlan}>
|
||||||
|
{planning ? (
|
||||||
|
<LoadingState label={t`Planning`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles />
|
||||||
|
<Trans>Plan</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</ViewHeader>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(18rem,1fr))]">
|
||||||
|
{variants.map((variant) => (
|
||||||
|
<VariantCard
|
||||||
|
key={variant.profileId}
|
||||||
|
variant={variant}
|
||||||
|
profile={profiles.get(variant.profileId) ?? null}
|
||||||
|
navigate={navigate}
|
||||||
|
projectId={projectId}
|
||||||
|
onChanged={load}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VariantCard({
|
||||||
|
variant,
|
||||||
|
profile,
|
||||||
|
navigate,
|
||||||
|
projectId,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
variant: Variant;
|
||||||
|
profile: PlatformProfileSummary | null;
|
||||||
|
navigate: MediaViewProps["navigate"];
|
||||||
|
projectId: string;
|
||||||
|
onChanged: () => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const { plan, draft, final } = variant;
|
||||||
|
// The final render once there is one, otherwise the draft.
|
||||||
|
const shown = final ?? draft;
|
||||||
|
const status = shown ? gateStatus(shown) : null;
|
||||||
|
const playable = shown?.status === "done" ? shown : null;
|
||||||
|
const videoUrl = useMediaObjectUrl(playable ? renderVideoUrl(playable) : null);
|
||||||
|
const posterUrl = useMediaObjectUrl(playable ? renderPosterUrl(playable) : null);
|
||||||
|
const fixRounds = shown?.gateReport?.autoFixRound ?? 0;
|
||||||
|
const finalVideo = final?.status === "done" ? renderVideoUrl(final) : null;
|
||||||
|
const draftApprovable =
|
||||||
|
plan !== null &&
|
||||||
|
draft?.status === "done" &&
|
||||||
|
draft.planId === plan.id &&
|
||||||
|
gateStatus(draft) !== "blocked" &&
|
||||||
|
!(final && (isRenderActive(final) || final.planId === plan.id));
|
||||||
|
|
||||||
|
async function act(work: () => Promise<unknown>) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
await onChanged();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = (quality: "draft" | "final") =>
|
||||||
|
plan ? act(() => rpc.media.renders.start({ planId: plan.id, quality })) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card data-testid="media-variant" data-profile={variant.profileId}>
|
||||||
|
<CardHeader className="flex flex-row items-center gap-2">
|
||||||
|
<CardTitle className="min-w-0 flex-1 truncate">
|
||||||
|
{profile?.name ?? variant.profileId}
|
||||||
|
</CardTitle>
|
||||||
|
{profile && (profile.unverified || profile.stale) ? (
|
||||||
|
<span
|
||||||
|
className="text-ui-caption text-muted-foreground"
|
||||||
|
title={t`Some limits of this profile are unverified or out of date.`}
|
||||||
|
>
|
||||||
|
<Trans>Unverified</Trans>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{status ? <GateBadge status={status} /> : null}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
{shown && isRenderActive(shown) ? (
|
||||||
|
<div className="flex aspect-video items-center justify-center rounded-lg bg-muted">
|
||||||
|
<LoadingState
|
||||||
|
label={`${shown.status === "gating" ? t`Checking` : t`Rendering`} ${Math.round(shown.progress * 100)}%`}
|
||||||
|
startedAt={Date.parse(shown.createdAt)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : playable ? (
|
||||||
|
<RenderVideo src={videoUrl} poster={posterUrl} label={t`Variant preview`} />
|
||||||
|
) : null}
|
||||||
|
{shown?.status === "failed" && shown.error ? (
|
||||||
|
<p className="text-ui-body-sm text-destructive">{shown.error}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-2 text-ui-body-sm text-muted-foreground">
|
||||||
|
{plan ? <span>{formatMs(edlDurationMs(plan.edl))}</span> : <Trans>No plan yet</Trans>}
|
||||||
|
{fixRounds > 0 ? (
|
||||||
|
<span>
|
||||||
|
<Trans>Auto-fix {fixRounds}/3</Trans>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{plan ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ name: "editor", projectId, planId: plan.id })}
|
||||||
|
>
|
||||||
|
<Pencil />
|
||||||
|
<Trans>Edit</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{plan && !(draft && isRenderActive(draft)) ? (
|
||||||
|
<Button variant="outline" size="sm" disabled={busy} onClick={() => start("draft")}>
|
||||||
|
<Play />
|
||||||
|
<Trans>Draft</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{shown && isRenderActive(shown) ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => act(() => rpc.media.renders.cancel({ renderId: shown.id }))}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
<Trans>Cancel</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{shown?.gateReport ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate({ name: "review", projectId, renderId: shown.id })}
|
||||||
|
>
|
||||||
|
<Trans>Review</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{draftApprovable ? (
|
||||||
|
<Button size="sm" disabled={busy} onClick={() => start("final")}>
|
||||||
|
<ShieldCheck />
|
||||||
|
<Trans>Approve</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{finalVideo ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={t`Download`}
|
||||||
|
onClick={() =>
|
||||||
|
act(() => downloadMediaFile(`${finalVideo}?download=1`, `${variant.profileId}.mp4`))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
{final?.status === "done" ? <PublishPanel render={final} /> : null}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,597 @@
|
|||||||
|
import { Trans, useLingui } from "@lingui/react/macro";
|
||||||
|
import type {
|
||||||
|
AnonymizeMode,
|
||||||
|
Edl,
|
||||||
|
GateIssue,
|
||||||
|
MarkTreatment,
|
||||||
|
MediaCapabilities,
|
||||||
|
MediaEditPlan,
|
||||||
|
MediaMark,
|
||||||
|
MediaPerson,
|
||||||
|
MediaRender,
|
||||||
|
} from "@rakazo/contracts";
|
||||||
|
import { MAX_AUTO_FIX_ROUNDS } from "@rakazo/contracts";
|
||||||
|
import { Button, cn, NativeSelect, NativeSelectOption, Skeleton } from "@rakazo/ui-web";
|
||||||
|
import { Crosshair, Play, Plus, Save, Trash2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { LoadingState } from "../../components/ai/primitives";
|
||||||
|
import {
|
||||||
|
ErrorLine,
|
||||||
|
errorMessage,
|
||||||
|
GateBadge,
|
||||||
|
ViewHeader,
|
||||||
|
} from "../../components/media/editor/chrome";
|
||||||
|
import {
|
||||||
|
renderPosterUrl,
|
||||||
|
renderVideoUrl,
|
||||||
|
useMediaObjectUrl,
|
||||||
|
usePolling,
|
||||||
|
} from "../../components/media/editor/media-files";
|
||||||
|
import type { PlayerHandle, SourceFrame } from "../../components/media/editor/Player";
|
||||||
|
import { RenderVideo, SourcePlayer } from "../../components/media/editor/Player";
|
||||||
|
import {
|
||||||
|
gateStatus,
|
||||||
|
isRenderActive,
|
||||||
|
RENDER_POLL_MS,
|
||||||
|
reviewedPlan,
|
||||||
|
sortedIssues,
|
||||||
|
} from "../../components/media/editor/renders";
|
||||||
|
import type { CoverageRow } from "../../components/media/editor/review-parts";
|
||||||
|
import { CoverageHeatmap, MaskBoxEditor } from "../../components/media/editor/review-parts";
|
||||||
|
import {
|
||||||
|
addRegion,
|
||||||
|
edlDurationMs,
|
||||||
|
formatMs,
|
||||||
|
regionBoxAt,
|
||||||
|
removeRegion,
|
||||||
|
setRegionKeyframe,
|
||||||
|
} from "../../components/media/editor/timeline";
|
||||||
|
import { rpc } from "../../lib/rpc";
|
||||||
import type { MediaViewProps } from "./nav";
|
import type { MediaViewProps } from "./nav";
|
||||||
|
|
||||||
/** Placeholder; the W3-web-editor slice replaces this view. */
|
type Mode = "after" | "before";
|
||||||
export function ReviewView(_props: MediaViewProps & Record<string, unknown>) {
|
|
||||||
return null;
|
/**
|
||||||
|
* Review of one render (spec 7 Prüfansicht): gate blockers that jump the
|
||||||
|
* player, coverage per person and mark, before/after, manual masks, and
|
||||||
|
* per-person and per-mark treatment.
|
||||||
|
*/
|
||||||
|
export function ReviewView({
|
||||||
|
navigate,
|
||||||
|
projectId,
|
||||||
|
renderId,
|
||||||
|
}: MediaViewProps & { projectId: string; renderId: string }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [render, setRender] = useState<MediaRender | null>(null);
|
||||||
|
const [plans, setPlans] = useState<MediaEditPlan[]>([]);
|
||||||
|
const [edl, setEdl] = useState<Edl | null>(null);
|
||||||
|
const [savedPlan, setSavedPlan] = useState<MediaEditPlan | null>(null);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [people, setPeople] = useState<MediaPerson[]>([]);
|
||||||
|
const [marks, setMarks] = useState<MediaMark[]>([]);
|
||||||
|
const [capabilities, setCapabilities] = useState<MediaCapabilities | null>(null);
|
||||||
|
const [mode, setMode] = useState<Mode>("after");
|
||||||
|
const [marking, setMarking] = useState(false);
|
||||||
|
const [selectedMask, setSelectedMask] = useState<string | null>(null);
|
||||||
|
const [frame, setFrame] = useState<SourceFrame | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const renderPlayer = useRef<PlayerHandle>(null);
|
||||||
|
const sourcePlayer = useRef<PlayerHandle>(null);
|
||||||
|
const kindLabels = useMarkKindLabels();
|
||||||
|
|
||||||
|
const loadRender = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setRender(await rpc.media.renders.get({ renderId }));
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
}
|
||||||
|
}, [renderId]);
|
||||||
|
|
||||||
|
const loadMarks = useCallback(async () => {
|
||||||
|
setMarks(await rpc.media.marks.list({}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEdl(null);
|
||||||
|
setSavedPlan(null);
|
||||||
|
setDirty(false);
|
||||||
|
void loadRender();
|
||||||
|
void rpc.media.projects
|
||||||
|
.get({ projectId })
|
||||||
|
.then((data) => setPlans(data.plans))
|
||||||
|
.catch((cause) => setError(errorMessage(cause)));
|
||||||
|
void rpc.media.people
|
||||||
|
.list({})
|
||||||
|
.then(setPeople)
|
||||||
|
.catch(() => undefined);
|
||||||
|
void loadMarks().catch(() => undefined);
|
||||||
|
void rpc.media
|
||||||
|
.capabilities({})
|
||||||
|
.then(setCapabilities)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [projectId, loadRender, loadMarks]);
|
||||||
|
|
||||||
|
usePolling(loadRender, render ? isRenderActive(render) : false, RENDER_POLL_MS);
|
||||||
|
|
||||||
|
const plan = useMemo(
|
||||||
|
() => savedPlan ?? (render ? reviewedPlan(render, plans) : null),
|
||||||
|
[savedPlan, render, plans],
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (plan && !dirty) setEdl(plan.edl);
|
||||||
|
}, [plan, dirty]);
|
||||||
|
|
||||||
|
const videoUrl = useMediaObjectUrl(render?.status === "done" ? renderVideoUrl(render) : null);
|
||||||
|
const posterUrl = useMediaObjectUrl(render?.status === "done" ? renderPosterUrl(render) : null);
|
||||||
|
const hasVideo = render?.status === "done" && renderVideoUrl(render) !== null;
|
||||||
|
const shownMode: Mode = hasVideo ? mode : "before";
|
||||||
|
|
||||||
|
if (!render || !edl || !plan) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
{error ? null : <Skeleton className="h-64 w-full" />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issues = sortedIssues(render.gateReport?.issues ?? []);
|
||||||
|
const status = gateStatus(render);
|
||||||
|
const round = render.gateReport?.autoFixRound ?? 0;
|
||||||
|
const durationMs = edlDurationMs(edl);
|
||||||
|
|
||||||
|
const seek = (ms: number) => {
|
||||||
|
if (shownMode === "after") renderPlayer.current?.seek(ms);
|
||||||
|
else sourcePlayer.current?.seek(ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeEdl = (next: Edl) => {
|
||||||
|
setEdl(next);
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
async function act(key: string, work: () => Promise<void>) {
|
||||||
|
setBusy(key);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await work();
|
||||||
|
} catch (cause) {
|
||||||
|
setError(errorMessage(cause));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const savePlan = () =>
|
||||||
|
act("save", async () => {
|
||||||
|
const result = await rpc.media.plans.submit({ projectId, edl, rationale: null });
|
||||||
|
if (!result.ok) {
|
||||||
|
setError(result.errors.map((item) => item.message).join(" "));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavedPlan(result.plan);
|
||||||
|
setPlans((current) => [...current, result.plan]);
|
||||||
|
setDirty(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rerender = () =>
|
||||||
|
act("render", async () => {
|
||||||
|
const started = await rpc.media.renders.start({ planId: plan.id, quality: "draft" });
|
||||||
|
navigate({ name: "review", projectId, renderId: started.id });
|
||||||
|
});
|
||||||
|
|
||||||
|
// People in this video: face redactions by person plus anyone the gate named.
|
||||||
|
const personIds = new Set<string>();
|
||||||
|
for (const face of edl.redactions.faces) {
|
||||||
|
if ("personId" in face.target) personIds.add(face.target.personId);
|
||||||
|
}
|
||||||
|
for (const issue of issues) if (issue.personId) personIds.add(issue.personId);
|
||||||
|
const shownPeople = people.filter((person) => personIds.has(person.id));
|
||||||
|
|
||||||
|
const assetIds = new Set(edl.tracks.video.map((clip) => clip.assetId));
|
||||||
|
const plannedMarks = new Set(edl.redactions.marks.map((mark) => mark.markId));
|
||||||
|
const shownMarks = marks.filter(
|
||||||
|
(mark) =>
|
||||||
|
(mark.assetId !== null && assetIds.has(mark.assetId)) ||
|
||||||
|
(mark.personId !== null && personIds.has(mark.personId)) ||
|
||||||
|
plannedMarks.has(mark.id) ||
|
||||||
|
issues.some((issue) => issue.markId === mark.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const coverage: CoverageRow[] = [
|
||||||
|
...shownPeople.map((person) => ({
|
||||||
|
key: `person-${person.id}`,
|
||||||
|
label: person.displayName,
|
||||||
|
issues: issues.filter((issue) => issue.personId === person.id),
|
||||||
|
})),
|
||||||
|
...shownMarks.map((mark) => ({
|
||||||
|
key: `mark-${mark.id}`,
|
||||||
|
label: mark.label ?? kindLabels[mark.kind],
|
||||||
|
issues: issues.filter((issue) => issue.markId === mark.id),
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
const unassigned = issues.filter(
|
||||||
|
(issue) => issue.check === "anonymization" && !issue.personId && !issue.markId,
|
||||||
|
);
|
||||||
|
if (unassigned.length > 0) {
|
||||||
|
coverage.push({ key: "unassigned", label: t`Unknown faces`, issues: unassigned });
|
||||||
|
}
|
||||||
|
|
||||||
|
const clipRegions = frame
|
||||||
|
? edl.redactions.regions.filter((region) => region.clipId === frame.clipId)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 p-4" data-testid="media-review">
|
||||||
|
<ViewHeader
|
||||||
|
title={`${render.profileId} · ${render.quality === "final" ? t`Final` : t`Draft`}`}
|
||||||
|
backLabel={t`Back to project`}
|
||||||
|
onBack={() => navigate({ name: "project", projectId })}
|
||||||
|
>
|
||||||
|
{status ? <GateBadge status={status} /> : null}
|
||||||
|
</ViewHeader>
|
||||||
|
|
||||||
|
{isRenderActive(render) ? (
|
||||||
|
<LoadingState
|
||||||
|
label={`${render.status === "gating" ? t`Checking` : t`Rendering`} ${Math.round(render.progress * 100)}%`}
|
||||||
|
startedAt={Date.parse(render.createdAt)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{round > 0 ? (
|
||||||
|
<p className="text-ui-body-sm text-muted-foreground" data-testid="media-autofix">
|
||||||
|
<Trans>
|
||||||
|
Auto-fix {round}/{MAX_AUTO_FIX_ROUNDS}
|
||||||
|
</Trans>
|
||||||
|
{plan.createdBy === "agent" && plan.rationale ? ` · ${plan.rationale}` : null}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{render.status === "failed" && render.error ? (
|
||||||
|
<p className="text-ui-body-sm text-destructive">{render.error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mx-auto flex w-full max-w-2xl flex-col gap-2">
|
||||||
|
{hasVideo ? (
|
||||||
|
<div className="flex gap-1 self-center rounded-lg bg-muted p-0.5">
|
||||||
|
{(["before", "after"] as const).map((value) => (
|
||||||
|
<Button
|
||||||
|
key={value}
|
||||||
|
size="xs"
|
||||||
|
variant={mode === value ? "secondary" : "ghost"}
|
||||||
|
aria-pressed={mode === value}
|
||||||
|
onClick={() => setMode(value)}
|
||||||
|
>
|
||||||
|
{value === "before" ? t`Before` : t`After`}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{shownMode === "after" ? (
|
||||||
|
<RenderVideo
|
||||||
|
src={videoUrl}
|
||||||
|
poster={posterUrl}
|
||||||
|
handleRef={renderPlayer}
|
||||||
|
label={t`Rendered video`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SourcePlayer
|
||||||
|
edl={edl}
|
||||||
|
handleRef={sourcePlayer}
|
||||||
|
onFrame={setFrame}
|
||||||
|
label={t`Source video`}
|
||||||
|
>
|
||||||
|
{frame
|
||||||
|
? clipRegions.map((region) => (
|
||||||
|
<MaskBoxEditor
|
||||||
|
key={region.id}
|
||||||
|
box={regionBoxAt(region, frame.sourceMs)}
|
||||||
|
label={t`Mask ${region.id}`}
|
||||||
|
selected={selectedMask === region.id}
|
||||||
|
onSelect={() => setSelectedMask(region.id)}
|
||||||
|
onChange={(box) =>
|
||||||
|
changeEdl(setRegionKeyframe(edl, region.id, frame.sourceMs, box))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
: null}
|
||||||
|
{marking && frame ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t`Mark this spot`}
|
||||||
|
className="pointer-events-auto absolute inset-0 cursor-crosshair"
|
||||||
|
onClick={(event) => {
|
||||||
|
const box = event.currentTarget.getBoundingClientRect();
|
||||||
|
const point = {
|
||||||
|
x: Math.min(1, Math.max(0, (event.clientX - box.left) / box.width)),
|
||||||
|
y: Math.min(1, Math.max(0, (event.clientY - box.top) / box.height)),
|
||||||
|
};
|
||||||
|
setMarking(false);
|
||||||
|
void act("mark", async () => {
|
||||||
|
await rpc.media.marks.create({
|
||||||
|
kind: "custom",
|
||||||
|
label: null,
|
||||||
|
personId: null,
|
||||||
|
assetId: frame.assetId,
|
||||||
|
prompt: { tMs: frame.sourceMs, point },
|
||||||
|
treatment: "blur",
|
||||||
|
});
|
||||||
|
await loadMarks();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SourcePlayer>
|
||||||
|
)}
|
||||||
|
{shownMode === "before" ? (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!frame}
|
||||||
|
onClick={() => {
|
||||||
|
if (!frame) return;
|
||||||
|
const added = addRegion(edl, frame.clipId, frame.sourceMs);
|
||||||
|
changeEdl(added.edl);
|
||||||
|
setSelectedMask(added.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus />
|
||||||
|
<Trans>Mask</Trans>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={marking ? "secondary" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
aria-pressed={marking}
|
||||||
|
disabled={!frame || busy === "mark"}
|
||||||
|
onClick={() => setMarking((value) => !value)}
|
||||||
|
>
|
||||||
|
{busy === "mark" ? (
|
||||||
|
<LoadingState label={t`Marking`} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Crosshair />
|
||||||
|
<Trans>Mark</Trans>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{selectedMask && edl.redactions.regions.some((r) => r.id === selectedMask) ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
changeEdl(removeRegion(edl, selectedMask));
|
||||||
|
setSelectedMask(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 />
|
||||||
|
<Trans>Remove mask</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{dirty ? (
|
||||||
|
<Button size="sm" className="self-start" disabled={busy !== null} onClick={savePlan}>
|
||||||
|
<Save />
|
||||||
|
<Trans>Save</Trans>
|
||||||
|
</Button>
|
||||||
|
) : savedPlan ? (
|
||||||
|
<Button size="sm" className="self-start" disabled={busy !== null} onClick={rerender}>
|
||||||
|
<Play />
|
||||||
|
<Trans>Draft</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CoverageHeatmap rows={coverage} durationMs={durationMs} onSeek={seek} />
|
||||||
|
|
||||||
|
{issues.length > 0 ? (
|
||||||
|
<ol className="flex flex-col divide-y divide-border rounded-lg border border-border">
|
||||||
|
{issues.map((issue, index) => (
|
||||||
|
<IssueRow
|
||||||
|
key={`${issue.code}-${issue.atMs ?? "x"}-${index}`}
|
||||||
|
issue={issue}
|
||||||
|
onSeek={seek}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shownPeople.length > 0 ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
{shownPeople.map((person) => (
|
||||||
|
<PersonRow
|
||||||
|
key={person.id}
|
||||||
|
person={person}
|
||||||
|
syntheticAvailable={capabilities?.operations.synthesizeIdentity?.available === true}
|
||||||
|
needsConsent={issues.some(
|
||||||
|
(issue) => issue.check === "consent" && issue.personId === person.id,
|
||||||
|
)}
|
||||||
|
onConsent={() => navigate({ name: "people" })}
|
||||||
|
onChange={(next) =>
|
||||||
|
setPeople((current) => current.map((p) => (p.id === next.id ? next : p)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{shownMarks.length > 0 ? (
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
{shownMarks.map((mark) => (
|
||||||
|
<MarkRow
|
||||||
|
key={mark.id}
|
||||||
|
mark={mark}
|
||||||
|
onChange={(next) =>
|
||||||
|
setMarks((current) => current.map((m) => (m.id === next.id ? next : m)))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IssueRow({ issue, onSeek }: { issue: GateIssue; onSeek: (ms: number) => void }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const timed = issue.atMs !== undefined;
|
||||||
|
return (
|
||||||
|
<li data-testid="media-gate-issue" data-severity={issue.severity}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!timed}
|
||||||
|
onClick={() => timed && onSeek(issue.atMs ?? 0)}
|
||||||
|
className="flex w-full items-start gap-3 px-3 py-2 text-left enabled:hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
aria-hidden
|
||||||
|
className={cn(
|
||||||
|
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||||
|
issue.severity === "block" ? "bg-destructive" : "bg-warning",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span className="w-14 shrink-0 font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
||||||
|
{timed ? formatMs(issue.atMs ?? 0) : "—"}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1 text-ui-body-sm">
|
||||||
|
<span className="sr-only">{issue.severity === "block" ? t`Blocker` : t`Warning`}: </span>
|
||||||
|
{issue.message}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonRow({
|
||||||
|
person,
|
||||||
|
syntheticAvailable,
|
||||||
|
needsConsent,
|
||||||
|
onConsent,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
person: MediaPerson;
|
||||||
|
syntheticAvailable: boolean;
|
||||||
|
needsConsent: boolean;
|
||||||
|
onConsent: () => void;
|
||||||
|
onChange: (person: MediaPerson) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const modes: Array<{ value: AnonymizeMode; label: string }> = [
|
||||||
|
{ value: "none", label: t`Visible` },
|
||||||
|
{ value: "blur", label: t`Blur` },
|
||||||
|
{ value: "pixelate", label: t`Pixelate` },
|
||||||
|
{ value: "solid", label: t`Solid` },
|
||||||
|
{ value: "sticker", label: t`Sticker` },
|
||||||
|
{ value: "partial_eyes", label: t`Eyes` },
|
||||||
|
{ value: "partial_upper", label: t`Upper face` },
|
||||||
|
{ value: "partial_lower", label: t`Lower face` },
|
||||||
|
{ value: "reframe_out", label: t`Crop out` },
|
||||||
|
...(syntheticAvailable || person.anonymizeByDefault === "synthetic_identity"
|
||||||
|
? [{ value: "synthetic_identity" as const, label: t`Synthetic face (experimental)` }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2" data-testid="media-review-person">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ui-body">{person.displayName}</span>
|
||||||
|
{needsConsent ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={onConsent}>
|
||||||
|
<Trans>Record consent</Trans>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<NativeSelect
|
||||||
|
size="sm"
|
||||||
|
aria-label={t`Anonymization for ${person.displayName}`}
|
||||||
|
value={person.anonymizeByDefault}
|
||||||
|
onChange={(event) => {
|
||||||
|
const anonymizeByDefault = event.target.value as AnonymizeMode;
|
||||||
|
setError(null);
|
||||||
|
void rpc.media.people
|
||||||
|
.update({ personId: person.id, anonymizeByDefault })
|
||||||
|
.then(onChange)
|
||||||
|
.catch((cause) => setError(errorMessage(cause)));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{modes.map((mode) => (
|
||||||
|
<NativeSelectOption key={mode.value} value={mode.value}>
|
||||||
|
{mode.label}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MarkRow({ mark, onChange }: { mark: MediaMark; onChange: (mark: MediaMark) => void }) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const labels = useTreatmentLabels();
|
||||||
|
const kindLabels = useMarkKindLabels();
|
||||||
|
const update = (treatment: MarkTreatment) => {
|
||||||
|
setError(null);
|
||||||
|
void rpc.media.marks
|
||||||
|
.update({ markId: mark.id, treatment })
|
||||||
|
.then(onChange)
|
||||||
|
.catch((cause) => setError(errorMessage(cause)));
|
||||||
|
};
|
||||||
|
const fallback =
|
||||||
|
mark.suggestedFallback && mark.suggestedFallback !== mark.treatment
|
||||||
|
? mark.suggestedFallback
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2" data-testid="media-review-mark">
|
||||||
|
<span className="min-w-0 flex-1 truncate text-ui-body">
|
||||||
|
{mark.label ?? kindLabels[mark.kind]}
|
||||||
|
</span>
|
||||||
|
{mark.qualityScore !== null ? (
|
||||||
|
<span className="font-mono text-ui-body-sm tabular-nums text-muted-foreground">
|
||||||
|
{Math.round(mark.qualityScore * 100)}%
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{fallback ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={() => update(fallback)}>
|
||||||
|
{t`Use ${labels[fallback]}`}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<NativeSelect
|
||||||
|
size="sm"
|
||||||
|
aria-label={t`Treatment`}
|
||||||
|
value={mark.treatment}
|
||||||
|
onChange={(event) => update(event.target.value as MarkTreatment)}
|
||||||
|
>
|
||||||
|
{(Object.keys(labels) as MarkTreatment[]).map((value) => (
|
||||||
|
<NativeSelectOption key={value} value={value}>
|
||||||
|
{labels[value]}
|
||||||
|
</NativeSelectOption>
|
||||||
|
))}
|
||||||
|
</NativeSelect>
|
||||||
|
<ErrorLine error={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function useTreatmentLabels(): Record<MarkTreatment, string> {
|
||||||
|
const { t } = useLingui();
|
||||||
|
return {
|
||||||
|
inpaint: t`Remove`,
|
||||||
|
blur: t`Blur`,
|
||||||
|
cover: t`Cover`,
|
||||||
|
reframe_out: t`Crop out`,
|
||||||
|
none: t`Keep`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function useMarkKindLabels(): Record<MediaMark["kind"], string> {
|
||||||
|
const { t } = useLingui();
|
||||||
|
return {
|
||||||
|
tattoo: t`Tattoo`,
|
||||||
|
birthmark: t`Birthmark`,
|
||||||
|
scar: t`Scar`,
|
||||||
|
jewelry: t`Jewelry`,
|
||||||
|
background_detail: t`Background detail`,
|
||||||
|
license_plate: t`License plate`,
|
||||||
|
custom: t`Marked spot`,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user