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";
/**
* Phone-first video trim: set in/out points, then send the cut to the Video
* Studio. Under the hood the group file's R2 object is registered as a
* video_asset (same storage key — no re-upload), an EDL project is created
* with the single trimmed clip, and the produce worker renders the derived
* cut into the Video library. The original stays untouched.
* Phone-first video trim: set in/out points on live sliders (each drag seeks
* the preview), then send the cut to the Video Studio. The cut is versioned
* automatically (v2, v3, … after the original v1) and renders as a NEW derived
* clip — the original is never modified.
*/
import { useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { api } from "../../lib/api-client";
import { useFilesLang } from "./i18n";
import type { GroupFile } from "./types";
@ -20,6 +19,7 @@ export function VideoTrim({ item, videoUrl, onClose }: {
onClose: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const videoRef = useRef<HTMLVideoElement>(null);
const [duration, setDuration] = useState(0);
const [inMs, setInMs] = 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 span = Math.max(0, outMs - inMs);
const ready = duration > 0;
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> => {
setStage("working");
@ -40,12 +70,20 @@ export function VideoTrim({ item, videoUrl, onClose }: {
mimeType: item.mimeType,
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", {
project: {
version: 1, aspect: "16:9", fit: "cover", fps: 30,
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" });
@ -56,6 +94,8 @@ export function VideoTrim({ item, videoUrl, onClose }: {
}
};
const slider = "w-full accent-accent";
return (
<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">
@ -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">
<p className="font-medium">{t({ en: "Cut queued ✓", es: "Corte en cola ✓" })}</p>
<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>
<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" })}
@ -83,24 +123,27 @@ export function VideoTrim({ item, videoUrl, onClose }: {
</div>
) : (
<>
<video src={videoUrl} controls className="max-h-[40vh] w-full rounded-lg bg-black"
onLoadedMetadata={(e) => {
const d = e.currentTarget.duration;
if (Number.isFinite(d) && d > 0) { setDuration(d); setOutMs(Math.min(10_000, Math.round(d))); }
}} />
<div className="mt-4 space-y-3">
<video ref={videoRef} src={videoUrl} controls className="max-h-[40vh] w-full rounded-lg bg-black" />
{!ready && (
<p className="mt-3 text-xs text-text-dim">{t({ en: "Reading the video duration…", es: "Leyendo la duración del vídeo…" })}</p>
)}
<div className={`mt-4 space-y-3 ${ready ? "" : "pointer-events-none opacity-40"}`}>
<label className="block">
<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"
onChange={(e) => { const v = Number(e.target.value); setInMs(Math.min(v, outMs - 500)); }} />
<input type="range" min={0} max={duration} step={100} value={inMs} className={slider}
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 className="block">
<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"
onChange={(e) => { const v = Number(e.target.value); setOutMs(Math.max(v, inMs + 500)); }} />
<input type="range" min={0} max={duration} step={100} value={outMs} className={slider}
aria-label={t({ en: "Cut end", es: "Fin del corte" })}
onChange={(e) => { const v = clampOut(Number(e.target.value)); setOutMs(v); seekPreview(v); }} />
</label>
<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>
<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))}` })}
@ -116,7 +159,7 @@ export function VideoTrim({ item, videoUrl, onClose }: {
: t({ en: "Cut in Video Studio", es: "Cortar en el estudio de vídeo" })}
</button>
<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>
</div>
</>