/** * public-host.ts — upload a LOCAL file to a temporary public HTTPS host and * return its URL. * * WHY: the hosted post-production backends (the apiz/suitui aggregator, and the * fal.ai models behind it — Topaz upscale, OmniHuman lip-sync) fetch their * inputs by URL and CANNOT read a local path. They also reliably BLOCK * `catbox.moe` (server-side fetch returns a download error). `uguu.se` is the * proven host: a plain multipart POST, fal-reachable, ~128 MiB cap, ~3h TTL — * which is fine for a transient render input. * * The transport is injectable (`fetchImpl`) so callers can unit-test the upload * shape offline without touching the network. */ import { readFile, stat } from 'node:fs/promises'; import { basename } from 'node:path'; export type PublicHostFetchLike = ( url: string, init?: { method?: string; body?: unknown; headers?: Record }, ) => Promise<{ ok: boolean; status: number; text(): Promise }>; /** Default temporary host. Override via `VCLAW_PUBLIC_HOST_ENDPOINT` for self-hosting. */ export const UGUU_ENDPOINT = 'https://uguu.se/upload.php'; /** uguu.se's documented upload cap. Override via maxBytes / VCLAW_PUBLIC_HOST_MAX_BYTES. */ export const DEFAULT_PUBLIC_HOST_MAX_BYTES = 128 * 1024 * 1024; export interface HostPublicUrlOptions { fetchImpl?: PublicHostFetchLike; env?: NodeJS.ProcessEnv; /** Override the multipart field name (uguu uses `files[]`). */ fieldName?: string; /** Override the upload endpoint. */ endpoint?: string; /** * Reject files larger than this BEFORE reading them into memory (default * {@link DEFAULT_PUBLIC_HOST_MAX_BYTES} — uguu's cap; also settable via * VCLAW_PUBLIC_HOST_MAX_BYTES for self-hosted endpoints with other limits). */ maxBytes?: number; } export interface HostPublicUrlResult { /** The public HTTPS URL a hosted backend can fetch. */ url: string; /** Raw parsed upload response (for logging/diagnostics). */ rawResult: unknown; } /** * MIME type for a local file by extension (uguu infers from the filename, but a * correct Content-Type avoids the occasional rejected upload). */ function contentTypeFor(path: string): string { const ext = path.slice(path.lastIndexOf('.')).toLowerCase(); const map: Record = { '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.webm': 'video/webm', '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', }; return map[ext] ?? 'application/octet-stream'; } /** Pull the first URL out of uguu's `{ success, files: [{ url }] }` response. */ function extractUrl(parsed: unknown): string | null { if (parsed && typeof parsed === 'object') { const files = (parsed as { files?: Array<{ url?: unknown }> }).files; if (Array.isArray(files) && files[0] && typeof files[0].url === 'string') { return files[0].url; } const direct = (parsed as { url?: unknown }).url; if (typeof direct === 'string') return direct; } return null; } /** * Upload `localPath` and return a public URL. Throws on a non-2xx response or an * unparseable body. Uses the global `fetch`/`FormData`/`Blob` (Node 20+) unless * an injected `fetchImpl` is provided. */ export async function hostPublicUrl( localPath: string, options: HostPublicUrlOptions = {}, ): Promise { const env = options.env ?? process.env; const endpoint = options.endpoint ?? env.VCLAW_PUBLIC_HOST_ENDPOINT ?? UGUU_ENDPOINT; const fieldName = options.fieldName ?? 'files[]'; const fetchImpl = options.fetchImpl ?? (globalThis.fetch as unknown as PublicHostFetchLike); const envMax = Number(env.VCLAW_PUBLIC_HOST_MAX_BYTES); const maxBytes = options.maxBytes ?? (Number.isFinite(envMax) && envMax > 0 ? envMax : DEFAULT_PUBLIC_HOST_MAX_BYTES); // Guard the host's size cap BEFORE buffering the whole file in memory — a // multi-hundred-MB master would otherwise burn RAM + upload time only to be // rejected (or truncated) server-side. const { size } = await stat(localPath); if (size > maxBytes) { throw new Error( `public-host: ${localPath} is ${(size / 1024 / 1024).toFixed(1)} MiB — over the host's ` + `${Math.round(maxBytes / 1024 / 1024)} MiB cap. Trim/re-encode the input, or set ` + `VCLAW_PUBLIC_HOST_ENDPOINT + VCLAW_PUBLIC_HOST_MAX_BYTES for a roomier host.`, ); } const bytes = await readFile(localPath); const form = new FormData(); const blob = new Blob([bytes], { type: contentTypeFor(localPath) }); form.append(fieldName, blob, basename(localPath)); const response = await fetchImpl(endpoint, { method: 'POST', body: form }); const text = await response.text(); if (!response.ok) { throw new Error(`public-host upload failed (${response.status}): ${text.slice(0, 300)}`); } let parsed: unknown = text; try { parsed = JSON.parse(text); } catch { /* keep raw text for the error below */ } const url = extractUrl(parsed); if (!url) { throw new Error(`public-host upload returned no URL: ${text.slice(0, 300)}`); } return { url, rawResult: parsed }; }