diff --git a/js/CanvasIO.js b/js/CanvasIO.js index 6646b41..af60c57 100644 --- a/js/CanvasIO.js +++ b/js/CanvasIO.js @@ -3,6 +3,7 @@ import { createModuleLogger } from "./log_system/log_funcs.js"; import { showErrorNotification } from "./utils/NotificationUtils.js"; import { webSocketManager } from "./utils/WebSocketManager.js"; import { scaleImageToFit, loadImage, blobToDataUrl, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js"; +import { postImageBlob } from "./utils/ImageUploadUtils.js"; const log = createModuleLogger('CanvasIO'); export class CanvasIO { constructor(canvas) { @@ -112,14 +113,8 @@ export class CanvasIO { if (!blobWithoutMask) return; log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); - const formDataWithoutMask = new FormData(); - formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); - formDataWithoutMask.append("overwrite", "true"); try { - const response = await fetch("/upload/image", { - method: "POST", - body: formDataWithoutMask, - }); + const response = await postImageBlob({ blob: blobWithoutMask, filename: fileNameWithoutMask }, fetch); log.debug(`Image without mask upload response: ${response.status}`); } catch (error) { @@ -131,14 +126,8 @@ export class CanvasIO { if (!blob) return; log.debug(`Created blob for main image, size: ${blob.size} bytes`); - const formData = new FormData(); - formData.append("image", blob, fileName); - formData.append("overwrite", "true"); try { - const resp = await fetch("/upload/image", { - method: "POST", - body: formData, - }); + const resp = await postImageBlob({ blob, filename: fileName }, fetch); log.debug(`Main image upload response: ${resp.status}`); if (resp.status === 200) { const maskFileName = fileName.replace('.png', '_mask.png'); @@ -147,14 +136,8 @@ export class CanvasIO { if (!maskBlob) return; log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); - const maskFormData = new FormData(); - maskFormData.append("image", maskBlob, maskFileName); - maskFormData.append("overwrite", "true"); try { - const maskResp = await fetch("/upload/image", { - method: "POST", - body: maskFormData, - }); + const maskResp = await postImageBlob({ blob: maskBlob, filename: maskFileName }, fetch); log.debug(`Mask upload response: ${maskResp.status}`); if (maskResp.status === 200) { const data = await resp.json(); diff --git a/js/utils/ImageUploadUtils.js b/js/utils/ImageUploadUtils.js index 0615e69..0a484df 100644 --- a/js/utils/ImageUploadUtils.js +++ b/js/utils/ImageUploadUtils.js @@ -4,6 +4,18 @@ import { createModuleLogger } from "../log_system/log_funcs.js"; import { withErrorHandling, createValidationError, createNetworkError } from "../ErrorHandler.js"; import { getFlattenedCanvasBlob, supportsFlattenedCanvasBlob } from './CanvasBlobUtils.js'; const log = createModuleLogger('ImageUploadUtils'); +export async function postImageBlob(request, transport = (input, init) => api.fetchApi(input, init)) { + const formData = new FormData(); + formData.append("image", request.blob, request.filename); + formData.append("overwrite", (request.overwrite ?? true).toString()); + if (request.type !== undefined) { + formData.append("type", request.type); + } + return transport("/upload/image", { + method: "POST", + body: formData, + }); +} async function getCanvasBlobForUpload(canvas, uploadOptions, config) { if (!canvas) { throw createValidationError("Canvas is required", { canvas }); @@ -53,15 +65,12 @@ export const uploadImageBlob = withErrorHandling(async function (blob, options = type, overwrite }); - // Create FormData - const formData = new FormData(); - formData.append("image", blob, filename); - formData.append("overwrite", overwrite.toString()); - formData.append("type", type); // Upload to server - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + const response = await postImageBlob({ + blob, + filename, + overwrite, + type }); if (!response.ok) { throw createNetworkError(`Failed to upload image: ${response.statusText}`, { diff --git a/src/CanvasIO.ts b/src/CanvasIO.ts index 5a192c2..0434a6e 100644 --- a/src/CanvasIO.ts +++ b/src/CanvasIO.ts @@ -3,6 +3,7 @@ import { createModuleLogger } from "./log_system/log_funcs.js"; import { showErrorNotification } from "./utils/NotificationUtils.js"; import { webSocketManager } from "./utils/WebSocketManager.js"; import { scaleImageToFit, loadImage, blobToDataUrl, tensorToImageData, createImageFromImageData } from "./utils/ImageUtils.js"; +import { postImageBlob } from "./utils/ImageUploadUtils.js"; import type { Canvas } from './Canvas'; import type { Layer, Shape, AddMode } from './types'; @@ -153,15 +154,12 @@ export class CanvasIO { tempCanvas.toBlob(async (blobWithoutMask) => { if (!blobWithoutMask) return; log.debug(`Created blob for image without mask, size: ${blobWithoutMask.size} bytes`); - const formDataWithoutMask = new FormData(); - formDataWithoutMask.append("image", blobWithoutMask, fileNameWithoutMask); - formDataWithoutMask.append("overwrite", "true"); try { - const response = await fetch("/upload/image", { - method: "POST", - body: formDataWithoutMask, - }); + const response = await postImageBlob( + { blob: blobWithoutMask, filename: fileNameWithoutMask }, + fetch + ); log.debug(`Image without mask upload response: ${response.status}`); } catch (error) { log.error(`Error uploading image without mask:`, error); @@ -171,15 +169,12 @@ export class CanvasIO { tempCanvas.toBlob(async (blob) => { if (!blob) return; log.debug(`Created blob for main image, size: ${blob.size} bytes`); - const formData = new FormData(); - formData.append("image", blob, fileName); - formData.append("overwrite", "true"); try { - const resp = await fetch("/upload/image", { - method: "POST", - body: formData, - }); + const resp = await postImageBlob( + { blob, filename: fileName }, + fetch + ); log.debug(`Main image upload response: ${resp.status}`); if (resp.status === 200) { @@ -189,15 +184,12 @@ export class CanvasIO { maskCanvas.toBlob(async (maskBlob) => { if (!maskBlob) return; log.debug(`Created blob for mask, size: ${maskBlob.size} bytes`); - const maskFormData = new FormData(); - maskFormData.append("image", maskBlob, maskFileName); - maskFormData.append("overwrite", "true"); try { - const maskResp = await fetch("/upload/image", { - method: "POST", - body: maskFormData, - }); + const maskResp = await postImageBlob( + { blob: maskBlob, filename: maskFileName }, + fetch + ); log.debug(`Mask upload response: ${maskResp.status}`); if (maskResp.status === 200) { diff --git a/src/utils/ImageUploadUtils.ts b/src/utils/ImageUploadUtils.ts index 8690e6e..d643824 100644 --- a/src/utils/ImageUploadUtils.ts +++ b/src/utils/ImageUploadUtils.ts @@ -39,6 +39,32 @@ interface CanvasBlobUploadConfig { emptyBlobMessage: string; } +interface ImageBlobUploadRequest { + blob: Blob; + filename: string; + overwrite?: boolean; + type?: string; +} + +type ImageUploadTransport = (input: string, init: RequestInit) => Promise; + +export async function postImageBlob( + request: ImageBlobUploadRequest, + transport: ImageUploadTransport = (input, init) => api.fetchApi(input, init) +): Promise { + const formData = new FormData(); + formData.append("image", request.blob, request.filename); + formData.append("overwrite", (request.overwrite ?? true).toString()); + if (request.type !== undefined) { + formData.append("type", request.type); + } + + return transport("/upload/image", { + method: "POST", + body: formData, + }); +} + async function getCanvasBlobForUpload( canvas: any, uploadOptions: UploadImageOptions, @@ -104,16 +130,12 @@ export const uploadImageBlob = withErrorHandling(async function(blob: Blob, opti overwrite }); - // Create FormData - const formData = new FormData(); - formData.append("image", blob, filename); - formData.append("overwrite", overwrite.toString()); - formData.append("type", type); - // Upload to server - const response = await api.fetchApi("/upload/image", { - method: "POST", - body: formData, + const response = await postImageBlob({ + blob, + filename, + overwrite, + type }); if (!response.ok) { diff --git a/tests/test_canvas_renderer_characterization.mjs b/tests/test_canvas_renderer_characterization.mjs index 7522b2e..d6415e7 100644 --- a/tests/test_canvas_renderer_characterization.mjs +++ b/tests/test_canvas_renderer_characterization.mjs @@ -24,7 +24,7 @@ test('output paths preserve their current render bounds and mask responsibilitie test('output rendering keeps persistence and transport in CanvasIO', () => { assert.match(canvasIOSource, /async _performSave\(fileName: string, outputMode: string\)/); assert.match(canvasIOSource, /tempCanvas\.toDataURL\('image\/png'\)/); - assert.match(canvasIOSource, /fetch\("\/upload\/image"/); + assert.equal((canvasIOSource.match(/postImageBlob\(/g) ?? []).length, 3); assert.match(canvasLayersSource, /async getFlattenedCanvasAsBlob\(\)/); assert.match(canvasLayersSource, /async getFlattenedMaskAsBlob\(\)/); }); diff --git a/tests/test_image_upload_transport_characterization.mjs b/tests/test_image_upload_transport_characterization.mjs new file mode 100644 index 0000000..f186cbc --- /dev/null +++ b/tests/test_image_upload_transport_characterization.mjs @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const [canvasIOSource, imageUploadSource] = await Promise.all([ + readFile(new URL('../src/CanvasIO.ts', import.meta.url), 'utf8'), + readFile(new URL('../src/utils/ImageUploadUtils.ts', import.meta.url), 'utf8'), +]); + +test('current upload callers preserve their endpoint and form-data contracts', () => { + assert.equal((canvasIOSource.match(/new FormData\(\)/g) ?? []).length, 0); + assert.equal((canvasIOSource.match(/fetch\("\/upload\/image"/g) ?? []).length, 0); + assert.equal((canvasIOSource.match(/postImageBlob\(/g) ?? []).length, 3); + assert.equal((imageUploadSource.match(/new FormData\(\)/g) ?? []).length, 1); + assert.equal((imageUploadSource.match(/api\.fetchApi\(/g) ?? []).length, 1); + + assert.match(canvasIOSource, /postImageBlob\(\s*\{ blob: blobWithoutMask, filename: fileNameWithoutMask \},\s*fetch\s*\)/s); + assert.match(canvasIOSource, /postImageBlob\(\s*\{ blob, filename: fileName \},\s*fetch\s*\)/s); + assert.match(canvasIOSource, /postImageBlob\(\s*\{ blob: maskBlob, filename: maskFileName \},\s*fetch\s*\)/s); + + assert.match(imageUploadSource, /export async function postImageBlob\(/); + assert.match(imageUploadSource, /formData\.append\("image", request\.blob, request\.filename\)/); + assert.match(imageUploadSource, /formData\.append\("overwrite", \(request\.overwrite \?\? true\)\.toString\(\)\)/); + assert.match(imageUploadSource, /formData\.append\("type", request\.type\)/); + assert.match(imageUploadSource, /return transport\("\/upload\/image"/); + assert.match(imageUploadSource, /postImageBlob\(\{[\s\S]*?overwrite,[\s\S]*?type/s); +});