/** * Google Flow inline @-mention markers (useapi.net Google Flow API v1, * blog 260609). Markers anchor a reference to a position in the prompt text: * * POST /videos: @character_1..7, @referenceImage_1..7, @referenceAudio_1..5 * POST /images: @character_1..7, @reference_1..10 * * Case-INSENSITIVE and opt-in — a slot without a marker is always fine, but a * marker without a matching body slot makes the API 400. There is NO * `@referenceVideo_1` marker — V2V stays flag-only (`--ref-video`). * * Pure module: no I/O, no env, no provider calls. */ export type FlowMarkerFamily = 'character' | 'referenceImage' | 'referenceAudio' | 'reference'; export interface FlowMarker { family: FlowMarkerFamily; /** 1-based slot index as written (out-of-range values are reported by the validators, not here). */ index: number; /** The token exactly as matched in the prompt, e.g. '@Character_2'. */ raw: string; } // Ordering: keep the longer alternatives (referenceImage, referenceAudio) // before 'reference' so correctness does not depend on backtracking semantics // and the intent stays obvious. (JavaScript's backtracking engine would parse // '@referenceImage_1' correctly either way — after 'reference' fails on the // '_' it retries the longer alternative; only a non-backtracking engine like // RE2 would mis-parse.) The dedicated mis-parse test in // src/tests/flow-markers.test.ts pins the behavior. const FLOW_MARKER_RE = /@(referenceImage|referenceAudio|reference|character)_(\d+)\b/gi; const CANONICAL_FAMILY: Record = { character: 'character', referenceimage: 'referenceImage', referenceaudio: 'referenceAudio', reference: 'reference', }; /** Per-family maximum slot index on POST /videos and POST /images. */ const FAMILY_MAX: Record = { character: 7, referenceImage: 7, referenceAudio: 5, reference: 10, }; /** * Extract every Flow @-marker occurrence in order, duplicates included. * Family is normalized to canonical camelCase regardless of input casing; * `raw` keeps the token exactly as written. */ export function extractFlowMarkers(prompt: string): FlowMarker[] { const markers: FlowMarker[] = []; for (const match of prompt.matchAll(FLOW_MARKER_RE)) { const family = CANONICAL_FAMILY[match[1].toLowerCase()]; markers.push({ family, index: Number.parseInt(match[2], 10), raw: match[0] }); } return markers; } export interface FlowVideoMarkerSlots { characterCount: number; referenceImageCount: number; referenceAudioCount: number; } export interface FlowImageMarkerSlots { characterCount: number; referenceCount: number; } /** Duplicate markers for one index are fine — report each distinct problem once. */ function dedupe(errors: string[]): string[] { return [...new Set(errors)]; } function rangeError(marker: FlowMarker, family: FlowMarkerFamily): string { return `${marker.raw}: index out of range — @${family}_N must satisfy 1 <= N <= ${FAMILY_MAX[family]}.`; } function slotError(marker: FlowMarker, family: FlowMarkerFamily, provided: number): string { return `${marker.raw}: no matching ${family} slot — only ${provided} provided, so @${family}_${marker.index} would make the API 400.`; } /** * Validate Flow @-markers in a VIDEO prompt (POST /videos grammar) against the * body slots the caller intends to send. Returns human-readable error strings * (empty = valid). Slots WITHOUT markers are never an error (markers are * opt-in); duplicate markers for one index are fine. */ export function validateFlowVideoMarkers(prompt: string, slots: FlowVideoMarkerSlots): string[] { const errors: string[] = []; for (const marker of extractFlowMarkers(prompt)) { switch (marker.family) { case 'character': if (marker.index < 1 || marker.index > FAMILY_MAX.character) { errors.push(rangeError(marker, 'character')); } else if (marker.index > slots.characterCount) { errors.push(slotError(marker, 'character', slots.characterCount)); } break; case 'referenceImage': if (marker.index < 1 || marker.index > FAMILY_MAX.referenceImage) { errors.push(rangeError(marker, 'referenceImage')); } else if (marker.index > slots.referenceImageCount) { errors.push(slotError(marker, 'referenceImage', slots.referenceImageCount)); } break; case 'referenceAudio': if (marker.index < 1 || marker.index > FAMILY_MAX.referenceAudio) { errors.push(rangeError(marker, 'referenceAudio')); } else if (marker.index > slots.referenceAudioCount) { errors.push(slotError(marker, 'referenceAudio', slots.referenceAudioCount)); } break; case 'reference': errors.push( `${marker.raw}: @reference_N is an image-endpoint (POST /images) marker — in a video prompt use @referenceImage_${marker.index}.`, ); break; } } return dedupe(errors); } /** * Validate Flow @-markers in an IMAGE prompt (POST /images grammar) against the * body slots the caller intends to send. Same contract as the video validator. * (The model x ref-count matrix intentionally lives with the callers, not here.) */ export function validateFlowImageMarkers(prompt: string, slots: FlowImageMarkerSlots): string[] { const errors: string[] = []; for (const marker of extractFlowMarkers(prompt)) { switch (marker.family) { case 'character': if (marker.index < 1 || marker.index > FAMILY_MAX.character) { errors.push(rangeError(marker, 'character')); } else if (marker.index > slots.characterCount) { errors.push(slotError(marker, 'character', slots.characterCount)); } break; case 'reference': if (marker.index < 1 || marker.index > FAMILY_MAX.reference) { errors.push(rangeError(marker, 'reference')); } else if (marker.index > slots.referenceCount) { errors.push(slotError(marker, 'reference', slots.referenceCount)); } break; case 'referenceImage': errors.push( `${marker.raw}: @referenceImage_N is a video-only marker — in an image prompt use @reference_${marker.index}.`, ); break; case 'referenceAudio': errors.push( `${marker.raw}: @referenceAudio_N is a video-only marker — image prompts have no audio reference slots.`, ); break; } } return dedupe(errors); } // ── Auto-injection: @Name tags → @character_N markers (veo-useapi) ─────────── /** * Same @-tag token pattern resolveAssetTags scans (prompt-rules.ts * ASSET_TAG_RE): starts with a letter, then letters/digits/underscore/hyphen. * Duplicated here (not imported) so this module stays dependency-free. */ const NAME_TAG_RE = /@([A-Za-z][\w-]*)/g; /** * Tag names that are NOT character names: the Flow marker grammar itself * (hand-authored `@character_2` etc. must pass through untouched) and the * seedance `@imageN` positional-binding contract. */ const RESERVED_TAG_NAME_RE = /^(?:(?:character|referenceimage|referenceaudio|reference)_\d+|image\d+)$/i; /** POST /videos accepts at most 7 character body slots (`character_1..7`). */ const FLOW_CHARACTER_SLOT_MAX = 7; export interface FlowCharacterSlotPlan { /** Lowercased character name → 1-based slot index into `orderedRefs`. */ slotByLowerName: Map; /** The final characterRefs array — slot N is `orderedRefs[N-1]`. */ orderedRefs: string[]; /** Names (original casing, first occurrence) aligned with `orderedRefs`. */ orderedNames: string[]; /** Registered names beyond the 7-slot cap, in the order they were dropped. */ overflow: string[]; } /** * Plan the `character_1..7` body slots for one scene. The final array is the * ordered union of: * 1. `unique(sceneCharacters)` filtered/mapped via * `characterRefByName.get(name)` — EXACT-case lookup, verbatim the legacy * cast semantics, so a tagless prompt yields exactly today's * characterRefs list (byte-identical payload). A cast name that only * case-mismatches the registry ('clawbot' vs registered 'Clawbot') stays * unresolved, exactly as before this feature. * 2. registered names appearing as `@Name` tags in `rawPrompt` (tag scan * order) that are not already included. TAG matching is case-INSENSITIVE * — tags are single tokens and `@clawbot` must resolve to a registered * 'Clawbot' (the plan-mandated case-insensitivity applies to tags only). * Capped at 7; registered names beyond the cap land in `overflow`. * `orderedNames` keeps the casing of the first occurrence. Unregistered names * are never slot candidates — they keep the descriptor-substitution path in * resolveAssetTags. * * `slotByLowerName` is keyed by the lowercased FINAL ordered names: if two * case-only-distinct cast names both carry refs (e.g. 'Bob' and 'BOB' both * registered), both keep their slots in `orderedRefs`, but the lowercased * injection key first-wins — a prompt tag cannot disambiguate * case-only-distinct names (pathological; exact-case cast behavior is * preserved either way). */ export function planFlowCharacterSlots( rawPrompt: string, sceneCharacters: string[], characterRefByName: Map, ): FlowCharacterSlotPlan { const slotByLowerName = new Map(); const orderedRefs: string[] = []; const orderedNames: string[] = []; const overflow: string[] = []; // Lowercased names that already went through slot planning (slotted OR // overflowed) — consulted only by the TAG phase, so a tag never re-adds a // character the cast already placed (regardless of casing). const plannedLower = new Set(); const addPlanned = (name: string, ref: string): void => { plannedLower.add(name.toLowerCase()); if (orderedRefs.length >= FLOW_CHARACTER_SLOT_MAX) { overflow.push(name); return; } orderedRefs.push(ref); orderedNames.push(name); const lower = name.toLowerCase(); if (!slotByLowerName.has(lower)) slotByLowerName.set(lower, orderedRefs.length); }; // CAST phase — EXACT matching, the legacy semantics verbatim: // unique(sceneCharacters) filtered/mapped via characterRefByName.get(name). // No trim, no case folding. const seenCast = new Set(); for (const name of sceneCharacters) { if (seenCast.has(name)) continue; seenCast.add(name); const ref = characterRefByName.get(name); if (ref === undefined) continue; // unregistered (exact) → not a slot candidate addPlanned(name, ref); } // TAG phase — case-insensitive registry lookup for @Name tokens. const refByLowerName = new Map(); for (const [name, ref] of characterRefByName) { const lower = name.trim().toLowerCase(); if (lower && !refByLowerName.has(lower)) refByLowerName.set(lower, ref); } for (const match of rawPrompt.matchAll(NAME_TAG_RE)) { if (RESERVED_TAG_NAME_RE.test(match[1])) continue; const tagName = match[1]; const lower = tagName.toLowerCase(); if (plannedLower.has(lower)) continue; const ref = refByLowerName.get(lower); if (ref === undefined) continue; // unregistered → not a slot candidate addPlanned(tagName, ref); } return { slotByLowerName, orderedRefs, orderedNames, overflow }; } /** * Replace each `@Name` tag whose lowercased name is in `slotByLowerName` with * the canonical lowercase `@character_N` marker. Duplicate mentions of one name * all resolve to the SAME marker (one slot — the API dedup rule). Every other * tag — unregistered names, `@imageN`, hand-authored Flow markers like * `@character_2` — is untouched. `injected` lists `Name->@character_N` strings * in order of first replacement. Tagless text comes back byte-identical. */ export function injectFlowCharacterMarkers( text: string, slotByLowerName: Map, ): { text: string; injected: string[] } { const injected: string[] = []; if (slotByLowerName.size === 0) { return { text, injected }; } const seenFirst = new Set(); const out = text.replace(NAME_TAG_RE, (match, name: string) => { if (RESERVED_TAG_NAME_RE.test(name)) { return match; // hand-authored marker / @imageN — preserve verbatim } const lower = name.toLowerCase(); const slot = slotByLowerName.get(lower); if (slot === undefined) { return match; // not a planned character — resolveAssetTags handles it } if (!seenFirst.has(lower)) { seenFirst.add(lower); injected.push(`${name}->@character_${slot}`); } return `@character_${slot}`; }); return { text: out, injected }; } /** * Remove every Flow @-marker token from `text`, collapsing the doubled * whitespace left behind and trimming the ends. `stripped` lists the removed * raw tokens in order. Text without markers comes back byte-identical. */ export function stripFlowMarkers(text: string): { text: string; stripped: string[] } { const stripped: string[] = []; if (extractFlowMarkers(text).length === 0) { return { text, stripped }; // byte-identical when there is nothing to strip } const removed = text.replace(FLOW_MARKER_RE, (match) => { stripped.push(match); return ''; }); // Collapse doubled horizontal whitespace left by removal (newlines kept). const collapsed = removed.replace(/[ \t]{2,}/g, ' ').replace(/[ \t]+(\n)/g, '$1').trim(); return { text: collapsed, stripped }; }