/** * Standalone blob → server upload for clip recordings. Shared * between the SDK-driven recorder (VideoClipRecorder, for both * video + audio kinds) and the customer-driven upload path * (ProctoringClient.uploadVideoClip). Same HTTP shape, same * headers, same retry semantics -- extracted so both paths stay * in lockstep without duplicate logic. */ export interface UploadClipArgs { /** * Discriminator: routes to /video-clips/sessions/... or * /audio-clips/sessions/... and picks the default MIME type * when the blob doesn't carry one. */ kind?: "video" | "audio"; sessionId: string; ingestUrl: string; appId: string | undefined; /** * Per-device fingerprint stamped by the SDK; sent as * `x-fingerprint-id` so the server can attribute the clip * to the device that recorded it. Optional for callers * built before phase 7. */ fingerprintId?: string; clipNumber: number; blob: Blob; durationMs: number; } import type { ClipDropCode } from "./clip-error-codes.js"; import { RequestDeadlineError, mediaRequestTimeoutMs, withRequestDeadline, } from "../internal/request-deadline.js"; export type UploadClipResult = | { kind: "uploaded"; clipNumber: number; byteSize: number; durationMs: number } | { kind: "dropped"; clipNumber: number; /** Stable code for the drop. */ code: ClipDropCode; /** Human-readable detail (the network error message, or the code). */ reason: string; }; export async function uploadClip(args: UploadClipArgs): Promise { const baseUrl = stripTrailingPath(args.ingestUrl); const kind = args.kind ?? "video"; const pathSegment = kind === "audio" ? "audio-clips" : "video-clips"; const defaultMime = kind === "audio" ? "audio/webm" : "video/webm"; const url = `${baseUrl}/${pathSegment}/sessions/${encodeURIComponent( args.sessionId, )}/clips/${args.clipNumber}`; const headers: Record = { "content-type": args.blob.type || defaultMime, "x-clip-duration-ms": String(Math.max(0, Math.floor(args.durationMs))), }; if (args.appId) headers["x-app-id"] = args.appId; if (args.fingerprintId) headers["x-fingerprint-id"] = args.fingerprintId; let lastError: unknown; for (let attempt = 1; attempt <= 2; attempt += 1) { try { const response = await withRequestDeadline( `${kind}-clip-upload`, mediaRequestTimeoutMs(args.blob.size), (signal) => fetch(url, { method: "POST", headers, body: args.blob, credentials: "omit", signal, }), ); if (response.ok) { return { kind: "uploaded", clipNumber: args.clipNumber, byteSize: args.blob.size, durationMs: args.durationMs, }; } const retryable = response.status === 408 || response.status === 429 || response.status >= 500; if (retryable && attempt === 1) continue; const code: ClipDropCode = retryable ? "server-error" : "rejected"; return { kind: "dropped", clipNumber: args.clipNumber, code, reason: code, }; } catch (error) { lastError = error; if (attempt === 1) continue; } } const timedOut = lastError instanceof RequestDeadlineError; return { kind: "dropped", clipNumber: args.clipNumber, code: timedOut ? "timeout" : "network-error", reason: lastError instanceof Error ? lastError.message : timedOut ? "timeout" : "network-error", }; } /** * Backwards-compatible alias retained for any external callers * that imported `uploadVideoClip` directly. New code should use * `uploadClip` and pass `kind` explicitly. * * @deprecated use `uploadClip` with `kind: "video"`. */ export const uploadVideoClip = (args: Omit): Promise => uploadClip({ ...args, kind: "video" }); function stripTrailingPath(url: string): string { try { const u = new URL(url); return `${u.protocol}//${u.host}`; } catch { return url.replace(/\/[^/]*$/, ""); } }