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:
parent
0bdf13816d
commit
fe483a0822
3 changed files with 426 additions and 17 deletions
202
apps/web/src/components/files/image-editor.tsx
Normal file
202
apps/web/src/components/files/image-editor.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,9 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useFilesLang } from "./i18n";
|
import { useFilesLang } from "./i18n";
|
||||||
import { cachedMedia, type CachedMedia } from "../../lib/media-cache";
|
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 { VideoPlayer } from "./video-player";
|
||||||
import { formatBytes } from "../video/format";
|
import { formatBytes } from "../video/format";
|
||||||
import type { GroupFile } from "./types";
|
import type { GroupFile } from "./types";
|
||||||
|
|
@ -28,29 +31,56 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
|
||||||
const [scale, setScale] = useState(1);
|
const [scale, setScale] = useState(1);
|
||||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||||
const [rotation, setRotation] = useState(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 dragRef = useRef<{ x: number; y: number; ox: number; oy: number } | null>(null);
|
||||||
const closeRef = useRef<HTMLButtonElement>(null);
|
const closeRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
const isVideo = item?.mimeClass === "video";
|
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(() => {
|
useEffect(() => {
|
||||||
if (item === undefined) return;
|
if (item === undefined) return;
|
||||||
setMedia(null); setError(null); setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0);
|
setMedia(null); setError(null); setScale(1); setOffset({ x: 0, y: 0 }); setRotation(0);
|
||||||
|
setMode("view"); setSaved(false); setTextContent(null); setHtmlSource(false);
|
||||||
let alive = true;
|
let alive = true;
|
||||||
cachedMedia(item)
|
cachedMedia(item)
|
||||||
.then((m) => { if (alive) setMedia(m); })
|
.then((m) => { if (alive) setMedia(m); })
|
||||||
.catch((err: unknown) => { if (alive) setError(err instanceof Error ? err.message : String(err)); });
|
.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; };
|
return () => { alive = false; };
|
||||||
}, [item]);
|
}, [item, isText]);
|
||||||
|
|
||||||
useEffect(() => { closeRef.current?.focus(); }, [index]);
|
|
||||||
|
|
||||||
const step = useCallback((delta: number): void => {
|
const step = useCallback((delta: number): void => {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
onIndex((index + delta + items.length) % items.length);
|
onIndex((index + delta + items.length) % items.length);
|
||||||
}, [index, items.length, onIndex]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent): void => {
|
const onKey = (e: KeyboardEvent): void => {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
|
|
@ -67,7 +97,8 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
|
||||||
};
|
};
|
||||||
const trap = (e: KeyboardEvent): void => {
|
const trap = (e: KeyboardEvent): void => {
|
||||||
if (e.key !== "Tab") return;
|
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;
|
if (focusables === undefined || focusables.length === 0) return;
|
||||||
const first = focusables[0]!;
|
const first = focusables[0]!;
|
||||||
const last = focusables[focusables.length - 1]!;
|
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="truncate font-medium text-white">{item.name}</span>
|
||||||
<span className="text-white/50">{formatBytes(item.sizeBytes)}</span>
|
<span className="text-white/50">{formatBytes(item.sizeBytes)}</span>
|
||||||
<span className="ml-auto tabular-nums">{index + 1} / {items.length}</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>
|
||||||
|
|
||||||
<div className="relative flex-1 overflow-hidden">
|
<div className="relative flex-1 overflow-hidden">
|
||||||
|
|
@ -105,19 +146,54 @@ export function MediaViewer({ items, index, onClose, onIndex }: {
|
||||||
{error === null && media === null && (
|
{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>
|
<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">
|
<div className="h-full w-full max-w-5xl">
|
||||||
<VideoPlayer src={media.url} title={item.name} />
|
<VideoPlayer src={media.url} title={item.name} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
|
{error === null && media !== null && mode === "view" && isImage && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center overflow-hidden"
|
className="absolute inset-0 flex items-center justify-center overflow-hidden"
|
||||||
onWheel={(e) => {
|
onWheel={(e) => { e.preventDefault(); setScale((s) => Math.max(1, Math.min(8, e.deltaY < 0 ? s * 1.15 : s / 1.15))); }}
|
||||||
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))}
|
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 }; }}
|
onPointerDown={(e) => { if (scale > 1) dragRef.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y }; }}
|
||||||
onPointerMove={(e) => {
|
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" }}
|
style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale}) rotate(${rotation}deg)`, cursor: scale > 1 ? "grab" : "default" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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()}>
|
<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>
|
<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>
|
<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; };
|
return () => { alive = false; };
|
||||||
}, [item]);
|
}, [item]);
|
||||||
if (item.mimeClass === "video") {
|
if (item.mimeClass === "video") {
|
||||||
return (
|
return <span className="flex h-full w-full items-center justify-center bg-surface-2 text-xs text-text-dim">▶</span>;
|
||||||
<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" />;
|
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
|
// eslint-disable-next-line @next/next/no-img-element -- decrypted object URL
|
||||||
|
|
|
||||||
133
apps/web/src/components/files/video-trim.tsx
Normal file
133
apps/web/src/components/files/video-trim.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue