Browse Source
Browser-file-system workflow saves were still bypassing external media storage, so local projects could keep large image, video, and audio data embedded instead of writing refs into the selected browser-backed workspace. The storage path now uses the browser file helpers for image, video, and audio refs and hydrates them back without API calls. Constraint: Keep local browserfs storage on the existing external media storage path instead of adding a new persistence mode. Rejected: Keep browserfs excluded from externalization | it preserves the old large-payload behavior for local projects. Confidence: high Scope-risk: moderate Directive: Do not route browserfs media through server API endpoints; browserfs paths must stay client-local. Tested: npm run test:run Tested: npm run build Tested: git diff --check -- src/store/workflowStore.ts src/utils/browserFileSystem.ts src/utils/mediaStorage.ts src/utils/__tests__/mediaStorage.browserfs.test.ts Not-tested: npm run lint is blocked because next lint is no longer a valid Next 16 command in this repo Not-tested: npx tsc --noEmit reports existing test-file type debt outside this change while next build TypeScript passestest-0523
4 changed files with 304 additions and 8 deletions
@ -0,0 +1,204 @@ |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import { externalizeWorkflowMedia, hydrateWorkflowMedia } from "../mediaStorage"; |
|||
import type { WorkflowFile } from "@/store/workflowStore"; |
|||
|
|||
const browserFsMocks = vi.hoisted(() => ({ |
|||
writeBrowserGenerationFile: vi.fn(), |
|||
loadBrowserGenerationFile: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("../browserFileSystem", () => ({ |
|||
isBrowserFileSystemPath: (path: string | null | undefined) => |
|||
typeof path === "string" && path.startsWith("browserfs://"), |
|||
joinBrowserFileSystemPath: (basePath: string, folderName: string) => |
|||
`${basePath.replace(/\/+$/, "")}/${encodeURIComponent(folderName)}`, |
|||
writeBrowserGenerationFile: browserFsMocks.writeBrowserGenerationFile, |
|||
loadBrowserGenerationFile: browserFsMocks.loadBrowserGenerationFile, |
|||
})); |
|||
|
|||
const imageData = "data:image/png;base64,aW1hZ2U="; |
|||
const videoData = "data:video/mp4;base64,dmlkZW8="; |
|||
const audioData = "data:audio/mpeg;base64,YXVkaW8="; |
|||
const workflowPath = "browserfs://root/project"; |
|||
|
|||
function makeWorkflow(): WorkflowFile { |
|||
return { |
|||
version: 1, |
|||
id: "wf-browserfs", |
|||
name: "browserfs media", |
|||
nodes: [ |
|||
{ |
|||
id: "imageInput-1", |
|||
type: "imageInput", |
|||
position: { x: 0, y: 0 }, |
|||
data: { |
|||
image: imageData, |
|||
filename: "input.png", |
|||
dimensions: null, |
|||
}, |
|||
}, |
|||
{ |
|||
id: "generateVideo-1", |
|||
type: "generateVideo", |
|||
position: { x: 200, y: 0 }, |
|||
data: { |
|||
inputImages: [imageData], |
|||
inputPrompt: "move", |
|||
outputVideo: videoData, |
|||
outputVideoRef: undefined, |
|||
outputVideoRemoteUrl: undefined, |
|||
outputVideoStorageStatus: undefined, |
|||
selectedVideoHistoryIndex: 0, |
|||
videoHistory: [ |
|||
{ |
|||
id: "vid-history-1", |
|||
video: videoData, |
|||
timestamp: 1, |
|||
prompt: "move", |
|||
}, |
|||
], |
|||
status: "completed", |
|||
error: null, |
|||
}, |
|||
}, |
|||
{ |
|||
id: "generateAudio-1", |
|||
type: "generateAudio", |
|||
position: { x: 400, y: 0 }, |
|||
data: { |
|||
inputPrompt: "sound", |
|||
outputAudio: audioData, |
|||
outputAudioRef: undefined, |
|||
selectedAudioHistoryIndex: 0, |
|||
audioHistory: [ |
|||
{ |
|||
id: "aud-history-1", |
|||
audio: audioData, |
|||
timestamp: 1, |
|||
prompt: "sound", |
|||
}, |
|||
], |
|||
status: "completed", |
|||
error: null, |
|||
}, |
|||
}, |
|||
], |
|||
edges: [], |
|||
edgeStyle: "angular", |
|||
} as WorkflowFile; |
|||
} |
|||
|
|||
describe("mediaStorage browserfs support", () => { |
|||
beforeEach(() => { |
|||
browserFsMocks.writeBrowserGenerationFile.mockReset(); |
|||
browserFsMocks.loadBrowserGenerationFile.mockReset(); |
|||
browserFsMocks.writeBrowserGenerationFile.mockResolvedValue({ filePath: "browserfs://root/project/media.bin" }); |
|||
vi.stubGlobal("fetch", vi.fn(async () => ({ |
|||
json: async () => ({ success: true, imageId: "server-ref" }), |
|||
}))); |
|||
}); |
|||
|
|||
it("externalizes browserfs media through browser file helpers instead of API fetch", async () => { |
|||
const workflow = makeWorkflow(); |
|||
|
|||
const externalized = await externalizeWorkflowMedia(workflow, workflowPath); |
|||
|
|||
expect(fetch).not.toHaveBeenCalled(); |
|||
expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith( |
|||
"browserfs://root/project/inputs", |
|||
expect.any(String), |
|||
imageData |
|||
); |
|||
expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith( |
|||
"browserfs://root/project/generations", |
|||
"vid-history-1", |
|||
videoData |
|||
); |
|||
expect(browserFsMocks.writeBrowserGenerationFile).toHaveBeenCalledWith( |
|||
"browserfs://root/project/generations", |
|||
"aud-history-1", |
|||
audioData |
|||
); |
|||
|
|||
expect(externalized.nodes[0].data).toMatchObject({ |
|||
image: null, |
|||
imageRef: expect.any(String), |
|||
}); |
|||
expect(externalized.nodes[1].data).toMatchObject({ |
|||
inputImages: [], |
|||
inputImageRefs: [expect.any(String)], |
|||
outputVideo: null, |
|||
outputVideoRef: "vid-history-1", |
|||
outputVideoStorageStatus: "localized", |
|||
videoHistory: [ |
|||
{ |
|||
id: "vid-history-1", |
|||
timestamp: 1, |
|||
prompt: "move", |
|||
}, |
|||
], |
|||
}); |
|||
expect(externalized.nodes[2].data).toMatchObject({ |
|||
outputAudio: null, |
|||
outputAudioRef: "aud-history-1", |
|||
}); |
|||
}); |
|||
|
|||
it("hydrates browserfs media refs through browser file helpers instead of API fetch", async () => { |
|||
browserFsMocks.loadBrowserGenerationFile.mockImplementation(async (directoryPath: string, mediaId: string) => { |
|||
if (directoryPath.endsWith("/inputs") && mediaId === "input-ref") return imageData; |
|||
if (directoryPath.endsWith("/generations") && mediaId === "video-ref") return videoData; |
|||
if (directoryPath.endsWith("/generations") && mediaId === "audio-ref") return audioData; |
|||
return null; |
|||
}); |
|||
|
|||
const workflow: WorkflowFile = { |
|||
...makeWorkflow(), |
|||
nodes: [ |
|||
{ |
|||
id: "imageInput-1", |
|||
type: "imageInput", |
|||
position: { x: 0, y: 0 }, |
|||
data: { image: null, imageRef: "input-ref" }, |
|||
}, |
|||
{ |
|||
id: "generateVideo-1", |
|||
type: "generateVideo", |
|||
position: { x: 200, y: 0 }, |
|||
data: { |
|||
inputImages: [], |
|||
inputImageRefs: ["input-ref"], |
|||
outputVideo: null, |
|||
outputVideoRef: "video-ref", |
|||
outputVideoStorageStatus: "localized", |
|||
status: "idle", |
|||
error: null, |
|||
}, |
|||
}, |
|||
{ |
|||
id: "generateAudio-1", |
|||
type: "generateAudio", |
|||
position: { x: 400, y: 0 }, |
|||
data: { |
|||
inputPrompt: "sound", |
|||
outputAudio: null, |
|||
outputAudioRef: "audio-ref", |
|||
status: "idle", |
|||
error: null, |
|||
}, |
|||
}, |
|||
], |
|||
} as WorkflowFile; |
|||
|
|||
const hydrated = await hydrateWorkflowMedia(workflow, workflowPath); |
|||
|
|||
expect(fetch).not.toHaveBeenCalled(); |
|||
expect(hydrated.nodes[0].data).toMatchObject({ image: imageData }); |
|||
expect(hydrated.nodes[1].data).toMatchObject({ |
|||
inputImages: [imageData], |
|||
outputVideo: videoData, |
|||
outputVideoStorageStatus: "localized", |
|||
}); |
|||
expect(hydrated.nodes[2].data).toMatchObject({ outputAudio: audioData }); |
|||
}); |
|||
}); |
|||
Loading…
Reference in new issue