import { existsSync } from 'node:fs'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { readFile } from 'node:fs/promises'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { appendProjectEvent } from './events.js'; import { VclawError } from './errors.js'; import { resolveProjectWorkspace } from './workspace.js'; import type { ProviderRouteId, VideoOperationKind } from './provider-platform/types.js'; import type { VideoExecutionPlan, VideoProductionMode } from './types.js'; import type { BriefArtifact } from './artifacts.js'; type ExecutionProfile = VideoExecutionPlan['executionProfile']; function normalizeAspectRatio(value: unknown): ExecutionProfile['aspectRatio'] | null { return value === '16:9' || value === '9:16' || value === '1:1' ? value : null; } function normalizeQuality(value: unknown): ExecutionProfile['quality'] | null { return value === 'fast' || value === 'quality' ? value : null; } function normalizeResolution(value: unknown): ExecutionProfile['resolution'] | null { return value === '720p' || value === '1080p' ? value : null; } function normalizeOutputCount(value: unknown): number | null { return Number.isInteger(value) && Number(value) >= 1 && Number(value) <= 4 ? Number(value) : null; } function normalizeGenerateAudio(value: unknown): boolean | null { return typeof value === 'boolean' ? value : null; } /** * Canonical short names plus every alias `flow.ts mapModelToUseApi` already * accepts. Qualified ids matter: an operator who reads the provider docs types * `veo-3.1-fast`, and dropping it silently is not a no-op — the profile then * falls back to `quality` → `veo-3.1-quality`, the ONE Veo model that rejects * `character_*` saved-entity refs. That is how a whole identity-locked run got * rejected at submit while every local artifact looked correct. */ const VEO_MODEL_ALIASES: Record> = { fast: 'fast', 'veo-3.1-fast': 'fast', quality: 'quality', 'veo-3.1-quality': 'quality', lite: 'lite', 'veo-3.1-lite': 'lite', free: 'free', relaxed: 'free', 'lite-low-priority': 'free', 'veo-3.1-lite-low-priority': 'free', omni: 'omni-flash', 'omni-flash': 'omni-flash', }; export const ACCEPTED_VEO_MODELS = Object.keys(VEO_MODEL_ALIASES); /** * Normalize the omni-flash GENERATION resolution (`360p` | `720p`). Distinct * from {@link normalizeResolution}, which is the shared `720p|1080p` field every * route reads — that one cannot express 360p, and widening it would leak a * Flow-only value into every other provider's contract. */ function normalizeFlowResolution(value: unknown): NonNullable | null { if (typeof value !== 'string') return null; const v = value.trim().toLowerCase(); return v === '360p' || v === '720p' ? v : null; } /** * Tolerant normalizer — returns null for anything unrecognized. Callers reading * PERSISTED artifacts use this directly so a bad stored value degrades to the * default rather than making the project unreadable. Callers taking OPERATOR * input must reject instead (see `parseExecutionProfileInput`). */ function normalizeVeoModel(value: unknown): NonNullable | null { if (typeof value !== 'string') return null; return VEO_MODEL_ALIASES[value.trim().toLowerCase()] ?? null; } async function readExecutionProfileOverrides( projectSlug: string, root: string, ): Promise> { const workspace = resolveProjectWorkspace(projectSlug, root); const briefPath = artifactPathFor(workspace, 'brief'); if (!existsSync(briefPath)) return {}; const brief = JSON.parse(await readFile(briefPath, 'utf-8')) as { metadata?: { platform?: string; executionProfile?: Record; }; }; const executionProfile = brief.metadata?.executionProfile ?? {}; const platform = String(brief.metadata?.platform ?? '').toLowerCase(); return { ...(normalizeAspectRatio(executionProfile.aspectRatio) ? { aspectRatio: normalizeAspectRatio(executionProfile.aspectRatio)! } : {}), ...(normalizeQuality(executionProfile.quality) ? { quality: normalizeQuality(executionProfile.quality)! } : {}), ...(normalizeResolution(executionProfile.resolution) ? { resolution: normalizeResolution(executionProfile.resolution)! } : {}), ...(typeof executionProfile.generateAudio === 'boolean' ? { generateAudio: executionProfile.generateAudio } : {}), ...(normalizeOutputCount(executionProfile.outputCount) ? { outputCount: normalizeOutputCount(executionProfile.outputCount)! } : {}), ...(normalizeVeoModel(executionProfile.veoModel) ? { veoModel: normalizeVeoModel(executionProfile.veoModel)! } : {}), ...(normalizeFlowResolution(executionProfile.flowResolution) ? { flowResolution: normalizeFlowResolution(executionProfile.flowResolution)! } : {}), ...(!normalizeAspectRatio(executionProfile.aspectRatio) && ['tiktok', 'reels', 'shorts'].includes(platform) ? { aspectRatio: '9:16' as const } : {}), }; } /** * Operator-facing profile input. Identical to a partial profile except that * `flowResolution` may be `null`, meaning CLEAR IT: the omni-flash generation * tier is refused on every Veo model, so an operator who set 360p and then * switches `--veo-model` to a Veo tier would otherwise be stuck with a project * that cannot render and no CLI way out short of editing brief.json by hand. */ export type ExecutionProfileInput = Omit, 'flowResolution'> & { flowResolution?: NonNullable | null; }; export async function setExecutionProfileOverrides( projectSlug: string, input: ExecutionProfileInput, root = resolveWorkspaceRootFromEnv(), ): Promise<{ artifactPath: string; brief: BriefArtifact; }> { const workspace = resolveProjectWorkspace(projectSlug, root); const briefPath = artifactPathFor(workspace, 'brief'); if (!existsSync(briefPath)) { throw new Error(`Execution profile cannot be updated for "${projectSlug}" because the brief artifact is missing.`); } const brief = JSON.parse(await readFile(briefPath, 'utf-8')) as BriefArtifact; const existingProfile = ((brief.metadata ?? {}).executionProfile ?? {}) as Record; const nextProfile = { ...existingProfile, ...(input.aspectRatio ? { aspectRatio: input.aspectRatio } : {}), ...(input.quality ? { quality: input.quality } : {}), ...(input.resolution ? { resolution: input.resolution } : {}), ...(typeof input.generateAudio === 'boolean' ? { generateAudio: input.generateAudio } : {}), ...(typeof input.outputCount === 'number' ? { outputCount: input.outputCount } : {}), ...(input.veoModel ? { veoModel: input.veoModel } : {}), ...(input.flowResolution ? { flowResolution: input.flowResolution } : {}), }; if (input.flowResolution === null) delete (nextProfile as Record).flowResolution; const nextBrief: BriefArtifact = { ...brief, metadata: { ...(brief.metadata ?? {}), executionProfile: nextProfile, }, }; const artifactPath = await writeArtifact(workspace, 'brief', nextBrief); await appendProjectEvent(workspace, { type: 'artifact.brief.execution-profile.updated', payload: { artifactPath, executionProfile: nextProfile, }, }); return { artifactPath, brief: nextBrief }; } export function parseExecutionProfileInput(input: { aspectRatio?: unknown; quality?: unknown; resolution?: unknown; generateAudio?: unknown; outputCount?: unknown; veoModel?: unknown; flowResolution?: unknown; }): ExecutionProfileInput { const profile: ExecutionProfileInput = {}; const aspectRatio = normalizeAspectRatio(input.aspectRatio); const quality = normalizeQuality(input.quality); const resolution = normalizeResolution(input.resolution); const generateAudio = normalizeGenerateAudio(input.generateAudio); const outputCount = normalizeOutputCount(input.outputCount); const veoModel = normalizeVeoModel(input.veoModel); const clearFlowResolution = typeof input.flowResolution === 'string' && ['none', 'off', 'clear'].includes(input.flowResolution.trim().toLowerCase()); const flowResolution = clearFlowResolution ? null : normalizeFlowResolution(input.flowResolution); // An operator who explicitly passed a model must never have it silently // discarded — the fallback is `quality`, which cannot carry character refs. if (input.veoModel !== undefined && input.veoModel !== '' && veoModel === null) { throw new VclawError( 'invalid_flag_value', `Unknown veo model ${JSON.stringify(input.veoModel)}. Accepted: ${ACCEPTED_VEO_MODELS.join(', ')}.`, { flag: '--veo-model', value: input.veoModel }, ); } if (aspectRatio) profile.aspectRatio = aspectRatio; if (quality) profile.quality = quality; if (resolution) profile.resolution = resolution; if (generateAudio !== null) profile.generateAudio = generateAudio; if (outputCount !== null) profile.outputCount = outputCount; // Same rule as --veo-model: an explicitly passed value must never be silently // discarded. A dropped `360p` here renders at FULL price while the operator // believes they are drafting. if (!clearFlowResolution && input.flowResolution !== undefined && input.flowResolution !== '' && flowResolution === null) { throw new VclawError( 'invalid_flag_value', `Unknown flow resolution ${JSON.stringify(input.flowResolution)}. Accepted: 360p, 720p, or none to clear ` + `(1080p is an UPSCALE target, not a generation resolution).`, { flag: '--veo-resolution', value: input.flowResolution }, ); } if (veoModel) profile.veoModel = veoModel; if (flowResolution) profile.flowResolution = flowResolution; if (clearFlowResolution) profile.flowResolution = null; return profile; } export async function buildExecutionProfile(input: { projectSlug: string; root?: string; productionMode: VideoProductionMode; routeId: ProviderRouteId | null; operationKind: VideoOperationKind; }): Promise { const root = input.root ?? resolveWorkspaceRootFromEnv(); const overrides = await readExecutionProfileOverrides(input.projectSlug, root); const defaults: ExecutionProfile = { aspectRatio: '16:9', quality: input.productionMode === 'director' ? 'quality' : 'fast', resolution: '720p', generateAudio: input.routeId === 'seedance-direct', outputCount: 1, }; return { ...defaults, ...overrides, }; }