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:
parent
a7c3f26a4f
commit
1429e4e9af
15 changed files with 1325 additions and 90 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
|||
96
apps/web/src/lib/video-direct.ts
Normal file
96
apps/web/src/lib/video-direct.ts
Normal 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" }));
|
||||
});
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue