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 } : {}), }; }