From 304804dae84ee5498667de19b695dc4ae116886d Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Tue, 4 Aug 2026 04:38:58 +0530 Subject: [PATCH 1/3] fix(projects): relink moved media from registry --- electron/ipc/handlers.ts | 7 ++- electron/media/mediaLinksRegistry.test.ts | 40 ++++++++++++ electron/media/mediaLinksRegistry.ts | 52 ++++++++++++++++ electron/media/projectMediaRelinker.test.ts | 53 ++++++++++++++++ electron/media/projectMediaRelinker.ts | 69 +++++++++++++++++++++ 5 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 electron/media/projectMediaRelinker.test.ts create mode 100644 electron/media/projectMediaRelinker.ts diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index 715dc85153..b84b233982 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,7 +3545,7 @@ 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)); @@ -3581,7 +3582,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 @@ -3618,7 +3619,7 @@ export function registerIpcHandlers( } const content = await fs.readFile(currentProjectPath, "utf-8"); - const project = JSON.parse(content); + const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR); setCurrentRecordingSessionState(await getApprovedProjectSession(project, currentProjectPath)); return { success: true, diff --git a/electron/media/mediaLinksRegistry.test.ts b/electron/media/mediaLinksRegistry.test.ts index 9e6521ebb1..24e21a90c5 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,45 @@ 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("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 974266e302..a2c1206441 100644 --- a/electron/media/mediaLinksRegistry.ts +++ b/electron/media/mediaLinksRegistry.ts @@ -227,6 +227,58 @@ 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: when the project recorded a file size it must match the + * fingerprint, the candidate on disk must still match that fingerprint size, + * and ambiguous matches are rejected rather than guessing at the user's media. + */ +export async function findRelocatedMediaByStoredPath( + baseDir: string, + stalePath: string, + sizeBytes?: number, +): Promise { + const basename = portableBasename(stalePath).toLocaleLowerCase(); + if (!basename) return null; + + const expectedSize = + typeof sizeBytes === "number" && Number.isFinite(sizeBytes) && sizeBytes >= 0 + ? sizeBytes + : undefined; + const registry = await readRegistry(baseDir); + const matches: MediaLinkEntry[] = []; + + for (const entry of registry.entries) { + if (portableBasename(entry.lastKnownPath).toLocaleLowerCase() !== basename) continue; + if (expectedSize !== undefined && entry.fingerprint.sizeBytes !== expectedSize) 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 0000000000..8ce4f5cddf --- /dev/null +++ b/electron/media/projectMediaRelinker.test.ts @@ -0,0 +1,53 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { registerMediaLinks } from "./mediaLinksRegistry"; +import { relinkProjectMedia } from "./projectMediaRelinker"; + +describe("relinkProjectMedia", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openscreen-project-relink-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + 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", + ); + }); +}); diff --git a/electron/media/projectMediaRelinker.ts b/electron/media/projectMediaRelinker.ts new file mode 100644 index 0000000000..52faa426fc --- /dev/null +++ b/electron/media/projectMediaRelinker.ts @@ -0,0 +1,69 @@ +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; + + let links: RelocatedMediaLookup | null = null; + if (await fileExists(originalPath)) { + try { + const existing = await findMediaLinksByFingerprint(baseDir, originalPath); + links = existing ? { screenVideoPath: originalPath, ...existing } : null; + } catch { + links = null; + } + } else { + links = await findRelocatedMediaByStoredPath( + baseDir, + originalPath, + typeof asset.sizeBytes === "number" ? asset.sizeBytes : undefined, + ); + } + if (!links) return asset; + + let cameraTrack = asset.cameraTrack; + if (isRecord(cameraTrack) && typeof cameraTrack.sourcePath === "string") { + const cameraIsMissing = !(await fileExists(cameraTrack.sourcePath)); + if (cameraIsMissing && links.webcamVideoPath && (await fileExists(links.webcamVideoPath))) { + cameraTrack = { ...cameraTrack, sourcePath: links.webcamVideoPath }; + } + } + + return { + ...asset, + originalPath: links.screenVideoPath, + ...(cameraTrack === asset.cameraTrack ? {} : { cameraTrack }), + }; +} + +/** + * 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 }; +} From 94889b580edf3d1f38f52716eedaf6c2f3511440 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Tue, 4 Aug 2026 04:51:02 +0530 Subject: [PATCH 2/3] fix(projects): keep unresolved projects loadable --- electron/ipc/handlers.ts | 22 ++++++++++++++++-- electron/media/mediaLinksRegistry.test.ts | 13 +++++++++++ electron/media/mediaLinksRegistry.ts | 4 ++-- electron/media/projectMediaRelinker.test.ts | 25 +++++++++++++++++++++ 4 files changed, 60 insertions(+), 4 deletions(-) diff --git a/electron/ipc/handlers.ts b/electron/ipc/handlers.ts index b84b233982..c60cd6d968 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3547,7 +3547,16 @@ export function registerIpcHandlers( const content = await fs.readFile(filePath, "utf-8"); 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, @@ -3620,7 +3629,16 @@ export function registerIpcHandlers( const content = await fs.readFile(currentProjectPath, "utf-8"); const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR); - setCurrentRecordingSessionState(await getApprovedProjectSession(project, currentProjectPath)); + let session: RecordingSession | null = null; + try { + session = await getApprovedProjectSession(project, currentProjectPath); + } catch (sessionError) { + console.warn( + "[loadCurrentProjectFile] Could not approve session paths, proceeding without session:", + sessionError, + ); + } + setCurrentRecordingSessionState(session); return { success: true, path: currentProjectPath, diff --git a/electron/media/mediaLinksRegistry.test.ts b/electron/media/mediaLinksRegistry.test.ts index 24e21a90c5..8d224094b4 100644 --- a/electron/media/mediaLinksRegistry.test.ts +++ b/electron/media/mediaLinksRegistry.test.ts @@ -124,6 +124,19 @@ describe("mediaLinksRegistry", () => { ).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 a2c1206441..3e2722cd8b 100644 --- a/electron/media/mediaLinksRegistry.ts +++ b/electron/media/mediaLinksRegistry.ts @@ -247,7 +247,7 @@ export async function findRelocatedMediaByStoredPath( stalePath: string, sizeBytes?: number, ): Promise { - const basename = portableBasename(stalePath).toLocaleLowerCase(); + const basename = portableBasename(stalePath).toLowerCase(); if (!basename) return null; const expectedSize = @@ -258,7 +258,7 @@ export async function findRelocatedMediaByStoredPath( const matches: MediaLinkEntry[] = []; for (const entry of registry.entries) { - if (portableBasename(entry.lastKnownPath).toLocaleLowerCase() !== basename) continue; + if (portableBasename(entry.lastKnownPath).toLowerCase() !== basename) continue; if (expectedSize !== undefined && entry.fingerprint.sizeBytes !== expectedSize) continue; try { const current = await fs.stat(entry.lastKnownPath); diff --git a/electron/media/projectMediaRelinker.test.ts b/electron/media/projectMediaRelinker.test.ts index 8ce4f5cddf..d3ee472857 100644 --- a/electron/media/projectMediaRelinker.test.ts +++ b/electron/media/projectMediaRelinker.test.ts @@ -50,4 +50,29 @@ describe("relinkProjectMedia", () => { "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); + }); }); From 27df811550763d698a79b67ce18be888febafc12 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Tue, 4 Aug 2026 14:53:53 +0200 Subject: [PATCH 3/3] fix(projects): relink moved media where every project open passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relink only ran in the `.openscreen` import handlers, which is the one moment a project is least likely to need it. Every real open — the project picker, the agent, the auto-load-last-project effect on launch — goes through DocumentService.getProject and never touched the stored paths, so a document already broken by issue #212 was re-read as broken forever, and media moved after the import was never noticed at all. The hook moves to that choke point, applied to the upgraded JSON so `documentSchema.parse` still validates what we hand back. The two import handlers keep their own call: they hand the parsed project straight to the renderer and use it to approve the recording session, before anything has been through DocumentService at all. loadCurrentProjectFile is dropped from the patch rather than moved. It is plumbed all the way out through preload, the native bridge and projectService, and nothing under src/ ever calls it, so patching it only made the diff look like it covered more ground than it did. An asset with no recorded `sizeBytes` is now refused instead of falling back to a case-insensitive basename compare. That fallback was not a weaker match, it was no match: `recording.mp4` is the least distinctive name a screen recorder can produce, and hitting it repointed the project at unrelated footage AND attached that footage's webcam, silently, with the renderer persisting the result on the next save. `sizeBytes` is optional in the schema and only DocumentService.addAsset ever writes it — migrateProjectDataToAxcutDocument does not — so every document migrated from v1.7 took that path. Media the user can see is missing is recoverable; media that is quietly someone else's is not. The size is now a required argument of findRelocatedMediaByStoredPath, so a caller with nothing to match on cannot ask for a guess. Every rewrite is logged, and so is the refusal: this changes paths the renderer then writes back to disk, which should not happen without a trace. Two stats, not a fingerprint, now decide whether there is anything to repair at all. With this running on every open (and on addAsset/removeAsset through getProject), the previous unconditional registry lookup would open and read 128KB of every asset in the project each time just to confirm that the paths already resolve. Known gap, unchanged by this: registerMediaLinks early-returns unless a webcam or cursor sidecar is known, so a screen-only recording never enters the registry and cannot be relinked by this mechanism at all. Issue #212's literal repro still needs either the "locate this media" prompt it asks for, or fingerprints persisted into the document. --- electron/ai-edition/chat-service.test.ts | 7 +- electron/ai-edition/document-service.test.ts | 78 +++++++++++++++++++- electron/ai-edition/document-service.ts | 22 +++++- electron/ipc/handlers.ts | 18 ++--- electron/media/mediaLinksRegistry.ts | 23 +++--- electron/media/projectMediaRelinker.test.ts | 45 ++++++++++- electron/media/projectMediaRelinker.ts | 56 +++++++++++--- 7 files changed, 207 insertions(+), 42 deletions(-) diff --git a/electron/ai-edition/chat-service.test.ts b/electron/ai-edition/chat-service.test.ts index 1f78e0dcc4..081aa9a85c 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 39ed1f2e0e..995044f753 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 3a1663cfd3..3c93e3bc0d 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 c60cd6d968..3800671e5b 100644 --- a/electron/ipc/handlers.ts +++ b/electron/ipc/handlers.ts @@ -3628,17 +3628,8 @@ export function registerIpcHandlers( } const content = await fs.readFile(currentProjectPath, "utf-8"); - const project = await relinkProjectMedia(JSON.parse(content), RECORDINGS_DIR); - let session: RecordingSession | null = null; - try { - session = await getApprovedProjectSession(project, currentProjectPath); - } catch (sessionError) { - console.warn( - "[loadCurrentProjectFile] Could not approve session paths, proceeding without session:", - sessionError, - ); - } - setCurrentRecordingSessionState(session); + const project = JSON.parse(content); + setCurrentRecordingSessionState(await getApprovedProjectSession(project, currentProjectPath)); return { success: true, path: currentProjectPath, @@ -3835,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.ts b/electron/media/mediaLinksRegistry.ts index 3e2722cd8b..5fafb9a027 100644 --- a/electron/media/mediaLinksRegistry.ts +++ b/electron/media/mediaLinksRegistry.ts @@ -238,28 +238,33 @@ function portableBasename(filePath: string): string { /** * 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: when the project recorded a file size it must match the - * fingerprint, the candidate on disk must still match that fingerprint size, - * and ambiguous matches are rejected rather than guessing at the user's media. + * 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, + sizeBytes: number, ): Promise { const basename = portableBasename(stalePath).toLowerCase(); if (!basename) return null; + if (!Number.isFinite(sizeBytes) || sizeBytes < 0) return null; - const expectedSize = - typeof sizeBytes === "number" && Number.isFinite(sizeBytes) && sizeBytes >= 0 - ? sizeBytes - : undefined; const registry = await readRegistry(baseDir); const matches: MediaLinkEntry[] = []; for (const entry of registry.entries) { if (portableBasename(entry.lastKnownPath).toLowerCase() !== basename) continue; - if (expectedSize !== undefined && entry.fingerprint.sizeBytes !== expectedSize) 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); diff --git a/electron/media/projectMediaRelinker.test.ts b/electron/media/projectMediaRelinker.test.ts index d3ee472857..2b52d32b51 100644 --- a/electron/media/projectMediaRelinker.test.ts +++ b/electron/media/projectMediaRelinker.test.ts @@ -1,19 +1,27 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +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 () => { @@ -49,6 +57,41 @@ describe("relinkProjectMedia", () => { 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 () => { diff --git a/electron/media/projectMediaRelinker.ts b/electron/media/projectMediaRelinker.ts index 52faa426fc..0c0b8c259f 100644 --- a/electron/media/projectMediaRelinker.ts +++ b/electron/media/projectMediaRelinker.ts @@ -1,3 +1,15 @@ +// 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, @@ -24,35 +36,55 @@ async function resolveAssetMedia( 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 (await fileExists(originalPath)) { + 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 { - links = await findRelocatedMediaByStoredPath( - baseDir, - originalPath, - typeof asset.sizeBytes === "number" ? asset.sizeBytes : undefined, + // 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 cameraTrack = asset.cameraTrack; - if (isRecord(cameraTrack) && typeof cameraTrack.sourcePath === "string") { - const cameraIsMissing = !(await fileExists(cameraTrack.sourcePath)); - if (cameraIsMissing && links.webcamVideoPath && (await fileExists(links.webcamVideoPath))) { - cameraTrack = { ...cameraTrack, sourcePath: links.webcamVideoPath }; - } + 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, - ...(cameraTrack === asset.cameraTrack ? {} : { cameraTrack }), + ...(nextCameraTrack === cameraTrack ? {} : { cameraTrack: nextCameraTrack }), }; }