diff --git a/apps/web/src/components/files/video-trim.tsx b/apps/web/src/components/files/video-trim.tsx index b401401..c87ce8c 100644 --- a/apps/web/src/components/files/video-trim.tsx +++ b/apps/web/src/components/files/video-trim.tsx @@ -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(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(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 => { 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 (
e.stopPropagation()}>
@@ -67,7 +107,7 @@ export function VideoTrim({ item, videoUrl, onClose }: {

{t({ en: "Cut queued ✓", es: "Corte en cola ✓" })}

- {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." })}

) : ( <> -