import { mkdir, readFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join, resolve, relative, isAbsolute, sep } from 'node:path'; import type { VideoPipelineManifest, VideoProductionMode } from './types.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; export interface VideoProjectWorkspace { root: string; slug: string; projectDir: string; artifactsDir: string; artifactsHistoryDir: string; checkpointsDir: string; charactersDir: string; charactersPath: string; eventsDir: string; eventsPath: string; stateDir: string; manifestPath: string; } /** * Project-level cinematic look profile. Persisted on the manifest so a project * carries its default cinematography register across every `vclaw` invocation * (Joey 2.0: "photorealism is the universal default, dial down by exception"). * Every field is optional and backward-compatible — an absent block resolves to * the HARD DEFAULT in {@link ../video/cinema-profile.resolveCinemaProfile}. */ export interface VideoCinemaProfile { /** Cinematography language density. Default (resolved) `'rich'`. */ detail?: 'terse' | 'standard' | 'rich'; /** Whether the anti-plastic capture-realism block is emitted. Default `true`. */ realism?: boolean; /** Prose (behaviour) vs numeric (Kelvin/°/ratio) cinematography register. Default `'prose'`. */ register?: 'prose' | 'numeric'; /** Volumetric-haze density for the realism block. Default `'light'`. */ haze?: 'thin' | 'light' | 'heavy'; /** Emit the moisture-matte clause in the realism block. Default off. */ wet?: boolean; /** Lighting register id (see cinematography `lightingSpec`/`lightingProse`). */ lightingId?: string; /** Color-grade register id (see cinematography `gradeSpec`/`gradeProse`). */ gradeId?: string; /** Backdrop plate kind. Default `'mid-gray'`. */ plateKind?: 'mid-gray' | 'white' | 'black'; /** Cinema (film hardware) vs phone (UGC smartphone) capture register. */ captureRegister?: 'cinema' | 'phone'; } export interface VideoProjectManifest { slug: string; productionMode: VideoProductionMode; createdAt: string; updatedAt: string; pipeline: VideoPipelineManifest; owner?: string | null; priority?: 'low' | 'medium' | 'high' | 'critical' | null; dueDate?: string | null; tags?: string[]; blockedBy?: string[]; blockedReason?: string | null; currentStage?: string | null; lastCompletedStage?: string | null; lastCheckpointStatus?: string | null; /** Optional project-level cinematic look profile (backward-compatible). */ cinemaProfile?: VideoCinemaProfile; /** * Optional project-relative path to the selected soundtrack audio file. * Written by `vclaw video soundtrack --select ` and read by the * preview portal's soundtrack discovery (preview-portal/discovery.ts). */ soundtrack?: string | null; /** * Optional project-level provider-route preference: an ordered list of route * ids that overrides the built-in per-mode default ordering when the * execution plan picks a route. Routes not listed here still apply as * fallback (appended after the preferred ones), so a partial preference is * safe. Each entry must be a known `ProviderRouteId` — `buildExecutionPlan` * rejects unknown ids at plan time (free, before any submit). Absent/empty = * byte-identical to the built-in defaults. */ routePreference?: ProviderRouteId[] | null; /** * Opt-OUT of the standing per-scene render rules (the no-speech / * natural-motion / nothing-appears block `buildExecutionPayload` bakes into * every scene's animation prompt). Defaults to enabled when ABSENT — set to * `false` to suppress the injection for a project (the env kill-switch * `VCLAW_DISABLE_STANDING_RULES=1` is the per-invocation equivalent). When * disabled the per-scene prompt is byte-identical to the legacy output. */ standingRenderRules?: boolean; } export function resolveProjectWorkspace(slug: string, root = resolveWorkspaceRootFromEnv()): VideoProjectWorkspace { const normalizedRoot = resolve(root); const projectsRoot = join(normalizedRoot, 'projects'); const projectDir = resolve(projectsRoot, slug); // Containment guard. resolveProjectWorkspace is the single chokepoint every // `--project`-accepting command flows through, so rejecting a path-traversal // slug here (e.g. `--project ../../etc/passwd`) protects all callers at once — // otherwise the filesystem helpers below would read/write outside the // workspace. Valid slugs (isProjectSlug: kebab-case, no `/` or `.`) always // resolve to a direct child of projects/; only escaping or absolute values // produce a `..`/absolute relative path and are rejected. const rel = relative(projectsRoot, projectDir); if (rel === '' || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { throw new Error( `Invalid project slug ${JSON.stringify(slug)}: a slug must name a project under projects/, ` + `not a path that escapes it. Use a slug like 'my-project'.`, ); } return { root: normalizedRoot, slug, projectDir, artifactsDir: join(projectDir, 'artifacts'), artifactsHistoryDir: join(projectDir, 'artifacts', 'history'), checkpointsDir: join(projectDir, 'checkpoints'), charactersDir: join(projectDir, 'characters'), charactersPath: join(projectDir, 'characters', 'characters.json'), eventsDir: join(projectDir, 'events'), eventsPath: join(projectDir, 'events', 'events.jsonl'), stateDir: join(projectDir, 'state'), manifestPath: join(projectDir, 'project.json'), }; } export async function ensureProjectWorkspace( slug: string, root = resolveWorkspaceRootFromEnv(), ): Promise { const workspace = resolveProjectWorkspace(slug, root); await mkdir(workspace.artifactsDir, { recursive: true }); await mkdir(workspace.artifactsHistoryDir, { recursive: true }); await mkdir(workspace.checkpointsDir, { recursive: true }); await mkdir(workspace.charactersDir, { recursive: true }); await mkdir(workspace.eventsDir, { recursive: true }); await mkdir(workspace.stateDir, { recursive: true }); return workspace; } export async function writeProjectManifest( workspace: VideoProjectWorkspace, manifest: VideoProjectManifest, ): Promise { await writeTextFileAtomic(workspace.manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); } export async function readProjectManifest( workspace: VideoProjectWorkspace, ): Promise { if (!existsSync(workspace.manifestPath)) return null; const raw = await readFile(workspace.manifestPath, 'utf-8'); return JSON.parse(raw) as VideoProjectManifest; } export async function updateProjectManifestState( workspace: VideoProjectWorkspace, input: { updatedAt?: string; currentStage?: string | null; lastCompletedStage?: string | null; lastCheckpointStatus?: string | null; }, ): Promise { const manifest = await readProjectManifest(workspace); if (!manifest) { throw new Error(`Cannot update project manifest for ${workspace.slug}: manifest missing`); } const updatedManifest: VideoProjectManifest = { ...manifest, updatedAt: input.updatedAt ?? new Date().toISOString(), ...(input.currentStage !== undefined ? { currentStage: input.currentStage } : {}), ...(input.lastCompletedStage !== undefined ? { lastCompletedStage: input.lastCompletedStage } : {}), ...(input.lastCheckpointStatus !== undefined ? { lastCheckpointStatus: input.lastCheckpointStatus } : {}), }; await writeProjectManifest(workspace, updatedManifest); return updatedManifest; } export async function updateProjectManifestMetadata( workspace: VideoProjectWorkspace, input: { updatedAt?: string; owner?: string | null; priority?: 'low' | 'medium' | 'high' | 'critical' | null; dueDate?: string | null; tags?: string[]; blockedBy?: string[]; blockedReason?: string | null; }, ): Promise { const manifest = await readProjectManifest(workspace); if (!manifest) { throw new Error(`Cannot update project metadata for ${workspace.slug}: manifest missing`); } const updatedManifest: VideoProjectManifest = { ...manifest, updatedAt: input.updatedAt ?? new Date().toISOString(), ...(input.owner !== undefined ? { owner: input.owner } : {}), ...(input.priority !== undefined ? { priority: input.priority } : {}), ...(input.dueDate !== undefined ? { dueDate: input.dueDate } : {}), ...(input.tags !== undefined ? { tags: input.tags } : {}), ...(input.blockedBy !== undefined ? { blockedBy: input.blockedBy } : {}), ...(input.blockedReason !== undefined ? { blockedReason: input.blockedReason } : {}), }; await writeProjectManifest(workspace, updatedManifest); return updatedManifest; } /** * Set (or clear) the project manifest's `soundtrack` field — the project-relative * path to the human-selected soundtrack audio that the preview portal renders. * Pass `null` to clear it. */ export async function updateProjectManifestSoundtrack( workspace: VideoProjectWorkspace, soundtrack: string | null, updatedAt?: string, ): Promise { const manifest = await readProjectManifest(workspace); if (!manifest) { throw new Error(`Cannot update soundtrack for ${workspace.slug}: manifest missing`); } const updatedManifest: VideoProjectManifest = { ...manifest, updatedAt: updatedAt ?? new Date().toISOString(), soundtrack, }; await writeProjectManifest(workspace, updatedManifest); return updatedManifest; } /** * Merge a partial {@link VideoCinemaProfile} into the project manifest's * `cinemaProfile` block. Only the provided fields are written; absent fields are * preserved from any existing block. Mirrors {@link updateProjectManifestMetadata}. */ export async function updateProjectManifestCinemaProfile( workspace: VideoProjectWorkspace, patch: VideoCinemaProfile, updatedAt?: string, ): Promise { const manifest = await readProjectManifest(workspace); if (!manifest) { throw new Error(`Cannot update cinema profile for ${workspace.slug}: manifest missing`); } const merged: VideoCinemaProfile = { ...(manifest.cinemaProfile ?? {}) }; for (const key of Object.keys(patch) as (keyof VideoCinemaProfile)[]) { const value = patch[key]; if (value !== undefined) { // Narrowing through `unknown` keeps the heterogeneous union assignment safe. (merged as Record)[key] = value; } } const updatedManifest: VideoProjectManifest = { ...manifest, updatedAt: updatedAt ?? new Date().toISOString(), cinemaProfile: merged, }; await writeProjectManifest(workspace, updatedManifest); return updatedManifest; }