diff --git a/electron/ai-edition/chat-service.test.ts b/electron/ai-edition/chat-service.test.ts index 1f78e0dcc..081aa9a85 100644 --- a/electron/ai-edition/chat-service.test.ts +++ b/electron/ai-edition/chat-service.test.ts @@ -156,11 +156,12 @@ describe("runTimelineOperation", () => { // state (projectsRoot, the per-project write queue), so no object literal can // stand in for it. Subclassing keeps the stub a real DocumentService while // replacing the only two methods runTimelineOperation calls with in-memory - // versions — nothing here touches the filesystem, so projectsRoot is never - // read and no directory is created. + // versions — nothing here touches the filesystem, so neither projectsRoot nor + // the media-links directory is ever read and no directory is created. class StubDocumentService extends DocumentService { constructor(readonly file: { stored: AxcutDocument | undefined }) { - super(path.join(tmpdir(), "openscreen-chat-service-test-unused")); + const unused = path.join(tmpdir(), "openscreen-chat-service-test-unused"); + super(unused, unused); } override async getProject(): Promise { diff --git a/electron/ai-edition/document-service.test.ts b/electron/ai-edition/document-service.test.ts index 39ed1f2e0..995044f75 100644 --- a/electron/ai-edition/document-service.test.ts +++ b/electron/ai-edition/document-service.test.ts @@ -2,8 +2,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { AxcutDocument } from "../../src/lib/ai-edition/schema"; +import type { AxcutAsset, AxcutDocument } from "../../src/lib/ai-edition/schema"; import { axcutSchemaVersion } from "../../src/lib/ai-edition/schema"; +import { registerMediaLinks } from "../media/mediaLinksRegistry"; import { DocumentNotFoundError, DocumentService, ProjectFileError } from "./document-service"; async function makeTempDir(): Promise { @@ -13,15 +14,18 @@ async function makeTempDir(): Promise { describe("DocumentService", () => { let tempDir: string; + let mediaDir: string; let service: DocumentService; beforeEach(async () => { tempDir = await makeTempDir(); - service = new DocumentService(tempDir); + mediaDir = await makeTempDir(); + service = new DocumentService(tempDir, mediaDir); }); afterEach(async () => { await fs.rm(tempDir, { recursive: true, force: true }); + await fs.rm(mediaDir, { recursive: true, force: true }); }); describe("createProject", () => { @@ -63,6 +67,74 @@ describe("DocumentService", () => { await expect(service.getProject("../etc/passwd")).rejects.toBeInstanceOf(ProjectFileError); await expect(service.getProject("proj/with/slash")).rejects.toBeInstanceOf(ProjectFileError); }); + + // Issue #212 — a project authored on another machine opens with every asset + // pointing at a path that does not exist here. The relink runs on this read, + // not on import, so a document already saved broken still recovers. + describe("relinking moved media", () => { + const stalePath = "C:\\Users\\demo\\recording-42.mp4"; + const staleWebcamPath = "C:\\Users\\demo\\recording-42-webcam.mp4"; + const screenBytes = "screen bytes"; + let screenPath: string; + let webcamPath: string; + let logged: string[]; + + beforeEach(async () => { + screenPath = path.join(mediaDir, "recording-42.mp4"); + webcamPath = path.join(mediaDir, "recording-42-webcam.mp4"); + await fs.writeFile(screenPath, screenBytes, "utf8"); + await fs.writeFile(webcamPath, "webcam bytes", "utf8"); + await registerMediaLinks(mediaDir, screenPath, { webcamVideoPath: webcamPath }); + logged = []; + const record = (...args: unknown[]) => { + logged.push(args.join(" ")); + }; + vi.spyOn(console, "log").mockImplementation(record); + vi.spyOn(console, "warn").mockImplementation(record); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function writeStaleProject(sizeBytes: number | undefined): Promise { + const doc = await service.createProject("Moved media"); + const asset: AxcutAsset = { + id: "asset_moved", + kind: "video", + label: "recording-42.mp4", + originalPath: stalePath, + sizeBytes, + cameraTrack: { sourcePath: staleWebcamPath, startMs: 0, offsetMs: 0, visible: true }, + }; + await service.saveProject({ + ...doc, + assets: [asset], + project: { ...doc.project, primaryAssetId: asset.id }, + }); + return doc.project.id; + } + + it("repoints screen and webcam paths at the registry's copies", async () => { + const projectId = await writeStaleProject(Buffer.byteLength(screenBytes)); + const loaded = await service.getProject(projectId); + expect(loaded.assets[0]?.originalPath).toBe(screenPath); + expect(loaded.assets[0]?.cameraTrack?.sourcePath).toBe(webcamPath); + // The renderer saves what it is handed, so a rewrite must be traceable. + expect(logged.join("\n")).toContain(screenPath); + }); + + it("leaves the paths alone when the document recorded no file size", async () => { + // Every v1.7-migrated document is in this state: only addAsset records a + // size. Matching on the basename alone would hand this project a + // different recording — and that recording's webcam — without a word. + const projectId = await writeStaleProject(undefined); + const loaded = await service.getProject(projectId); + expect(loaded.assets[0]?.originalPath).toBe(stalePath); + expect(loaded.assets[0]?.cameraTrack?.sourcePath).toBe(staleWebcamPath); + expect(logged.join("\n")).toContain(stalePath); + }); + }); }); describe("listProjects", () => { @@ -91,7 +163,7 @@ describe("DocumentService", () => { // A fresh service (new process) must still surface and load it, renaming // the file across in the process. - const fresh = new DocumentService(tempDir); + const fresh = new DocumentService(tempDir, mediaDir); const summaries = await fresh.listProjects(); expect(summaries.map((s) => s.id)).toEqual([created.project.id]); await expect(fresh.getProject(created.project.id)).resolves.toMatchObject({ diff --git a/electron/ai-edition/document-service.ts b/electron/ai-edition/document-service.ts index 3a1663cfd..3c93e3bc0 100644 --- a/electron/ai-edition/document-service.ts +++ b/electron/ai-edition/document-service.ts @@ -21,6 +21,7 @@ import { documentSchema, migrateRawDocumentToCurrent, } from "../../src/lib/ai-edition/schema"; +import { relinkProjectMedia } from "../media/projectMediaRelinker"; const PROJECT_FILE_EXTENSION = ".openscreen"; // Older builds stored these same v3/v4 AxcutDocuments under `.axcut`. We read @@ -86,6 +87,8 @@ function safeProjectId(raw: string): string { // `documentSchema.parse` is now a pure v6 validator — every JSON-read path // (list, get, future bulk-export) must run the upgrader chain first via this // helper so the in-memory parse is a single `z.literal(6)` + shape check. +// `getProject` spells the same two steps out inline because it relinks moved +// media between them; keep the order (upgrade, then validate) in step. function parseLoadedDocument(raw: string): AxcutDocument { return documentSchema.parse(migrateRawDocumentToCurrent(JSON.parse(raw))); } @@ -113,12 +116,17 @@ async function renameWithRetry(from: string, to: string): Promise { export class DocumentService { private readonly projectsRoot: string; + private readonly mediaRegistryDir: string; private legacyMigrationDone = false; /** Tail of the in-flight save chain per project id — see writeProject. */ private readonly writeQueues = new Map>(); - constructor(projectsRoot: string) { + // `mediaRegistryDir` is where the media-links registry file lives + // (RECORDINGS_DIR in production) — see getProject. Injected for the same + // reason as `projectsRoot`: this module stays free of any `electron` import. + constructor(projectsRoot: string, mediaRegistryDir: string) { this.projectsRoot = projectsRoot; + this.mediaRegistryDir = mediaRegistryDir; } async ensureProjectsDir(): Promise { @@ -223,7 +231,17 @@ export class DocumentService { ); } } - return parseLoadedDocument(raw); + // Relink here rather than in the .openscreen import handlers, because this + // is the one place every open funnels through — the project picker, the + // agent, and the auto-load-last-project effect on launch. A document whose + // media moved (or that was authored on another machine, issue #212) is + // otherwise re-read as broken on every subsequent open, and media that + // moves after the import is never noticed at all. The relink is applied to + // the upgraded JSON so `documentSchema.parse` still validates what we hand + // back, and it is not persisted from here: the renderer saves the document + // it was given, as it does for any other load-time repair. + const migrated = migrateRawDocumentToCurrent(JSON.parse(raw)); + return documentSchema.parse(await relinkProjectMedia(migrated, this.mediaRegistryDir)); } async createProject(title: string): Promise { diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 715dc8515..3800671e5 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -62,6 +62,7 @@ import { readCursorTelemetryFile as readCursorTelemetryFileFrom, } from "../media/cursorSidecar"; import { findMediaLinksByFingerprint, registerMediaLinks } from "../media/mediaLinksRegistry"; +import { relinkProjectMedia } from "../media/projectMediaRelinker"; import { type LinuxCaptureSourceKind, LinuxNativeCaptureSession, @@ -3544,9 +3545,18 @@ export function registerIpcHandlers( const filePath = result.filePaths[0]; const content = await fs.readFile(filePath, "utf-8"); - const project = JSON.parse(content); + const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR); currentProjectPath = filePath; - setCurrentRecordingSessionState(await getApprovedProjectSession(project, filePath)); + let session: RecordingSession | null = null; + try { + session = await getApprovedProjectSession(project, filePath); + } catch (sessionError) { + console.warn( + "[loadProjectFile] Could not approve session paths, proceeding without session:", + sessionError, + ); + } + setCurrentRecordingSessionState(session); return { success: true, @@ -3581,7 +3591,7 @@ export function registerIpcHandlers( return { success: false, message: "File not found" }; } const content = await fs.readFile(filePath, "utf-8"); - const project = JSON.parse(content); + const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR); currentProjectPath = filePath; // Approve session paths but tolerate failures (e.g. video moved outside trusted @@ -3816,7 +3826,10 @@ export function registerIpcHandlers( // race destroyed two real project files), so a second instance means a second // queue racing for the same path: temp+rename still keeps the file valid, but // a save can land under a concurrent one and be silently lost. - const aiEditionDocuments = new DocumentService(path.join(app.getPath("userData"), "projects")); + const aiEditionDocuments = new DocumentService( + path.join(app.getPath("userData"), "projects"), + RECORDINGS_DIR, + ); // LlmConfigStore is single-instance for a duller reason — its constructor does // two sync readFileSync plus a safeStorage decrypt, and it was running on every diff --git a/electron/media/mediaLinksRegistry.test.ts b/electron/media/mediaLinksRegistry.test.ts index 9e6521ebb..8d224094b 100644 --- a/electron/media/mediaLinksRegistry.test.ts +++ b/electron/media/mediaLinksRegistry.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { computeFingerprint, findMediaLinksByFingerprint, + findRelocatedMediaByStoredPath, registerMediaLinks, } from "./mediaLinksRegistry"; @@ -84,6 +85,58 @@ describe("mediaLinksRegistry", () => { }); describe("resolution via fingerprint (moved/imported-elsewhere)", () => { + it("finds a registry-known recording from a stale cross-platform path", async () => { + const currentDir = path.join(tempDir, "current-machine"); + await fs.mkdir(currentDir, { recursive: true }); + const currentScreenPath = path.join(currentDir, "recording-42.mp4"); + const webcamPath = path.join(currentDir, "recording-42-webcam.mp4"); + await writeFileOfSize(currentScreenPath, 5_000, "s"); + await writeFileOfSize(webcamPath, 3_000, "w"); + await registerMediaLinks(tempDir, currentScreenPath, { webcamVideoPath: webcamPath }); + + const resolved = await findRelocatedMediaByStoredPath( + tempDir, + "C:\\Users\\demo\\recording-42.mp4", + 5_000, + ); + expect(resolved).toMatchObject({ + screenVideoPath: currentScreenPath, + webcamVideoPath: webcamPath, + }); + }); + + it("refuses to guess when multiple existing recordings match the stored name and size", async () => { + for (const [folder, fill] of [ + ["first", "a"], + ["second", "b"], + ] as const) { + const currentDir = path.join(tempDir, folder); + await fs.mkdir(currentDir, { recursive: true }); + const screenPath = path.join(currentDir, "recording.mp4"); + await writeFileOfSize(screenPath, 5_000, fill); + await registerMediaLinks(tempDir, screenPath, { + webcamVideoPath: `${screenPath}.webcam`, + }); + } + + await expect( + findRelocatedMediaByStoredPath(tempDir, "C:\\Users\\demo\\recording.mp4", 5_000), + ).resolves.toBeNull(); + }); + + it("rejects a registry candidate whose contents changed after registration", async () => { + const screenPath = path.join(tempDir, "recording-changed.mp4"); + await writeFileOfSize(screenPath, 5_000, "a"); + await registerMediaLinks(tempDir, screenPath, { + webcamVideoPath: `${screenPath}.webcam`, + }); + await writeFileOfSize(screenPath, 5_001, "b"); + + await expect( + findRelocatedMediaByStoredPath(tempDir, "C:\\Users\\demo\\recording-changed.mp4", 5_000), + ).resolves.toBeNull(); + }); + it("re-links a copy of the screen video at a brand new path with no sidecars", async () => { const originalDir = await makeTempDir(); try { diff --git a/electron/media/mediaLinksRegistry.ts b/electron/media/mediaLinksRegistry.ts index 974266e30..5fafb9a02 100644 --- a/electron/media/mediaLinksRegistry.ts +++ b/electron/media/mediaLinksRegistry.ts @@ -227,6 +227,63 @@ export interface MediaLinksLookup { cursorCaptureMode?: CursorCaptureMode; } +export interface RelocatedMediaLookup extends MediaLinksLookup { + screenVideoPath: string; +} + +function portableBasename(filePath: string): string { + return filePath.split(/[\\/]/).filter(Boolean).pop() ?? ""; +} + +/** + * Resolves a stored media path that no longer exists on this machine through + * the registry's last-known path. This is intentionally stricter than a plain + * basename lookup: `sizeBytes` — the size the project recorded for that file — + * must match the registered fingerprint, the candidate on disk must still match + * that fingerprint size, and ambiguous matches are rejected rather than guessing + * at the user's media. + * + * `sizeBytes` is not optional on purpose. A name-only match is worthless as a + * safety check — `recording.mp4` is the least distinctive name a screen recorder + * can produce — and repointing a project at unrelated footage (plus whatever + * webcam that footage was recorded with) is worse than leaving it visibly + * broken. A caller that has no recorded size has nothing to match on and must + * not relink at all. + */ +export async function findRelocatedMediaByStoredPath( + baseDir: string, + stalePath: string, + sizeBytes: number, +): Promise { + const basename = portableBasename(stalePath).toLowerCase(); + if (!basename) return null; + if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return null; + + const registry = await readRegistry(baseDir); + const matches: MediaLinkEntry[] = []; + + for (const entry of registry.entries) { + if (portableBasename(entry.lastKnownPath).toLowerCase() !== basename) continue; + if (entry.fingerprint.sizeBytes !== sizeBytes) continue; + try { + const current = await fs.stat(entry.lastKnownPath); + if (current.isFile() && current.size === entry.fingerprint.sizeBytes) matches.push(entry); + } catch { + // A stale registry entry is not a usable relocation candidate. + } + } + + if (matches.length !== 1) return null; + const match = matches[0]; + return { + screenVideoPath: match.lastKnownPath, + ...(match.webcamVideoPath ? { webcamVideoPath: match.webcamVideoPath } : {}), + ...(typeof match.webcamOffsetMs === "number" ? { webcamOffsetMs: match.webcamOffsetMs } : {}), + ...(match.cursorTelemetryPath ? { cursorTelemetryPath: match.cursorTelemetryPath } : {}), + ...(match.cursorCaptureMode ? { cursorCaptureMode: match.cursorCaptureMode } : {}), + }; +} + /** * Looks up `videoPath` in the registry by content fingerprint — used as the * fallback when the file has no (or a stale) sidecar sitting next to it, diff --git a/electron/media/projectMediaRelinker.test.ts b/electron/media/projectMediaRelinker.test.ts new file mode 100644 index 000000000..2b52d32b5 --- /dev/null +++ b/electron/media/projectMediaRelinker.test.ts @@ -0,0 +1,121 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerMediaLinks } from "./mediaLinksRegistry"; +import { relinkProjectMedia } from "./projectMediaRelinker"; + +describe("relinkProjectMedia", () => { + let tempDir: string; + let logged: string[]; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-project-relink-")); + logged = []; + const record = (...args: unknown[]) => { + logged.push(args.join(" ")); + }; + vi.spyOn(console, "log").mockImplementation(record); + vi.spyOn(console, "warn").mockImplementation(record); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it("relinks stale screen and webcam paths without mutating the loaded project", async () => { + const currentScreenPath = path.join(tempDir, "recording-42.mp4"); + const currentWebcamPath = path.join(tempDir, "recording-42-webcam.mp4"); + await fs.writeFile(currentScreenPath, "screen bytes"); + await fs.writeFile(currentWebcamPath, "webcam bytes"); + await registerMediaLinks(tempDir, currentScreenPath, { + webcamVideoPath: currentWebcamPath, + }); + + const project = { + assets: [ + { + id: "asset-1", + originalPath: "C:\\Users\\demo\\recording-42.mp4", + sizeBytes: Buffer.byteLength("screen bytes"), + cameraTrack: { + sourcePath: "C:\\Users\\demo\\recording-42-webcam.mp4", + startMs: 0, + offsetMs: 0, + visible: true, + }, + }, + ], + }; + + const relinked = (await relinkProjectMedia(project, tempDir)) as typeof project; + + expect(relinked.assets[0].originalPath).toBe(currentScreenPath); + expect(relinked.assets[0].cameraTrack.sourcePath).toBe(currentWebcamPath); + expect(project.assets[0].originalPath).toBe("C:\\Users\\demo\\recording-42.mp4"); + expect(project.assets[0].cameraTrack.sourcePath).toBe( + "C:\\Users\\demo\\recording-42-webcam.mp4", + ); + // The renderer persists whatever it was handed, so both rewrites are logged. + expect(logged.join("\n")).toContain(currentScreenPath); + expect(logged.join("\n")).toContain(currentWebcamPath); + }); + + it("refuses to relink an asset the document recorded no size for", async () => { + // A same-named recording exists and is registered with its webcam, so a + // basename match would resolve — that is exactly what must not happen. The + // project has no fingerprint to check it against, and every document + // migrated from v1.7 is in that state, so the only safe answer is no. + const currentScreenPath = path.join(tempDir, "recording-42.mp4"); + const currentWebcamPath = path.join(tempDir, "recording-42-webcam.mp4"); + await fs.writeFile(currentScreenPath, "screen bytes"); + await fs.writeFile(currentWebcamPath, "webcam bytes"); + await registerMediaLinks(tempDir, currentScreenPath, { + webcamVideoPath: currentWebcamPath, + }); + + const project = { + assets: [ + { + id: "asset-1", + originalPath: "C:\\Users\\demo\\recording-42.mp4", + cameraTrack: { sourcePath: "C:\\Users\\demo\\recording-42-webcam.mp4", visible: true }, + }, + ], + }; + + const relinked = (await relinkProjectMedia(project, tempDir)) as typeof project; + + expect(logged.join("\n")).toContain("recording-42.mp4"); + expect(relinked.assets[0].originalPath).toBe("C:\\Users\\demo\\recording-42.mp4"); + expect(relinked.assets[0].cameraTrack.sourcePath).toBe( + "C:\\Users\\demo\\recording-42-webcam.mp4", + ); + }); + + it("preserves unresolved screen and webcam paths without mutating the project", async () => { + const project = { + assets: [ + { + id: "asset-missing", + originalPath: "C:\\Users\\demo\\missing.mp4", + sizeBytes: 42, + cameraTrack: { + sourcePath: "C:\\Users\\demo\\missing-webcam.mp4", + visible: true, + }, + }, + ], + }; + const before = structuredClone(project); + + const relinked = (await relinkProjectMedia(project, tempDir)) as typeof project; + + expect(relinked).toEqual(project); + expect(relinked).not.toBe(project); + expect(relinked.assets[0].originalPath).toBe("C:\\Users\\demo\\missing.mp4"); + expect(relinked.assets[0].cameraTrack.sourcePath).toBe("C:\\Users\\demo\\missing-webcam.mp4"); + expect(project).toEqual(before); + }); +}); diff --git a/electron/media/projectMediaRelinker.ts b/electron/media/projectMediaRelinker.ts new file mode 100644 index 000000000..0c0b8c259 --- /dev/null +++ b/electron/media/projectMediaRelinker.ts @@ -0,0 +1,101 @@ +// Relinks the media a project points at when those files are no longer where +// the document says they are — the project was authored on another machine, or +// the recordings were moved after it was last saved. Runs on every project open +// (DocumentService.getProject), not just on import, because a document already +// broken by a move stays broken otherwise. +// +// ponytail: this rewrites paths that the renderer then saves back, so it is +// deliberately conservative — it only ever accepts a candidate the media-links +// registry can vouch for by recorded size, and it logs every rewrite. Guessing +// wrong here means the user opens a project and silently gets someone else's +// footage, which is worse than opening it with a missing-media placeholder. + +import fs from "node:fs/promises"; +import { + findMediaLinksByFingerprint, + findRelocatedMediaByStoredPath, + type RelocatedMediaLookup, +} from "./mediaLinksRegistry"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +async function fileExists(filePath: string): Promise { + try { + return (await fs.stat(filePath)).isFile(); + } catch { + return false; + } +} + +async function resolveAssetMedia( + asset: Record, + baseDir: string, +): Promise> { + const originalPath = asset.originalPath; + if (typeof originalPath !== "string" || !originalPath) return asset; + + const cameraTrack = asset.cameraTrack; + const cameraPath = + isRecord(cameraTrack) && typeof cameraTrack.sourcePath === "string" && cameraTrack.sourcePath + ? cameraTrack.sourcePath + : null; + const screenExists = await fileExists(originalPath); + const cameraMissing = cameraPath !== null && !(await fileExists(cameraPath)); + // Nothing to repair, and this runs on every project open — don't fingerprint + // (i.e. open and read) every asset just to confirm what the stats already say. + if (screenExists && !cameraMissing) return asset; + + let links: RelocatedMediaLookup | null = null; + if (screenExists) { + try { + const existing = await findMediaLinksByFingerprint(baseDir, originalPath); + links = existing ? { screenVideoPath: originalPath, ...existing } : null; + } catch { + links = null; + } + } else if (typeof asset.sizeBytes === "number") { + links = await findRelocatedMediaByStoredPath(baseDir, originalPath, asset.sizeBytes); + if (links) { + console.log(`[media-relink] screen video ${originalPath} -> ${links.screenVideoPath}`); + } + } else { + // Documents migrated from v1.7 carry no size (only DocumentService.addAsset + // records one), so this is the common case for old projects. Without it + // there is nothing to tell one `recording.mp4` from another. + console.warn( + `[media-relink] ${originalPath} is missing and the project recorded no file size for it — refusing to guess a replacement`, + ); + } + if (!links) return asset; + + let nextCameraTrack = cameraTrack; + if ( + isRecord(cameraTrack) && + cameraMissing && + links.webcamVideoPath && + (await fileExists(links.webcamVideoPath)) + ) { + console.log(`[media-relink] webcam video ${cameraPath} -> ${links.webcamVideoPath}`); + nextCameraTrack = { ...cameraTrack, sourcePath: links.webcamVideoPath }; + } + + return { + ...asset, + originalPath: links.screenVideoPath, + ...(nextCameraTrack === cameraTrack ? {} : { cameraTrack: nextCameraTrack }), + }; +} + +/** + * Relink registry-known media in a loaded Axcut document without mutating the + * parsed JSON. Unknown project shapes and unresolved assets pass through. + */ +export async function relinkProjectMedia(project: unknown, baseDir: string): Promise { + if (!isRecord(project) || !Array.isArray(project.assets)) return project; + const assets = await Promise.all( + project.assets.map((asset) => (isRecord(asset) ? resolveAssetMedia(asset, baseDir) : asset)), + ); + return { ...project, assets }; +}