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