evOo-local/apps/web/src/pages/media/EditorView.tsx
Claude bab398cb52
wip(media): editor e2e and player fixes (unverified)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NLSCTnhge4umMnemGXWr1w
2026-10-03 19:29:59 +00:00

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