/** * FlowMusic (useapi.net) transport — Google Lyria 3 Pro vocal-song generation. * * Pure transport: submit → poll → download. Mirrors providers/dreamina-useapi.ts * (async job, namespaced jobid, signed-URL/auth download) and reuses the SAME * useapi.net bearer token as the dreamina/runway routes (USEAPI_API_TOKEN). * * AUTHORITATIVE SPEC: https://useapi.net/assets/aibot/api-flowmusic-v1.txt * (the rendered docs page returns fabricated fields — do not trust it). Key * facts encoded here: status enum is exactly pending|completed|failed (NO * submitted/processing); `POST /flowmusic/music` with mode:'async' returns a * jobid to poll; each generation yields up to TWO clips (an A/B pair) each with * a signed `audio_url` (m4a) + `wav_url`, plus an encoded `clip` id usable with * the auth'd `GET /flowmusic/music/download?id=&format=mp3` (raw mp3 * bytes). The jobid contains ':' and '@' and MUST be percent-encoded in the * poll/download paths. */ import { withRetry, fetchTransientRetry } from '../with-retry.js'; export type FlowMusicGhostwriter = 'standard' | 'pro'; /** * Minimal `fetch` shape that lets tests inject a mock. Adds `arrayBuffer()` for * the binary mp3 download endpoint (submit/poll only need json()/text()). */ export type FlowMusicFetchLike = ( input: string, init?: { method?: string; headers?: Record; body?: string | Uint8Array }, ) => Promise<{ ok: boolean; status: number; text: () => Promise; json: () => Promise; arrayBuffer: () => Promise; }>; const USEAPI_BASE = 'https://api.useapi.net/v1'; const FLOWMUSIC_BASE = `${USEAPI_BASE}/flowmusic`; /** Prompt length ceiling per the FlowMusic spec. */ export const FLOWMUSIC_MAX_PROMPT_CHARS = 10000; export interface SubmitFlowMusicJobInput { apiToken: string; /** Song description (style, mood, genre, instruments, BPM, vocal direction). Required, 1..10000 chars. */ prompt: string; /** flowmusic.app account email to pin; omitted → useapi auto-selects an account with capacity. */ account?: string; /** Force an instrumental render (prepends an [Instrumental] tag). Only emitted when true. */ instrumental?: boolean; /** User-supplied lyrics ([Verse]/[Chorus]-tagged). Present ⇒ a vocal render of these words. */ lyrics?: string; /** Lyrics-writer version used when the MODEL writes lyrics; ignored by the API when lyrics are supplied. */ ghostwriter?: FlowMusicGhostwriter; /** Optional fetch override (defaults to global fetch). Used by tests + the native backend. */ fetchImpl?: FlowMusicFetchLike; } export interface SubmitFlowMusicJobResult { /** Namespaced jobid, e.g. "job:-user:--bot:flowmusic". Percent-encode in poll. */ jobid: string; status: FlowMusicPollStatus; } /** POST https://api.useapi.net/v1/flowmusic/music — create song endpoint. */ export function flowmusicMusicEndpoint(): string { return `${FLOWMUSIC_BASE}/music`; } /** GET https://api.useapi.net/v1/flowmusic/jobs/{jobid} — poll endpoint (jobid percent-encoded). */ export function flowmusicJobEndpoint(jobid: string): string { // The namespaced jobid contains ':' and '@'; FlowMusic requires it encoded. return `${FLOWMUSIC_BASE}/jobs/${encodeURIComponent(jobid)}`; } /** GET https://api.useapi.net/v1/flowmusic/music/download?id={clip}&format=mp3 — raw mp3 bytes (auth). */ export function flowmusicDownloadEndpoint(clip: string): string { return `${FLOWMUSIC_BASE}/music/download?id=${encodeURIComponent(clip)}&format=mp3`; } /** * Builds the `POST /flowmusic/music` body. Always `mode:'async'` so submit * returns a jobid to poll rather than blocking the CLI for the ~40–150 s sync * render. Optional fields are omitted when unset (never sent null), matching the * dreamina/suno convention. */ export function buildFlowMusicSubmitBody(input: SubmitFlowMusicJobInput): Record { const body: Record = { prompt: input.prompt, mode: 'async', }; if (input.account) body.email = input.account; if (input.instrumental === true) body.instrumental = true; if (input.lyrics && input.lyrics.trim() !== '') body.lyrics = input.lyrics; if (input.ghostwriter) body.ghostwriter = input.ghostwriter; return body; } export async function submitFlowMusicJob( input: SubmitFlowMusicJobInput, ): Promise { const body = buildFlowMusicSubmitBody(input); const fetchImpl = input.fetchImpl ?? (fetch as unknown as FlowMusicFetchLike); // Paid, non-idempotent generation POST (~5 credits) — deliberately NOT // retried: replaying on a transient 5xx could create a duplicate charged job // if the provider accepted the first request before the gateway error. Poll // (GET) is idempotent and keeps its retry below. const response = await fetchImpl(flowmusicMusicEndpoint(), { method: 'POST', headers: { Authorization: `Bearer ${input.apiToken}`, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); if (!response.ok) { const text = await response.text(); throw new Error(`FlowMusic submit failed: ${response.status} ${text}`); } const json = (await response.json()) as { jobid?: string; status?: string }; if (!json.jobid) { throw new Error(`FlowMusic submit returned unexpected shape (no jobid): ${JSON.stringify(json)}`); } return { jobid: json.jobid, status: mapFlowMusicStatus(json.status ?? 'pending') }; } export type FlowMusicPollStatus = 'pending' | 'completed' | 'failed'; export interface FlowMusicClip { /** Encoded clip asset id; used with /music/download and /music/edit. */ clip: string; title: string | null; /** Clip length in seconds, when present on the job record. */ durationS: number | null; /** Signed m4a URL (public, no auth) when present. */ audioUrl: string | null; /** Signed wav URL (public, no auth) when present. */ wavUrl: string | null; /** Auto-generated artwork URL, when present. */ imageUrl: string | null; } export interface PollFlowMusicJobResult { status: FlowMusicPollStatus; /** Up to two clips (an A/B pair) on a completed job. */ clips: FlowMusicClip[]; /** Error summary on failure, e.g. "moderation_reject: ...". */ error: string | null; raw: Record; } /** FlowMusic status is exactly pending|completed|failed (no submitted/processing/created). */ export function mapFlowMusicStatus(raw: string): FlowMusicPollStatus { const s = raw.toLowerCase(); if (s === 'completed') return 'completed'; if (s === 'failed') return 'failed'; return 'pending'; } /** Defensive parse of a single clip object; returns null if it lacks a clip id. */ function parseClip(raw: unknown): FlowMusicClip | null { if (typeof raw !== 'object' || raw === null) return null; const c = raw as Record; const clip = typeof c.clip === 'string' ? c.clip : null; if (!clip) return null; return { clip, title: typeof c.title === 'string' ? c.title : null, durationS: typeof c.duration_s === 'number' ? c.duration_s : null, audioUrl: typeof c.audio_url === 'string' ? c.audio_url : null, wavUrl: typeof c.wav_url === 'string' ? c.wav_url : null, imageUrl: typeof c.image_url === 'string' ? c.image_url : null, }; } /** Parses a GET /jobs/{jobid} (or sync POST) response into videoclaw's poll result. */ export function parseFlowMusicJobResponse(json: unknown): PollFlowMusicJobResult { const record = (json ?? {}) as { status?: string; clips?: unknown; error?: { code?: string; message?: string } | string; }; const status = mapFlowMusicStatus(record.status ?? ''); const clips = Array.isArray(record.clips) ? record.clips.map(parseClip).filter((c): c is FlowMusicClip => c !== null) : []; let error: string | null = null; if (record.error) { if (typeof record.error === 'string') { error = record.error; } else { const code = record.error.code ?? ''; const message = record.error.message ?? ''; error = [code, message].filter(Boolean).join(': ') || null; } } return { status, clips, error, raw: (json ?? {}) as Record }; } export interface PollFlowMusicJobInput { apiToken: string; /** Namespaced jobid from SubmitFlowMusicJobResult. */ jobid: string; fetchImpl?: FlowMusicFetchLike; } export async function pollFlowMusicJob(input: PollFlowMusicJobInput): Promise { const fetchImpl = input.fetchImpl ?? (fetch as unknown as FlowMusicFetchLike); // Retry transient network failures + HTTP 5xx; 4xx/non-ok flow through to the // "FlowMusic poll failed" message unchanged. const response = await withRetry(() => fetchTransientRetry(fetchImpl, flowmusicJobEndpoint(input.jobid), { method: 'GET', headers: { Authorization: `Bearer ${input.apiToken}` }, }), ); if (!response.ok) { const text = await response.text(); throw new Error(`FlowMusic poll failed: ${response.status} ${text}`); } return parseFlowMusicJobResponse(await response.json()); } export interface DownloadFlowMusicClipInput { apiToken: string; /** Encoded clip asset id from a completed job's clips[].clip. */ clip: string; fetchImpl?: FlowMusicFetchLike; } /** * Fetches raw mp3 bytes for a completed clip via the auth'd download endpoint. * Keeps the MusicBackend `.mp3` contract intact (the signed audio_url is m4a). */ export async function downloadFlowMusicClipMp3( input: DownloadFlowMusicClipInput, ): Promise { const fetchImpl = input.fetchImpl ?? (fetch as unknown as FlowMusicFetchLike); const response = await withRetry(() => fetchTransientRetry(fetchImpl, flowmusicDownloadEndpoint(input.clip), { method: 'GET', headers: { Authorization: `Bearer ${input.apiToken}` }, }), ); if (!response.ok) { const text = await response.text(); throw new Error(`FlowMusic download failed: ${response.status} ${text}`); } const buf = await response.arrayBuffer(); return new Uint8Array(buf); }