import { existsSync, realpathSync } from 'node:fs'; import { isAbsolute, join, resolve } from 'node:path'; import { VclawError } from './errors.js'; export interface ParsedAssetSpec { id: string; kind: 'image' | 'video' | 'audio' | 'subtitle' | 'other'; path: string; sceneIndex?: number; backend?: string; } /** * The five asset kinds the pipeline understands, in report order. An unknown * kind used to be COERCED to `other`, which let `--asset "bogus:/tmp/x.png"` * write a manifest entry and flip readiness to ready — the fabricated-entry * hole a fresh-install test walked straight through. Unknown kinds now fail. */ export const ASSET_KINDS = ['image', 'video', 'audio', 'subtitle', 'other'] as const; const ALLOWED_KINDS = new Set(ASSET_KINDS); // A URI/URL scheme prefix (http://, https://, Asset://, gobananas://, gs://, s3://, …) // — its internal `://` and any `:port` would be shredded by a naive split(':'). const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.\-]*:\/\//; /** * True when an asset path is a URI/URL rather than a local file path. Callers * that check a path exists on disk must skip these — an `Asset://` avatar URI * or an `https://` rendition has no local file to stat. */ export function isSchemeAssetPath(path: string): boolean { return SCHEME_RE.test(path); } // For a scheme path, peel an optional trailing `:sceneIndex[:backend]` from the END // so the URL (which may itself contain colons) is preserved intact. const TRAILING_RE = /^(.*?)(?::(\d+)(?::([^:]+))?)?$/; /** * Parse a `--asset kind:path[:sceneIndex][:backend]` spec. The path may be a * local file path OR a URL / `Asset://` URI whose internal colons must survive. * Local paths keep the original positional split (byte-identical behavior); * scheme paths peel the trailing `:sceneIndex[:backend]` from the end instead. */ export function parseAssetSpec(raw: string): ParsedAssetSpec { const firstColon = raw.indexOf(':'); const kindRaw = firstColon >= 0 ? raw.slice(0, firstColon) : ''; const remainder = firstColon >= 0 ? raw.slice(firstColon + 1) : ''; if (!kindRaw || !remainder) { throw new VclawError( 'invalid_flag_value', `Invalid --asset value: "${raw}". Expected kind:path[:sceneIndex][:backend]`, { flag: '--asset', value: raw }, ); } let path: string; let sceneIndexRaw: string | undefined; let backend: string | undefined; if (SCHEME_RE.test(remainder)) { const match = TRAILING_RE.exec(remainder); path = match?.[1] ?? remainder; sceneIndexRaw = match?.[2]; backend = match?.[3]; } else { [path, sceneIndexRaw, backend] = remainder.split(':'); } if (!ALLOWED_KINDS.has(kindRaw)) { throw new VclawError( 'invalid_flag_value', `--asset kind ${JSON.stringify(kindRaw)} is not one of ${ASSET_KINDS.join(' | ')}.`, { flag: '--asset', value: raw, kind: kindRaw, allowedKinds: [...ASSET_KINDS] }, ); } const kind = kindRaw as ParsedAssetSpec['kind']; const sceneIndex = sceneIndexRaw !== undefined && sceneIndexRaw !== '' ? Number(sceneIndexRaw) : undefined; return { id: `${kind}-${path}`, kind, path, ...(Number.isFinite(sceneIndex) ? { sceneIndex } : {}), ...(backend ? { backend } : {}), }; } /** * Anchor a local asset path at the door. `video assets` checks a path exists * against the CURRENT directory and the transports read the same string from * wherever a later command happens to run, while the run contract measures * reference bytes against the PROJECT directory — so a path given relative to * the current directory uploaded fine and was frozen as `missing` forever. An * absolute path means one file to every reader. `resolve`, not `realpath`: * `migrate-home` leaves symlinks precisely so old absolute paths keep working. * The id is rebuilt because it embeds the path (`${kind}-${path}`), which is * also the form `execution-status` mints for an on-disk keyframe, so the two * now merge instead of listing one file twice. Scheme paths name remote objects. */ export function absolutiseAssetSpec(spec: ParsedAssetSpec, cwd: string = process.cwd()): ParsedAssetSpec { if (isSchemeAssetPath(spec.path) || isAbsolute(spec.path)) return spec; const path = resolve(cwd, spec.path); return { ...spec, id: `${spec.kind}-${path}`, path }; } /** * The read-side half, for manifests already on disk: a relative entry that * exists under the project directory (a stock import records * `assets/stock/...`) is what the byte measurement resolves, so the transport * is handed that same file. Anything else is returned untouched — an older * manifest holding a path relative to some other directory keeps reading the * way it always did rather than being pointed at a file that is not there. */ export function anchorManifestAssetPath(projectDir: string, path: string): string { if (!path || isSchemeAssetPath(path) || isAbsolute(path)) return path; const anchored = join(projectDir, path); if (existsSync(anchored)) return anchored; // The other form shipped artifacts carry: relative to the WORKSPACE // (`projects//assets/look-board.png`, the documented way to type it from // the workspace root). A project lives at `/projects/`. if (path.startsWith('projects/')) { const fromWorkspace = join(projectDir, '..', '..', path); if (existsSync(fromWorkspace)) return fromWorkspace; } return path; } /** * A prompt packet's references, anchored the same way. A packet that is ready * supplies the scene's reference paths itself (the manifest's image entries are * set aside), and shipped packets hold paths as they were typed, so without * this the byte measurement read the project file while the upload read * whatever sat at that string under the current directory. One function for the * render and the run dashboard, so the two cannot disagree about a path. */ export function anchorPacketReferences }>(projectDir: string, packet: T): T { return { ...packet, references: packet.references.map((reference) => ( reference.path ? { ...reference, path: anchorManifestAssetPath(projectDir, reference.path) } : reference )), }; } /** * A local path typed on the command line, turned into the one absolute file it * names. People type these three ways — relative to where they are, to the * project (`assets/storyboard-grid.png`) and to the workspace * (`projects//assets/storyboard-grid.png`, the documented form) — so each * base is tried. Exactly one may hold the file; a file in none of them is * refused, naming every place that was looked, and so is a string that names a * different file under two bases: a path recorded * `ready` for a file that is not there is a hole in the gate before a paid * render. Scheme paths pass through untouched. */ /** The three bases a typed path is tried against, in order: where the command ran, the project, the workspace. */ export function typedPathBases(projectDir: string, workspaceRoot: string, cwd: string = process.cwd()): string[] { return [cwd, projectDir, workspaceRoot]; } export function resolveTypedLocalPath(raw: string, flag: string, bases: string[]): string { const value = raw.trim(); if (!value || isSchemeAssetPath(value)) return value; const tried = [...new Set(bases.map((base) => resolve(base, value)))]; // One file reached two ways is one file: `dirname(resolve(manifest))` keeps a // symlink (macOS /var, a `migrate-home` link) that `process.cwd()` has already // resolved, so the same file would otherwise be counted twice and refused. const seenFiles = new Set(); const found = tried.filter((candidate) => { if (!existsSync(candidate)) return false; const real = realpathSync(candidate); if (seenFiles.has(real)) return false; seenFiles.add(real); return true; }); if (found.length === 0) { throw new VclawError('invalid_flag_value', `${flag} path does not exist. Looked for: ${tried.join(', ')}`, { flag, value, tried }); } // `assets/storyboard-grid.png` exists in most projects, so typed from inside // ANOTHER project it would quietly take that project's grid. Two different // files answering to one string is a question for the person, not a guess. if (found.length > 1) { throw new VclawError( 'invalid_flag_value', `${flag} "${value}" names more than one file: ${found.join(', ')}. Pass the absolute path of the one you mean.`, { flag, value, found }, ); } return found[0]!; }