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:
parent
884d8903ca
commit
844f7fe7c1
210
apps/web/src/components/media/editor/Player.tsx
Normal file
210
apps/web/src/components/media/editor/Player.tsx
Normal 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;
|
||||
}
|
||||
191
apps/web/src/components/media/editor/PublishPanel.tsx
Normal file
191
apps/web/src/components/media/editor/PublishPanel.tsx
Normal file
@ -0,0 +1,191 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type {
|
||||
MarketingCopy,
|
||||
MediaPublication,
|
||||
MediaRender,
|
||||
MediaRenderOutput,
|
||||
} from "@rakazo/contracts";
|
||||
import { Button, cn, Input, Textarea } from "@rakazo/ui-web";
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { rpc } from "../../../lib/rpc";
|
||||
import { LoadingState } from "../../ai/primitives";
|
||||
import { ErrorLine, errorMessage } from "./chrome";
|
||||
import {
|
||||
bundleUrl,
|
||||
downloadMediaFile,
|
||||
mediaArtifactUrl,
|
||||
renderFileUrl,
|
||||
useMediaObjectUrl,
|
||||
} from "./media-files";
|
||||
import { thumbnailVariant } from "./renders";
|
||||
|
||||
/**
|
||||
* Thumbnails, copy, and the publication bundle for a final render that passed
|
||||
* its gate. Each step appears once the one before it has produced something.
|
||||
*/
|
||||
export function PublishPanel({ render }: { render: MediaRender }) {
|
||||
const { t, i18n } = useLingui();
|
||||
const [thumbs, setThumbs] = useState<MediaRenderOutput[]>(() =>
|
||||
render.outputs.filter((output) => output.kind === "thumbnail"),
|
||||
);
|
||||
const [chosen, setChosen] = useState<string | null>(null);
|
||||
const [copy, setCopy] = useState<MarketingCopy | null>(null);
|
||||
const [title, setTitle] = useState("");
|
||||
const [caption, setCaption] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [publication, setPublication] = useState<MediaPublication | null>(null);
|
||||
const [busy, setBusy] = useState<"thumbs" | "copy" | "bundle" | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function run(step: "thumbs" | "copy" | "bundle", work: () => Promise<void>) {
|
||||
setBusy(step);
|
||||
setError(null);
|
||||
try {
|
||||
await work();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
const makeThumbs = () =>
|
||||
run("thumbs", async () => {
|
||||
const outputs = await rpc.media.renders.thumbnails({ renderId: render.id, count: 3 });
|
||||
setThumbs(outputs);
|
||||
setChosen(outputs[0] ? thumbnailVariant(outputs[0]) : null);
|
||||
});
|
||||
|
||||
const makeCopy = () =>
|
||||
run("copy", async () => {
|
||||
const next = await rpc.media.marketing.copy({
|
||||
renderId: render.id,
|
||||
language: i18n.locale.slice(0, 2) || "en",
|
||||
});
|
||||
setCopy(next);
|
||||
setTitle(next.title);
|
||||
setCaption([next.hook, next.caption].filter(Boolean).join("\n\n"));
|
||||
setTags(next.hashtags.join(" "));
|
||||
});
|
||||
|
||||
const download = (bundled: MediaPublication) =>
|
||||
downloadMediaFile(
|
||||
bundled.bundleArtifactId ? mediaArtifactUrl(bundled.bundleArtifactId) : bundleUrl(bundled.id),
|
||||
`bundle-${bundled.id}.zip`,
|
||||
);
|
||||
|
||||
const makeBundle = () =>
|
||||
run("bundle", async () => {
|
||||
if (publication) {
|
||||
await download(publication);
|
||||
return;
|
||||
}
|
||||
const created = await rpc.media.publications.create({
|
||||
renderId: render.id,
|
||||
title: title.trim() || null,
|
||||
caption: caption.trim() || null,
|
||||
tags: tags
|
||||
.split(/\s+/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
thumbnailVariant: chosen,
|
||||
});
|
||||
const bundled = await rpc.media.publications.bundle({ publicationId: created.id });
|
||||
setPublication(bundled);
|
||||
await download(bundled);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3" data-testid="media-publish">
|
||||
{thumbs.length === 0 ? (
|
||||
<Button variant="outline" size="sm" disabled={busy !== null} onClick={makeThumbs}>
|
||||
{busy === "thumbs" ? <LoadingState label={t`Thumbnails`} /> : <Trans>Thumbnails</Trans>}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{thumbs.map((output) => {
|
||||
const variant = thumbnailVariant(output);
|
||||
return (
|
||||
<Thumbnail
|
||||
key={output.fileName}
|
||||
src={renderFileUrl(render.id, output.fileName)}
|
||||
selected={chosen === variant}
|
||||
label={variant}
|
||||
onSelect={() => setChosen(variant)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{copy ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input aria-label={t`Title`} value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
<Textarea
|
||||
aria-label={t`Caption`}
|
||||
rows={4}
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
/>
|
||||
<Input aria-label={t`Hashtags`} value={tags} onChange={(e) => setTags(e.target.value)} />
|
||||
{copy.issues.map((issue) => (
|
||||
<p
|
||||
key={`${issue.code}-${issue.message}`}
|
||||
className={cn(
|
||||
"text-ui-body-sm",
|
||||
issue.severity === "block" ? "text-destructive" : "text-warning",
|
||||
)}
|
||||
>
|
||||
{issue.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled={busy !== null} onClick={makeCopy}>
|
||||
{busy === "copy" ? <LoadingState label={t`Copy`} /> : <Trans>Write copy</Trans>}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button size="sm" disabled={busy !== null} onClick={makeBundle}>
|
||||
{busy === "bundle" ? (
|
||||
<LoadingState label={t`Bundle`} />
|
||||
) : (
|
||||
<>
|
||||
<Download />
|
||||
{publication ? <Trans>Download again</Trans> : <Trans>Bundle</Trans>}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<ErrorLine error={error} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbnail({
|
||||
src,
|
||||
selected,
|
||||
label,
|
||||
onSelect,
|
||||
}: {
|
||||
src: string;
|
||||
selected: boolean;
|
||||
label: string;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
const url = useMediaObjectUrl(src);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={label}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"aspect-video overflow-hidden rounded-md border bg-muted",
|
||||
selected ? "border-primary ring-2 ring-ring/50" : "border-border",
|
||||
)}
|
||||
>
|
||||
{url ? <img src={url} alt="" className="h-full w-full object-cover" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
320
apps/web/src/components/media/editor/Timeline.tsx
Normal file
320
apps/web/src/components/media/editor/Timeline.tsx
Normal 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;
|
||||
}
|
||||
60
apps/web/src/components/media/editor/chrome.tsx
Normal file
60
apps/web/src/components/media/editor/chrome.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import { Badge, Button, cn } from "@rakazo/ui-web";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import type { GateStatus } from "./renders";
|
||||
|
||||
/** Header row of an editor-side view: back, title, and the view's actions. */
|
||||
export function ViewHeader({
|
||||
title,
|
||||
onBack,
|
||||
backLabel,
|
||||
children,
|
||||
}: {
|
||||
title: React.ReactNode;
|
||||
onBack: () => void;
|
||||
backLabel: string;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="flex min-h-control-lg items-center gap-2">
|
||||
<Button variant="ghost" size="icon-sm" aria-label={backLabel} onClick={onBack}>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<h2 className="min-w-0 flex-1 truncate text-ui-title font-semibold">{title}</h2>
|
||||
{children}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
/** Gate outcome as a small status badge. */
|
||||
export function GateBadge({ status }: { status: GateStatus }) {
|
||||
const { t } = useLingui();
|
||||
const label = { pass: t`Passed`, warn: t`Warnings`, blocked: t`Blocked` }[status];
|
||||
return (
|
||||
<Badge
|
||||
data-testid="media-gate-status"
|
||||
data-status={status}
|
||||
variant={status === "blocked" ? "destructive" : "outline"}
|
||||
className={cn(
|
||||
status === "pass" && "border-success/40 text-success",
|
||||
status === "warn" && "border-warning/40 text-warning",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
/** A request error in one line, under the control that caused it. */
|
||||
export function ErrorLine({ error }: { error: string | null }) {
|
||||
if (!error) return null;
|
||||
return (
|
||||
<p role="alert" className="text-ui-body-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : String(error);
|
||||
}
|
||||
147
apps/web/src/components/media/editor/fields.tsx
Normal file
147
apps/web/src/components/media/editor/fields.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import { Field, FieldLabel, Input, NativeSelect, NativeSelectOption, Switch } from "@rakazo/ui-web";
|
||||
import { useEffect, useId, useState } from "react";
|
||||
|
||||
/*
|
||||
* Compact labelled inputs for the inspector panels. Times are edited in
|
||||
* seconds and stored in milliseconds.
|
||||
*/
|
||||
|
||||
export function SecondsField({
|
||||
label,
|
||||
ms,
|
||||
min = 0,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
ms: number;
|
||||
min?: number;
|
||||
onChange: (ms: number) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
const [text, setText] = useState(() => (ms / 1000).toFixed(1));
|
||||
useEffect(() => setText((ms / 1000).toFixed(1)), [ms]);
|
||||
return (
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
step={0.1}
|
||||
min={min / 1000}
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
onBlur={() => {
|
||||
const seconds = Number(text);
|
||||
if (Number.isFinite(seconds) && seconds * 1000 >= min)
|
||||
onChange(Math.round(seconds * 1000));
|
||||
else setText((ms / 1000).toFixed(1));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberField({
|
||||
label,
|
||||
value,
|
||||
min,
|
||||
max,
|
||||
step = 1,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
min: number;
|
||||
max: number;
|
||||
step?: number;
|
||||
onChange: (value: number) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={id}
|
||||
type="number"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
value={value}
|
||||
onChange={(event) => {
|
||||
const next = Number(event.target.value);
|
||||
if (Number.isFinite(next)) onChange(Math.min(max, Math.max(min, next)));
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectField<T extends string>({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: T;
|
||||
options: ReadonlyArray<{ value: T; label: string }>;
|
||||
onChange: (value: T) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<NativeSelect id={id} value={value} onChange={(event) => onChange(event.target.value as T)}>
|
||||
{options.map((option) => (
|
||||
<NativeSelectOption key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</NativeSelectOption>
|
||||
))}
|
||||
</NativeSelect>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function TextField({
|
||||
label,
|
||||
value,
|
||||
maxLength,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
maxLength?: number;
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field className="gap-1">
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
<Input
|
||||
id={id}
|
||||
value={value}
|
||||
maxLength={maxLength}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToggleField({
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const id = useId();
|
||||
return (
|
||||
<Field orientation="horizontal" className="items-center gap-2">
|
||||
<Switch id={id} checked={checked} onCheckedChange={onChange} />
|
||||
<FieldLabel htmlFor={id}>{label}</FieldLabel>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/media/editor/media-files.ts
Normal file
93
apps/web/src/components/media/editor/media-files.ts
Normal file
@ -0,0 +1,93 @@
|
||||
import type { MediaRender } from "@rakazo/contracts";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { withSpaceHeaders } from "../../../lib/rpc";
|
||||
|
||||
/*
|
||||
* Media byte routes need the selected space header, which `<video src>` and
|
||||
* `<img src>` cannot send. Files are fetched with the header and shown through
|
||||
* object URLs; renders and previews are short, so whole-file fetches are fine.
|
||||
*/
|
||||
|
||||
export function renderFileUrl(renderId: string, fileName: string, download = false): string {
|
||||
const base = `/api/media/renders/${encodeURIComponent(renderId)}/files/${encodeURIComponent(fileName)}`;
|
||||
return download ? `${base}?download=1` : base;
|
||||
}
|
||||
|
||||
export function assetFileUrl(assetId: string, kind: "thumb" | "proxy"): string {
|
||||
return `/api/media/assets/${encodeURIComponent(assetId)}/${kind}`;
|
||||
}
|
||||
|
||||
/** Thumbnails, intro previews, and other media outputs stored as space artifacts. */
|
||||
export function mediaArtifactUrl(artifactId: string): string {
|
||||
return `/api/media/artifacts/${encodeURIComponent(artifactId)}`;
|
||||
}
|
||||
|
||||
export function bundleUrl(publicationId: string): string {
|
||||
return `/api/media/bundles/${encodeURIComponent(publicationId)}`;
|
||||
}
|
||||
|
||||
/** The streamable video of a render, when it has one. */
|
||||
export function renderVideoUrl(render: Pick<MediaRender, "id" | "outputs">): string | null {
|
||||
const video = render.outputs.find((output) => output.kind === "video");
|
||||
return video ? renderFileUrl(render.id, video.fileName) : null;
|
||||
}
|
||||
|
||||
export function renderPosterUrl(render: Pick<MediaRender, "id" | "outputs">): string | null {
|
||||
const poster = render.outputs.find((output) => output.kind === "poster");
|
||||
return poster ? renderFileUrl(render.id, poster.fileName) : null;
|
||||
}
|
||||
|
||||
/** An object URL for a media byte route, or null while loading or when missing. */
|
||||
export function useMediaObjectUrl(url: string | null): string | null {
|
||||
const [objectUrl, setObjectUrl] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
setObjectUrl(null);
|
||||
if (!url) return;
|
||||
const controller = new AbortController();
|
||||
let created: string | null = null;
|
||||
void fetch(url, {
|
||||
headers: withSpaceHeaders(),
|
||||
credentials: "include",
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
if (controller.signal.aborted) return;
|
||||
created = URL.createObjectURL(blob);
|
||||
setObjectUrl(created);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (created) URL.revokeObjectURL(created);
|
||||
};
|
||||
}, [url]);
|
||||
return objectUrl;
|
||||
}
|
||||
|
||||
/** Download a media byte route as a file. */
|
||||
export async function downloadMediaFile(url: string, fileName: string): Promise<void> {
|
||||
const response = await fetch(url, { headers: withSpaceHeaders(), credentials: "include" });
|
||||
if (!response.ok) throw new Error(`Download failed (${response.status})`);
|
||||
const href = URL.createObjectURL(await response.blob());
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.download = fileName;
|
||||
anchor.click();
|
||||
window.setTimeout(() => URL.revokeObjectURL(href), 60_000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `tick` every `intervalMs` while `active`; stops as soon as work is idle.
|
||||
* The latest `tick` is always used, so callers need not memoize it.
|
||||
*/
|
||||
export function usePolling(tick: () => unknown, active: boolean, intervalMs: number): void {
|
||||
const latest = useRef(tick);
|
||||
latest.current = tick;
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const timer = window.setInterval(() => void latest.current(), intervalMs);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [active, intervalMs]);
|
||||
}
|
||||
135
apps/web/src/components/media/editor/renders.test.ts
Normal file
135
apps/web/src/components/media/editor/renders.test.ts
Normal file
@ -0,0 +1,135 @@
|
||||
import type { GateIssue, MediaEditPlan, MediaRender } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
gateStatus,
|
||||
projectVariants,
|
||||
reviewedPlan,
|
||||
sortedIssues,
|
||||
thumbnailVariant,
|
||||
} from "./renders";
|
||||
|
||||
function render(patch: Partial<MediaRender>): MediaRender {
|
||||
return {
|
||||
id: "r1",
|
||||
planId: "p1",
|
||||
projectId: "proj",
|
||||
profileId: "youtube-shorts",
|
||||
quality: "draft",
|
||||
status: "done",
|
||||
progress: 1,
|
||||
outputs: [],
|
||||
gateReport: null,
|
||||
ffprobe: null,
|
||||
error: null,
|
||||
createdAt: "2026-10-01T10:00:00.000Z",
|
||||
updatedAt: "2026-10-01T10:00:00.000Z",
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function plan(patch: Partial<MediaEditPlan>): MediaEditPlan {
|
||||
return {
|
||||
id: "p1",
|
||||
projectId: "proj",
|
||||
profileId: "youtube-shorts",
|
||||
version: 1,
|
||||
edl: {} as MediaEditPlan["edl"],
|
||||
createdBy: "user",
|
||||
rationale: null,
|
||||
createdAt: "2026-10-01T09:00:00.000Z",
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
const issue = (patch: Partial<GateIssue>): GateIssue => ({
|
||||
check: "content",
|
||||
severity: "block",
|
||||
code: "x",
|
||||
message: "m",
|
||||
...patch,
|
||||
});
|
||||
|
||||
const report = (issues: GateIssue[], passed: boolean, autoFixRound = 0) => ({
|
||||
profileId: "youtube-shorts",
|
||||
passed,
|
||||
checkedAt: "2026-10-01T10:00:00.000Z",
|
||||
sampledFrames: 10,
|
||||
issues,
|
||||
autoFixRound,
|
||||
});
|
||||
|
||||
describe("gateStatus", () => {
|
||||
it("is null while running or without a report", () => {
|
||||
expect(gateStatus(render({ status: "rendering" }))).toBeNull();
|
||||
expect(gateStatus(render({ gateReport: null }))).toBeNull();
|
||||
});
|
||||
|
||||
it("separates pass, warnings, and blocked", () => {
|
||||
expect(gateStatus(render({ gateReport: report([], true) }))).toBe("pass");
|
||||
expect(gateStatus(render({ gateReport: report([issue({ severity: "warn" })], true) }))).toBe(
|
||||
"warn",
|
||||
);
|
||||
expect(gateStatus(render({ status: "blocked", gateReport: report([issue({})], false) }))).toBe(
|
||||
"blocked",
|
||||
);
|
||||
// A failed report never reads as passed, whatever the issue list says.
|
||||
expect(gateStatus(render({ gateReport: report([], false) }))).toBe("blocked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("sortedIssues", () => {
|
||||
it("orders by time, untimed last, blockers first at equal time", () => {
|
||||
const sorted = sortedIssues([
|
||||
issue({ code: "untimed" }),
|
||||
issue({ code: "late", atMs: 5000 }),
|
||||
issue({ code: "warn", atMs: 1000, severity: "warn" }),
|
||||
issue({ code: "block", atMs: 1000 }),
|
||||
]);
|
||||
expect(sorted.map((item) => item.code)).toEqual(["block", "warn", "late", "untimed"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectVariants", () => {
|
||||
it("keeps one card per profile with the newest plan and renders", () => {
|
||||
const plans = [
|
||||
plan({ id: "p1", version: 1 }),
|
||||
plan({ id: "p2", version: 2 }),
|
||||
plan({ id: "q1", profileId: "tiktok" }),
|
||||
];
|
||||
const renders = [
|
||||
render({ id: "d1", createdAt: "2026-10-01T10:00:00.000Z" }),
|
||||
render({ id: "d2", createdAt: "2026-10-01T11:00:00.000Z" }),
|
||||
render({ id: "f1", quality: "final" }),
|
||||
];
|
||||
const variants = projectVariants(["youtube-shorts", "instagram-reels"], plans, renders);
|
||||
expect(variants.map((variant) => variant.profileId)).toEqual([
|
||||
"youtube-shorts",
|
||||
"instagram-reels",
|
||||
"tiktok",
|
||||
]);
|
||||
expect(variants[0]?.plan?.id).toBe("p2");
|
||||
expect(variants[0]?.draft?.id).toBe("d2");
|
||||
expect(variants[0]?.final?.id).toBe("f1");
|
||||
expect(variants[1]?.plan).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewedPlan", () => {
|
||||
it("follows auto-fix versions created by the gate", () => {
|
||||
const plans = [
|
||||
plan({ id: "p1", version: 1 }),
|
||||
plan({ id: "p2", version: 2, createdBy: "agent" }),
|
||||
plan({ id: "p3", version: 3, createdBy: "agent" }),
|
||||
];
|
||||
expect(reviewedPlan(render({ planId: "p1" }), plans)?.id).toBe("p1");
|
||||
expect(
|
||||
reviewedPlan(render({ planId: "p1", gateReport: report([], false, 1) }), plans)?.id,
|
||||
).toBe("p2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("thumbnailVariant", () => {
|
||||
it("strips the extension", () => {
|
||||
expect(thumbnailVariant({ fileName: "thumb-b.png" })).toBe("thumb-b");
|
||||
});
|
||||
});
|
||||
108
apps/web/src/components/media/editor/renders.ts
Normal file
108
apps/web/src/components/media/editor/renders.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type {
|
||||
GateIssue,
|
||||
MediaEditPlan,
|
||||
MediaRender,
|
||||
MediaRenderOutput,
|
||||
MediaRenderQuality,
|
||||
MediaRenderStatus,
|
||||
} from "@rakazo/contracts";
|
||||
|
||||
/** Renders still moving; clients poll while any is in one of these states. */
|
||||
const ACTIVE: ReadonlySet<MediaRenderStatus> = new Set(["queued", "rendering", "gating"]);
|
||||
|
||||
/** How often to re-read a render (or project) while work is in progress. */
|
||||
export const RENDER_POLL_MS = 2000;
|
||||
|
||||
export function isRenderActive(render: Pick<MediaRender, "status">): boolean {
|
||||
return ACTIVE.has(render.status);
|
||||
}
|
||||
|
||||
export type GateStatus = "pass" | "warn" | "blocked";
|
||||
|
||||
/** Gate outcome of a finished render, or null while there is no report yet. */
|
||||
export function gateStatus(render: Pick<MediaRender, "status" | "gateReport">): GateStatus | null {
|
||||
const report = render.gateReport;
|
||||
if (render.status === "blocked") return "blocked";
|
||||
if (!report || isRenderActive(render)) return null;
|
||||
if (!report.passed || report.issues.some((issue) => issue.severity === "block")) {
|
||||
return "blocked";
|
||||
}
|
||||
return report.issues.length > 0 ? "warn" : "pass";
|
||||
}
|
||||
|
||||
/** Gate issues in timeline order (untimed ones last), blockers before warnings at equal times. */
|
||||
export function sortedIssues(issues: GateIssue[]): GateIssue[] {
|
||||
const at = (issue: GateIssue) => issue.atMs ?? Number.POSITIVE_INFINITY;
|
||||
return [...issues].sort((a, b) => {
|
||||
if (at(a) !== at(b)) return at(a) < at(b) ? -1 : 1;
|
||||
if (a.severity !== b.severity) return a.severity === "block" ? -1 : 1;
|
||||
return a.code.localeCompare(b.code);
|
||||
});
|
||||
}
|
||||
|
||||
function newest<T extends { createdAt: string }>(items: T[]): T | null {
|
||||
let best: T | null = null;
|
||||
for (const item of items) if (!best || item.createdAt > best.createdAt) best = item;
|
||||
return best;
|
||||
}
|
||||
|
||||
export interface Variant {
|
||||
profileId: string;
|
||||
/** Newest plan version for the profile. */
|
||||
plan: MediaEditPlan | null;
|
||||
/** Newest render of the profile in each quality. */
|
||||
draft: MediaRender | null;
|
||||
final: MediaRender | null;
|
||||
}
|
||||
|
||||
/** One variant per project profile: its newest plan and newest draft and final renders. */
|
||||
export function projectVariants(
|
||||
profileIds: string[],
|
||||
plans: MediaEditPlan[],
|
||||
renders: MediaRender[],
|
||||
): Variant[] {
|
||||
const ids = [...profileIds];
|
||||
for (const plan of plans) if (!ids.includes(plan.profileId)) ids.push(plan.profileId);
|
||||
return ids.map((profileId) => {
|
||||
const own = plans.filter((plan) => plan.profileId === profileId);
|
||||
let plan: MediaEditPlan | null = null;
|
||||
for (const candidate of own) if (!plan || candidate.version > plan.version) plan = candidate;
|
||||
const ofQuality = (quality: MediaRenderQuality) =>
|
||||
newest(
|
||||
renders.filter((render) => render.profileId === profileId && render.quality === quality),
|
||||
);
|
||||
return { profileId, plan, draft: ofQuality("draft"), final: ofQuality("final") };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The plan whose timeline a render's gate report refers to: the started plan,
|
||||
* or the newest auto-fix version of it when the gate repaired the plan.
|
||||
*/
|
||||
export function reviewedPlan(render: MediaRender, plans: MediaEditPlan[]): MediaEditPlan | null {
|
||||
const started = plans.find((plan) => plan.id === render.planId) ?? null;
|
||||
const rounds = render.gateReport?.autoFixRound ?? 0;
|
||||
if (!started || rounds === 0) return started;
|
||||
let best = started;
|
||||
for (const plan of plans) {
|
||||
if (
|
||||
plan.profileId === started.profileId &&
|
||||
plan.version > best.version &&
|
||||
plan.version <= started.version + rounds &&
|
||||
plan.createdBy === "agent"
|
||||
) {
|
||||
best = plan;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** True when anything in a project still renders. */
|
||||
export function anyRenderActive(renders: MediaRender[]): boolean {
|
||||
return renders.some(isRenderActive);
|
||||
}
|
||||
|
||||
/** Thumbnail variant name from its output file (`thumb-a.png` → `thumb-a`). */
|
||||
export function thumbnailVariant(output: Pick<MediaRenderOutput, "fileName">): string {
|
||||
return output.fileName.replace(/\.[a-z0-9]+$/i, "");
|
||||
}
|
||||
162
apps/web/src/components/media/editor/review-parts.tsx
Normal file
162
apps/web/src/components/media/editor/review-parts.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
import { useLingui } from "@lingui/react/macro";
|
||||
import type { GateIssue } from "@rakazo/contracts";
|
||||
import { cn } from "@rakazo/ui-web";
|
||||
import { useRef } from "react";
|
||||
import type { MaskBox } from "./timeline";
|
||||
import { clampBox, formatMs } from "./timeline";
|
||||
|
||||
/**
|
||||
* A draggable, resizable mask box over the source frame. Coordinates are
|
||||
* normalized to the frame; the parent must be the displayed frame itself.
|
||||
*/
|
||||
export function MaskBoxEditor({
|
||||
box,
|
||||
label,
|
||||
selected,
|
||||
onSelect,
|
||||
onChange,
|
||||
}: {
|
||||
box: MaskBox;
|
||||
label: string;
|
||||
selected: boolean;
|
||||
onSelect: () => void;
|
||||
onChange: (box: MaskBox) => void;
|
||||
}) {
|
||||
const gesture = useRef<{
|
||||
mode: "move" | "resize";
|
||||
x: number;
|
||||
y: number;
|
||||
start: MaskBox;
|
||||
frame: DOMRect;
|
||||
} | null>(null);
|
||||
|
||||
const begin = (mode: "move" | "resize") => (event: React.PointerEvent<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
const frame = event.currentTarget.closest("[data-frame]")?.getBoundingClientRect();
|
||||
if (!frame) return;
|
||||
onSelect();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
gesture.current = { mode, x: event.clientX, y: event.clientY, start: box, frame };
|
||||
};
|
||||
const move = (event: React.PointerEvent<HTMLElement>) => {
|
||||
const active = gesture.current;
|
||||
if (!active) return;
|
||||
const dx = (event.clientX - active.x) / active.frame.width;
|
||||
const dy = (event.clientY - active.y) / active.frame.height;
|
||||
const { start } = active;
|
||||
onChange(
|
||||
clampBox(
|
||||
active.mode === "move"
|
||||
? { ...start, x: start.x + dx, y: start.y + dy }
|
||||
: { ...start, w: start.w + dx, h: start.h + dy },
|
||||
),
|
||||
);
|
||||
};
|
||||
const end = () => {
|
||||
gesture.current = null;
|
||||
};
|
||||
const nudge = (event: React.KeyboardEvent) => {
|
||||
const step = event.shiftKey ? 0.05 : 0.01;
|
||||
const delta: Record<string, [number, number]> = {
|
||||
ArrowLeft: [-step, 0],
|
||||
ArrowRight: [step, 0],
|
||||
ArrowUp: [0, -step],
|
||||
ArrowDown: [0, step],
|
||||
};
|
||||
const [dx, dy] = delta[event.key] ?? [0, 0];
|
||||
if (dx === 0 && dy === 0) return;
|
||||
event.preventDefault();
|
||||
onChange(clampBox({ ...box, x: box.x + dx, y: box.y + dy }));
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-pressed={selected}
|
||||
data-testid="media-mask"
|
||||
onPointerDown={begin("move")}
|
||||
onPointerMove={move}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
onKeyDown={nudge}
|
||||
className={cn(
|
||||
"pointer-events-auto absolute cursor-move touch-none rounded-sm border-2 bg-foreground/20 backdrop-blur-sm",
|
||||
selected ? "border-primary" : "border-background",
|
||||
)}
|
||||
style={{
|
||||
left: `${box.x * 100}%`,
|
||||
top: `${box.y * 100}%`,
|
||||
width: `${box.w * 100}%`,
|
||||
height: `${box.h * 100}%`,
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
onPointerDown={begin("resize")}
|
||||
onPointerMove={move}
|
||||
onPointerUp={end}
|
||||
onPointerCancel={end}
|
||||
className="absolute -right-1.5 -bottom-1.5 size-3 cursor-nwse-resize rounded-sm border border-background bg-primary"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CoverageRow {
|
||||
key: string;
|
||||
label: string;
|
||||
issues: GateIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Per person and per mark: a bar over the program length, tinted where the
|
||||
* gate found the protection incomplete. A clean row means full coverage.
|
||||
*/
|
||||
export function CoverageHeatmap({
|
||||
rows,
|
||||
durationMs,
|
||||
onSeek,
|
||||
}: {
|
||||
rows: CoverageRow[];
|
||||
durationMs: number;
|
||||
onSeek: (ms: number) => void;
|
||||
}) {
|
||||
const { t } = useLingui();
|
||||
if (rows.length === 0 || durationMs <= 0) return null;
|
||||
const pct = (ms: number) => `${Math.min(100, Math.max(0, (ms / durationMs) * 100))}%`;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5" data-testid="media-coverage">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="grid grid-cols-[8rem_1fr] items-center gap-2">
|
||||
<span className="truncate text-ui-body-sm">{row.label}</span>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-3 rounded-full",
|
||||
row.issues.length === 0 ? "bg-success/30" : "bg-muted",
|
||||
)}
|
||||
>
|
||||
{row.issues.map((issue, index) => {
|
||||
const at = issue.atMs ?? 0;
|
||||
const end = Math.max(issue.endMs ?? at, at + durationMs * 0.01);
|
||||
return (
|
||||
<button
|
||||
key={`${issue.code}-${at}-${index}`}
|
||||
type="button"
|
||||
aria-label={t`${formatMs(at)}: ${issue.message}`}
|
||||
onClick={() => onSeek(at)}
|
||||
className={cn(
|
||||
"absolute inset-y-0 rounded-full",
|
||||
issue.severity === "block" ? "bg-destructive" : "bg-warning",
|
||||
)}
|
||||
style={{ left: pct(at), width: `calc(${pct(end)} - ${pct(at)})` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
152
apps/web/src/components/media/editor/timeline.test.ts
Normal file
152
apps/web/src/components/media/editor/timeline.test.ts
Normal file
@ -0,0 +1,152 @@
|
||||
import type { Edl } from "@rakazo/contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addRegion,
|
||||
applyDrag,
|
||||
clampBox,
|
||||
edlDurationMs,
|
||||
errorTarget,
|
||||
formatMs,
|
||||
freeId,
|
||||
locateOutputTime,
|
||||
moveClip,
|
||||
regionBoxAt,
|
||||
setRegionKeyframe,
|
||||
trimClip,
|
||||
} from "./timeline";
|
||||
|
||||
function edl(): Edl {
|
||||
return {
|
||||
edlVersion: 1,
|
||||
output: {
|
||||
profileId: "example-short-vertical",
|
||||
width: 1080,
|
||||
height: 1920,
|
||||
fps: 30,
|
||||
durationTargetMs: 8000,
|
||||
durationHardMinMs: 1000,
|
||||
durationHardMaxMs: 60000,
|
||||
},
|
||||
tracks: {
|
||||
video: [
|
||||
{
|
||||
id: "a",
|
||||
assetId: "asset-1",
|
||||
inMs: 1000,
|
||||
outMs: 4000,
|
||||
atMs: 0,
|
||||
speed: 1,
|
||||
reframe: "auto",
|
||||
stabilize: false,
|
||||
muted: false,
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
assetId: "asset-2",
|
||||
inMs: 0,
|
||||
outMs: 4000,
|
||||
atMs: 3000,
|
||||
speed: 2,
|
||||
reframe: "none",
|
||||
stabilize: false,
|
||||
muted: false,
|
||||
},
|
||||
],
|
||||
audio: [],
|
||||
},
|
||||
transitions: [],
|
||||
overlays: [{ id: "text-1", kind: "text", atMs: 500, durationMs: 1000, text: "Hi" }],
|
||||
intro: { templateId: "intro-minimal", params: {}, durationMs: 1500 },
|
||||
redactions: { faces: [], marks: [], regions: [] },
|
||||
metadata: { tags: [], aiAltered: false, aiAlterations: [] },
|
||||
};
|
||||
}
|
||||
|
||||
describe("timeline math", () => {
|
||||
it("counts intro, clips at their speed, and outro", () => {
|
||||
// intro 1500 + clip b ends at 3000 + 4000/2
|
||||
expect(edlDurationMs(edl())).toBe(6500);
|
||||
});
|
||||
|
||||
it("maps output time to the clip and source time, intro to nothing", () => {
|
||||
expect(locateOutputTime(edl(), 1000)).toBeNull();
|
||||
const first = locateOutputTime(edl(), 1500 + 500);
|
||||
expect(first?.clip.id).toBe("a");
|
||||
expect(first?.sourceMs).toBe(1500);
|
||||
const second = locateOutputTime(edl(), 1500 + 4000);
|
||||
expect(second?.clip.id).toBe("b");
|
||||
expect(second?.sourceMs).toBe(2000);
|
||||
});
|
||||
|
||||
it("moves clips but never before zero", () => {
|
||||
expect(moveClip(edl(), "b", 500).tracks.video[1]?.atMs).toBe(3500);
|
||||
expect(moveClip(edl(), "a", -900).tracks.video[0]?.atMs).toBe(0);
|
||||
});
|
||||
|
||||
it("trims the end inside the asset and keeps a minimum length", () => {
|
||||
expect(trimClip(edl(), "a", "end", 5000, 4500).tracks.video[0]?.outMs).toBe(4500);
|
||||
expect(trimClip(edl(), "a", "end", -10_000, 4500).tracks.video[0]?.outMs).toBe(1100);
|
||||
});
|
||||
|
||||
it("trims the start without moving the clip end on the timeline", () => {
|
||||
const trimmed = trimClip(edl(), "b", "start", 500, 4000).tracks.video[1];
|
||||
// 500 ms of output at speed 2 is 1000 ms of source.
|
||||
expect(trimmed?.inMs).toBe(1000);
|
||||
expect(trimmed?.atMs).toBe(3500);
|
||||
// Cannot pull the start before the asset begins.
|
||||
const extended = trimClip(edl(), "a", "start", -5000, 4000).tracks.video[0];
|
||||
expect(extended?.inMs).toBe(1000);
|
||||
expect(extended?.atMs).toBe(0);
|
||||
});
|
||||
|
||||
it("applies drags by handle id", () => {
|
||||
const durations = new Map([
|
||||
["asset-1", 4500],
|
||||
["asset-2", 4000],
|
||||
]);
|
||||
expect(applyDrag(edl(), "trim-end:a", 300, durations).tracks.video[0]?.outMs).toBe(4300);
|
||||
expect(applyDrag(edl(), "overlay:text-1", 250, durations).overlays[0]?.atMs).toBe(750);
|
||||
expect(applyDrag(edl(), "clip:a", 100, durations).tracks.video[0]?.atMs).toBe(100);
|
||||
});
|
||||
|
||||
it("points EDL errors at the clip or overlay they name", () => {
|
||||
expect(errorTarget(edl(), { path: "tracks.video[1].outMs" })).toEqual({
|
||||
kind: "clip",
|
||||
id: "b",
|
||||
});
|
||||
expect(errorTarget(edl(), { path: "overlays[0].box" })).toEqual({
|
||||
kind: "overlay",
|
||||
id: "text-1",
|
||||
});
|
||||
expect(errorTarget(edl(), { path: "output" })).toBeNull();
|
||||
expect(errorTarget(edl(), { path: "tracks.video[9]" })).toBeNull();
|
||||
});
|
||||
|
||||
it("formats timestamps", () => {
|
||||
expect(formatMs(0)).toBe("0:00.0");
|
||||
expect(formatMs(65_300)).toBe("1:05.3");
|
||||
});
|
||||
|
||||
it("finds free ids", () => {
|
||||
expect(freeId(["mask-1", "mask-2"], "mask")).toBe("mask-3");
|
||||
});
|
||||
});
|
||||
|
||||
describe("masks", () => {
|
||||
it("adds a region and keyframes it in source time", () => {
|
||||
const added = addRegion(edl(), "a", 2000);
|
||||
expect(added.id).toBe("mask-1");
|
||||
const moved = setRegionKeyframe(added.edl, added.id, 3000, { x: 0.9, y: 0.1, w: 0.3, h: 0.2 });
|
||||
const region = moved.redactions.regions[0]!;
|
||||
expect(region.keyframes.map((keyframe) => keyframe.tMs)).toEqual([2000, 3000]);
|
||||
// Clamped into the frame.
|
||||
expect(region.keyframes[1]?.box.x).toBeCloseTo(0.7);
|
||||
expect(regionBoxAt(region, 2500)).toEqual(region.keyframes[0]?.box);
|
||||
expect(regionBoxAt(region, 3500)).toEqual(region.keyframes[1]?.box);
|
||||
expect(regionBoxAt(region, 0)).toEqual(region.keyframes[0]?.box);
|
||||
});
|
||||
|
||||
it("clamps boxes to a minimum size inside the frame", () => {
|
||||
expect(clampBox({ x: -1, y: 2, w: 0, h: 5 })).toEqual({ x: 0, y: 0, w: 0.02, h: 1 });
|
||||
});
|
||||
});
|
||||
271
apps/web/src/components/media/editor/timeline.ts
Normal file
271
apps/web/src/components/media/editor/timeline.ts
Normal file
@ -0,0 +1,271 @@
|
||||
import type { Edl, EdlError, EdlOverlay, EdlVideoClip } from "@rakazo/contracts";
|
||||
|
||||
/*
|
||||
* Timeline math for the editor and review player. Mirrors the timing helpers
|
||||
* in `@rakazo/media` (`clipLengthMs`, `edlDurationMs`, `locateOutputTime`),
|
||||
* which the web app cannot import yet; the server validates every plan anyway.
|
||||
*/
|
||||
|
||||
/** Shortest clip the editor lets a trim produce. */
|
||||
export const MIN_CLIP_MS = 100;
|
||||
|
||||
export function clipLengthMs(clip: Pick<EdlVideoClip, "inMs" | "outMs" | "speed">): number {
|
||||
return Math.max(0, Math.round((clip.outMs - clip.inMs) / clip.speed));
|
||||
}
|
||||
|
||||
export function introMs(edl: Edl): number {
|
||||
return edl.intro?.durationMs ?? 0;
|
||||
}
|
||||
|
||||
export function mainProgramMs(edl: Edl): number {
|
||||
let end = 0;
|
||||
for (const clip of edl.tracks.video) end = Math.max(end, clip.atMs + clipLengthMs(clip));
|
||||
return end;
|
||||
}
|
||||
|
||||
export function edlDurationMs(edl: Edl): number {
|
||||
return introMs(edl) + mainProgramMs(edl) + (edl.outro?.durationMs ?? 0);
|
||||
}
|
||||
|
||||
/** The clip and source time an output timestamp shows; the incoming clip wins during a blend. */
|
||||
export function locateOutputTime(
|
||||
edl: Edl,
|
||||
outputMs: number,
|
||||
): { clip: EdlVideoClip; sourceMs: number } | null {
|
||||
const t = outputMs - introMs(edl);
|
||||
if (t < 0) return null;
|
||||
const visible = edl.tracks.video
|
||||
.filter((clip) => t >= clip.atMs && t < clip.atMs + clipLengthMs(clip))
|
||||
.sort((a, b) => a.atMs - b.atMs || a.id.localeCompare(b.id));
|
||||
const clip = visible.at(-1);
|
||||
if (!clip) return null;
|
||||
return {
|
||||
clip,
|
||||
sourceMs: Math.min(clip.outMs, Math.round(clip.inMs + (t - clip.atMs) * clip.speed)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Output timestamp where a clip starts (intro included). */
|
||||
export function clipOutputStartMs(edl: Edl, clip: EdlVideoClip): number {
|
||||
return introMs(edl) + clip.atMs;
|
||||
}
|
||||
|
||||
function replaceClip(edl: Edl, clipId: string, next: (clip: EdlVideoClip) => EdlVideoClip): Edl {
|
||||
return {
|
||||
...edl,
|
||||
tracks: {
|
||||
...edl.tracks,
|
||||
video: edl.tracks.video.map((clip) => (clip.id === clipId ? next(clip) : clip)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Move a clip on the output timeline. */
|
||||
export function moveClip(edl: Edl, clipId: string, deltaMs: number): Edl {
|
||||
return replaceClip(edl, clipId, (clip) => ({
|
||||
...clip,
|
||||
atMs: Math.max(0, Math.round(clip.atMs + deltaMs)),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trim a clip edge by an output-time delta. The start edge keeps the clip's
|
||||
* end in place on the timeline; both edges stay inside the source asset.
|
||||
*/
|
||||
export function trimClip(
|
||||
edl: Edl,
|
||||
clipId: string,
|
||||
edge: "start" | "end",
|
||||
deltaMs: number,
|
||||
assetDurationMs: number | null,
|
||||
): Edl {
|
||||
return replaceClip(edl, clipId, (clip) => {
|
||||
const sourceDelta = Math.round(deltaMs * clip.speed);
|
||||
const minSource = Math.round(MIN_CLIP_MS * clip.speed);
|
||||
if (edge === "end") {
|
||||
const max = assetDurationMs ?? Number.POSITIVE_INFINITY;
|
||||
const outMs = Math.min(max, Math.max(clip.inMs + minSource, clip.outMs + sourceDelta));
|
||||
return { ...clip, outMs };
|
||||
}
|
||||
// Never start before the asset, the timeline, or past the end edge.
|
||||
const lowest = Math.max(0, clip.inMs - Math.round(clip.atMs * clip.speed));
|
||||
const inMs = Math.min(clip.outMs - minSource, Math.max(lowest, clip.inMs + sourceDelta));
|
||||
const atMs = Math.max(0, clip.atMs + Math.round((inMs - clip.inMs) / clip.speed));
|
||||
return { ...clip, inMs, atMs };
|
||||
});
|
||||
}
|
||||
|
||||
/** Move an overlay on the output timeline. */
|
||||
export function moveOverlay(edl: Edl, overlayId: string, deltaMs: number): Edl {
|
||||
return {
|
||||
...edl,
|
||||
overlays: edl.overlays.map((overlay) =>
|
||||
overlay.id === overlayId
|
||||
? { ...overlay, atMs: Math.max(0, Math.round(overlay.atMs + deltaMs)) }
|
||||
: overlay,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateOverlay(edl: Edl, overlayId: string, patch: Partial<EdlOverlay>): Edl {
|
||||
return {
|
||||
...edl,
|
||||
overlays: edl.overlays.map((overlay) =>
|
||||
overlay.id === overlayId ? { ...overlay, ...patch } : overlay,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** A fresh `prefix-n` id in `[a-z0-9_-]` that `taken` does not contain. */
|
||||
export function freeId(taken: Iterable<string>, prefix: string): string {
|
||||
const used = new Set(taken);
|
||||
for (let index = 1; ; index += 1) {
|
||||
const id = `${prefix}-${index}`;
|
||||
if (!used.has(id)) return id;
|
||||
}
|
||||
}
|
||||
|
||||
/** A fresh id that no clip, overlay, or redaction of the plan uses yet. */
|
||||
export function nextId(edl: Edl, prefix: string): string {
|
||||
return freeId(
|
||||
[
|
||||
...edl.tracks.video.map((clip) => clip.id),
|
||||
...edl.tracks.audio.map((clip) => clip.id),
|
||||
...edl.overlays.map((overlay) => overlay.id),
|
||||
...edl.redactions.faces.map((face) => face.id),
|
||||
...edl.redactions.regions.map((region) => region.id),
|
||||
],
|
||||
prefix,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which timeline item an EDL error points at, from its machine-readable path
|
||||
* (`tracks.video[2].outMs`, `overlays[0].box`). Null for plan-wide errors.
|
||||
*/
|
||||
export function errorTarget(
|
||||
edl: Edl,
|
||||
error: Pick<EdlError, "path">,
|
||||
): { kind: "clip" | "overlay"; id: string } | null {
|
||||
const clip = /^tracks\.video\[(\d+)\]/.exec(error.path);
|
||||
if (clip) {
|
||||
const id = edl.tracks.video[Number(clip[1])]?.id;
|
||||
return id ? { kind: "clip", id } : null;
|
||||
}
|
||||
const overlay = /^overlays\[(\d+)\]/.exec(error.path);
|
||||
if (overlay) {
|
||||
const id = edl.overlays[Number(overlay[1])]?.id;
|
||||
return id ? { kind: "overlay", id } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Masks drawn in review: region redactions keyed to a clip, keyframes in source time. */
|
||||
export type RegionRedaction = Edl["redactions"]["regions"][number];
|
||||
export type MaskBox = RegionRedaction["keyframes"][number]["box"];
|
||||
|
||||
/** The box a region shows at a source time: the last keyframe at or before it, else the first. */
|
||||
export function regionBoxAt(region: RegionRedaction, sourceMs: number): MaskBox {
|
||||
const sorted = [...region.keyframes].sort((a, b) => a.tMs - b.tMs);
|
||||
let box = sorted[0]?.box ?? { x: 0, y: 0, w: 0, h: 0 };
|
||||
for (const keyframe of sorted) if (keyframe.tMs <= sourceMs) box = keyframe.box;
|
||||
return box;
|
||||
}
|
||||
|
||||
/** Clamp a box into the frame with a minimum size. */
|
||||
export function clampBox(box: MaskBox): MaskBox {
|
||||
const w = Math.min(1, Math.max(0.02, box.w));
|
||||
const h = Math.min(1, Math.max(0.02, box.h));
|
||||
return {
|
||||
x: Math.min(1 - w, Math.max(0, box.x)),
|
||||
y: Math.min(1 - h, Math.max(0, box.y)),
|
||||
w,
|
||||
h,
|
||||
};
|
||||
}
|
||||
|
||||
/** Add a mask to a clip with one keyframe at `sourceMs`. Returns the plan and the new id. */
|
||||
export function addRegion(edl: Edl, clipId: string, sourceMs: number): { edl: Edl; id: string } {
|
||||
const id = nextId(edl, "mask");
|
||||
const region: RegionRedaction = {
|
||||
id,
|
||||
clipId,
|
||||
treatment: "blur",
|
||||
keyframes: [{ tMs: Math.round(sourceMs), box: { x: 0.4, y: 0.35, w: 0.2, h: 0.3 } }],
|
||||
};
|
||||
return {
|
||||
edl: {
|
||||
...edl,
|
||||
redactions: { ...edl.redactions, regions: [...edl.redactions.regions, region] },
|
||||
},
|
||||
id,
|
||||
};
|
||||
}
|
||||
|
||||
/** Set a region's box at `sourceMs`, replacing a keyframe at that exact time or adding one. */
|
||||
export function setRegionKeyframe(edl: Edl, regionId: string, sourceMs: number, box: MaskBox): Edl {
|
||||
const tMs = Math.round(sourceMs);
|
||||
const clamped = clampBox(box);
|
||||
return {
|
||||
...edl,
|
||||
redactions: {
|
||||
...edl.redactions,
|
||||
regions: edl.redactions.regions.map((region) => {
|
||||
if (region.id !== regionId) return region;
|
||||
const others = region.keyframes.filter((keyframe) => keyframe.tMs !== tMs);
|
||||
const keyframes = [...others, { tMs, box: clamped }].sort((a, b) => a.tMs - b.tMs);
|
||||
return { ...region, keyframes };
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function removeRegion(edl: Edl, regionId: string): Edl {
|
||||
return {
|
||||
...edl,
|
||||
redactions: {
|
||||
...edl.redactions,
|
||||
regions: edl.redactions.regions.filter((region) => region.id !== regionId),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type DragId =
|
||||
| `clip:${string}`
|
||||
| `trim-start:${string}`
|
||||
| `trim-end:${string}`
|
||||
| `overlay:${string}`;
|
||||
|
||||
/** Apply an in-flight drag (pixels converted to ms) to the plan. */
|
||||
export function applyDrag(
|
||||
edl: Edl,
|
||||
id: DragId,
|
||||
deltaMs: number,
|
||||
assetDurations: ReadonlyMap<string, number | null>,
|
||||
): Edl {
|
||||
const [kind, target] = id.split(/:(.*)/s, 2) as [string, string];
|
||||
const assetDuration = (clipId: string) => {
|
||||
const clip = edl.tracks.video.find((candidate) => candidate.id === clipId);
|
||||
return clip ? (assetDurations.get(clip.assetId) ?? null) : null;
|
||||
};
|
||||
switch (kind) {
|
||||
case "clip":
|
||||
return moveClip(edl, target, deltaMs);
|
||||
case "trim-start":
|
||||
return trimClip(edl, target, "start", deltaMs, assetDuration(target));
|
||||
case "trim-end":
|
||||
return trimClip(edl, target, "end", deltaMs, assetDuration(target));
|
||||
case "overlay":
|
||||
return moveOverlay(edl, target, deltaMs);
|
||||
default:
|
||||
return edl;
|
||||
}
|
||||
}
|
||||
|
||||
/** `1:05.3` style timestamp. */
|
||||
export function formatMs(ms: number): string {
|
||||
const tenths = Math.max(0, Math.round(ms / 100));
|
||||
const minutes = Math.floor(tenths / 600);
|
||||
const seconds = (tenths % 600) / 10;
|
||||
return `${minutes}:${seconds.toFixed(1).padStart(4, "0")}`;
|
||||
}
|
||||
@ -1,6 +1,587 @@
|
||||
import { Trans, useLingui } from "@lingui/react/macro";
|
||||
import type {
|
||||
BrandKit,
|
||||
BrandKitRecord,
|
||||
CaptionStylePreset,
|
||||
IntroVariant,
|
||||
MotionTemplateInfo,
|
||||
Watermark,
|
||||
} from "@rakazo/contracts";
|
||||
import { Button, Card, CardContent, Field, FieldLabel, Input, Skeleton } from "@rakazo/ui-web";
|
||||
import { Check, Plus, Save, Sparkles, Trash2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useId, useState } from "react";
|
||||
import { LoadingState, SuccessPop } from "../../components/ai/primitives";
|
||||
import { ErrorLine, errorMessage, ViewHeader } from "../../components/media/editor/chrome";
|
||||
import {
|
||||
NumberField,
|
||||
SelectField,
|
||||
TextField,
|
||||
ToggleField,
|
||||
} from "../../components/media/editor/fields";
|
||||
import { mediaArtifactUrl, useMediaObjectUrl } from "../../components/media/editor/media-files";
|
||||
import { formatMs, freeId } from "../../components/media/editor/timeline";
|
||||
import { rpc } from "../../lib/rpc";
|
||||
import type { MediaViewProps } from "./nav";
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user