Skip to content
Open

[wip] #1462

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
90 changes: 90 additions & 0 deletions packages/core/src/file-upload/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,96 @@ async function uploadSmallFile(url: URL, init: RequestInit): Promise<Response> {
);
}

export interface BuilderResumableSession {
resumableSessionUri: string;
assetId: string;
}

/**
* Server-side: initiate a GCS resumable upload session via Builder.io.
* The browser can then PUT chunks directly to `resumableSessionUri` using
* `Content-Range` headers — no app-server hop per chunk.
*
* The session URI carries its own GCS auth; no Authorization header is needed
* when the browser PUTs chunks to it.
*
* GCS resumable upload rules:
* - Non-final chunks: PUT with `Content-Range: bytes start-end/*`
* (size multiple of 256 KB recommended, minimum 256 KB)
* - Final chunk: PUT with `Content-Range: bytes start-end/totalSize`
* - GCS returns 308 Resume Incomplete for non-final, 200/201 for final
*/
export async function requestBuilderResumableSession(
filename: string,
mimeType: string,
size: number,
): Promise<BuilderResumableSession> {
const { resolveBuilderPrivateKey } = await import(
"../server/credential-provider.js"
);
const privateKey = await resolveBuilderPrivateKey();
if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");

const host = builderUploadHost();
const url = new URL("/api/v1/upload/signed-url", host);
url.searchParams.set("isResumable", "true");

console.log(`[builder-upload] resumable session: ${filename} ${mimeType} ${size} bytes`);
const res = await fetchWithTimeout(url.toString(), {
method: "POST",
headers: {
Authorization: `Bearer ${privateKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ fileName: filename, contentType: mimeType, size }),
});
await assertOk(res, "Builder.io resumable session request failed");

const json = (await res.json()) as { uploadUrl?: string; assetId?: string };
if (!json.uploadUrl || !json.assetId) {
throw new Error(
`Builder.io resumable session response missing fields: ${JSON.stringify(Object.keys(json))}`,
);
}
console.log(`[builder-upload] resumable session ok: assetId=${json.assetId}`);
return { resumableSessionUri: json.uploadUrl, assetId: json.assetId };
}

/**
* Server-side: register a completed GCS resumable upload with Builder.io and
* return the CDN URL. Call this after all browser chunks are PUT to GCS.
*/
export async function completeBuilderResumableUpload(
assetId: string,
filename: string,
): Promise<string> {
const { resolveBuilderPrivateKey } = await import(
"../server/credential-provider.js"
);
const privateKey = await resolveBuilderPrivateKey();
if (!privateKey) throw new Error("BUILDER_PRIVATE_KEY is not set");

const host = builderUploadHost();
console.log(`[builder-upload] completing resumable upload: assetId=${assetId}`);
const res = await fetchWithTimeout(
new URL("/api/v1/upload/complete", host).toString(),
{
method: "POST",
headers: {
Authorization: `Bearer ${privateKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ assetId, name: filename }),
},
);
await assertOk(res, "Builder.io resumable upload complete failed");

const { url } = (await res.json()) as { url?: string };
if (!url) throw new Error("Builder.io upload/complete returned no URL");
console.log(`[builder-upload] resumable upload complete: ${url}`);
return url;
}

/**
* Built-in Builder.io file upload provider.
* Uses the same BUILDER_PRIVATE_KEY as the browser/background-agent flows,
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/file-upload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ export {
getActiveFileUploadProvider,
uploadFile,
} from "./registry.js";
export { builderFileUploadProvider } from "./builder.js";
export {
builderFileUploadProvider,
requestBuilderResumableSession,
completeBuilderResumableUpload,
type BuilderResumableSession,
} from "./builder.js";
export {
preUploadImageAttachments,
preUploadAttachments,
Expand Down
6 changes: 6 additions & 0 deletions templates/clips/actions/create-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ export default defineAction({

console.log(`Created recording "${title}" (${id})`);

// Signal that the browser may use the GCS resumable upload path instead of
// the chunk-through-app-server path. Only available when Builder.io is the
// configured storage provider (it's the source of the resumable session URIs).
const supportsResumableUpload = !!process.env.BUILDER_PRIVATE_KEY;

return {
id,
organizationId,
Expand All @@ -83,6 +88,7 @@ export default defineAction({
abortUrl: `/api/uploads/${id}/abort`,
// Frontend substitutes {index}/{total}/{isFinal}
uploadChunkUrlTemplate: `/api/uploads/${id}/chunk?index={index}&total={total}&isFinal={isFinal}`,
supportsResumableUpload,
};
},
});
88 changes: 88 additions & 0 deletions templates/clips/actions/finalize-recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@ export default defineAction({
.string()
.optional()
.describe("MIME type of the assembled blob (e.g. video/webm)"),
videoUrl: z
.string()
.optional()
.describe(
"Pre-uploaded video URL (GCS direct upload path). When provided, chunk assembly and server-side upload are skipped.",
),
videoSizeBytes: z
.number()
.optional()
.describe("Byte size of the uploaded video file (required with videoUrl)"),
}),
run: async (args) => {
const db = getDb();
Expand Down Expand Up @@ -197,6 +207,84 @@ export default defineAction({
? args.hasCamera
: (stateBoolean(uploadState, "hasCamera") ?? existing.hasCamera);

// Direct upload bypass: the video was already uploaded to GCS by the
// browser via a resumable session. Skip chunk assembly and uploadFile().
if (args.videoUrl) {
const videoUrl = args.videoUrl;
const videoSizeBytes = args.videoSizeBytes ?? 0;
const now = new Date().toISOString();
debugLog("[finalize] direct upload bypass", { id, videoUrl });

await db
.update(schema.recordings)
.set({
status: "ready",
videoUrl,
videoFormat,
videoSizeBytes,
durationMs: finalDurationMs,
width: finalWidth,
height: finalHeight,
hasAudio: finalHasAudio,
hasCamera: finalHasCamera,
failureReason: null,
uploadProgress: 100,
updatedAt: now,
})
.where(eq(schema.recordings.id, id));

const [existingTranscript] = await db
.select({ recordingId: schema.recordingTranscripts.recordingId })
.from(schema.recordingTranscripts)
.where(eq(schema.recordingTranscripts.recordingId, id));
if (!existingTranscript) {
await db.insert(schema.recordingTranscripts).values({
recordingId: id,
ownerEmail,
status: "pending",
createdAt: now,
updatedAt: now,
});
}

await writeAppState(`recording-upload-${id}`, {
recordingId: id,
status: "ready",
progress: 100,
videoUrl,
finishedAt: now,
});
await writeAppState("refresh-signal", { ts: Date.now() });

void Promise.resolve(
requestTranscript.run({ recordingId: id, force: true }),
).catch((err: unknown) => {
console.error("[finalize] background transcript failed", {
id,
error: err instanceof Error ? err.message : String(err),
});
});

try {
emit(
"clip.created",
{
clipId: id,
title: existing.title,
createdBy: ownerEmail,
duration: finalDurationMs,
url: videoUrl,
},
{ owner: ownerEmail },
);
} catch (err) {
console.warn("[finalize] clip.created emit failed:", err);
}

debugLog("[finalize] direct upload done", { id, videoUrl });
return { id, status: "ready" as const, videoUrl, videoSizeBytes, durationMs: finalDurationMs };
}

// The recorder stashes compression metadata at
// `recording-compression-{id}` when its browser-side ffmpeg.wasm
// pass ran to bring the assembled blob under Builder.io's 100 MB
Expand Down
Loading
Loading