import { existsSync } from 'node:fs'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { resolveProjectWorkspace } from './workspace.js'; // Google Flow v1 Characters & Voices library (useapi.net). Mirrors the // seedance-asset-library pattern: register reusable identities once, persist the // returned refs to a project artifact, and resolve them at execution time into // the veo-useapi transport's `flow.ts --character` flags (Flow v1 character_1..7). // // Live-verified API quirks (the HTML docs are wrong on these): // - POST /google-flow/characters REJECTS `email` in the body (account = token). // - POST /google-flow/voices REQUIRES `email` in the body. // - character_* routes through R2V, so realistic human faces are // moderation-filtered (PUBLIC_ERROR_UNSAFE_GENERATION / INPUT_OTHER); this // path is for stylized / mascot identities. Photoreal humans use Veo-I2V. interface FetchLikeResponse { ok: boolean; status: number; text(): Promise; json(): Promise; } export type FetchLike = (input: string, init?: { method?: string; headers?: Record; body?: string | Uint8Array; }) => Promise; const DEFAULT_BASE_URL = 'https://api.useapi.net/v1'; /** * The 30 Google Flow system base-voice presets accepted by POST /voices `voice` * (case-sensitive). `createVoice` validates against this list so a typo'd preset * fails fast before the network rather than 400ing at the provider. */ export const FLOW_VOICE_PRESETS = [ 'Achernar', 'Achird', 'Algenib', 'Algieba', 'Alnilam', 'Aoede', 'Autonoe', 'Callirrhoe', 'Charon', 'Despina', 'Enceladus', 'Erinome', 'Fenrir', 'Gacrux', 'Iapetus', 'Kore', 'Laomedeia', 'Leda', 'Orus', 'Puck', 'Pulcherrima', 'Rasalgethi', 'Sadachbia', 'Sadaltager', 'Schedar', 'Sulafat', 'Umbriel', 'Vindemiatrix', 'Zephyr', 'Zubenelgenubi', ] as const; export type FlowVoicePreset = typeof FLOW_VOICE_PRESETS[number]; export interface FlowLibraryClientOptions { apiToken: string; accountEmail: string; baseUrl?: string; /** Injectable fetch so tests run offline. Defaults to global fetch. */ fetchImpl?: FetchLike; /** Injectable file reader (returns raw image bytes). Defaults to fs.readFile. */ readFileImpl?: (path: string) => Promise; /** * captcha-retry auto-solve count sent on POST /voices (`captchaRetry`). Omit * (or 0) to send no field; the handler resolves it from VCLAW_FLOW_CAPTCHA_RETRY. */ captchaRetry?: number; } function mimeForPath(path: string): 'image/png' | 'image/jpeg' { const ext = path.toLowerCase().split('.').pop(); if (ext === 'png') return 'image/png'; if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'; throw new Error(`Unsupported image format: .${ext}. Only PNG and JPEG are supported.`); } /** * Thin client for the Google Flow v1 Characters/Voices + asset-upload endpoints. * Pure transport — no fs/project knowledge beyond reading an image to upload. */ export class FlowLibraryClient { private readonly apiToken: string; private readonly accountEmail: string; private readonly baseUrl: string; private readonly fetchImpl: FetchLike; private readonly readFileImpl: (path: string) => Promise; private readonly captchaRetry?: number; constructor(options: FlowLibraryClientOptions) { const apiToken = options.apiToken?.trim(); if (!apiToken) throw new Error('Flow library requires an API token (USEAPI_API_TOKEN).'); if (!options.accountEmail?.trim()) throw new Error('Flow library requires an account email (USEAPI_ACCOUNT_EMAIL).'); this.apiToken = apiToken; this.accountEmail = options.accountEmail.trim(); this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/$/, ''); this.fetchImpl = options.fetchImpl ?? ((globalThis as { fetch?: FetchLike }).fetch as FetchLike); this.readFileImpl = options.readFileImpl ?? (async (p) => Buffer.from(await readFile(p))); this.captchaRetry = options.captchaRetry; if (typeof this.fetchImpl !== 'function') { throw new Error('Flow library requires a fetch implementation (none injected and global fetch is unavailable).'); } } private authHeaders(extra: Record = {}): Record { return { Authorization: `Bearer ${this.apiToken}`, ...extra }; } private async parse(res: FetchLikeResponse, label: string): Promise { const text = await res.text(); let json: unknown = null; try { json = JSON.parse(text); } catch { /* non-JSON body */ } if (!res.ok) { const err = (json as { error?: unknown })?.error; const msg = typeof err === 'string' ? err : err ? JSON.stringify(err) : text.slice(0, 300); throw new Error(`Flow ${label} failed (HTTP ${res.status}): ${msg}`); } return json as T; } /** Upload a local PNG/JPEG → returns its mediaGenerationId (for imageReference_*). */ async uploadImage(imagePath: string): Promise { let body: Buffer; try { body = await this.readFileImpl(imagePath); // injectable; real fs throws ENOENT for missing files } catch (err) { throw new Error(`Image file not found or unreadable: ${imagePath} (${err instanceof Error ? err.message : String(err)})`); } const url = `${this.baseUrl}/google-flow/assets/${encodeURIComponent(this.accountEmail)}`; const res = await this.fetchImpl(url, { method: 'POST', headers: this.authHeaders({ 'Content-Type': mimeForPath(imagePath) }), body, }); const json = await this.parse<{ mediaGenerationId?: string | { mediaGenerationId?: string } }>(res, 'asset upload'); const raw = json.mediaGenerationId; const mediaId = typeof raw === 'object' ? raw?.mediaGenerationId : raw; if (!mediaId) throw new Error(`Flow asset upload returned no mediaGenerationId for ${imagePath}.`); return mediaId; } /** * POST /google-flow/characters — NO email in body (account from token). * Returns the saved character with its `character` ref string. */ async createCharacter(params: { displayName: string; imageReference_1: string; imageReference_2?: string; voice?: string; personalityNotes?: string; }): Promise<{ entityId: string; character: string; voice?: string }> { const url = `${this.baseUrl}/google-flow/characters`; const res = await this.fetchImpl(url, { method: 'POST', headers: this.authHeaders({ 'Content-Type': 'application/json', Accept: 'application/json' }), body: JSON.stringify(params), }); return this.parse(res, 'create character'); } /** * POST /google-flow/voices — REQUIRES email in body. Returns the saved voice * with its `voice` ref string (usable as referenceAudio or a character voice). */ async createVoice(params: { voice: string; displayName: string; dialog: string; voicePerformance: string; }): Promise<{ voice: string; baseVoice?: string; displayName?: string }> { if (!(FLOW_VOICE_PRESETS as readonly string[]).includes(params.voice)) { throw new Error( `Flow voice base preset must be one of the ${FLOW_VOICE_PRESETS.length} system voices ` + `(e.g. Charon, Puck, Kore, Zephyr), got: ${params.voice}.`, ); } const url = `${this.baseUrl}/google-flow/voices`; const res = await this.fetchImpl(url, { method: 'POST', headers: this.authHeaders({ 'Content-Type': 'application/json', Accept: 'application/json' }), body: JSON.stringify({ email: this.accountEmail, ...params, ...(this.captchaRetry && this.captchaRetry > 0 ? { captchaRetry: this.captchaRetry } : {}), }), }); return this.parse(res, 'create voice'); } } // ── Artifacts ─────────────────────────────────────────────────────────────── /** One registered character in `flow-characters.json`. */ export interface FlowCharacterEntry { name: string; entityId: string; characterRef: string; // value used as character_1..7 voice?: string; } export interface FlowCharactersArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; characters: FlowCharacterEntry[]; } /** One registered custom voice in `flow-voices.json`. */ export interface FlowVoiceEntry { name: string; voiceRef: string; // value used as referenceAudio / character voice baseVoice?: string; } export interface FlowVoicesArtifact { schemaVersion: 1; projectSlug: string; generatedAt: string; voices: FlowVoiceEntry[]; } export interface FlowCharactersLookup { characters: FlowCharacterEntry[]; characterRefByName: Map; } /** * Read `artifacts/flow-characters.json`. Returns an empty lookup when absent so * the execution layer degrades gracefully (no `--character` flags) rather than * failing. A present-but-malformed file is NOT swallowed (JSON.parse surfaces it). */ export async function readFlowCharacters( workspaceRoot: string, slug: string, ): Promise { const workspace = resolveProjectWorkspace(slug, workspaceRoot); const path = join(workspace.artifactsDir, 'flow-characters.json'); if (!existsSync(path)) { return { characters: [], characterRefByName: new Map() }; } const parsed = JSON.parse(await readFile(path, 'utf-8')) as Partial; const characters: FlowCharacterEntry[] = Array.isArray(parsed.characters) ? parsed.characters .filter((c): c is FlowCharacterEntry => !!c && !!c.name && !!c.characterRef) .map((c) => ({ name: c.name, entityId: c.entityId, characterRef: c.characterRef, voice: c.voice })) : []; const characterRefByName = new Map(); for (const c of characters) characterRefByName.set(c.name, c.characterRef); return { characters, characterRefByName }; } /** Read `artifacts/flow-voices.json` → name -> voice ref (empty when absent). */ export async function readFlowVoices( workspaceRoot: string, slug: string, ): Promise> { const workspace = resolveProjectWorkspace(slug, workspaceRoot); const path = join(workspace.artifactsDir, 'flow-voices.json'); if (!existsSync(path)) return new Map(); const parsed = JSON.parse(await readFile(path, 'utf-8')) as Partial; const map = new Map(); if (Array.isArray(parsed.voices)) { for (const v of parsed.voices) { if (v?.name && v?.voiceRef) map.set(v.name, v.voiceRef); } } return map; } // ── Registration ────────────────────────────────────────────────────────────── export interface FlowCharacterInput { /** Project-facing character name (matches a storyboard scene's `characters`). */ name: string; /** 1-2 local image paths to upload, OR already-uploaded mediaGenerationIds. */ images: string[]; /** Optional voice: a system preset (e.g. "Charon") or a user voice ref. */ voice?: string; /** Optional personality notes (≤2000 chars). */ personalityNotes?: string; /** Optional display name override (defaults to `name`). */ displayName?: string; } /** True if a string is already an uploaded media ref rather than a local path. */ function isMediaRef(s: string): boolean { return s.startsWith('user:') || s.includes('-image:') || s.includes('mediaGenerationId'); } /** * Register characters with Google Flow and write `flow-characters.json`. Each * input's images are uploaded (local paths) or used directly (existing refs), * then bundled into a saved character. `generatedAt` is injected (callers stamp * it) so this stays deterministic/testable. */ export async function registerFlowCharacters(opts: { workspaceRoot: string; slug: string; characters: FlowCharacterInput[]; client: FlowLibraryClient; generatedAt: string; }): Promise { const entries: FlowCharacterEntry[] = []; for (const input of opts.characters) { if (!input.images || input.images.length === 0) { throw new Error(`Character "${input.name}" needs at least one image.`); } const mediaIds: string[] = []; for (const img of input.images.slice(0, 2)) { mediaIds.push(isMediaRef(img) ? img : await opts.client.uploadImage(img)); } const created = await opts.client.createCharacter({ displayName: input.displayName ?? input.name, imageReference_1: mediaIds[0], imageReference_2: mediaIds[1], voice: input.voice, personalityNotes: input.personalityNotes, }); entries.push({ name: input.name, entityId: created.entityId, characterRef: created.character, voice: created.voice ?? input.voice, }); } const artifact: FlowCharactersArtifact = { schemaVersion: 1, projectSlug: opts.slug, generatedAt: opts.generatedAt, characters: entries, }; const workspace = resolveProjectWorkspace(opts.slug, opts.workspaceRoot); await mkdir(workspace.artifactsDir, { recursive: true }); await writeFile( join(workspace.artifactsDir, 'flow-characters.json'), `${JSON.stringify(artifact, null, 2)}\n`, 'utf-8', ); return artifact; } export interface FlowVoiceInput { name: string; basePreset: string; // a FLOW_VOICE_PRESETS value dialog: string; voicePerformance: string; } /** Register custom voices with Google Flow and write `flow-voices.json`. */ export async function registerFlowVoices(opts: { workspaceRoot: string; slug: string; voices: FlowVoiceInput[]; client: FlowLibraryClient; generatedAt: string; }): Promise { const entries: FlowVoiceEntry[] = []; for (const input of opts.voices) { const created = await opts.client.createVoice({ voice: input.basePreset, displayName: input.name, dialog: input.dialog, voicePerformance: input.voicePerformance, }); entries.push({ name: input.name, voiceRef: created.voice, baseVoice: created.baseVoice ?? input.basePreset }); } const artifact: FlowVoicesArtifact = { schemaVersion: 1, projectSlug: opts.slug, generatedAt: opts.generatedAt, voices: entries, }; const workspace = resolveProjectWorkspace(opts.slug, opts.workspaceRoot); await mkdir(workspace.artifactsDir, { recursive: true }); await writeFile( join(workspace.artifactsDir, 'flow-voices.json'), `${JSON.stringify(artifact, null, 2)}\n`, 'utf-8', ); return artifact; }