feat(media): web project, editor, review, and brand kit views

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

View File

@ -0,0 +1,210 @@
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">
{/* 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;
}

View 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>
);
}

View File

@ -0,0 +1,320 @@
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}
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;
}

View 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);
}

View 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>
);
}

View 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]);
}

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

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

View 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>
);
}

View 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 });
});
});

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

View File

@ -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";
/** Placeholder; the W3-web-editor slice replaces this view. */
export function BrandKitView(_props: MediaViewProps & Record<string, unknown>) {
return null;
/** Starting point for a new kit. Brand colors are render data, not UI colors. */
function emptyKit(name: string): BrandKit {
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>
);
}

View File

@ -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";
/** Placeholder; the W3-web-editor slice replaces this view. */
export function EditorView(_props: MediaViewProps & Record<string, unknown>) {
return null;
interface Loaded {
plan: MediaEditPlan;
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, t]);
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>
);
}

View File

@ -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";
/** Placeholder; the W3-web-editor slice replaces this view. */
export function ProjectView(_props: MediaViewProps & Record<string, unknown>) {
return null;
interface ProjectData {
project: MediaProject;
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>
);
}

View File

@ -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";
/** Placeholder; the W3-web-editor slice replaces this view. */
export function ReviewView(_props: MediaViewProps & Record<string, unknown>) {
return null;
type Mode = "after" | "before";
/**
* 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`,
};
}