diff --git a/agent.md b/agent.md index 6d2c74f..9a19bfb 100644 --- a/agent.md +++ b/agent.md @@ -338,6 +338,10 @@ in line, and asks Drive again now for the clips waiting on it. `DELETE /api/proj | POST | `/api/assets` | Upload one file into app storage (multipart, field `file`) → the asset | | POST | `/api/assets/uploads` | `{ name, size, type?, duration? }` opens a resumable upload of a video on the media service → `{ uid, upload_url }` (503 `media_unavailable` in local dev) | | POST | `/api/assets/media` | `{ uid }` adds a finished upload to the library → the asset | +| POST | `/api/assets/originals` | `{ name, size, width, height }` for a video above 1080p: opens an upload in parts that keeps the file as sent → `{ id, upload_id, part_size, parts }` (409 `originals_full`: use `/api/assets/uploads`) | +| POST | `/api/assets/originals/{id}/parts` | `{ upload_id, part_numbers }` → `{ urls }`, a link to PUT each part to; keep the `ETag` each answers | +| POST | `/api/assets/originals/{id}/complete` | `{ upload_id, parts: [{ part_number, etag }], name, size, type?, duration? }` joins the parts → the asset | +| DELETE | `/api/assets/originals/{id}?upload_id=` | Gives up an unfinished upload | | DELETE | `/api/assets/uploads/{uid}` | Cancels or discards an upload that has not joined the library | | POST | `/api/assets/{id}/analyze` | AI cut/caption proposals for a clip (ms timestamps) | | GET | `/api/drive` | Whether Google Drive is connected: `{ connected }` | diff --git a/src/client/uploads.ts b/src/client/uploads.ts index 1da4caf..4644571 100644 --- a/src/client/uploads.ts +++ b/src/client/uploads.ts @@ -5,6 +5,10 @@ // one, and arrives with the same playback, frames and transcript as footage // imported from Drive. Stills and sound are small and are posted to the app. // +// A video above 1080p takes another road to the same place: the service's +// playable copies stop at 1080p, so the file goes in parts to the service's +// own storage, which keeps it as sent for exports above 1080p. +// // The queue lives here, outside the editor, so leaving the editor does not // lose an upload in flight: it carries on, and reopening the editor shows it. @@ -26,6 +30,10 @@ export interface UploadItem { interface Job extends UploadItem { file: File; duration: number | null; + /** The picture's size, read from the file; null when the browser cannot read it. */ + shape?: { width: number; height: number } | null; + /** An upload of the file as sent (a video above 1080p), kept so a retry resumes it. */ + original?: { id: string; uploadId: string; partSize: number; parts: number; done: Map }; /** The open upload on the media service, kept so a retry resumes it. */ uid?: string; uploadUrl?: string; @@ -84,22 +92,36 @@ export function isVideoFile(file: File): boolean { * not depend on how the file is laid out the way a probe over the network does. */ export function probeDuration(file: File): Promise { + return probe(file).then((p) => p.duration); +} + +interface Probe { + duration: number | null; + shape: { width: number; height: number } | null; +} + +function probe(file: File): Promise { return new Promise((res) => { - if (!/^(video|audio)\//.test(file.type) && !isVideoFile(file)) return res(null); + if (!/^(video|audio)\//.test(file.type) && !isVideoFile(file)) return res({ duration: null, shape: null }); const url = URL.createObjectURL(file); const media = document.createElement(file.type.startsWith("audio/") ? "audio" : "video"); media.preload = "metadata"; media.src = url; let settled = false; - const done = (d: number | null) => { + const done = (found: boolean) => { if (settled) return; settled = true; + const v = media as HTMLVideoElement; + const result: Probe = { + duration: found && Number.isFinite(media.duration) && media.duration > 0 ? media.duration : null, + shape: found && v.videoWidth > 0 && v.videoHeight > 0 ? { width: v.videoWidth, height: v.videoHeight } : null, + }; URL.revokeObjectURL(url); - res(d); + res(result); }; - media.onloadedmetadata = () => done(Number.isFinite(media.duration) && media.duration > 0 ? media.duration : null); - media.onerror = () => done(null); - setTimeout(() => done(null), 3_000); + media.onloadedmetadata = () => done(true); + media.onerror = () => done(false); + setTimeout(() => done(false), 3_000); }); } @@ -137,13 +159,13 @@ async function run(id: string) { const job = find(id); if (!job) return; patch(id, { status: "uploading", error: undefined }); - if (job.duration === null) { - const duration = await probeDuration(job.file); + if (job.shape === undefined) { + const found = await probe(job.file); if (!find(id)) return; - patch(id, { duration }); + patch(id, found); } if (isVideoFile(job.file)) { - const ok = await toMediaService(id); + const ok = (await asOriginal(id)) || (await toMediaService(id)); if (ok) return; // No media service (local dev): fall through to the app's own storage. if (!find(id) || find(id)!.status === "failed") return; @@ -151,6 +173,85 @@ async function run(id: string) { toAppStorage(id); } +const json = (body: unknown): RequestInit => ({ + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), +}); + +/** PUT one part; resolves with the ETag the storage answers, or null when it did not land. */ +function putPart(id: string, url: string, part: Blob, before: number): Promise { + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.open("PUT", url); + xhr.upload.onprogress = (e) => find(id) && patch(id, { sent: before + e.loaded }); + xhr.onload = () => resolve(xhr.status >= 200 && xhr.status < 300 ? xhr.getResponseHeader("ETag") : null); + xhr.onerror = xhr.onabort = () => resolve(null); + patch(id, { xhr }); + xhr.send(part); + }); +} + +/** + * Upload a video above 1080p as its original. True when this road took the + * file (it arrived, failed, or was cancelled); false when the file should go + * the usual way: at or below 1080p, no room for originals, or no service. + */ +async function asOriginal(id: string): Promise { + let job = find(id)!; + if (job.uploadUrl) return false; // already on the usual road + if (!job.original) { + const s = job.shape; + if (!s || Math.min(s.width, s.height) <= 1080) return false; + const r = await fetch("/api/assets/originals", json({ name: job.name, size: job.size, width: s.width, height: s.height })).catch(() => null); + if (!find(id)) return true; + if (!r?.ok) return false; + const o = (await r.json()) as { id: string; upload_id: string; part_size: number; parts: number }; + patch(id, { original: { id: o.id, uploadId: o.upload_id, partSize: o.part_size, parts: o.parts, done: new Map() } }); + job = find(id)!; + } + const o = job.original!; + const base = `/api/assets/originals/${o.id}`; + for (let n = 1; n <= o.parts; n++) { + if (o.done.has(n)) continue; + const before = (n - 1) * o.partSize; + let etag: string | null = null; + // A fresh link each try: one that failed may have run out. + for (const wait of [0, 2000, 5000, 15000]) { + if (wait) await new Promise((r) => setTimeout(r, wait)); + if (!find(id)) return true; + const links = await fetch(`${base}/parts`, json({ upload_id: o.uploadId, part_numbers: [n] })).catch(() => null); + const url = links?.ok ? ((await links.json()) as { urls: Record }).urls[n] : null; + if (!url) continue; + etag = await putPart(id, url, job.file.slice(before, before + o.partSize), before); + if (etag || !find(id)) break; + } + if (!find(id)) return true; + if (!etag) { + if (o.done.size === 0) { + // Not one part landed: the storage is not taking this upload at all, so + // send the file the usual way. Its exports stay at 1080p. + fetch(`${base}?upload_id=${encodeURIComponent(o.uploadId)}`, { method: "DELETE" }).catch(() => {}); + patch(id, { original: undefined, sent: 0, xhr: undefined }); + return false; + } + fail(id, "The connection dropped. Retry picks up where it stopped."); + return true; + } + o.done.set(n, etag); + } + patch(id, { status: "finishing", sent: job.size, xhr: undefined }); + const parts = [...o.done].map(([part_number, etag]) => ({ part_number, etag })); + const r = await fetch(`${base}/complete`, json({ upload_id: o.uploadId, parts, name: job.name, type: job.file.type, size: job.size, duration: job.duration })).catch(() => null); + if (!find(id)) return true; + if (!r?.ok) { + fail(id, (r && (await failure(r)).detail) || "Uploaded, but not added to the library yet. Retry adds it."); + return true; + } + arrived(id, (await r.json()) as Asset); + return true; +} + /** False when the media service is not available here; the caller falls back. */ async function toMediaService(id: string): Promise { let job = find(id)!; @@ -255,4 +356,7 @@ export function cancelUpload(id: string) { job.xhr?.abort(); if (job.tus) job.tus.abort().catch(() => {}); if (job.uid) fetch(`/api/assets/uploads/${job.uid}`, { method: "DELETE" }).catch(() => {}); + if (job.original) { + fetch(`/api/assets/originals/${job.original.id}?upload_id=${encodeURIComponent(job.original.uploadId)}`, { method: "DELETE" }).catch(() => {}); + } } diff --git a/src/server/index.ts b/src/server/index.ts index 3485ba2..e423c4d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -22,6 +22,10 @@ import { mediaState, mediaTranscript, openMediaUpload, + openOriginal, + originalPartLinks, + finishOriginal, + abandonOriginal, prepareMedia, } from "./media"; import { @@ -242,6 +246,67 @@ app.post("/api/assets/uploads", async (c) => { return c.json({ uid: opened.id, upload_url: opened.uploadUrl }, 201); }); +// A video above 1080p uploads in parts to the media service's own storage, +// which keeps the file as sent (src/server/media.ts). 409 `originals_full` +// tells the browser to upload it the usual way instead. +const ORIGINAL_ID = /^(eu|us)-[0-9a-f-]{36}$/; + +app.post("/api/assets/originals", async (c) => { + type Body = { name?: string; size?: number; width?: number; height?: number }; + const b = await c.req.json().catch(() => ({}) as Body); + if (!b.name || !b.size || !Number.isInteger(b.size) || b.size <= 0 || !b.width || !b.height) { + return c.json({ error: "invalid_request", detail: "name, size, width and height are required" }, 400); + } + const opened = await openOriginal(mediaCfg(c.env), { size: b.size, width: b.width, height: b.height, name: b.name.slice(0, 200) }); + if ("failure" in opened) { + const e = opened.failure.error; + return c.json(opened.failure, e === "media_unavailable" ? 503 : e === "originals_full" ? 409 : 422); + } + return c.json(opened.upload, 201); +}); + +app.post("/api/assets/originals/:oid/parts", async (c) => { + const oid = c.req.param("oid"); + type Body = { upload_id?: string; part_numbers?: number[] }; + const b = await c.req.json().catch(() => ({}) as Body); + if (!ORIGINAL_ID.test(oid) || !b.upload_id || !Array.isArray(b.part_numbers)) { + return c.json({ error: "invalid_request", detail: "upload_id and part_numbers are required" }, 400); + } + const links = await originalPartLinks(mediaCfg(c.env), oid, b.upload_id, b.part_numbers); + return "failure" in links ? c.json(links.failure, 422) : c.json(links); +}); + +// Every part is in: the service joins them and the video joins the library. +app.post("/api/assets/originals/:oid/complete", async (c) => { + const oid = c.req.param("oid"); + type Body = { upload_id?: string; parts?: { part_number: number; etag: string }[]; name?: string; type?: string; size?: number; duration?: number }; + const b = await c.req.json().catch(() => ({}) as Body); + if (!ORIGINAL_ID.test(oid) || !b.upload_id || !Array.isArray(b.parts) || !b.name || !b.size) { + return c.json({ error: "invalid_request", detail: "upload_id, parts, name and size are required" }, 400); + } + const name = b.name.slice(0, 200); + const done = await finishOriginal(mediaCfg(c.env), oid, b.upload_id, b.parts, name); + if ("failure" in done) return c.json(done.failure, 422); + const duration = typeof b.duration === "number" && Number.isFinite(b.duration) && b.duration > 0 ? b.duration : null; + await run("INSERT OR IGNORE INTO media_uploads (uid, name, content_type, size, duration) VALUES (?, ?, ?, ?, ?)", [ + done.media.id, + name, + b.type?.startsWith("video/") ? b.type : "video/mp4", + b.size, + duration, + ]); + const asset = await promoteUpload(done.media.id); + return asset ? c.json(asset, 201) : c.json({ error: "not_found", detail: "the video did not join the library" }, 500); +}); + +app.delete("/api/assets/originals/:oid", async (c) => { + const oid = c.req.param("oid"); + const uploadId = c.req.query("upload_id"); + if (!ORIGINAL_ID.test(oid) || !uploadId) return c.json({ error: "invalid_request", detail: "upload_id is required" }, 400); + await abandonOriginal(mediaCfg(c.env), oid, uploadId).catch(() => {}); + return c.json({ ok: true }); +}); + // The bytes are in: the upload joins the library. app.post("/api/assets/media", async (c) => { const b = await c.req.json<{ uid?: string }>().catch(() => ({}) as { uid?: string }); diff --git a/src/server/media.ts b/src/server/media.ts index 2312f61..cb96fb8 100644 --- a/src/server/media.ts +++ b/src/server/media.ts @@ -155,6 +155,59 @@ export async function openMediaUpload( return "failure" in res ? res : { id: res.data.id, uploadUrl: res.data.upload_url, expiresAt: res.data.expires_at ?? null }; } +// ── Originals: footage above 1080p ────────────────────────────────────────── +// Every copy the service makes of a video stops at 1080p, so a larger source +// is sent in parts to the service's own storage, which keeps it as sent for +// exports above 1080p, and the service makes its playable copy from there. + +export interface OriginalUpload { + id: string; + upload_id: string; + part_size: number; + parts: number; +} + +export async function openOriginal( + cfg: MediaConfig, + file: { size: number; width: number; height: number; name: string }, +): Promise<{ upload: OriginalUpload } | { failure: MediaFailure }> { + const res = await call(cfg, "/originals", { method: "POST", body: JSON.stringify(file) }); + return "failure" in res ? res : { upload: res.data }; +} + +/** A link to PUT each of these parts to. */ +export async function originalPartLinks( + cfg: MediaConfig, + id: string, + uploadId: string, + partNumbers: number[], +): Promise<{ urls: Record } | { failure: MediaFailure }> { + const res = await call<{ urls: Record }>(cfg, `/originals/${id}/parts`, { + method: "POST", + body: JSON.stringify({ upload_id: uploadId, part_numbers: partNumbers }), + }); + return "failure" in res ? res : { urls: res.data.urls }; +} + +/** Join the parts. The answer is the video the service now makes from the original. */ +export async function finishOriginal( + cfg: MediaConfig, + id: string, + uploadId: string, + parts: { part_number: number; etag: string }[], + name: string, +): Promise<{ media: MediaState } | { failure: MediaFailure }> { + const res = await call(cfg, `/originals/${id}/complete`, { + method: "POST", + body: JSON.stringify({ upload_id: uploadId, parts, name }), + }); + return "failure" in res ? res : { media: res.data }; +} + +export async function abandonOriginal(cfg: MediaConfig, id: string, uploadId: string): Promise { + await call(cfg, `/originals/${id}?upload_id=${encodeURIComponent(uploadId)}`, { method: "DELETE" }); +} + export async function mediaState(cfg: MediaConfig, uid: string): Promise<{ media: MediaState } | { failure: MediaFailure }> { const res = await call(cfg, `/${uid}`); return "failure" in res ? res : { media: res.data }; diff --git a/test/e2e/run.mjs b/test/e2e/run.mjs index 0f5c7a7..9fdd00b 100644 --- a/test/e2e/run.mjs +++ b/test/e2e/run.mjs @@ -181,6 +181,33 @@ globalThis.fetch = async (input, init = {}) => { world.uploads.set(id, { size: body.size, at: 0, name: body.name, patches: [] }); return json(201, { id, upload_url: `https://upload.test/tus/${id}?tusv2=true`, expires_at: new Date(Date.now() + 6 * 3600_000).toISOString() }); } + // A video above 1080p: parts go to the service's own storage, then it + // makes the playable copy. Refused at or below 1080p, and when full. + if (u.pathname === "/media/originals" && method === "POST") { + const body = JSON.parse(init.body); + if (Math.min(body.width, body.height) <= 1080) return json(422, { error: "invalid_request", detail: "an original is kept only for a video above 1080p" }); + if (world.originalsFull) return json(409, { error: "originals_full", detail: "this workspace keeps 200 of 200 GB of originals" }); + const id = `eu-00000000-0000-4000-8000-${(++uidN).toString(16).padStart(12, "0")}`; + (world.originals ??= new Map()).set(id, { name: body.name, size: body.size, asked: [] }); + return json(201, { id, upload_id: `up-${id}`, part_size: 64 * 2 ** 20, parts: Math.ceil(body.size / (64 * 2 ** 20)) }); + } + const orig = /^\/media\/originals\/((?:eu|us)-[0-9a-f-]{36})(?:\/(parts|complete))?$/.exec(u.pathname); + if (orig) { + const o = world.originals?.get(orig[1]); + if (!o) return json(404, { error: "not_found" }); + if (method === "DELETE") { o.abandoned = u.searchParams.get("upload_id"); return new Response(null, { status: 204 }); } + const body = JSON.parse(init.body); + if (body.upload_id !== `up-${orig[1]}`) return json(422, { error: "upload_incomplete", detail: "no such upload" }); + if (orig[2] === "parts") { + o.asked.push(...body.part_numbers); + return json(200, { urls: Object.fromEntries(body.part_numbers.map((n) => [n, `https://r2.test/${orig[1]}?partNumber=${n}`])) }); + } + const n = Math.ceil(o.size / (64 * 2 ** 20)); + if (body.parts.length !== n) return json(422, { error: "upload_incomplete", detail: "parts are missing" }); + const id = (++uidN).toString(16).padStart(32, "0"); + world.media.set(id, { polls: 0, name: body.name, original: orig[1] }); + return json(201, { id, state: "downloading", ready: false, original: true }); + } if (u.pathname === "/media/import") { if (world.importRefusal) return json(409, world.importRefusal); const id = (++uidN).toString(16).padStart(32, "0"); @@ -1799,3 +1826,35 @@ console.log("ALL OK"); assert.equal((await call('POST','/api/highlights/projects',{ids,request_id:crypto.randomUUID()})).status,409); console.log('15 ok: shared catalog migration, range deduplication, two-format projects started on request, retry identity and source lifetime'); } + +{ + // A video above 1080p uploads as its original: parts, then it joins the library. + const GB = 2 ** 30; + let r = await call("POST", "/api/assets/originals", { name: "A7_4K.MP4", size: 3 * GB, width: 1920, height: 1080 }); + assert.equal(r.status, 422, "1080p footage has no original"); + world.originalsFull = true; + r = await call("POST", "/api/assets/originals", { name: "A7_4K.MP4", size: 3 * GB, width: 3840, height: 2160 }); + assert.equal(r.status, 409, "no room: the browser uploads it the usual way"); + world.originalsFull = false; + r = await call("POST", "/api/assets/originals", { name: "A7_4K.MP4", size: 3 * GB, width: 3840, height: 2160 }); + assert.equal(r.status, 201); + const { id, upload_id, parts } = r.data; + assert.equal(parts, 48); + r = await call("POST", `/api/assets/originals/${id}/parts`, { upload_id, part_numbers: [1, 2] }); + assert.deepEqual(Object.keys(r.data.urls), ["1", "2"]); + assert.equal((await call("POST", "/api/assets/originals/..%2Fx/parts", { upload_id, part_numbers: [1] })).status, 400); + const done = Array.from({ length: parts }, (_, i) => ({ part_number: i + 1, etag: `e${i + 1}` })); + r = await call("POST", `/api/assets/originals/${id}/complete`, { upload_id, parts: done.slice(1), name: "A7_4K.MP4", size: 3 * GB }); + assert.equal(r.status, 422, "a missing part is the service's refusal, passed on"); + r = await call("POST", `/api/assets/originals/${id}/complete`, { upload_id, parts: done, name: "A7_4K.MP4", type: "video/quicktime", size: 3 * GB, duration: 61.5 }); + assert.equal(r.status, 201); + assert.equal(r.data.name, "A7_4K.MP4"); + assert.equal(world.media.get(r.data.media_uid).original, id, "the library's video is the one made from the original"); + assert.deepEqual([r.data.content_type, r.data.size, r.data.duration], ["video/quicktime", 3 * GB, 61.5]); + assert.equal(db.prepare("SELECT COUNT(*) n FROM media_uploads WHERE uid = ?").get(r.data.media_uid).n, 0, "nothing left waiting"); + // Giving up an upload tells the service, so its parts are not kept. + r = await call("POST", "/api/assets/originals", { name: "B_4K.MP4", size: GB, width: 2160, height: 3840 }); + await call("DELETE", `/api/assets/originals/${r.data.id}?upload_id=${r.data.upload_id}`); + assert.equal(world.originals.get(r.data.id).abandoned, r.data.upload_id); + console.log("17 ok: a video above 1080p uploads as its original and joins the library"); +}