fix: trim sliders now live + auto-versioned cuts (v2, v3…)

The sliders rendered dead when the video's duration hadn't loaded into
state (max=0). Duration discovery now polls loadedmetadata/durationchange
until it lands, the sliders stay disabled with a reading note until then,
and dragging either handle seeks the preview. Cuts are auto-versioned
(v2, v3… after the original v1) by counting same-source names in the
library — a cut always creates a new clip, never touches the original.
This commit is contained in:
glass beadian 2026-10-18 07:35:54 +00:00
parent adaa621129
commit 215952fe5e

View file

@ -1,13 +1,12 @@
"use client"; "use client";
/** /**
* Phone-first video trim: set in/out points, then send the cut to the Video * Phone-first video trim: set in/out points on live sliders (each drag seeks
* Studio. Under the hood the group file's R2 object is registered as a * the preview), then send the cut to the Video Studio. The cut is versioned
* video_asset (same storage key — no re-upload), an EDL project is created * automatically (v2, v3, … after the original v1) and renders as a NEW derived
* with the single trimmed clip, and the produce worker renders the derived * clip — the original is never modified.
* cut into the Video library. The original stays untouched.
*/ */
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../lib/api-client"; import { api } from "../../lib/api-client";
import { useFilesLang } from "./i18n"; import { useFilesLang } from "./i18n";
import type { GroupFile } from "./types"; import type { GroupFile } from "./types";
@ -20,6 +19,7 @@ export function VideoTrim({ item, videoUrl, onClose }: {
onClose: () => void; onClose: () => void;
}): React.ReactElement { }): React.ReactElement {
const { t } = useFilesLang(); const { t } = useFilesLang();
const videoRef = useRef<HTMLVideoElement>(null);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
const [inMs, setInMs] = useState(0); const [inMs, setInMs] = useState(0);
const [outMs, setOutMs] = useState(0); const [outMs, setOutMs] = useState(0);
@ -27,7 +27,37 @@ export function VideoTrim({ item, videoUrl, onClose }: {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const span = Math.max(0, outMs - inMs); const span = Math.max(0, outMs - inMs);
const ready = duration > 0;
const fmt = (ms: number): string => `${(ms / 1000).toFixed(1)}s`; const fmt = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
const clampIn = (v: number): number => Math.max(0, Math.min(v, Math.max(0, outMs - 500)));
const clampOut = (v: number): number => Math.min(Math.max(v, Math.min(inMs + 500, duration)), duration);
// Duration discovery with a fallback poll: blob-backed videos sometimes only
// surface metadata after the first frames decode.
useEffect(() => {
const v = videoRef.current;
if (v === null) return;
const read = (): void => {
const d = v.duration;
if (Number.isFinite(d) && d > 0) {
setDuration(Math.round(d * 1000));
setOutMs((current) => (current === 0 ? Math.min(10_000, Math.round(d * 1000)) : current));
}
};
v.addEventListener("loadedmetadata", read);
v.addEventListener("durationchange", read);
const poll = window.setInterval(read, 700);
const stop = window.setTimeout(() => window.clearInterval(poll), 12_000);
return () => {
window.clearInterval(poll); window.clearTimeout(stop);
v.removeEventListener("loadedmetadata", read); v.removeEventListener("durationchange", read);
};
}, [videoUrl]);
const seekPreview = useCallback((ms: number): void => {
const v = videoRef.current;
if (v !== null) v.currentTime = Math.max(0, Math.min(ms / 1000, duration / 1000));
}, [duration]);
const cut = async (): Promise<void> => { const cut = async (): Promise<void> => {
setStage("working"); setStage("working");
@ -40,12 +70,20 @@ export function VideoTrim({ item, videoUrl, onClose }: {
mimeType: item.mimeType, mimeType: item.mimeType,
sizeBytes: item.sizeBytes, sizeBytes: item.sizeBytes,
}); });
// 2. One-clip EDL project with the trim, 3. render it. // 2. Version label: the original is v1; each cut of the same source bumps it.
const base = item.name.replace(/\.[^.]+$/, "");
let version = 2;
try {
const lib = await api.get<{ items: Array<{ name: string }> }>("/video?limit=200");
version = 2 + lib.items.filter((v) => v.name.startsWith(`${base} v`)).length;
} catch { /* versioning is best-effort; the cut still renders */ }
const label = `${base} v${version}`.slice(0, 80);
// 3. One-clip EDL project with the trim, 4. render it.
const edl = await api.post<{ id: string }>("/video/edl", { const edl = await api.post<{ id: string }>("/video/edl", {
project: { project: {
version: 1, aspect: "16:9", fit: "cover", fps: 30, version: 1, aspect: "16:9", fit: "cover", fps: 30,
items: [{ kind: "clip", assetId: reg.videoAsset.id, trimStartMs: inMs, trimEndMs: outMs, rotate: 0, volumeDb: 0 }], items: [{ kind: "clip", assetId: reg.videoAsset.id, trimStartMs: inMs, trimEndMs: outMs, rotate: 0, volumeDb: 0 }],
overlays: [], voiceover: null, outputLabel: `${item.name}-cut`, overlays: [], voiceover: null, outputLabel: label,
}, },
}); });
await api.post(`/video/edl/${edl.id}/render`, { lane: "local" }); await api.post(`/video/edl/${edl.id}/render`, { lane: "local" });
@ -56,6 +94,8 @@ export function VideoTrim({ item, videoUrl, onClose }: {
} }
}; };
const slider = "w-full accent-accent";
return ( return (
<div className="flex h-full w-full flex-col bg-surface" onClick={(e) => e.stopPropagation()}> <div className="flex h-full w-full flex-col bg-surface" onClick={(e) => e.stopPropagation()}>
<div className="border-b border-border-subtle px-3 py-2 text-sm"> <div className="border-b border-border-subtle px-3 py-2 text-sm">
@ -67,7 +107,7 @@ export function VideoTrim({ item, videoUrl, onClose }: {
<div className="rounded-lg border border-border-subtle bg-surface-2 p-4 text-sm"> <div className="rounded-lg border border-border-subtle bg-surface-2 p-4 text-sm">
<p className="font-medium">{t({ en: "Cut queued ✓", es: "Corte en cola ✓" })}</p> <p className="font-medium">{t({ en: "Cut queued ✓", es: "Corte en cola ✓" })}</p>
<p className="mt-1 text-text-dim"> <p className="mt-1 text-text-dim">
{t({ en: "The cut is rendering. It will appear in the Video library as a new clip when it lands — the original file stays untouched.", es: "El corte se está renderizando. Aparecerá en la videoteca como un clip nuevo — el archivo original no se toca." })} {t({ en: "The cut is rendering. It will appear in the Video library as a new versioned clip when it lands — the original file stays untouched.", es: "El corte se está renderizando. Aparecerá en la videoteca como un clip nuevo — el archivo original no se toca." })}
</p> </p>
<button type="button" className="mt-3 rounded px-3 py-1.5 text-xs hover:bg-surface-2" onClick={onClose}> <button type="button" className="mt-3 rounded px-3 py-1.5 text-xs hover:bg-surface-2" onClick={onClose}>
{t({ en: "Close", es: "Cerrar" })} {t({ en: "Close", es: "Cerrar" })}
@ -83,24 +123,27 @@ export function VideoTrim({ item, videoUrl, onClose }: {
</div> </div>
) : ( ) : (
<> <>
<video src={videoUrl} controls className="max-h-[40vh] w-full rounded-lg bg-black" <video ref={videoRef} src={videoUrl} controls className="max-h-[40vh] w-full rounded-lg bg-black" />
onLoadedMetadata={(e) => { {!ready && (
const d = e.currentTarget.duration; <p className="mt-3 text-xs text-text-dim">{t({ en: "Reading the video duration…", es: "Leyendo la duración del vídeo…" })}</p>
if (Number.isFinite(d) && d > 0) { setDuration(d); setOutMs(Math.min(10_000, Math.round(d))); } )}
}} /> <div className={`mt-4 space-y-3 ${ready ? "" : "pointer-events-none opacity-40"}`}>
<div className="mt-4 space-y-3">
<label className="block"> <label className="block">
<span className="text-xs text-text-dim">{t({ en: "Start", es: "Inicio" })} — {fmt(inMs)}</span> <span className="text-xs text-text-dim">{t({ en: "Start", es: "Inicio" })} — {fmt(inMs)}</span>
<input type="range" min={0} max={Math.max(0, Math.floor(duration))} value={inMs} className="w-full accent-accent" <input type="range" min={0} max={duration} step={100} value={inMs} className={slider}
onChange={(e) => { const v = Number(e.target.value); setInMs(Math.min(v, outMs - 500)); }} /> aria-label={t({ en: "Cut start", es: "Inicio del corte" })}
onChange={(e) => { const v = clampIn(Number(e.target.value)); setInMs(v); seekPreview(v); }} />
</label> </label>
<label className="block"> <label className="block">
<span className="text-xs text-text-dim">{t({ en: "End", es: "Fin" })} — {fmt(outMs)}</span> <span className="text-xs text-text-dim">{t({ en: "End", es: "Fin" })} — {fmt(outMs)}</span>
<input type="range" min={0} max={Math.max(0, Math.floor(duration))} value={outMs} className="w-full accent-accent" <input type="range" min={0} max={duration} step={100} value={outMs} className={slider}
onChange={(e) => { const v = Number(e.target.value); setOutMs(Math.max(v, inMs + 500)); }} /> aria-label={t({ en: "Cut end", es: "Fin del corte" })}
onChange={(e) => { const v = clampOut(Number(e.target.value)); setOutMs(v); seekPreview(v); }} />
</label> </label>
<div className="relative h-2 w-full rounded bg-surface-2"> <div className="relative h-2 w-full rounded bg-surface-2">
<div className="absolute inset-y-0 bg-accent" style={{ left: `${duration > 0 ? (inMs / duration) * 100 : 0}%`, width: `${duration > 0 ? (span / duration) * 100 : 0}%` }} /> <div className="absolute inset-y-0 bg-accent" style={{ left: `${ready ? (inMs / duration) * 100 : 0}%`, width: `${ready ? (span / duration) * 100 : 0}%` }} />
<span className="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-accent" style={{ left: `${ready ? (inMs / duration) * 100 : 0}%` }} aria-hidden />
<span className="absolute top-1/2 h-4 w-4 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-accent" style={{ left: `${ready ? (outMs / duration) * 100 : 0}%` }} aria-hidden />
</div> </div>
<p className="text-xs text-text-dim"> <p className="text-xs text-text-dim">
{t({ en: `Cut length: ${fmt(span)} of ${fmt(Math.round(duration))}`, es: `Duración del corte: ${fmt(span)} de ${fmt(Math.round(duration))}` })} {t({ en: `Cut length: ${fmt(span)} of ${fmt(Math.round(duration))}`, es: `Duración del corte: ${fmt(span)} de ${fmt(Math.round(duration))}` })}
@ -116,7 +159,7 @@ export function VideoTrim({ item, videoUrl, onClose }: {
: t({ en: "Cut in Video Studio", es: "Cortar en el estudio de vídeo" })} : t({ en: "Cut in Video Studio", es: "Cortar en el estudio de vídeo" })}
</button> </button>
<p className="text-2xs text-text-faint"> <p className="text-2xs text-text-faint">
{t({ en: "The cut renders in the Video library — the original file is never modified.", es: "El corte se renderiza en la videoteca — el archivo original nunca se modifica." })} {t({ en: "The cut renders in the Video library as a new versioned clip — the original file is never modified.", es: "El corte se renderiza en la videoteca como un clip nuevo — el archivo original nunca se modifica." })}
</p> </p>
</div> </div>
</> </>