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