import { randomUUID } from "node:crypto"; import { isDeepStrictEqual } from "node:util"; import { mkdir, open, readFile, rename, rm, unlink, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import type { ArollSegment, EditorialPlanReceipt, BgmTrack, BrollPlacement, BrollPlacementInput, BrollVisualIdentity, BrollContinuityPlanReceipt, BrollNeed, TalkingHeadPolicy, TalkingHeadProject, TalkingHeadSnapshot, TranscriptAnalysis, WordTranscript, } from "./contracts.ts"; import { verifyBgmSelection } from "./bgm.ts"; import { assertBrollAssetInMatchReceipt, verifyBrollPlacementSelection } from "./broll.ts"; import { analyzeArollJumpCuts, findOverlappingBrollWindows, findShortArollFlashGaps, normalizeArollJumpCutStatuses, summarizeBrollCoverage, verifyBrollContinuityPlanReceipt, } from "./continuity.ts"; import { analyzeTranscript, DEFAULT_POLICY, timelineDuration } from "./transcript.ts"; import { resolveExistingWorkspaceFile, resolveWorkspacePath, snapshotFile, workspaceRelativePath } from "./workspace.ts"; import { editorialHash, verifyEditorialPlanReceipt } from "./editorial.ts"; const PROJECT_ID = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/; export interface CreateTalkingHeadProjectInput { projectId: string; sourcePath: string; transcriptPath: string; sourceDurationMs?: number; policy?: Partial; } export interface ApplyTimelineInput { editorialPlanReceipt?: EditorialPlanReceipt; projectId: string; expectedRevision: number; aroll: ArollSegment[]; broll?: BrollPlacementInput[]; inheritBroll?: boolean; bgm?: BgmTrack; continuityPlanReceipt?: BrollContinuityPlanReceipt; } function assertProjectId(projectId: string): void { if (!PROJECT_ID.test(projectId)) { throw new Error("Project ID must use 1-64 lowercase letters, numbers, or interior hyphens"); } } async function projectsRoot(cwd: string): Promise { return await resolveWorkspacePath(cwd, ".talking-head/projects/.keep").then(dirname); } async function projectDirectory(cwd: string, projectId: string): Promise { assertProjectId(projectId); return join(await projectsRoot(cwd), projectId); } async function readJson(path: string, label: string): Promise { try { return JSON.parse(await readFile(path, "utf8")) as T; } catch (error) { throw new Error(`Invalid ${label}: ${(error as Error).message}`); } } async function writeJson(path: string, value: unknown, exclusive = false): Promise { await writeFile(path, `${JSON.stringify(value, null, 2)}\n`, exclusive ? { flag: "wx" } : undefined); } function uniqueIds(values: Array<{ id: string }>, label: string): void { const ids = new Set(); for (const value of values) { if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value.id)) { throw new Error(`${label} ID must use lowercase letters, numbers, or interior hyphens: ${value.id}`); } if (ids.has(value.id)) throw new Error(`Duplicate ${label} ID: ${value.id}`); ids.add(value.id); } } function visualIdentityKey(identity: BrollVisualIdentity): string { const normalize = (value: string) => value.normalize("NFKC").toLowerCase().replace(/[\p{P}\p{S}\s]+/gu, ""); return [normalize(identity.subject), normalize(identity.action), identity.shotScale, identity.angle].join("|"); } type BrollReusePolicy = "asset-only" | "distinct-assets-and-visuals"; function assertDistinctBrollAssetsAndVisuals(broll: BrollPlacementInput[], policy: BrollReusePolicy): void { const enforceDistinctAssetsAndVisuals = policy === "distinct-assets-and-visuals"; if (enforceDistinctAssetsAndVisuals && broll.some((placement) => !placement.selectionReceipt.matchReceipt)) { throw new Error("B-roll matchReceipt is required for every v4-planned placement; run B-roll matching before selection"); } if (enforceDistinctAssetsAndVisuals && broll.some((placement) => ( !placement.selectionReceipt.visualIdentity || !placement.selectionReceipt.selectionSha256 ))) { throw new Error("B-roll visualIdentity and selectionSha256 are required for every v4-planned placement"); } for (let leftIndex = 0; leftIndex < broll.length; leftIndex += 1) { const left = broll[leftIndex]!; const leftEndMs = left.assetStartMs + (left.outputEndMs - left.outputStartMs); for (let rightIndex = leftIndex + 1; rightIndex < broll.length; rightIndex += 1) { const right = broll[rightIndex]!; if (left.selectionReceipt.assetSha256 === right.selectionReceipt.assetSha256) { const rightEndMs = right.assetStartMs + (right.outputEndMs - right.outputStartMs); if (left.assetStartMs === right.assetStartMs && leftEndMs === rightEndMs) { throw new Error(`B-roll placement ${right.id} reuses the same B-roll source window as ${left.id}; select a different asset`); } if (Math.max(left.assetStartMs, right.assetStartMs) < Math.min(leftEndMs, rightEndMs)) { throw new Error(`B-roll placement ${right.id} overlaps the B-roll source window used by ${left.id}; select a different asset`); } throw new Error(`B-roll placement ${right.id} uses the same B-roll asset as ${left.id}; select a different asset, not another moment from the same file`); } if (!enforceDistinctAssetsAndVisuals) continue; const leftIdentity = left.selectionReceipt.visualIdentity; const rightIdentity = right.selectionReceipt.visualIdentity; if (!leftIdentity || !rightIdentity) { throw new Error("B-roll visualIdentity is required for every v4-planned placement"); } if (leftIdentity && rightIdentity && visualIdentityKey(leftIdentity) === visualIdentityKey(rightIdentity)) { throw new Error(`B-roll placement ${right.id} repeats the visual identity of ${left.id}; select a visibly different subject, action, scale, or angle`); } } } } async function assertVisualReviewArtifactsUnchanged( cwd: string, receipt: BrollContinuityPlanReceipt, signal?: AbortSignal, ): Promise { if (receipt.schemaVersion === 1) return; for (const need of receipt.needs) { if (!need.visualReview) continue; const artifact = await snapshotFile(cwd, need.visualReview.artifactPath, signal); if (artifact.sha256 !== need.visualReview.artifactSha256) { throw new Error(`B-roll visual review artifact changed after continuity planning: ${need.visualReview.artifactPath}`); } } } async function validateTimeline( cwd: string, aroll: ArollSegment[], broll: BrollPlacementInput[], sourceDurationMs: number | undefined, projectId: string, revision: number, continuityPlanReceipt: BrollContinuityPlanReceipt | undefined, signal?: AbortSignal, ): Promise<{ outputDurationMs: number; broll: BrollPlacement[]; brollCoverage: ReturnType; jumpCuts: ReturnType; }> { if (aroll.length === 0 || aroll.length > 1_000) throw new Error("A-roll must contain 1-1000 segments"); uniqueIds(aroll, "A-roll segment"); for (const segment of aroll) { if (!Number.isFinite(segment.sourceStartMs) || !Number.isFinite(segment.sourceEndMs) || segment.sourceStartMs < 0 || segment.sourceEndMs <= segment.sourceStartMs) { throw new Error(`Invalid A-roll segment range: ${segment.id}`); } if (sourceDurationMs !== undefined && segment.sourceEndMs > sourceDurationMs) { throw new Error(`A-roll segment ${segment.id} exceeds source duration ${sourceDurationMs}ms`); } } const outputDurationMs = timelineDuration(aroll); if (broll.length > 500) throw new Error("B-roll must contain at most 500 placements"); uniqueIds(broll, "B-roll placement"); for (const placement of broll) { if (!Number.isFinite(placement.outputStartMs) || !Number.isFinite(placement.outputEndMs) || placement.outputStartMs < 0 || placement.outputEndMs <= placement.outputStartMs) { throw new Error(`Invalid B-roll output range: ${placement.id}`); } if (placement.outputEndMs > outputDurationMs) { throw new Error(`B-roll placement ${placement.id} exceeds output duration ${outputDurationMs}ms`); } if (!Number.isFinite(placement.assetStartMs) || placement.assetStartMs < 0) { throw new Error(`B-roll assetStartMs is required and must be valid: ${placement.id}`); } if (!placement.selectionReceipt) throw new Error(`B-roll selectionReceipt is required: ${placement.id}`); if (placement.audio !== "keep-primary") throw new Error("B-roll audio must keep the primary A-roll audio"); } const overlap = findOverlappingBrollWindows(broll)[0]; if (overlap) { throw new Error(`B-roll placements ${overlap.underBrollId} and ${overlap.overBrollId} overlap; ambiguous z-order is not supported`); } const flashGap = findShortArollFlashGaps(broll, 500)[0]; if (flashGap) { throw new Error(`B-roll placements ${flashGap.fromBrollId} and ${flashGap.toBrollId} leave a brief ${flashGap.durationMs}ms A-roll flash; run talking_head_broll_plan and reselect the bridged window`); } assertDistinctBrollAssetsAndVisuals( broll, continuityPlanReceipt?.schemaVersion === 4 ? "distinct-assets-and-visuals" : "asset-only", ); const brollCoverage = summarizeBrollCoverage(broll, outputDurationMs); if (broll.length > 0 && !continuityPlanReceipt) { throw new Error("B-roll continuityPlanReceipt is required; run talking_head_broll_plan first"); } if (continuityPlanReceipt) { verifyBrollContinuityPlanReceipt(projectId, revision, aroll, broll, continuityPlanReceipt); await assertVisualReviewArtifactsUnchanged(cwd, continuityPlanReceipt, signal); } const normalizedBroll: BrollPlacement[] = []; for (const placement of broll) { signal?.throwIfAborted(); if (continuityPlanReceipt?.schemaVersion === 4) { const need = continuityPlanReceipt.needs.find((candidate) => candidate.id === placement.id) as BrollNeed | undefined; if (!need) throw new Error(`B-roll placement ${placement.id} is not present in the continuity plan`); const matchReceipt = placement.selectionReceipt.matchReceipt; if (!matchReceipt) { throw new Error(`B-roll matchReceipt is required for placement ${placement.id}; run B-roll matching before selection`); } assertBrollAssetInMatchReceipt({ projectId, revision, continuityPlanReceipt, need, assetPath: placement.assetPath, matchReceipt, }); } const asset = await verifyBrollPlacementSelection(cwd, placement, signal); normalizedBroll.push({ ...structuredClone(placement), assetPath: asset.path, assetBytes: asset.bytes, assetSha256: asset.sha256, }); } return { outputDurationMs, broll: normalizedBroll, brollCoverage, jumpCuts: continuityPlanReceipt ? normalizeArollJumpCutStatuses(continuityPlanReceipt.jumpCuts) : analyzeArollJumpCuts(aroll, broll, outputDurationMs, 250, 500), }; } function summary(analysis: TranscriptAnalysis) { return { wordCount: analysis.words.length, contentCandidateCount: analysis.contentCandidates.length, diagnostics: analysis.diagnostics, editorialStatus: "draft" as const, sentenceCount: analysis.sentences.length, fillerCount: analysis.fillers.length, repetitionCount: analysis.repetitions.length, pauseCount: analysis.candidates.length, safePauses: analysis.candidates.filter((candidate) => candidate.classification === "safe").length, reviewPauses: analysis.candidates.filter((candidate) => candidate.classification === "review").length, unsafePauses: analysis.candidates.filter((candidate) => candidate.classification === "unsafe").length, automaticCutPauses: analysis.candidates.filter((candidate) => candidate.recommendation === "cut").length, editorialReviewPauses: analysis.candidates.filter((candidate) => candidate.recommendation === "review").length, defaultSegmentCount: analysis.segments.length, defaultOutputDurationMs: analysis.outputDurationMs, }; } function assertWordSafeSegments(aroll: ArollSegment[], analysis: TranscriptAnalysis): void { for (const segment of aroll) { for (const word of analysis.words) { if (segment.sourceStartMs > word.beginMs && segment.sourceStartMs < word.endMs) { throw new Error(`A-roll segment ${segment.id} starts inside word "${word.text}"`); } if (segment.sourceEndMs > word.beginMs && segment.sourceEndMs < word.endMs) { throw new Error(`A-roll segment ${segment.id} ends inside word "${word.text}"`); } } } } export async function createTalkingHeadProject(cwd: string, input: CreateTalkingHeadProjectInput) { assertProjectId(input.projectId); const source = await snapshotFile(cwd, input.sourcePath); const transcript = await snapshotFile(cwd, input.transcriptPath); const transcriptAbsolute = await resolveExistingWorkspaceFile(cwd, input.transcriptPath); const transcriptPayload = await readJson(transcriptAbsolute, "word transcript"); const policy = { ...DEFAULT_POLICY, ...input.policy }; let analysis = analyzeTranscript(transcriptPayload, policy); if (input.sourceDurationMs !== undefined) { if (!Number.isFinite(input.sourceDurationMs) || input.sourceDurationMs <= 0) { throw new Error("sourceDurationMs must be a finite positive duration from media_probe"); } if (analysis.words.some((word) => word.endMs > input.sourceDurationMs!)) { throw new Error("Word transcript exceeds the probed source duration"); } const segments = analysis.segments.map((segment) => ({ ...segment, sourceEndMs: Math.min(segment.sourceEndMs, input.sourceDurationMs!), })); analysis = { ...analysis, segments, outputDurationMs: timelineDuration(segments) }; } const root = await projectsRoot(cwd); await mkdir(root, { recursive: true }); const target = await projectDirectory(cwd, input.projectId); const temporary = join(root, `.${input.projectId}.${randomUUID()}.tmp`); await mkdir(join(temporary, "snapshots"), { recursive: true }); const now = new Date().toISOString(); const analysisPath = `.talking-head/projects/${input.projectId}/analysis.json`; const project: TalkingHeadProject = { schemaVersion: 1, projectId: input.projectId, currentRevision: 1, source, ...(input.sourceDurationMs === undefined ? {} : { sourceDurationMs: input.sourceDurationMs }), transcript, analysisPath, createdAt: now, updatedAt: now, }; const snapshot: TalkingHeadSnapshot = { schemaVersion: 2, projectId: input.projectId, revision: 1, parentRevision: null, createdAt: now, policy, aroll: analysis.segments, editorialStatus: "draft", broll: [], brollCoverage: summarizeBrollCoverage([], analysis.outputDurationMs), jumpCuts: analyzeArollJumpCuts(analysis.segments, [], analysis.outputDurationMs, 250, 500), outputDurationMs: analysis.outputDurationMs, }; try { await writeJson(join(temporary, "analysis.json"), analysis, true); await writeJson(join(temporary, "snapshots", "1.json"), snapshot, true); await writeJson(join(temporary, "project.json"), project, true); try { await rename(temporary, target); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST" || (error as NodeJS.ErrnoException).code === "ENOTEMPTY") { throw new Error(`Project already exists: ${input.projectId}`); } throw error; } } catch (error) { await rm(temporary, { recursive: true, force: true }); throw error; } return { project, snapshot, summary: summary(analysis) }; } export async function getTalkingHeadProject(cwd: string, projectId: string, revision?: number) { const directory = await projectDirectory(cwd, projectId); const project = await readJson(join(directory, "project.json"), `talking-head project ${projectId}`); if (project.schemaVersion !== 1 || project.projectId !== projectId || !Number.isInteger(project.currentRevision)) { throw new Error(`Invalid talking-head project: ${projectId}`); } const selectedRevision = revision ?? project.currentRevision; if (!Number.isInteger(selectedRevision) || selectedRevision < 1 || selectedRevision > project.currentRevision) { throw new Error(`Invalid talking-head revision: ${projectId}@${selectedRevision}`); } const snapshot = await readJson( join(directory, "snapshots", `${selectedRevision}.json`), `talking-head snapshot ${projectId}@${selectedRevision}`, ); if ((snapshot.schemaVersion !== 1 && snapshot.schemaVersion !== 2 && snapshot.schemaVersion !== 3) || snapshot.projectId !== projectId || snapshot.revision !== selectedRevision) { throw new Error(`Invalid talking-head snapshot: ${projectId}@${selectedRevision}`); } return { project, snapshot }; } async function acquireLock(directory: string): Promise<() => Promise> { const lockPath = join(directory, ".write-lock"); let handle; try { handle = await open(lockPath, "wx"); await handle.writeFile(`${process.pid}\n`); } catch (error) { await handle?.close().catch(() => undefined); if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Project is busy: ${directory.split("/").at(-1)}`); throw error; } await handle.close(); return async () => { await unlink(lockPath).catch(() => undefined); }; } export async function applyTimeline(cwd: string, input: ApplyTimelineInput, signal?: AbortSignal): Promise { const directory = await projectDirectory(cwd, input.projectId); const release = await acquireLock(directory); try { const { project, snapshot: current } = await getTalkingHeadProject(cwd, input.projectId); if (project.currentRevision !== input.expectedRevision) { throw new Error(`Project ${input.projectId} expected revision ${input.expectedRevision} but current revision is ${project.currentRevision}`); } await assertProjectSourcesUnchanged(cwd, project); await assertSnapshotAssetsUnchanged(cwd, current); const analysis = await getAnalysis(cwd, project); assertWordSafeSegments(input.aroll, analysis); const unchangedAroll = editorialHash(input.aroll) === editorialHash(current.aroll); if (input.inheritBroll && (input.broll !== undefined || input.continuityPlanReceipt !== undefined)) { throw new Error("inheritBroll cannot be combined with broll or continuityPlanReceipt; omit both to reuse the current verified track"); } if (input.inheritBroll && !unchangedAroll) throw new Error("B-roll inheritance requires unchanged A-roll; plan and select again for changed source ranges"); if (!input.inheritBroll && !Array.isArray(input.broll)) throw new Error("Provide broll (use [] to remove it) or inheritBroll: true"); // Older callers can still submit the full, unchanged placement objects. Compare only // input fields: stored snapshots also contain materialized asset metadata. const placementInput = (placement: BrollPlacement | BrollPlacementInput) => ({ id: placement.id, assetPath: placement.assetPath, assetStartMs: placement.assetStartMs, outputStartMs: placement.outputStartMs, outputEndMs: placement.outputEndMs, fit: placement.fit, audio: placement.audio, selectionReceipt: placement.selectionReceipt }); const reuseBroll = input.inheritBroll || (unchangedAroll && current.continuityPlanReceipt !== undefined && isDeepStrictEqual(input.broll?.map(placementInput), current.broll.map(placementInput)) && (input.continuityPlanReceipt === undefined || isDeepStrictEqual(input.continuityPlanReceipt, current.continuityPlanReceipt))); const broll = reuseBroll ? current.broll.map((placement) => { if (placement.assetStartMs === undefined || !placement.selectionReceipt) throw new Error("Stored B-roll lacks verified selection evidence; plan and select again"); return { ...placement, assetStartMs: placement.assetStartMs, selectionReceipt: placement.selectionReceipt }; }) : input.broll!; const continuityPlanReceipt = reuseBroll ? current.continuityPlanReceipt : input.continuityPlanReceipt; const brollRevision = reuseBroll && continuityPlanReceipt ? continuityPlanReceipt.revision : input.expectedRevision; if (reuseBroll && continuityPlanReceipt && (!Number.isInteger(brollRevision) || brollRevision < 1 || brollRevision >= current.revision)) { throw new Error("Stored B-roll continuity plan has an invalid originating revision"); } // Revalidate all original receipts, files, visual evidence and current policies at // their originating revision; never rewrite a receipt or skip provenance checks. const validated = await validateTimeline( cwd, input.aroll, broll, project.sourceDurationMs, input.projectId, brollRevision, continuityPlanReceipt, signal, ); if (input.bgm) { await verifyBgmSelection(cwd, project, input.expectedRevision, input.aroll, input.bgm); } const editorialPlanReceipt = input.editorialPlanReceipt ?? (unchangedAroll ? current.editorialPlanReceipt : undefined); if (!input.editorialPlanReceipt && editorialPlanReceipt && (!Number.isInteger(editorialPlanReceipt.revision) || editorialPlanReceipt.revision < 1 || editorialPlanReceipt.revision >= current.revision)) { throw new Error("Stored editorial plan has an invalid originating revision"); } const editorialStatus = input.editorialPlanReceipt ? verifyEditorialPlanReceipt(project, input.expectedRevision, input.aroll, analysis, input.editorialPlanReceipt) : editorialPlanReceipt ? verifyEditorialPlanReceipt( { ...project, currentRevision: editorialPlanReceipt.revision }, editorialPlanReceipt.revision, input.aroll, analysis, editorialPlanReceipt, ) : "draft"; const revision = project.currentRevision + 1; const now = new Date().toISOString(); const snapshot: TalkingHeadSnapshot = { schemaVersion: input.bgm ? 3 : 2, projectId: input.projectId, revision, parentRevision: current.revision, createdAt: now, policy: current.policy, aroll: structuredClone(input.aroll), editorialStatus, ...(editorialPlanReceipt ? { editorialPlanReceipt: structuredClone(editorialPlanReceipt) } : {}), broll: validated.broll, ...(input.bgm ? { bgm: structuredClone(input.bgm) } : {}), brollCoverage: validated.brollCoverage, jumpCuts: validated.jumpCuts, ...(continuityPlanReceipt === undefined ? {} : { continuityPlanReceipt: structuredClone(continuityPlanReceipt) }), outputDurationMs: validated.outputDurationMs, }; await writeJson(join(directory, "snapshots", `${revision}.json`), snapshot, true); const nextProject: TalkingHeadProject = { ...project, currentRevision: revision, updatedAt: now }; const temporary = join(directory, `.project.${randomUUID()}.tmp`); await writeJson(temporary, nextProject, true); await rename(temporary, join(directory, "project.json")); return snapshot; } finally { await release(); } } export async function getAnalysis(cwd: string, project: TalkingHeadProject): Promise { const absolute = await resolveExistingWorkspaceFile(cwd, project.analysisPath); const persisted = await readJson(absolute, `talking-head analysis ${project.projectId}`); if (!persisted || !Array.isArray(persisted.words) || !Array.isArray(persisted.candidates) || !Array.isArray(persisted.segments) || !Number.isFinite(persisted.outputDurationMs)) throw new Error(`Invalid talking-head analysis: ${project.projectId}`); if (persisted.schemaVersion === 3 && Array.isArray(persisted.contentCandidates) && persisted.diagnostics && Array.isArray(persisted.words) && Array.isArray(persisted.sentences)) return persisted; // Older analysis is recomputed from the unchanged transcript, never by rewriting snapshots. const currentTranscript = await snapshotFile(cwd, project.transcript.path); if (currentTranscript.sha256 !== project.transcript.sha256 || currentTranscript.bytes !== project.transcript.bytes) { throw new Error(`Transcript changed before legacy analysis migration: ${project.transcript.path}`); } const transcriptAbsolute = await resolveExistingWorkspaceFile(cwd, project.transcript.path); const transcriptPayload = await readJson(transcriptAbsolute, "word transcript"); const { snapshot } = await getTalkingHeadProject(cwd, project.projectId); const migrated = analyzeTranscript(transcriptPayload, snapshot.policy); if (project.sourceDurationMs !== undefined) { migrated.segments = migrated.segments.map((segment) => ({ ...segment, sourceEndMs: Math.min(segment.sourceEndMs, project.sourceDurationMs!) })); migrated.outputDurationMs = timelineDuration(migrated.segments); } return migrated; } export async function assertProjectSourcesUnchanged(cwd: string, project: TalkingHeadProject): Promise { const currentSource = await snapshotFile(cwd, project.source.path); const currentTranscript = await snapshotFile(cwd, project.transcript.path); if (currentSource.sha256 !== project.source.sha256 || currentSource.bytes !== project.source.bytes) { throw new Error(`Source media changed after project creation: ${project.source.path}`); } if (currentTranscript.sha256 !== project.transcript.sha256 || currentTranscript.bytes !== project.transcript.bytes) { throw new Error(`Transcript changed after project creation: ${project.transcript.path}`); } } export async function assertSnapshotAssetsUnchanged(cwd: string, snapshot: TalkingHeadSnapshot): Promise { for (const placement of snapshot.broll) { const current = await snapshotFile(cwd, placement.assetPath); if (current.sha256 !== placement.assetSha256 || current.bytes !== placement.assetBytes) { throw new Error(`B-roll asset changed after timeline revision ${snapshot.revision}: ${placement.assetPath}`); } } if (snapshot.bgm) { const current = await snapshotFile(cwd, snapshot.bgm.assetPath); if (current.sha256 !== snapshot.bgm.assetSha256 || current.bytes !== snapshot.bgm.assetBytes) { throw new Error(`BGM asset changed after timeline revision ${snapshot.revision}: ${snapshot.bgm.assetPath}`); } } } export async function projectStatePath(cwd: string, projectId: string, suffix: string): Promise { return await workspaceRelativePath(cwd, join(await projectDirectory(cwd, projectId), suffix)); }