import { STUDIO_RECIPES } from './recipes.js'; import type { StudioApprovalRequirement, StudioAspectRatio, StudioCreatorContract, StudioCreatorPlatform, StudioCredentialRequirement, StudioGoal, StudioInputRequirement, StudioOutputArtifact, StudioPlan, StudioPlanInput, StudioPlanStep, StudioSpendRequirement } from './types.js'; function shellQuote(value: string): string { if (/^[A-Za-z0-9._/:=-]+$/.test(value)) return value; return JSON.stringify(value); } function resolveGoal(input: StudioPlanInput): StudioGoal { if (input.goal) return input.goal; if (input.project) return 'existing-project'; return 'create-video'; } function defaultAspectRatio(platform: StudioCreatorPlatform): StudioAspectRatio { return platform === 'tiktok' || platform === 'youtube-shorts' || platform === 'instagram-reels' ? '9:16' : '16:9'; } function resolveCreator(input: StudioPlanInput): StudioCreatorContract { const platform = input.platform ?? 'generic'; return { platform, aspectRatio: input.aspectRatio ?? defaultAspectRatio(platform), durationSeconds: input.durationSeconds ?? 60, sourceMode: input.sourceMode ?? 'original-ai', audioMode: input.audioMode ?? 'none', executionIntent: input.executionIntent ?? 'plan', }; } function valueForPlaceholder(name: string, input: StudioPlanInput): string | undefined { const creator = resolveCreator(input); switch (name) { case 'project': return input.project; case 'intent': return input.intent; case 'input': return input.input; case 'client': return input.client ?? 'client'; case 'durationSeconds': return String(creator.durationSeconds); case 'platform': return creator.platform; case 'aspectRatio': return creator.aspectRatio; case 'sourceMode': return creator.sourceMode; case 'audioMode': return creator.audioMode; case 'audioEnabled': return creator.audioMode === 'none' ? 'off' : 'on'; case 'executionIntent': return creator.executionIntent; case 'template': return 'template'; case 'bucket': return 'bucket'; default: return undefined; } } function fillTemplate(template: string, input: StudioPlanInput): string { return template.replaceAll(/<([A-Za-z0-9]+)>/g, (match, rawName: string) => { const value = valueForPlaceholder(rawName, input); return value ? shellQuote(value) : match; }); } function commandWithRoot(command: string, input: StudioPlanInput): string { if (input.root === process.cwd()) return command; if (!command.startsWith('vclaw video ')) return command; if (command.includes(' --root ')) return command; return `${command} --root ${shellQuote(input.root)}`; } function requiredInputMissing(name: string, input: StudioPlanInput): boolean { switch (name) { case 'project': return !input.project; case 'intent': return !input.intent; case 'input': return !input.input; case 'client': return !input.client; default: return false; } } function inputValue(name: string, input: StudioPlanInput, creator: StudioCreatorContract): string | number | boolean | undefined { switch (name) { case 'project': return input.project; case 'intent': return input.intent; case 'input': return input.input; case 'client': return input.client; case 'title': return input.title; case 'durationSeconds': return creator.durationSeconds; case 'platform': return creator.platform; case 'aspectRatio': return creator.aspectRatio; case 'sourceMode': return creator.sourceMode; case 'audioMode': return creator.audioMode; case 'executionIntent': return creator.executionIntent; default: return undefined; } } function inputDescription(name: string): string { switch (name) { case 'project': return 'Project slug that owns the generated artifacts.'; case 'intent': return 'Creator idea or production brief.'; case 'input': return 'Reference file, URL, or source path.'; case 'client': return 'Client or delivery label.'; case 'title': return 'Human-readable project title.'; case 'durationSeconds': return 'Target duration in seconds.'; case 'platform': return 'Target platform or generic format.'; case 'aspectRatio': return 'Output aspect ratio.'; case 'sourceMode': return 'Source material strategy.'; case 'audioMode': return 'Audio production strategy.'; case 'executionIntent': return 'Requested execution boundary.'; default: return `${name} input.`; } } function buildInputRequirements(recipeRequired: string[], recipeOptional: string[], input: StudioPlanInput, creator: StudioCreatorContract): StudioInputRequirement[] { const names = [...recipeRequired, ...recipeOptional.filter((name) => !recipeRequired.includes(name))]; return names.map((name) => { const value = inputValue(name, input, creator); const required = recipeRequired.includes(name); return { name, required, provided: required ? !requiredInputMissing(name, input) : value !== undefined, description: inputDescription(name), ...(value !== undefined ? { value } : {}), }; }); } function credentialSatisfied( envVars: string[], env: Readonly>, ): { satisfied: boolean; missingEnvVars: string[] } { const satisfied = envVars.some((name) => Boolean(env[name]?.trim())); return { satisfied, missingEnvVars: satisfied ? [] : envVars, }; } function collectCredentialRequirements(steps: StudioPlanStep[], input: StudioPlanInput): StudioCredentialRequirement[] { const stepIds = new Set(steps.map((step) => step.id)); const recipe = STUDIO_RECIPES.find((item) => item.id === resolveGoal(input)); if (!recipe) return []; const requirements: StudioCredentialRequirement[] = []; for (const command of recipe.commands) { if (!stepIds.has(command.id)) continue; for (const requirement of command.credentialRequirements ?? []) { const { satisfied, missingEnvVars } = credentialSatisfied(requirement.envVars, input.env ?? {}); requirements.push({ ...requirement, satisfied, missingEnvVars, }); } } return requirements; } function collectSpendRequirements(steps: StudioPlanStep[], input: StudioPlanInput): StudioSpendRequirement[] { const stepIds = new Set(steps.map((step) => step.id)); const recipe = STUDIO_RECIPES.find((item) => item.id === resolveGoal(input)); if (!recipe) return []; return recipe.commands .filter((command) => stepIds.has(command.id) && command.spendRequirement) .map((command) => command.spendRequirement as StudioSpendRequirement); } function collectApprovalRequirements(steps: StudioPlanStep[], input: StudioPlanInput): StudioApprovalRequirement[] { const stepIds = new Set(steps.map((step) => step.id)); const recipe = STUDIO_RECIPES.find((item) => item.id === resolveGoal(input)); if (!recipe) return []; return recipe.commands .filter((command) => stepIds.has(command.id) && command.approvalRequirement) .map((command) => command.approvalRequirement as StudioApprovalRequirement); } function collectOutputArtifacts(steps: StudioPlanStep[], input: StudioPlanInput): StudioOutputArtifact[] { const stepIds = new Set(steps.map((step) => step.id)); const recipe = STUDIO_RECIPES.find((item) => item.id === resolveGoal(input)); if (!recipe) return []; return recipe.commands .filter((command) => stepIds.has(command.id)) .flatMap((command) => command.outputArtifacts ?? []); } export function buildStudioPlan(input: StudioPlanInput): StudioPlan { const goal = resolveGoal(input); const recipe = STUDIO_RECIPES.find((item) => item.id === goal); if (!recipe) { throw new Error(`Unknown studio goal: ${goal}`); } const creator = resolveCreator(input); const missingInputs = recipe.requiredInputs.filter((name) => requiredInputMissing(name, input)); const inputRequirements = buildInputRequirements(recipe.requiredInputs, recipe.optionalInputs, input, creator); const warnings: string[] = []; if (recipe.riskLevel === 'high') { warnings.push('This recipe can lead to provider credits being spent after approval; keep dry-run until assets and prompts are reviewed.'); } if (!input.dryRun) { warnings.push('Phase 1 studio planner is plan-only; execution is intentionally not enabled yet.'); } if (input.project && input.projectContext?.projectExists === false) { warnings.push(`Project ${input.project} does not exist yet; start with vclaw video init ${input.project}.`); } if (input.projectContext?.readinessNextAction) { warnings.push(`Project next action: ${input.projectContext.readinessNextAction}`); } if (input.projectContext?.nextActionCount !== undefined) { warnings.push(`Portfolio next-action count: ${input.projectContext.nextActionCount}.`); } const steps: StudioPlanStep[] = missingInputs.length > 0 ? [] : recipe.commands.map((command) => ({ id: command.id, title: command.title, command: commandWithRoot(fillTemplate(command.primary, input), input), reason: command.when, produces: command.produces, requiresApproval: recipe.executionPolicy === 'approval-gated' || Boolean(command.approvalRequirement), })); const credentialRequirements = missingInputs.length > 0 ? [] : collectCredentialRequirements(steps, input); const spendRequirements = missingInputs.length > 0 ? [] : collectSpendRequirements(steps, input); const approvalRequirements = missingInputs.length > 0 ? [] : collectApprovalRequirements(steps, input); const outputArtifacts = missingInputs.length > 0 ? [] : collectOutputArtifacts(steps, input); return { schemaVersion: 1, dryRun: true, goal, title: recipe.title, summary: recipe.goal, missingInputs, warnings, creator, inputRequirements, credentialRequirements, spendRequirements, approvalRequirements, outputArtifacts, steps, nextAction: missingInputs.length > 0 ? `Provide ${missingInputs.join(' and ')} to build this studio plan.` : 'Review the plan. Run the listed commands manually or continue to an approved execution slice.', }; }