Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }` |
Expand Down
124 changes: 114 additions & 10 deletions src/client/uploads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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<number, string> };
/** The open upload on the media service, kept so a retry resumes it. */
uid?: string;
uploadUrl?: string;
Expand Down Expand Up @@ -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<number | null> {
return probe(file).then((p) => p.duration);
}

interface Probe {
duration: number | null;
shape: { width: number; height: number } | null;
}

function probe(file: File): Promise<Probe> {
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);
});
}

Expand Down Expand Up @@ -137,20 +159,99 @@ 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;
}
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<string | null> {
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<boolean> {
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<string, string> }).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<boolean> {
let job = find(id)!;
Expand Down Expand Up @@ -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(() => {});
}
}
65 changes: 65 additions & 0 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
mediaState,
mediaTranscript,
openMediaUpload,
openOriginal,
originalPartLinks,
finishOriginal,
abandonOriginal,
prepareMedia,
} from "./media";
import {
Expand Down Expand Up @@ -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<Body>().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<Body>().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<Body>().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 });
Expand Down
53 changes: 53 additions & 0 deletions src/server/media.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OriginalUpload>(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<string, string> } | { failure: MediaFailure }> {
const res = await call<{ urls: Record<string, string> }>(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<MediaState>(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<void> {
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<MediaState>(cfg, `/${uid}`);
return "failure" in res ? res : { media: res.data };
Expand Down
Loading
Loading