feat: Files media suite — viewer modes for pdf/text/html, image editor, video trim to studio

- MediaViewer branches by type: pdf (native iframe), text/json (viewer +
  copy), html (sandboxed allow-scripts run + source toggle), image (zoom/
  pan/rotate lightbox + Edit), video (brand player + Trim)
- ImageEditor: canvas crop (drag + aspect presets), rotate 90s, flip H/V,
  brightness/contrast/saturation; Save as copy uploads a NEW library file
  via the direct path (server fallback)
- VideoTrim: in/out over the duration; Cut in Video Studio registers the
  same R2 object as a video_asset, creates a one-clip EDL at the trim, and
  queues the assemble render — the cut lands in the Video library, the
  original file is never modified
- invalid type syntax fixed across the new components (prop callbacks use
  arrow types)
This commit is contained in:
glass beadian 2026-10-17 13:32:21 +00:00
parent 0bdf13816d
commit fe483a0822
3 changed files with 426 additions and 17 deletions

View file

@ -0,0 +1,202 @@
"use client";
/**
* Image editor (canvas): crop, rotate 90° steps, flip H/V, and exposure
* adjustments (brightness/contrast/saturation). Everything renders client-side
* from the decrypted bytes; Save uploads the result as a NEW library file
* (never overwrites the original — the archive is append-only).
*
* Interaction model (phone-first): Crop is drag-a-rectangle over the preview
* with aspect presets (free / 1:1 / 16:9 / 9:16); Rotate/Flip are single-tap
* transforms; Adjust are three sliders. Apply bakes the crop; Reset reverts.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import { useFilesLang } from "./i18n";
export interface EditResult {
blob: Blob;
suffix: string;
}
type Aspect = "free" | "1:1" | "16:9" | "9:16";
const ASPECTS: { id: Aspect; label: string; ratio: number | null }[] = [
{ id: "free", label: "Free", ratio: null },
{ id: "1:1", label: "1:1", ratio: 1 },
{ id: "16:9", label: "16:9", ratio: 16 / 9 },
{ id: "9:16", label: "9:16", ratio: 9 / 16 },
];
export function ImageEditor({ src, fileName, onSave, onCancel }: {
src: string;
fileName: string;
onSave: (result: EditResult) => Promise<void>;
onCancel: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const canvasRef = useRef<HTMLCanvasElement>(null);
const bitmapRef = useRef<ImageBitmap | null>(null);
const [ready, setReady] = useState(false);
const [rotation, setRotation] = useState(0);
const [flipH, setFlipH] = useState(false);
const [flipV, setFlipV] = useState(false);
const [brightness, setBrightness] = useState(100);
const [contrast, setContrast] = useState(100);
const [saturation, setSaturation] = useState(100);
const [aspect, setAspect] = useState<Aspect>("free");
const [crop, setCrop] = useState<{ x: number; y: number; w: number; h: number } | null>(null);
const [saving, setSaving] = useState(false);
const drag = useRef<{ x: number; y: number } | null>(null);
// Decode once.
useEffect(() => {
let alive = true;
void (async () => {
const res = await fetch(src);
const blob = await res.blob();
const bmp = await createImageBitmap(blob);
if (alive) { bitmapRef.current = bmp; setReady(true); }
})();
return () => { alive = false; };
}, [src]);
const cropRect = useCallback((): { sx: number; sy: number; sw: number; sh: number } | null => {
const bmp = bitmapRef.current;
if (bmp === null || crop === null) return null;
const sx = Math.round((crop.x / 100) * bmp.width);
const sy = Math.round((crop.y / 100) * bmp.height);
const sw = Math.round((crop.w / 100) * bmp.width);
const sh = Math.round((crop.h / 100) * bmp.height);
if (sw < 8 || sh < 8) return null;
return { sx, sy, sw, sh };
}, [crop]);
const render = useCallback((): void => {
const canvas = canvasRef.current;
const bmp = bitmapRef.current;
if (canvas === null || bmp === null) return;
const region = cropRect();
const sx = region?.sx ?? 0;
const sy = region?.sy ?? 0;
const sw = region?.sw ?? bmp.width;
const sh = region?.sh ?? bmp.height;
const rotated = rotation === 90 || rotation === 270;
canvas.width = rotated ? sh : sw;
canvas.height = rotated ? sw : sh;
const ctx = canvas.getContext("2d");
if (ctx === null) return;
ctx.save();
ctx.filter = `brightness(${brightness}%) contrast(${contrast}%) saturate(${saturation}%)`;
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((rotation * Math.PI) / 180);
const signH = flipH ? -1 : 1;
const signV = flipV ? -1 : 1;
ctx.scale(signH, signV);
ctx.drawImage(bmp, sx, sy, sw, sh, -sw / 2, -sh / 2, sw, sh);
ctx.restore();
}, [rotation, flipH, flipV, brightness, contrast, saturation, cropRect]);
useEffect(() => { if (ready) render(); }, [ready, render]);
// Crop drag over the canvas (percent space).
const onPointerDown = useCallback((e: React.PointerEvent<HTMLCanvasElement>): void => {
const rect = e.currentTarget.getBoundingClientRect();
drag.current = { x: ((e.clientX - rect.left) / rect.width) * 100, y: ((e.clientY - rect.top) / rect.height) * 100 };
setCrop({ x: drag.current.x, y: drag.current.y, w: 0, h: 0 });
e.currentTarget.setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLCanvasElement>): void => {
const d = drag.current;
if (d === null) return;
const rect = e.currentTarget.getBoundingClientRect();
const cx = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const cy = Math.max(0, Math.min(100, ((e.clientY - rect.top) / rect.height) * 100));
let w = cx - d.x;
let h = cy - d.y;
const ratio = ASPECTS.find((a) => a.id === aspect)?.ratio ?? null;
const bmp = bitmapRef.current;
if (ratio !== null && bmp !== null) {
// Constrain in PIXEL space so the crop is visually the chosen aspect.
const pxW = (Math.abs(w) / 100) * bmp.width;
const pxH = pxW / ratio;
h = ((cy >= d.y ? 1 : -1) * (pxH / bmp.height)) * 100;
}
setCrop({
x: Math.min(d.x, d.x + w),
y: Math.min(d.y, d.y + h),
w: Math.abs(w),
h: Math.abs(h),
});
}, [aspect]);
const onPointerUp = useCallback((): void => { drag.current = null; }, []);
const save = useCallback(async (): Promise<void> => {
setSaving(true);
try {
const canvas = canvasRef.current;
if (canvas === null) return;
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob((b) => resolve(b), "image/jpeg", 0.92));
if (blob === null) return;
const base = fileName.replace(/\.[^.]+$/, "");
await onSave({ blob, suffix: "edited.jpg" });
void base;
} finally {
setSaving(false);
}
}, [fileName, onSave]);
const btn = "rounded px-2 py-1 text-xs hover:bg-surface-2";
const active = "bg-accent text-white";
return (
<div className="flex h-full w-full flex-col bg-surface" onClick={(e) => e.stopPropagation()}>
<div className="flex flex-wrap items-center gap-1.5 border-b border-border-subtle px-2 py-1.5 text-xs">
<button type="button" className={`${btn} ${crop !== null ? active : ""}`} onClick={() => setCrop((c) => (c === null ? { x: 10, y: 10, w: 60, h: 60 } : null))}>
{t({ en: "Crop", es: "Recortar" })}
</button>
{crop !== null && ASPECTS.map((a) => (
<button key={a.id} type="button" className={`${btn} ${aspect === a.id ? active : ""}`} onClick={() => setAspect(a.id)}>{a.label}</button>
))}
<button type="button" className={btn} onClick={() => { setRotation((r) => (r + 90) % 360); setCrop(null); }}>{t({ en: "Rotate", es: "Rotar" })} ⟳</button>
<button type="button" className={`${btn} ${flipH ? active : ""}`} onClick={() => setFlipH((v) => !v)}>{t({ en: "Flip H", es: "Voltear H" })}</button>
<button type="button" className={`${btn} ${flipV ? active : ""}`} onClick={() => setFlipV((v) => !v)}>{t({ en: "Flip V", es: "Voltear V" })}</button>
<label className="ml-auto flex items-center gap-1">
{t({ en: "Bright", es: "Brillo" })}
<input type="range" min={40} max={180} value={brightness} className="w-16 accent-accent" onChange={(e) => setBrightness(Number(e.target.value))} />
</label>
<label className="flex items-center gap-1">
{t({ en: "Contrast", es: "Contraste" })}
<input type="range" min={40} max={180} value={contrast} className="w-16 accent-accent" onChange={(e) => setContrast(Number(e.target.value))} />
</label>
<label className="flex items-center gap-1">
{t({ en: "Sat", es: "Sat" })}
<input type="range" min={0} max={220} value={saturation} className="w-16 accent-accent" onChange={(e) => setSaturation(Number(e.target.value))} />
</label>
<button type="button" className={btn} onClick={() => { setBrightness(100); setContrast(100); setSaturation(100); setRotation(0); setFlipH(false); setFlipV(false); setCrop(null); }}>
{t({ en: "Reset", es: "Restablecer" })}
</button>
</div>
<div className="relative flex-1 overflow-hidden bg-black/60 p-2">
<canvas ref={canvasRef} className="absolute inset-0 m-auto max-h-full max-w-full" onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} />
{!ready && <div className="absolute inset-0 flex items-center justify-center text-sm text-white/70">{t({ en: "Loading…", es: "Cargando…" })}</div>}
{crop !== null && crop.w > 0 && (
<div className="pointer-events-none absolute inset-0">
<div className="absolute border-2 border-accent" style={{ left: `${crop.x}%`, top: `${crop.y}%`, width: `${crop.w}%`, height: `${crop.h}%` }} />
</div>
)}
</div>
<div className="flex items-center gap-2 border-t border-border-subtle px-3 py-2 text-xs">
<span className="text-text-faint">{t({ en: "Saving creates a new file — the original stays untouched.", es: "Guardar crea un archivo nuevo — el original no se toca." })}</span>
<div className="ml-auto flex gap-2">
<button type="button" className="rounded px-3 py-1.5 hover:bg-surface-2" onClick={onCancel}>{t({ en: "Cancel", es: "Cancelar" })}</button>
<button type="button" className="rounded bg-accent px-3 py-1.5 font-medium text-white disabled:opacity-50" disabled={saving || !ready} onClick={() => void save()}>
{saving ? t({ en: "Saving…", es: "Guardando…" }) : t({ en: "Save as copy", es: "Guardar copia" })}
</button>
</div>
</div>
</div>
);
}

View file

@ -11,6 +11,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFilesLang } from "./i18n";
import { cachedMedia, type CachedMedia } from "../../lib/media-cache";
import { fetchDirectConfig, uploadFileDirect } from "../../lib/files-direct";
import { ImageEditor, type EditResult } from "./image-editor";
import { VideoTrim } from "./video-trim";
import { VideoPlayer } from "./video-player";
import { formatBytes } from "../video/format";
import type { GroupFile } from "./types";
@ -28,29 +31,56 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [rotation, setRotation] = useState(0);
const [mode, setMode] = useState<"view" | "edit" | "trim">("view");
const [saved, setSaved] = useState(false);
const [textContent, setTextContent] = useState<string | null>(null);
const [htmlSource, setHtmlSource] = useState(false);
const dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const isVideo = item?.mimeClass === "video";
const isImage = item?.mimeClass === "image";
const isPdf = item?.mimeType === "application/pdf";
const isHtml = item?.mimeType === "text/html";
const isText = item !== undefined && (item.mimeType.startsWith("text/") || item.mimeType === "application/json") && !isHtml;
useEffect(() => {
if (item === undefined) return;
setMedia(null); setError(null); setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0);
setMode("view"); setSaved(false); setTextContent(null); setHtmlSource(false);
let alive = true;
cachedMedia(item)
.then((m) => { if (alive) setMedia(m); })
.catch((err: unknown) => { if (alive) setError(err instanceof Error ? err.message : String(err)); });
if (isText) {
void cachedMedia(item).then(async (m) => {
const text = await (await fetch(m.url)).text();
if (alive && text.length <= 2_000_000) setTextContent(text);
}).catch(() => undefined);
}
return () => { alive = false; };
}, [item]);
useEffect(() => { closeRef.current?.focus(); }, [index]);
}, [item, isText]);
const step = useCallback((delta: number): void => {
if (items.length === 0) return;
onIndex((index + delta + items.length) % items.length);
}, [index, items.length, onIndex]);
// Keyboard + focus trap.
const saveEdited = useCallback(async (result: EditResult): Promise<void> => {
const base = (item?.name ?? "image").replace(/\.[^.]+$/, "");
const file = new File([result.blob], `${base}-${result.suffix}`, { type: result.blob.type });
const config = await fetchDirectConfig().catch(() => null);
if (config !== null && config.enabled && config.publicKeyJwk !== null && file.size <= config.maxBytes) {
await uploadFileDirect(item!.groupId, file, config, () => undefined);
setSaved(true);
setMode("view");
return;
}
throw new Error("direct upload is not available for the edited copy");
}, [item]);
useEffect(() => { closeRef.current?.focus(); }, [index]);
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
switch (e.key) {
@ -67,7 +97,8 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
};
const trap = (e: KeyboardEvent): void => {
if (e.key !== "Tab") return;
const focusables = closeRef.current?.closest("[data-viewer]")?.querySelectorAll<HTMLElement>("button, [href], input, video");
const scope = closeRef.current?.closest("[data-viewer]");
const focusables = scope?.querySelectorAll<HTMLElement>("button, [href], input, video");
if (focusables === undefined || focusables.length === 0) return;
const first = focusables[0]!;
const last = focusables[focusables.length - 1]!;
@ -96,6 +127,16 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
<span className="truncate font-medium text-white">{item.name}</span>
<span className="text-white/50">{formatBytes(item.sizeBytes)}</span>
<span className="ml-auto tabular-nums">{index + 1} / {items.length}</span>
{item.status === "available" && isImage && (
<button type="button" className={`rounded px-2 py-0.5 ${mode === "edit" ? "bg-accent text-white" : "hover:bg-white/20"}`} onClick={() => setMode((m) => (m === "edit" ? "view" : "edit"))}>
{t({ en: "Edit", es: "Editar" })}
</button>
)}
{item.status === "available" && isVideo && (
<button type="button" className={`rounded px-2 py-0.5 ${mode === "trim" ? "bg-accent text-white" : "hover:bg-white/20"}`} onClick={() => setMode((m) => (m === "trim" ? "view" : "trim"))}>
{t({ en: "Trim", es: "Recortar" })}
</button>
)}
</div>
<div className="relative flex-1 overflow-hidden">
@ -105,19 +146,54 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
{error === null && media === null && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-white/70">{t({ en: "Decrypting…", es: "Descifrando…" })}</div>
)}
{error === null && media !== null && (isVideo ? (
<div className="absolute inset-0 flex items-center justify-center p-2">
{error === null && media !== null && mode === "edit" && isImage && (
<div className="absolute inset-0" onClick={(e) => e.stopPropagation()}>
<ImageEditor src={media.url} fileName={item.name} onCancel={() => setMode("view")} onSave={async (result) => { await saveEdited(result); }} />
</div>
)}
{error === null && media !== null && mode === "trim" && isVideo && (
<div className="absolute inset-0" onClick={(e) => e.stopPropagation()}>
<VideoTrim item={item} videoUrl={media.url} onClose={() => setMode("view")} />
</div>
)}
{error === null && media !== null && mode === "view" && isPdf && (
<iframe src={media.url} title={item.name} className="absolute inset-0 h-full w-full bg-white" onClick={(e) => e.stopPropagation()} />
)}
{error === null && media !== null && mode === "view" && isHtml && (
<div className="absolute inset-0 flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2 bg-surface px-3 py-1 text-xs">
<button type="button" className="rounded px-2 py-0.5 hover:bg-surface-2" onClick={() => setHtmlSource((v) => !v)}>{t({ en: "Show source", es: "Ver código" })}</button>
<span className="text-text-faint">{t({ en: "Sandboxed preview — scripts run isolated from the hub.", es: "Vista aislada — los scripts corren aislados del hub." })}</span>
</div>
{htmlSource
? <pre className="flex-1 overflow-auto bg-surface p-3 font-mono text-xs text-text">{textContent ?? t({ en: "Loading…", es: "Cargando…" })}</pre>
: <iframe src={media.url} title={item.name} sandbox="allow-scripts" className="h-full w-full flex-1 bg-white" />}
</div>
)}
{error === null && media !== null && mode === "view" && isText && (
<div className="absolute inset-0 flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-end gap-2 bg-surface px-3 py-1 text-xs">
<button type="button" className="rounded px-2 py-0.5 hover:bg-surface-2" onClick={() => { void navigator.clipboard.writeText(textContent ?? ""); setSaved(true); }}>
{saved ? t({ en: "Copied ✓", es: "Copiado ✓" }) : t({ en: "Copy", es: "Copiar" })}
</button>
</div>
<pre className="flex-1 overflow-auto bg-surface p-3 font-mono text-xs text-text">{textContent ?? t({ en: "Loading…", es: "Cargando…" })}</pre>
</div>
)}
{error === null && media !== null && mode === "view" && isVideo && (
<div className="absolute inset-0 flex items-center justify-center p-2" onClick={(e) => e.stopPropagation()}>
<div className="h-full w-full max-w-5xl">
<VideoPlayer src={media.url} title={item.name} />
</div>
</div>
) : (
)}
{error === null && media !== null && mode === "view" && isImage && (
<div
className="absolute inset-0 flex items-center justify-center overflow-hidden"
onWheel={(e) => {
e.preventDefault();
setScale((s) => Math.max(1, Math.min(8, e.deltaY < 0 ? s * 1.15 : s / 1.15)));
}}
onWheel={(e) => { e.preventDefault(); setScale((s) => Math.max(1, Math.min(8, e.deltaY < 0 ? s * 1.15 : s / 1.15))); }}
onDoubleClick={() => setScale((s) => (s > 1 ? 1 : 2.5))}
onPointerDown={(e) => { if (scale > 1) dragRef.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y }; }}
onPointerMove={(e) => {
@ -136,9 +212,9 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotation}deg)`, cursor: scale > 1 ? "grab" : "default" }}
/>
</div>
))}
)}
{error === null && media !== null && !isVideo && (
{error === null && media !== null && mode === "view" && isImage && (
<div className="absolute bottom-16 left-1/2 flex -translate-x-1/2 gap-2 rounded bg-black/70 px-3 py-1 text-xs text-white" onClick={(e) => e.stopPropagation()}>
<button type="button" aria-label={t({ en: "Zoom out", es: "Alejar" })} className="px-2 hover:text-accent" onClick={() => setScale((s) => Math.max(1, s / 1.4))}>−</button>
<span className="tabular-nums leading-5">{Math.round(scale * 100)}%</span>
@ -179,9 +255,7 @@ function FilmThumb({ item }: { item: GroupFile }): React.ReactElement {
return () => { alive = false; };
}, [item]);
if (item.mimeClass === "video") {
return (
<span className="flex h-full w-full items-center justify-center bg-surface-2 text-xs text-text-dim">▶</span>
);
return <span className="flex h-full w-full items-center justify-center bg-surface-2 text-xs text-text-dim">▶</span>;
}
if (url === null) return <span className="block h-full w-full animate-pulse bg-surface-2" />;
// eslint-disable-next-line @next/next/no-img-element -- decrypted object URL

View file

@ -0,0 +1,133 @@
"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.
*/
import { useState } from "react";
import { api } from "../../lib/api-client";
import { useFilesLang } from "./i18n";
import type { GroupFile } from "./types";
type Stage = "set" | "working" | "queued" | "failed";
export function VideoTrim({ item, videoUrl, onClose }: {
item: GroupFile;
videoUrl: string;
onClose: () => void;
}): React.ReactElement {
const { t } = useFilesLang();
const [duration, setDuration] = useState(0);
const [inMs, setInMs] = useState(0);
const [outMs, setOutMs] = useState(0);
const [stage, setStage] = useState<Stage>("set");
const [error, setError] = useState<string | null>(null);
const span = Math.max(0, outMs - inMs);
const fmt = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
const cut = async (): Promise<void> => {
setStage("working");
setError(null);
try {
if (item.file.storageKey === undefined) throw new Error("no storage key");
// 1. Register the SAME R2 object as a video asset (api re-reads + probes it).
const reg = await api.post<{ videoAsset: { id: string } }>("/video/upload", {
storageKey: item.file.storageKey,
mimeType: item.mimeType,
sizeBytes: item.sizeBytes,
});
// 2. One-clip EDL project with the trim, 3. 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`,
},
});
await api.post(`/video/edl/${edl.id}/render`, { lane: "local" });
setStage("queued");
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStage("failed");
}
};
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">
{t({ en: "Trim — set where the cut starts and ends", es: "Recortar — marca dónde empieza y termina el corte" })}
</div>
<div className="flex-1 overflow-auto p-3">
{stage === "queued" ? (
<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." })}
</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" })}
</button>
</div>
) : stage === "failed" ? (
<div className="rounded-lg border border-danger bg-danger/10 p-4 text-sm">
<p className="font-medium text-danger">{t({ en: "The cut could not be queued", es: "No se pudo encolar el corte" })}</p>
<p className="mt-1 text-text-dim">{error}</p>
<button type="button" className="mt-3 rounded px-3 py-1.5 text-xs hover:bg-surface-2" onClick={() => setStage("set")}>
{t({ en: "Back", es: "Volver" })}
</button>
</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">
<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)); }} />
</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)); }} />
</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>
<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))}` })}
</p>
<button
type="button"
disabled={stage === "working" || span < 500}
className="w-full rounded bg-accent px-4 py-2.5 text-sm font-medium text-white disabled:opacity-50"
onClick={() => void cut()}
>
{stage === "working"
? t({ en: "Queueing the cut…", es: "Encolando el corte…" })
: 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." })}
</p>
</div>
</>
)}
</div>
<div className="border-t border-border-subtle px-3 py-2">
<button type="button" className="rounded px-3 py-1.5 text-xs hover:bg-surface-2" onClick={onClose}>
{t({ en: "Close", es: "Cerrar" })}
</button>
</div>
</div>
);
}