feat: video direct-to-R2 ingest — browser seals, worker probes off R2

POST /video/direct/init|complete: the browser seals (SPENC2) and PUTs
straight to object storage; the api HEAD-checks, registers the unprobed
asset and queues a probe job — video bytes never cross the api box.

Worker side: produce_job_claim widened to probe jobs (042), the runner
gains a probe lane (sealed fetch → SPENC1/SPENC2 open by magic → scratch →
ffprobe → metadata write-back → complete 100), worker storage reads SPENC2
frames via DIRECT_EC_PRIVATE_KEY (inherited through compose appenv), and a
ported ffprobe util. Probe rows carrying input_json fail instead of
stranding; the claim guard no longer strands probe rows as invalid.
This commit is contained in:
glass beadian 2026-10-16 10:52:47 +00:00
parent a7c3f26a4f
commit 1429e4e9af
15 changed files with 1325 additions and 90 deletions

View file

@ -5,19 +5,73 @@ import { Button } from "../ui/primitives";
import { Loading } from "../brand/loading";
import { apiUploadFile, errorMessage } from "../../lib/api-client";
import { CHUNK_SIZE_BYTES, uploadVideoChunked } from "../../lib/chunked-upload";
import type { VideoStatusDto } from "../../lib/video-types";
import {
directEligible,
fetchVideoDirectConfig,
uploadVideoDirect,
} from "../../lib/video-direct";
import type { VideoDirectConfigDto, VideoStatusDto } from "../../lib/video-types";
import { formatBytes } from "./format";
/**
* Drag-drop + file-picker target for video ingest. Files at or under one chunk
* keep the single multipart POST /video/upload; larger ones go through the
* chunked API (init → PUT slices → complete) so no single request approaches
* Cloudflare's ~100 MB proxied-body cap. The API stores encrypted bytes, mints
* the content-addressed key, and probes the stored object before responding.
* Progress measures the actual transfer.
* Drag-drop + file-picker target for video ingest — MULTIPLE files at once,
* one upload in flight at a time (sequential queue, per-file status rows).
*
* Per-file transport, first match wins:
* direct — browser seals (SPENC2) and PUTs straight to storage through a
* presigned URL, then POST /video/direct/complete. Chosen when
* the deployment enables it, a public key is served, and the file
* fits both the server cap and the browser memory cap (sealing
* holds plaintext + frame at once). Any failure here falls back
* to the paths below, surfacing the reason as a warning.
* chunked — over one chunk (10 MiB): the existing init → PUT slices →
* complete API path, so no single request approaches the ~100 MB
* proxied-body cap.
* whole — at or under one chunk: the single multipart POST /video/upload.
*
* One file's failure never aborts the queue.
*/
const MAX_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024;
const MAX_LABEL = "2 GiB";
/** formatBytes would render the chunk as decimal "10.0 MB"; the slicing is binary. */
const CHUNK_LABEL = "10 MiB";
/**
* EN/ES strings. The Files and Wiki sections each own a provider-backed
* `t({en, es})`; the Video section has none, so this reads the same
* per-browser localStorage preference directly instead of importing the
* Files context (whose provider is not mounted on this route).
*/
const LANG_KEY = "tmbh.files.lang";
interface LocalizedText {
readonly en: string;
readonly es: string;
}
function readLang(): "en" | "es" {
if (typeof window === "undefined") return "en";
try {
return window.localStorage.getItem(LANG_KEY) === "es" ? "es" : "en";
} catch {
return "en";
}
}
function useLang(): (text: LocalizedText) => string {
const [lang, setLang] = useState<"en" | "es">("en");
useEffect(() => {
setLang(readLang());
// Storage events keep two open tabs of the same browser in agreement.
const onStorage = (e: StorageEvent): void => {
if (e.key === LANG_KEY) setLang(readLang());
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
return useCallback((text: LocalizedText) => text[lang], [lang]);
}
export interface UploadDraftInfo {
key: string;
@ -33,66 +87,185 @@ interface Props {
onUploaded: (status: VideoStatusDto) => void;
}
export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUploaded }: Props): React.ReactElement {
const inputRef = useRef<HTMLInputElement>(null);
const busyRef = useRef(false);
const [dragging, setDragging] = useState(false);
const [uploading, setUploading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
type QueueState = "queued" | "uploading" | "done" | "failed";
const register = useCallback(
async (file: File): Promise<void> => {
if (!live || !canUpload || busyRef.current) return;
const draft: UploadDraftInfo = { key: `${file.name}:${file.size}:${file.lastModified}`, name: file.name, detail: formatBytes(file.size) };
if (!file.type.startsWith("video/")) {
setError(`${file.name}: not a video file (${file.type === "" ? "unknown type" : file.type})`);
return;
}
if (file.size === 0 || file.size > MAX_UPLOAD_BYTES) {
setError("Choose a non-empty video up to 2 GiB.");
return;
}
setError(null);
busyRef.current = true;
setProgress(0);
setUploading(`${file.name} (${formatBytes(file.size)})`);
onStarted(draft);
try {
// Above one chunk the whole-body POST would trip Cloudflare's body cap.
const result = file.size > CHUNK_SIZE_BYTES
? await uploadVideoChunked(file, setProgress)
: await apiUploadFile<VideoStatusDto>("/video/upload", file, setProgress);
onUploaded(result);
if (result.processingJob.state === "failed") setError(result.processingJob.error ?? "Video probing failed.");
} catch (err) {
setError(errorMessage(err));
} finally {
onSettled(draft.key);
setUploading(null);
busyRef.current = false;
interface QueueItem {
id: string;
name: string;
size: number;
state: QueueState;
progress: number;
error: string | null;
/** Set when the direct path failed and a fallback transport carried the file. */
warning: string | null;
file: File;
}
export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUploaded }: Props): React.ReactElement {
const t = useLang();
const inputRef = useRef<HTMLInputElement>(null);
/** Synchronous in-flight guard: a drop can land before `uploading` re-renders. */
const busyRef = useRef(false);
/** Direct config is cached for the lifetime of the mount: one fetch. */
const configRef = useRef<VideoDirectConfigDto | null | undefined>(undefined);
const [dragging, setDragging] = useState(false);
const [queue, setQueue] = useState<QueueItem[]>([]);
const [warning, setWarning] = useState<string | null>(null);
const uploading = queue.some((item) => item.state === "uploading" || item.state === "queued");
const disabled = !live || !canUpload || uploading || busyRef.current;
const patch = useCallback((id: string, changes: Partial<QueueItem>): void => {
setQueue((current) => current.map((item) => (item.id === id ? { ...item, ...changes } : item)));
}, []);
/** Direct config, fetched once per mount (undefined = not asked yet). */
const configFor = useCallback(async (): Promise<VideoDirectConfigDto | null> => {
if (configRef.current === undefined) {
configRef.current = await fetchVideoDirectConfig().catch(() => null);
}
return configRef.current;
}, []);
/**
* One file through whichever transport applies, first match wins. Returns
* the created video; throws only when every applicable path has failed.
*/
const uploadOne = useCallback(
async (item: QueueItem, onProgress: (percent: number) => void): Promise<VideoStatusDto> => {
const config = await configFor();
if (directEligible(config, item.size) && config !== null && config.publicKeyJwk !== null) {
try {
return await uploadVideoDirect(item.file, config, onProgress);
} catch (directError) {
// Direct is best-effort: fall through to the API paths, but say why.
const reason = errorMessage(directError);
setWarning(
t({
en: `${item.name}: direct upload failed (${reason}) — retrying through the API.`,
es: `${item.name}: la subida directa falló (${reason}) — reintentando por la API.`,
}),
);
onProgress(0);
patch(item.id, {
warning: t({ en: `direct upload failed (${reason}) — sent via the API`, es: `la subida directa falló (${reason}) — enviado por la API` }),
});
}
}
// Above one chunk the whole-body POST would trip the proxied-body cap.
return item.size > CHUNK_SIZE_BYTES
? uploadVideoChunked(item.file, onProgress)
: apiUploadFile<VideoStatusDto>("/video/upload", item.file, onProgress);
},
[live, canUpload, onStarted, onSettled, onUploaded],
[configFor, patch, t],
);
// Paste a video file straight into the library.
const runQueue = useCallback(
async (items: QueueItem[]): Promise<void> => {
busyRef.current = true;
for (const item of items) {
// A file can already be terminal (empty/oversize, marked at register time).
if (item.state !== "queued") continue;
const draft: UploadDraftInfo = { key: item.id, name: item.name, detail: formatBytes(item.size) };
patch(item.id, { state: "uploading", progress: 0 });
onStarted(draft);
try {
const status = await uploadOne(item, (progress) => patch(item.id, { progress }));
// The bytes landed; a failed probe is a processing result, not an
// upload failure — the library row carries it, so surface it inline.
const processingError = status.processingJob.state === "failed" ? status.processingJob.error ?? null : null;
patch(item.id, {
state: "done",
progress: 100,
warning: processingError === null ? null : t({ en: `probe failed: ${processingError}`, es: `falló el análisis: ${processingError}` }),
});
onUploaded(status);
} catch (err) {
patch(item.id, { state: "failed", error: errorMessage(err) });
} finally {
onSettled(draft.key);
}
}
busyRef.current = false;
// Let the results stay visible briefly, then clear finished entries.
window.setTimeout(() => {
setQueue((current) => current.filter((entry) => entry.state === "uploading" || entry.state === "queued"));
}, 8000);
},
[onStarted, onSettled, onUploaded, patch, t, uploadOne],
);
const register = useCallback(
(files: File[]): void => {
if (disabled || files.length === 0) return;
setWarning(null);
const accepted: QueueItem[] = [];
for (const file of files) {
const id = `${file.name}:${file.size}:${file.lastModified}:${Math.random().toString(36).slice(2, 8)}`;
const base = { id, name: file.name, size: file.size, progress: 0, file };
if (!file.type.startsWith("video/")) {
accepted.push({
...base,
state: "failed",
error: t({
en: `not a video file (${file.type === "" ? "unknown type" : file.type}).`,
es: `no es un archivo de vídeo (${file.type === "" ? "tipo desconocido" : file.type}).`,
}),
warning: null,
});
continue;
}
if (file.size === 0) {
accepted.push({
...base,
state: "failed",
error: t({ en: "the file is empty.", es: "el archivo está vacío." }),
warning: null,
});
continue;
}
if (file.size > MAX_UPLOAD_BYTES) {
accepted.push({
...base,
state: "failed",
error: t({
en: `is ${formatBytes(file.size)} — the limit is ${MAX_LABEL}.`,
es: `pesa ${formatBytes(file.size)} — el límite es ${MAX_LABEL}.`,
}),
warning: null,
});
continue;
}
accepted.push({ ...base, state: "queued", progress: 0, error: null, warning: null });
}
setQueue((current) => [...current, ...accepted]);
if (accepted.some((entry) => entry.state === "queued")) void runQueue(accepted);
},
[disabled, runQueue, t],
);
// Paste a video straight into the library.
useEffect(() => {
if (!live) return;
const onPaste = (e: ClipboardEvent): void => {
const file = Array.from(e.clipboardData?.files ?? []).find((f) => f.type.startsWith("video/"));
if (file !== undefined) void register(file);
if (file !== undefined) register([file]);
};
window.addEventListener("paste", onPaste);
return () => window.removeEventListener("paste", onPaste);
}, [live, register]);
const disabled = !live || !canUpload || uploading !== null;
const hint = !live
? "Offline — connect to the API to upload."
? t({ en: "Offline — connect to the API to upload.", es: "Sin conexión — conéctate a la API para subir." })
: !canUpload
? "Uploading needs the media.manage permission."
: "Drop a video file, paste one, or browse. Up to 2 GiB — larger videos upload in 10 MiB chunks; stored encrypted and checked before it is ready.";
? t({
en: "Uploading needs the media.manage permission.",
es: "Subir requiere el permiso media.manage.",
})
: t({
en: `Drop videos, paste one, or browse — several at a time, up to ${MAX_LABEL} each. Large videos upload in ${CHUNK_LABEL} chunks or straight to storage, encrypted end to end.`,
es: `Arrastra vídeos, pega uno o búscalos — varios a la vez, hasta ${MAX_LABEL} cada uno. Los vídeos grandes suben en trozos de ${CHUNK_LABEL} o directo al almacenamiento, cifrados de extremo a extremo.`,
});
return (
<div className="mb-4">
@ -100,11 +273,11 @@ export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUpload
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled}
aria-label="Upload a video"
aria-label={t({ en: "Upload videos", es: "Subir vídeos" })}
onClick={() => {
if (!disabled) inputRef.current?.click();
}}
onKeyDown={(e) => {
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
if (disabled) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
@ -120,8 +293,8 @@ export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUpload
e.preventDefault();
setDragging(false);
if (disabled) return;
const file = e.dataTransfer.files[0];
if (file !== undefined) void register(file);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) register(files);
}}
className={`flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center transition-colors duration-150 ${
disabled
@ -131,17 +304,47 @@ export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUpload
: "cursor-pointer border-border hover:border-accent hover:bg-surface-2"
}`}
>
{uploading !== null ? (
<div className="w-full max-w-md space-y-2">
<span className="inline-flex items-center gap-2 text-xs text-text-dim">
<Loading size={14} label={progress < 100 ? "Uploading" : "Processing"} />
{progress < 100 ? `Uploading ${progress}%` : "Upload sent — storing and probing…"} {uploading}
</span>
<progress className="h-2 w-full" aria-label="File upload progress" value={progress} max={100} />
{queue.length > 0 ? (
<div className="w-full max-w-md space-y-1">
{queue.map((item) => (
<div key={item.id} className="flex flex-wrap items-center gap-2 text-xs">
{item.state === "uploading" ? (
<Loading
size={12}
label={t({
en: item.progress < 100 ? "Uploading" : "Processing",
es: item.progress < 100 ? "Subiendo" : "Procesando",
})}
/>
) : item.state === "done" ? (
<span aria-label={t({ en: "Done", es: "Listo" })}>✓</span>
) : item.state === "failed" ? (
<span aria-label={t({ en: "Failed", es: "Falló" })}>✗</span>
) : (
<span aria-label={t({ en: "Queued", es: "En cola" })}>…</span>
)}
<span className={item.state === "failed" ? "text-danger" : "text-text-dim"}>
{item.name} ({formatBytes(item.size)})
{item.state === "uploading"
? ` — ${item.progress < 100 ? `${item.progress}%` : t({ en: "probing…", es: "analizando…" })}`
: ""}
{item.state === "failed" && item.error !== null ? ` — ${item.error}` : ""}
{item.warning !== null ? ` — ${item.warning}` : ""}
</span>
{item.state === "uploading" && (
<progress
className="h-1.5 w-20"
aria-label={t({ en: "Upload progress", es: "Progreso de subida" })}
value={item.progress}
max={100}
/>
)}
</div>
))}
</div>
) : (
<>
<span className="text-sm text-text">Drop a video here</span>
<span className="text-sm text-text">{t({ en: "Drop videos here", es: "Suelta vídeos aquí" })}</span>
<span className="text-2xs leading-relaxed text-text-faint">{hint}</span>
{!disabled && (
<Button
@ -152,7 +355,7 @@ export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUpload
inputRef.current?.click();
}}
>
Browse files
{t({ en: "Browse files", es: "Buscar archivos" })}
</Button>
)}
</>
@ -160,16 +363,21 @@ export function UploadDropzone({ live, canUpload, onStarted, onSettled, onUpload
<input
ref={inputRef}
type="file"
multiple
accept="video/mp4,video/webm,video/quicktime"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
const files = Array.from(e.target.files ?? []);
e.target.value = "";
if (file !== undefined) void register(file);
if (files.length > 0) register(files);
}}
/>
</div>
{error !== null && <p className="mt-2 text-xs text-danger">{error}</p>}
{warning !== null && (
<p role="status" className="mt-2 text-xs text-warn">
{warning}
</p>
)}
</div>
);
}

View file

@ -34,7 +34,8 @@ test("tampered segment ciphertext throws on open", async () => {
const plaintext = new Uint8Array(1024);
const { frame } = await sealDirect(plaintext, jwk);
const copy = Buffer.from(frame);
copy[copy.length - 20] ^= 0xff;
const tamperAt = copy.length - 20;
copy[tamperAt] = (copy[tamperAt] ?? 0) ^ 0xff;
assert.throws(() => openDirectFrame(copy, privateKey));
});

View file

@ -0,0 +1,96 @@
/**
* Direct-to-R2 ingest for video — the /video/direct routes. The API mints a
* presigned PUT and records metadata; payload bytes go browser → storage →
* browser sealed client-side (SPENC2 — see ./direct-storage.ts), so the
* server never sees plaintext and no single request is proxied through the
* API at all.
*
* Kept as a pure lib module (no React), same standing as ./chunked-upload.ts
* and ./files-direct.ts, so the transport is testable on its own.
*/
import { api } from "./api-client";
import { sealDirect } from "./direct-storage";
import type { VideoDirectConfigDto, VideoDirectInitDto, VideoStatusDto } from "./video-types";
/**
* Browser-side ceiling for the direct path: sealing holds the plaintext AND
* the sealed frame in memory at once, so above this the queue falls back to
* the chunked API path (which streams 10 MiB slices instead).
*/
export const DIRECT_MEMORY_CAP_BYTES = 512 * 1024 * 1024;
export async function fetchVideoDirectConfig(): Promise<VideoDirectConfigDto> {
return api.get<VideoDirectConfigDto>("/video/direct/config");
}
/** True when the direct path applies to this file: enabled, keyed, and fits both ceilings. */
export function directEligible(config: VideoDirectConfigDto | null, sizeBytes: number): boolean {
return (
config !== null &&
config.enabled &&
config.publicKeyJwk !== null &&
sizeBytes > 0 &&
sizeBytes <= config.maxBytes &&
sizeBytes <= DIRECT_MEMORY_CAP_BYTES
);
}
/** Empty mimeTypes (drag-drop of unknown extensions) would break metadata downstream. */
function normalizeMime(file: File): string {
return file.type === "" ? "application/octet-stream" : file.type;
}
/**
* Seal in the browser, PUT the frame straight to storage, then tell the API
* the object exists. `sizeBytes` attests the SEALED frame (what was actually
* stored); the processing job starts queued and the caller's existing status
* polling takes over from there.
*/
export async function uploadVideoDirect(
file: File,
config: VideoDirectConfigDto,
onProgress: (percent: number) => void,
): Promise<VideoStatusDto> {
if (config.publicKeyJwk === null) throw new Error("direct video upload is not enabled");
const bytes = new Uint8Array(await file.arrayBuffer());
const { frame } = await sealDirect(bytes, config.publicKeyJwk);
const mimeType = normalizeMime(file);
onProgress(0);
const init = await api.post<VideoDirectInitDto>("/video/direct/init", {
filename: file.name,
mimeType,
sizeBytes: file.size,
});
await putFrameToStorage(init.putUrl, frame, onProgress);
return api.post<VideoStatusDto>("/video/direct/complete", {
uploadId: init.uploadId,
objectKey: init.objectKey,
sizeBytes: frame.byteLength,
mimeType,
filename: file.name,
});
}
/**
* Presigned storage PUT. Deliberately NOT apiPutBytes: the signature lives in
* the query string and covers ONLY content-type, so any extra header — the
* bearer token, credentials, withCredentials — breaks it. XHR (not fetch)
* because fetch has no upload progress.
*/
function putFrameToStorage(url: string, frame: Uint8Array, onProgress: (percent: number) => void): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.setRequestHeader("Content-Type", "application/octet-stream");
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(Math.floor((event.loaded / event.total) * 100));
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`direct video upload failed (HTTP ${xhr.status})`));
};
xhr.onerror = () => reject(new Error("direct video upload failed — network error"));
xhr.ontimeout = () => reject(new Error("direct video upload timed out"));
xhr.send(new Blob([frame.slice()], { type: "application/octet-stream" }));
});
}

View file

@ -3,6 +3,7 @@
* apps/api VideoController/VideoService (same standing as api-types.ts;
* declared here because api-client.ts is owned by another worker right now).
*/
import type { DirectPublicJwk } from "./direct-storage";
/** video_processing_jobs.state — the processing pipeline a video walks. */
export type VideoProcessingState = "queued" | "probing" | "transcoding" | "complete" | "failed";
@ -56,6 +57,25 @@ export interface VideoStatusDto {
processingJob: VideoProcessingJobDto;
}
/**
* Direct-to-R2 ingest for video — same shape as /files/direct/config. When
* `enabled` and a public key is present, the browser seals the file itself
* (SPENC2, ./direct-storage.ts) and PUTs it straight to storage; the API only
* mints URLs and records metadata.
*/
export interface VideoDirectConfigDto {
enabled: boolean;
publicKeyJwk: DirectPublicJwk | null;
maxBytes: number;
}
/** Response of POST /video/direct/init — a presigned PUT plus the object it targets. */
export interface VideoDirectInitDto {
uploadId: string;
objectKey: string;
putUrl: string;
}
/**
* Producer (POST /video/:id/produce) request/response shapes — same
* hand-synced standing as the routes above.