diff --git a/containers/image-converter/Dockerfile b/containers/image-converter/Dockerfile
deleted file mode 100644
index e422c5ce2..000000000
--- a/containers/image-converter/Dockerfile
+++ /dev/null
@@ -1,11 +0,0 @@
-FROM node:22-trixie-slim
-
-RUN apt-get update \
- && apt-get install -y --no-install-recommends libvips-tools \
- && rm -rf /var/lib/apt/lists/*
-
-WORKDIR /app
-COPY server.mjs /app/server.mjs
-
-EXPOSE 8080
-CMD ["node", "/app/server.mjs"]
diff --git a/containers/image-converter/server.mjs b/containers/image-converter/server.mjs
deleted file mode 100644
index 59ccf9f5f..000000000
--- a/containers/image-converter/server.mjs
+++ /dev/null
@@ -1,170 +0,0 @@
-import { createServer } from "node:http";
-import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import { Readable } from "node:stream";
-import { execFile } from "node:child_process";
-import { promisify } from "node:util";
-
-const execFileAsync = promisify(execFile);
-const port = 8080;
-const maxInputBytes = 100 * 1024 * 1024;
-const jpegQuality = 92;
-const maxOutputBytes = 1024 * 1024;
-const outputProfiles = [
- { dimension: 2048, quality: 85 },
- { dimension: 1280, quality: 75 },
- { dimension: 768, quality: 65 },
- { dimension: 384, quality: 55 },
-];
-
-createServer(async (request, response) => {
- if (request.method === "GET" && request.url === "/health") {
- send(response, 200, "ok");
- return;
- }
-
- const isStandardConversion = request.method === "POST" && request.url === "/convert/jpeg";
- const isChatConversion = request.method === "POST" && request.url === "/convert/chat-jpeg";
-
- if (!isStandardConversion && !isChatConversion) {
- send(response, 404, "Not found.");
- return;
- }
-
- try {
- const jpeg = await convertRequestImageToJpeg(request, { bounded: isChatConversion });
- response.writeHead(200, {
- "Content-Length": String(jpeg.byteLength),
- "Content-Type": "image/jpeg",
- });
- response.end(jpeg);
- } catch (error) {
- const status = error instanceof ConversionError ? error.status : 500;
- send(response, status, error instanceof Error ? error.message : "Image conversion failed.");
- }
-}).listen(port, "0.0.0.0");
-
-async function convertRequestImageToJpeg(incomingMessage, options) {
- const contentLength = Number(incomingMessage.headers["content-length"] ?? 0);
-
- if (!Number.isFinite(contentLength) || contentLength <= 0) {
- throw new ConversionError(400, "Missing image upload.");
- }
-
- if (contentLength > maxInputBytes) {
- throw new ConversionError(400, "Image upload is too large.");
- }
-
- const request = new Request(`http://image-file-converter${incomingMessage.url}`, {
- body: Readable.toWeb(incomingMessage),
- duplex: "half",
- headers: incomingMessage.headers,
- method: incomingMessage.method,
- });
- const formData = await request.formData();
- const file = formData.get("file");
-
- if (!(file instanceof File)) {
- throw new ConversionError(400, "Missing image upload.");
- }
-
- const inputBytes = Buffer.from(await file.arrayBuffer());
-
- if (inputBytes.byteLength === 0) {
- throw new ConversionError(400, "Missing image upload.");
- }
-
- return convertImageBytesToJpeg(inputBytes, options);
-}
-
-async function convertImageBytesToJpeg(inputBytes, options) {
- const tempDir = await mkdtemp(join(tmpdir(), "thinkex-image-"));
-
- try {
- const inputPath = join(tempDir, "input.heic");
-
- await writeFile(inputPath, inputBytes);
- if (!options.bounded) {
- const outputPath = join(tempDir, "output.jpg");
- await execFileAsync("vips", ["autorot", inputPath, `${outputPath}[Q=${jpegQuality}]`], {
- timeout: 60_000,
- });
- return requireImageOutput(await readFile(outputPath));
- }
-
- for (const [index, profile] of outputProfiles.entries()) {
- const outputPath = join(tempDir, `output-${index}.jpg`);
- await createJpegThumbnail(inputPath, outputPath, profile);
- const outputBytes = await readFile(outputPath);
-
- requireImageOutput(outputBytes);
-
- if (outputBytes.byteLength <= maxOutputBytes) {
- return outputBytes;
- }
- }
-
- throw new ConversionError(422, "Image conversion did not produce an output.");
- } catch (error) {
- if (error instanceof ConversionError) {
- throw error;
- }
-
- const message = getProcessErrorMessage(error);
- throw new ConversionError(422, message || "Image conversion failed.");
- } finally {
- await rm(tempDir, { force: true, recursive: true });
- }
-}
-
-function requireImageOutput(outputBytes) {
- if (outputBytes.byteLength === 0) {
- throw new ConversionError(422, "Image conversion returned an empty JPEG.");
- }
-
- return outputBytes;
-}
-
-async function createJpegThumbnail(inputPath, outputPath, profile) {
- await execFileAsync(
- "vips",
- [
- "thumbnail",
- inputPath,
- `${outputPath}[Q=${profile.quality},strip]`,
- String(profile.dimension),
- "--height",
- String(profile.dimension),
- "--size",
- "down",
- "--auto-rotate",
- ],
- { timeout: 60_000 },
- );
-}
-
-function send(response, status, message) {
- response.writeHead(status, {
- "Content-Type": "text/plain; charset=utf-8",
- });
- response.end(message);
-}
-
-function getProcessErrorMessage(error) {
- if (typeof error !== "object" || error === null) {
- return "";
- }
-
- const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim() : "";
- const message = error instanceof Error ? error.message.trim() : "";
-
- return stderr || message;
-}
-
-class ConversionError extends Error {
- constructor(status, message) {
- super(message);
- this.status = status;
- }
-}
diff --git a/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx b/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx
index a7bbfc9e0..136914181 100644
--- a/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx
+++ b/src/features/workspaces/components/ai-chat/AiChatAttachmentItem.tsx
@@ -21,6 +21,7 @@ import {
AttachmentTrigger,
} from "#/components/ui/attachment";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "#/components/ui/dialog";
+import { Skeleton } from "#/components/ui/skeleton";
import { Spinner } from "#/components/ui/spinner";
import type {
AttachmentData,
@@ -51,7 +52,7 @@ export function AiChatAttachmentItem({
data: AttachmentData;
onRemove?: () => void;
}) {
- if (isPreviewableImageAttachment(data)) {
+ if (isImageAttachment(data)) {
return ;
}
@@ -64,57 +65,63 @@ export function AiChatAttachmentItem({
);
}
-function isPreviewableImageAttachment(
- data: AttachmentData,
-): data is FileAttachmentData & { status: "ready"; url: string } {
- return (
- data.type === "file" &&
- data.status === "ready" &&
- getMediaCategory(data) === "image" &&
- Boolean(data.url)
- );
+function isImageAttachment(data: AttachmentData): data is FileAttachmentData {
+ return data.type === "file" && getMediaCategory(data) === "image";
}
function AiChatImageAttachment({
data,
onRemove,
}: {
- data: FileAttachmentData & { status: "ready"; url: string };
+ data: FileAttachmentData;
onRemove?: () => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const label = getAttachmentLabel(data);
+ const imageUrl = data.status === "ready" ? data.url : undefined;
return (
<>
-
+ {imageUrl ? (
+
+ ) : (
+ <>
+
+ Preparing {label}
+ >
+ )}
- setIsOpen(true)} />
+ {imageUrl ? (
+ setIsOpen(true)} />
+ ) : null}
-
+ {imageUrl ? (
+
+ ) : null}
>
);
}
diff --git a/src/features/workspaces/conversion/errors.ts b/src/features/workspaces/conversion/errors.ts
index edccbac99..e51bc60c4 100644
--- a/src/features/workspaces/conversion/errors.ts
+++ b/src/features/workspaces/conversion/errors.ts
@@ -1,8 +1,15 @@
+export type WorkspaceFileConversionFailure = "conversion_failed" | "output_too_large";
+
export class WorkspaceFileConversionError extends Error {
readonly userMessage: string;
- constructor(message: string, userMessage: string) {
- super(message);
+ constructor(
+ message: string,
+ userMessage: string,
+ readonly failure: WorkspaceFileConversionFailure = "conversion_failed",
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
this.name = "WorkspaceFileConversionError";
this.userMessage = userMessage;
}
diff --git a/src/features/workspaces/conversion/image-file-converter.ts b/src/features/workspaces/conversion/image-file-converter.ts
deleted file mode 100644
index 8d3c40215..000000000
--- a/src/features/workspaces/conversion/image-file-converter.ts
+++ /dev/null
@@ -1,114 +0,0 @@
-import { Container, getRandom } from "@cloudflare/containers";
-
-import {
- convertFileStreamWithContainer,
- convertFileWithContainer,
-} from "#/features/workspaces/conversion/container-file-conversion";
-import { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors";
-
-const imageConverterPort = 8080;
-const imageConverterPath = "/convert/jpeg";
-const chatImageConverterPath = "/convert/chat-jpeg";
-const jpegContentType = "image/jpeg";
-const converterPoolSize = 1;
-
-export class ImageFileConverter extends Container {
- defaultPort = imageConverterPort;
- requiredPorts = [imageConverterPort];
- sleepAfter = "5m";
- enableInternet = false;
-}
-
-export interface ConvertImageFileToJpegInput {
- file: File;
- fileName: string;
-}
-
-export interface ConvertImageFileToJpegResult {
- bytes: ArrayBuffer;
- contentType: typeof jpegContentType;
- sizeBytes: number;
-}
-
-export class ImageFileConversionError extends WorkspaceFileConversionError {
- constructor(message: string) {
- super(message, "Unable to convert this image right now.");
- this.name = "ImageFileConversionError";
- }
-}
-
-export async function convertImageStreamToJpeg(
- env: Cloudflare.Env,
- input: {
- body: ReadableStream;
- contentType: string;
- fileName: string;
- sizeBytes: number;
- },
-) {
- return convertImageStreamToJpegAtPath(env, input, imageConverterPath);
-}
-
-export async function convertImageStreamToChatJpeg(
- env: Cloudflare.Env,
- input: {
- body: ReadableStream;
- contentType: string;
- fileName: string;
- sizeBytes: number;
- },
-) {
- return convertImageStreamToJpegAtPath(env, input, chatImageConverterPath);
-}
-
-async function convertImageStreamToJpegAtPath(
- env: Cloudflare.Env,
- input: {
- body: ReadableStream;
- contentType: string;
- fileName: string;
- sizeBytes: number;
- },
- path: string,
-) {
- const converter = await getRandom(env.IMAGE_FILE_CONVERTER, converterPoolSize);
-
- return convertFileStreamWithContainer({
- ...input,
- container: converter,
- emptyMessage: "Image conversion returned an empty JPEG.",
- error: (message) => new ImageFileConversionError(message),
- formFieldName: "file",
- url: `http://image-file-converter${path}`,
- });
-}
-
-export async function convertImageFileToChatJpeg(
- env: Cloudflare.Env,
- input: ConvertImageFileToJpegInput,
-): Promise {
- return convertImageFileToJpegAtPath(env, input, chatImageConverterPath);
-}
-
-async function convertImageFileToJpegAtPath(
- env: Cloudflare.Env,
- input: ConvertImageFileToJpegInput,
- path: string,
-): Promise {
- const converter = await getRandom(env.IMAGE_FILE_CONVERTER, converterPoolSize);
- const bytes = await convertFileWithContainer({
- container: converter,
- emptyMessage: "Image conversion returned an empty JPEG.",
- error: (message) => new ImageFileConversionError(message),
- file: input.file,
- fileName: input.fileName,
- formFieldName: "file",
- url: `http://image-file-converter${path}`,
- });
-
- return {
- bytes,
- contentType: jpegContentType,
- sizeBytes: bytes.byteLength,
- };
-}
diff --git a/src/features/workspaces/conversion/image-normalizer.test.ts b/src/features/workspaces/conversion/image-normalizer.test.ts
new file mode 100644
index 000000000..b0d2ea86a
--- /dev/null
+++ b/src/features/workspaces/conversion/image-normalizer.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it, vi } from "vitest";
+
+import type { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors";
+import {
+ normalizeChatImageToJpeg,
+ normalizeImageToJpeg,
+} from "#/features/workspaces/conversion/image-normalizer";
+
+describe("image normalizer", () => {
+ const maxBytes = 1024 * 1024;
+
+ it("streams canonical JPEG output from the Images binding", async () => {
+ const output = new Uint8Array([9, 8, 7]);
+ const images = createImagesBinding([output]);
+ const result = await normalizeImageToJpeg(
+ createEnv(images.binding),
+ stream(new Uint8Array([1, 2, 3])),
+ maxBytes,
+ );
+
+ expect(new Uint8Array(await new Response(result.body).arrayBuffer())).toEqual(output);
+ expect(result.sizeBytes).toBe(output.byteLength);
+ expect(images.input).toHaveBeenCalledTimes(1);
+ expect(images.image).toHaveBeenCalledTimes(1);
+ expect(images.output).toHaveBeenCalledWith({
+ anim: false,
+ background: "#ffffff",
+ format: "image/jpeg",
+ quality: 92,
+ });
+ });
+
+ it("uses one bounded profile for chat images", async () => {
+ const accepted = new Uint8Array([1, 2, 3, 4]);
+ const images = createImagesBinding([accepted]);
+
+ const result = await normalizeChatImageToJpeg(
+ createEnv(images.binding),
+ stream(new Uint8Array([1, 2, 3])),
+ maxBytes,
+ );
+
+ expect(new Uint8Array(result.bytes)).toEqual(accepted);
+ expect(images.input).toHaveBeenCalledTimes(1);
+ expect(images.transform).toHaveBeenCalledWith({
+ fit: "scale-down",
+ height: 1024,
+ width: 1024,
+ });
+ expect(images.output).toHaveBeenCalledWith({
+ anim: false,
+ background: "#ffffff",
+ format: "image/jpeg",
+ quality: 70,
+ });
+ });
+
+ it("translates Images failures at the Module boundary", async () => {
+ const cause = new Error("binding unavailable");
+ const images = createImagesBinding([cause]);
+
+ await expect(
+ normalizeChatImageToJpeg(
+ createEnv(images.binding),
+ stream(new Uint8Array([1, 2, 3])),
+ maxBytes,
+ ),
+ ).rejects.toMatchObject({
+ cause,
+ message: "binding unavailable",
+ name: "ImageNormalizationError",
+ userMessage: "Unable to convert this image right now.",
+ } satisfies Partial);
+ });
+
+ it("rejects chat images that exceed the normalized output limit", async () => {
+ const oversized = new Uint8Array(1024 * 1024 + 1);
+ const images = createImagesBinding([oversized]);
+
+ await expect(
+ normalizeChatImageToJpeg(
+ createEnv(images.binding),
+ stream(new Uint8Array([1, 2, 3])),
+ maxBytes,
+ ),
+ ).rejects.toMatchObject({
+ failure: "output_too_large",
+ name: "ImageNormalizationError",
+ } satisfies Partial);
+ expect(images.output).toHaveBeenCalledTimes(1);
+ });
+
+ it("bounds canonical workspace JPEG output", async () => {
+ const images = createImagesBinding([new Uint8Array(maxBytes + 1)]);
+
+ await expect(
+ normalizeImageToJpeg(createEnv(images.binding), stream(new Uint8Array([1, 2, 3])), maxBytes),
+ ).rejects.toMatchObject({
+ failure: "output_too_large",
+ name: "ImageNormalizationError",
+ });
+ });
+
+ it.each(["workspace", "chat"] as const)("rejects empty %s image output", async (kind) => {
+ const images = createImagesBinding([new Uint8Array()]);
+
+ const result =
+ kind === "workspace"
+ ? normalizeImageToJpeg(
+ createEnv(images.binding),
+ stream(new Uint8Array([1, 2, 3])),
+ maxBytes,
+ )
+ : normalizeChatImageToJpeg(
+ createEnv(images.binding),
+ stream(new Uint8Array([1, 2, 3])),
+ maxBytes,
+ );
+
+ await expect(result).rejects.toMatchObject({
+ message: "Image conversion returned an empty JPEG.",
+ name: "ImageNormalizationError",
+ });
+ expect(images.image).toHaveBeenCalledTimes(1);
+ });
+});
+
+function createImagesBinding(outputs: Array) {
+ let outputIndex = 0;
+ const image = vi.fn((value: Uint8Array) => stream(value));
+ const output = vi.fn(async () => {
+ const value = outputs[outputIndex++];
+
+ if (!value) {
+ throw new Error("Missing fake Images output.");
+ }
+ if (value instanceof Error) {
+ throw value;
+ }
+
+ return {
+ contentType: () => "image/jpeg",
+ image: () => image(value),
+ response: () =>
+ new Response(value.slice().buffer, {
+ headers: {
+ "content-type": "image/jpeg",
+ },
+ }),
+ } satisfies ImageTransformationResult;
+ });
+ const transform = vi.fn(() => ({ output }));
+ const input = vi.fn(() => ({ output, transform }));
+
+ return {
+ binding: { input } as unknown as ImagesBinding,
+ image,
+ input,
+ output,
+ transform,
+ };
+}
+
+function createEnv(images: ImagesBinding) {
+ return { IMAGES: images } as Cloudflare.Env;
+}
+
+function stream(bytes: Uint8Array) {
+ const body = new Response(bytes.slice().buffer).body;
+
+ if (!body) {
+ throw new Error("Test stream was not created.");
+ }
+
+ return body;
+}
diff --git a/src/features/workspaces/conversion/image-normalizer.ts b/src/features/workspaces/conversion/image-normalizer.ts
new file mode 100644
index 000000000..35666cf10
--- /dev/null
+++ b/src/features/workspaces/conversion/image-normalizer.ts
@@ -0,0 +1,155 @@
+import {
+ WorkspaceFileConversionError,
+ type WorkspaceFileConversionFailure,
+} from "#/features/workspaces/conversion/errors";
+import type { SizedResponseBody } from "#/lib/http/sized-response-body";
+
+const jpegContentType = "image/jpeg";
+const workspaceProfile = { quality: 92 } as const;
+const chatProfile = { dimension: 1024, quality: 70 } as const;
+
+type ImageProfile = {
+ dimension?: number;
+ quality: number;
+};
+
+interface NormalizedChatImage {
+ bytes: ArrayBuffer;
+ contentType: typeof jpegContentType;
+ sizeBytes: number;
+}
+
+class ImageNormalizationError extends WorkspaceFileConversionError {
+ constructor(
+ message: string,
+ failure: WorkspaceFileConversionFailure = "conversion_failed",
+ options?: ErrorOptions,
+ ) {
+ super(message, "Unable to convert this image right now.", failure, options);
+ this.name = "ImageNormalizationError";
+ }
+}
+
+export async function normalizeImageToJpeg(
+ env: Cloudflare.Env,
+ body: ReadableStream,
+ maxBytes: number,
+): Promise {
+ return translateImageNormalizationErrors(async () => {
+ const bytes = await normalizeWithProfile(env, body, workspaceProfile, maxBytes);
+
+ return {
+ body: new Blob([bytes], { type: jpegContentType }).stream(),
+ sizeBytes: bytes.byteLength,
+ };
+ });
+}
+
+export async function normalizeChatImageToJpeg(
+ env: Cloudflare.Env,
+ body: ReadableStream,
+ maxBytes: number,
+): Promise {
+ return translateImageNormalizationErrors(async () => {
+ const bytes = await normalizeWithProfile(env, body, chatProfile, maxBytes);
+
+ return {
+ bytes,
+ contentType: jpegContentType,
+ sizeBytes: bytes.byteLength,
+ };
+ });
+}
+
+async function normalizeWithProfile(
+ env: Cloudflare.Env,
+ body: ReadableStream,
+ profile: ImageProfile,
+ maxBytes: number,
+): Promise {
+ let transformer = env.IMAGES.input(body);
+
+ if (profile.dimension) {
+ transformer = transformer.transform({
+ fit: "scale-down",
+ height: profile.dimension,
+ width: profile.dimension,
+ });
+ }
+
+ const result = await transformer.output({
+ anim: false,
+ background: "#ffffff",
+ format: jpegContentType,
+ quality: profile.quality,
+ });
+
+ return readStreamWithinLimit(result.image(), maxBytes);
+}
+
+async function readStreamWithinLimit(
+ body: ReadableStream,
+ maxBytes: number,
+): Promise {
+ const reader = body.getReader();
+ const chunks: Uint8Array[] = [];
+ let totalBytes = 0;
+
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ if (totalBytes + value.byteLength > maxBytes) {
+ await reader.cancel("Image output exceeds the byte limit.");
+ throw createImageOutputTooLargeError(maxBytes);
+ }
+
+ chunks.push(value);
+ totalBytes += value.byteLength;
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ if (totalBytes === 0) {
+ throw createEmptyImageError();
+ }
+
+ const bytes = new Uint8Array(totalBytes);
+ let offset = 0;
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+
+ return bytes.buffer;
+}
+
+function createEmptyImageError() {
+ return new ImageNormalizationError("Image conversion returned an empty JPEG.");
+}
+
+function createImageOutputTooLargeError(maxBytes: number) {
+ return new ImageNormalizationError(
+ `Cloudflare Images output exceeds the ${maxBytes}-byte limit.`,
+ "output_too_large",
+ );
+}
+
+async function translateImageNormalizationErrors(run: () => Promise): Promise {
+ try {
+ return await run();
+ } catch (error) {
+ if (error instanceof ImageNormalizationError) {
+ throw error;
+ }
+
+ throw new ImageNormalizationError(
+ error instanceof Error ? error.message : "Image normalization failed.",
+ "conversion_failed",
+ { cause: error },
+ );
+ }
+}
diff --git a/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts b/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts
index 918e563fc..3aedc7cf8 100644
--- a/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts
+++ b/src/features/workspaces/extraction/providers/workers-ai-to-markdown.ts
@@ -1,4 +1,5 @@
-import { convertImageStreamToChatJpeg } from "#/features/workspaces/conversion/image-file-converter";
+import { normalizeChatImageToJpeg } from "#/features/workspaces/conversion/image-normalizer";
+import { WORKSPACE_AI_CHAT_ATTACHMENT_POLICY } from "#/features/workspaces/ai/chat-attachment-policy";
import type {
MarkdownExtractionProvider,
MarkdownExtractionResult,
@@ -9,17 +10,15 @@ export function createWorkersAiToMarkdownProvider(env: Env): MarkdownExtractionP
return {
id: "workers_ai_to_markdown",
async extract(input) {
- const conversion = await convertImageStreamToChatJpeg(env, {
- body: input.body,
- contentType: input.contentType,
- fileName: input.fileName,
- sizeBytes: input.sizeBytes,
- });
- const bytes = await new Response(conversion.body).arrayBuffer();
+ const conversion = await normalizeChatImageToJpeg(
+ env,
+ input.body,
+ WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize,
+ );
const result = await env.AI.toMarkdown(
{
name: input.fileName,
- blob: new Blob([bytes], {
+ blob: new Blob([conversion.bytes], {
type: "image/jpeg",
}),
},
diff --git a/src/features/workspaces/model/workspace-file/limits.ts b/src/features/workspaces/model/workspace-file/limits.ts
index 4fbc68117..deb5b0a1c 100644
--- a/src/features/workspaces/model/workspace-file/limits.ts
+++ b/src/features/workspaces/model/workspace-file/limits.ts
@@ -1,6 +1,7 @@
export const workspaceFileUploadLimits = {
maxFilesPerSelection: 50,
maxFileBytes: 100 * 1024 * 1024,
+ maxImageFileBytes: 20 * 1024 * 1024,
maxSelectionBytes: 100 * 1024 * 1024,
maxDocumentImportBytes: 10 * 1024 * 1024,
concurrency: 3,
diff --git a/src/features/workspaces/upload/workspace-file-upload-storage.test.ts b/src/features/workspaces/upload/workspace-file-upload-storage.test.ts
index be85e0086..60bf94fdf 100644
--- a/src/features/workspaces/upload/workspace-file-upload-storage.test.ts
+++ b/src/features/workspaces/upload/workspace-file-upload-storage.test.ts
@@ -6,14 +6,15 @@ import {
} from "#/features/workspaces/model/workspace-file";
import { finalizeWorkspaceFileUploadStorage } from "#/features/workspaces/upload/workspace-file-upload-storage";
-const { convertImageStreamToJpeg, convertOfficeStreamToPdf, createWorkspaceFilePreview } =
- vi.hoisted(() => ({
- convertImageStreamToJpeg: vi.fn(),
+const { normalizeImageToJpeg, convertOfficeStreamToPdf, createWorkspaceFilePreview } = vi.hoisted(
+ () => ({
+ normalizeImageToJpeg: vi.fn(),
convertOfficeStreamToPdf: vi.fn(),
createWorkspaceFilePreview: vi.fn(),
- }));
-vi.mock("#/features/workspaces/conversion/image-file-converter", () => ({
- convertImageStreamToJpeg,
+ }),
+);
+vi.mock("#/features/workspaces/conversion/image-normalizer", () => ({
+ normalizeImageToJpeg,
}));
vi.mock("#/features/workspaces/conversion/office-pdf-converter", () => ({
convertOfficeStreamToPdf,
@@ -45,7 +46,7 @@ beforeAll(() => {
describe("workspace file upload storage", () => {
beforeEach(() => {
- convertImageStreamToJpeg.mockReset();
+ normalizeImageToJpeg.mockReset();
convertOfficeStreamToPdf.mockReset();
createWorkspaceFilePreview.mockReset().mockResolvedValue({
body: stream(previewBytes),
@@ -90,7 +91,7 @@ describe("workspace file upload storage", () => {
it("streams conversion output to R2 and records source provenance", async () => {
const bucket = createR2Bucket();
const converted = new Uint8Array([9, 8, 7]);
- convertImageStreamToJpeg.mockResolvedValue({
+ normalizeImageToJpeg.mockResolvedValue({
body: stream(converted),
sizeBytes: converted.byteLength,
});
diff --git a/src/features/workspaces/upload/workspace-file-upload-storage.ts b/src/features/workspaces/upload/workspace-file-upload-storage.ts
index 662947888..e9dd962f4 100644
--- a/src/features/workspaces/upload/workspace-file-upload-storage.ts
+++ b/src/features/workspaces/upload/workspace-file-upload-storage.ts
@@ -1,4 +1,4 @@
-import { convertImageStreamToJpeg } from "#/features/workspaces/conversion/image-file-converter";
+import { normalizeImageToJpeg } from "#/features/workspaces/conversion/image-normalizer";
import { convertOfficeStreamToPdf } from "#/features/workspaces/conversion/office-pdf-converter";
import {
createWorkspaceFilePreview,
@@ -36,21 +36,6 @@ export interface StoredWorkspaceFileUpload {
type PreparedWorkspaceFileUpload = Omit;
-type WorkspaceUploadStreamConverter = (
- env: Cloudflare.Env,
- input: {
- body: ReadableStream;
- contentType: string;
- fileName: string;
- sizeBytes: number;
- },
-) => Promise<{ body: ReadableStream; sizeBytes: number }>;
-
-const defaultConverters = {
- heic_to_jpeg: convertImageStreamToJpeg,
- office_to_pdf: convertOfficeStreamToPdf,
-} satisfies Record;
-
interface FinalizeWorkspaceFileUploadStorageInput {
contentType: string;
descriptor: WorkspaceFileTypeDescriptor;
@@ -156,13 +141,7 @@ async function convertAndStoreWorkspaceFileUpload(
throw new Error("Converted workspace uploads must use a temporary input object.");
}
- const response = await defaultConverters[conversion](input.env, {
- body: input.uploadedObject.body,
- contentType: input.contentType,
- fileName: input.fileName,
- sizeBytes: input.fileSize,
- });
- const contentType = getConvertedContentType(conversion);
+ const { contentType, response } = await convertWorkspaceFileUpload(input, conversion);
const fileName = getWorkspaceConvertedFileName(input.fileName, conversion);
const descriptor = requireWorkspaceFileTypeFromHint({ fileName, contentType });
const stored = await putFixedLengthR2Object(
@@ -194,8 +173,30 @@ async function convertAndStoreWorkspaceFileUpload(
};
}
-function getConvertedContentType(conversion: WorkspaceUploadConversion) {
- return conversion === "office_to_pdf" ? "application/pdf" : "image/jpeg";
+async function convertWorkspaceFileUpload(
+ input: FinalizeWorkspaceFileUploadStorageInput,
+ conversion: WorkspaceUploadConversion,
+) {
+ if (conversion === "office_to_pdf") {
+ return {
+ contentType: "application/pdf",
+ response: await convertOfficeStreamToPdf(input.env, {
+ body: input.uploadedObject.body,
+ contentType: input.contentType,
+ fileName: input.fileName,
+ sizeBytes: input.fileSize,
+ }),
+ };
+ }
+
+ return {
+ contentType: "image/jpeg",
+ response: await normalizeImageToJpeg(
+ input.env,
+ input.uploadedObject.body,
+ workspaceFileUploadLimits.maxImageFileBytes,
+ ),
+ };
}
function createConvertedFileSizeError(): WorkspaceFileUploadError {
diff --git a/src/features/workspaces/upload/workspace-upload-intake.test.ts b/src/features/workspaces/upload/workspace-upload-intake.test.ts
index ae6bedb64..992f5249c 100644
--- a/src/features/workspaces/upload/workspace-upload-intake.test.ts
+++ b/src/features/workspaces/upload/workspace-upload-intake.test.ts
@@ -32,6 +32,30 @@ describe("workspace upload intake", () => {
});
});
+ it("bounds images at the Images binding input limit", () => {
+ expect(
+ validateWorkspaceUpload({
+ contentType: "image/heic",
+ fileName: "photo.heic",
+ sizeBytes: workspaceFileUploadLimits.maxImageFileBytes,
+ }),
+ ).toMatchObject({ ok: true, plan: { kind: "file" } });
+ expect(
+ validateWorkspaceUpload({
+ contentType: "image/jpeg",
+ fileName: "photo.jpg",
+ sizeBytes: workspaceFileUploadLimits.maxImageFileBytes + 1,
+ }),
+ ).toMatchObject({
+ error: {
+ code: "SELECTION_TOO_LARGE",
+ message: "Upload images up to 20 MB.",
+ status: 413,
+ },
+ ok: false,
+ });
+ });
+
it("bounds document imports before they are materialized in Worker memory", () => {
expect(
validateWorkspaceUpload({
diff --git a/src/features/workspaces/upload/workspace-upload-intake.ts b/src/features/workspaces/upload/workspace-upload-intake.ts
index 7523c4cb2..15296731e 100644
--- a/src/features/workspaces/upload/workspace-upload-intake.ts
+++ b/src/features/workspaces/upload/workspace-upload-intake.ts
@@ -134,6 +134,21 @@ export function validateWorkspaceUpload(input: {
};
}
+ if (
+ plan.kind === "file" &&
+ plan.descriptor.assetKind === "image" &&
+ input.sizeBytes > workspaceFileUploadLimits.maxImageFileBytes
+ ) {
+ return {
+ error: {
+ code: "SELECTION_TOO_LARGE",
+ message: "Upload images up to 20 MB.",
+ status: 413,
+ },
+ ok: false,
+ };
+ }
+
if (input.sizeBytes > workspaceFileUploadLimits.maxFileBytes) {
return {
error: {
diff --git a/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts b/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts
index 0561bab42..1699b425c 100644
--- a/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts
+++ b/src/routes/api/v1/workspaces.$workspaceId.ai-threads.$threadId.attachments.ts
@@ -7,8 +7,8 @@ import {
getChatAttachmentContentUrl,
getChatAttachmentObjectKey,
} from "#/features/workspaces/ai/chat-attachment-storage";
-import { convertImageFileToChatJpeg } from "#/features/workspaces/conversion/image-file-converter";
import { WorkspaceFileConversionError } from "#/features/workspaces/conversion/errors";
+import { normalizeChatImageToJpeg } from "#/features/workspaces/conversion/image-normalizer";
import {
observeWorkspaceFileIntake,
type WorkspaceFileIntakeObservation,
@@ -72,22 +72,14 @@ async function executeChatAttachmentUpload(
);
}
- const normalized = await convertImageFileToChatJpeg(env, {
- file,
- fileName: file.name,
- });
+ const normalized = await normalizeChatImageToJpeg(
+ env,
+ file.stream(),
+ WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize,
+ );
observation.conversion = "image_to_jpeg";
observation.outputBytes = normalized.sizeBytes;
- if (normalized.sizeBytes > WORKSPACE_AI_CHAT_ATTACHMENT_POLICY.maxNormalizedFileSize) {
- return apiError(
- requestId,
- 413,
- "ATTACHMENT_TOO_LARGE",
- "This image is too detailed to attach after optimization.",
- );
- }
-
const attachmentId = crypto.randomUUID();
const identity = { attachmentId, threadId, workspaceId };
objectKey = getChatAttachmentObjectKey({ ...identity, userId: authorized.userId });
@@ -112,6 +104,14 @@ async function executeChatAttachmentUpload(
} catch (error) {
observation.error = error;
if (error instanceof WorkspaceFileConversionError) {
+ if (error.failure === "output_too_large") {
+ return apiError(
+ requestId,
+ 413,
+ "ATTACHMENT_TOO_LARGE",
+ "This image is too detailed to attach after optimization.",
+ );
+ }
return apiError(requestId, 422, "CONVERSION_FAILED", error.userMessage);
}
diff --git a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts
index 439b1d8d3..27601cb4e 100644
--- a/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts
+++ b/src/routes/api/v1/workspaces.$workspaceId.file-upload.ts
@@ -354,6 +354,14 @@ function workspaceUploadErrorResponse(requestId: string, error: unknown) {
}
if (error instanceof WorkspaceFileConversionError) {
+ if (error.failure === "output_too_large") {
+ return apiError(
+ requestId,
+ 413,
+ "SELECTION_TOO_LARGE",
+ "This image is too detailed to upload after optimization.",
+ );
+ }
return apiError(requestId, 422, "CONVERSION_FAILED", error.userMessage);
}
diff --git a/src/server.ts b/src/server.ts
index f2ecb2d21..039b2143b 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -12,7 +12,6 @@ import { buildContentSecurityPolicy } from "#/lib/http/content-security-policy";
export { CodemodeRuntime } from "@cloudflare/codemode";
export { Sandbox } from "@cloudflare/sandbox";
export { AIThread, UserAIStore } from "#/features/workspaces/ai/user-ai-agents";
-export { ImageFileConverter } from "#/features/workspaces/conversion/image-file-converter";
export { OfficePdfConverter } from "#/features/workspaces/conversion/office-pdf-converter";
export { DocumentSession } from "#/features/workspaces/documents/document-session";
export { WorkspaceFileExtractionWorkflow } from "#/features/workspaces/extraction/workspace-file-extraction-workflow";
diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts
index a9e7da700..5ff33da39 100644
--- a/worker-configuration.d.ts
+++ b/worker-configuration.d.ts
@@ -1,6 +1,6 @@
/* eslint-disable */
-// Generated by Wrangler by running `wrangler types` (hash: 58de4e88b517f3692dd84708e9339a59)
-// Runtime types generated with workerd@1.20260701.1 2026-03-24 nodejs_compat
+// Generated by Wrangler by running `wrangler types` (hash: 5348c83093b741e804d43d2ed057d3fa)
+// Runtime types generated with workerd@1.20260722.1 2026-03-24 nodejs_compat
interface __BaseEnv_Env {
WORKSPACE_KERNEL_FILES: R2Bucket;
DB: D1Database;
@@ -8,6 +8,7 @@ interface __BaseEnv_Env {
LOADER: WorkerLoader;
BROWSER: BrowserRun;
AI: Ai;
+ IMAGES: ImagesBinding;
CF_VERSION_METADATA: WorkerVersionMetadata;
FIRECRAWL_API_URL: "https://api.firecrawl.dev";
R2_BUCKET_NAME: "thinkex-workspace-kernel-files-staging" | "thinkex-workspace-kernel-files";
@@ -30,14 +31,13 @@ interface __BaseEnv_Env {
DocumentSession: DurableObjectNamespace;
CODE_SANDBOX: DurableObjectNamespace;
OFFICE_PDF_CONVERTER: DurableObjectNamespace;
- IMAGE_FILE_CONVERTER: DurableObjectNamespace;
WORKSPACE_FILE_PROCESSOR: DurableObjectNamespace;
WORKSPACE_FILE_EXTRACTION_WORKFLOW: Workflow[0]['payload']>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./src/server");
- durableNamespaces: "UserAIStore" | "WorkspaceKernel" | "DocumentSession" | "OfficePdfConverter" | "Sandbox" | "ImageFileConverter" | "WorkspaceFileProcessor";
+ durableNamespaces: "UserAIStore" | "WorkspaceKernel" | "DocumentSession" | "OfficePdfConverter" | "Sandbox" | "WorkspaceFileProcessor";
}
interface StagingEnv {
WORKSPACE_KERNEL_FILES: R2Bucket;
@@ -46,6 +46,7 @@ declare namespace Cloudflare {
LOADER: WorkerLoader;
BROWSER: BrowserRun;
AI: Ai;
+ IMAGES: ImagesBinding;
CF_VERSION_METADATA: WorkerVersionMetadata;
FIRECRAWL_API_URL: "https://api.firecrawl.dev";
R2_BUCKET_NAME: "thinkex-workspace-kernel-files-staging";
@@ -68,7 +69,6 @@ declare namespace Cloudflare {
DocumentSession: DurableObjectNamespace;
CODE_SANDBOX: DurableObjectNamespace;
OFFICE_PDF_CONVERTER: DurableObjectNamespace;
- IMAGE_FILE_CONVERTER: DurableObjectNamespace;
WORKSPACE_FILE_PROCESSOR: DurableObjectNamespace;
WORKSPACE_FILE_EXTRACTION_WORKFLOW: Workflow[0]['payload']>;
}
@@ -79,6 +79,7 @@ declare namespace Cloudflare {
LOADER: WorkerLoader;
BROWSER: BrowserRun;
AI: Ai;
+ IMAGES: ImagesBinding;
CF_VERSION_METADATA: WorkerVersionMetadata;
FIRECRAWL_API_URL: "https://api.firecrawl.dev";
R2_BUCKET_NAME: "thinkex-workspace-kernel-files";
@@ -101,7 +102,6 @@ declare namespace Cloudflare {
DocumentSession: DurableObjectNamespace;
CODE_SANDBOX: DurableObjectNamespace;
OFFICE_PDF_CONVERTER: DurableObjectNamespace;
- IMAGE_FILE_CONVERTER: DurableObjectNamespace;
WORKSPACE_FILE_PROCESSOR: DurableObjectNamespace;
WORKSPACE_FILE_EXTRACTION_WORKFLOW: Workflow[0]['payload']>;
}
@@ -628,7 +628,7 @@ declare abstract class DurableObjectNamespace;
jurisdiction(jurisdiction: DurableObjectJurisdiction): DurableObjectNamespace;
}
-type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high";
+type DurableObjectJurisdiction = "eu" | "fedramp" | "fedramp-high" | "us";
interface DurableObjectNamespaceNewUniqueIdOptions {
jurisdiction?: DurableObjectJurisdiction;
}
@@ -12323,6 +12323,13 @@ interface ForwardableEmailMessage extends EmailMessage {
* @returns A promise that resolves when the email message is replied.
*/
reply(message: EmailMessage): Promise;
+ /**
+ * Reply to the sender of this email message with a message built from the given
+ * fields. Threading headers (In-Reply-To/References) are set automatically.
+ * @param builder The reply message contents.
+ * @returns A promise that resolves when the email message is replied.
+ */
+ reply(builder: EmailReplyMessageBuilder): Promise;
}
/** A file attachment for an email message */
type EmailAttachment = {
@@ -12343,23 +12350,46 @@ interface EmailAddress {
name: string;
email: string;
}
+/**
+ * Recipient fields for `SendEmail.send()`. At least one of `to`, `cc`, or
+ * `bcc` must be provided.
+ */
+type EmailDestinations = {
+ to?: string | EmailAddress | (string | EmailAddress)[];
+ cc?: string | EmailAddress | (string | EmailAddress)[];
+ bcc?: string | EmailAddress | (string | EmailAddress)[];
+} & ({
+ to: string | EmailAddress | (string | EmailAddress)[];
+} | {
+ cc: string | EmailAddress | (string | EmailAddress)[];
+} | {
+ bcc: string | EmailAddress | (string | EmailAddress)[];
+});
+/**
+ * Fields shared by all composed emails (no recipients). Used directly by
+ * `ForwardableEmailMessage.reply()`, which always replies to the original
+ * sender, and extended by `EmailMessageBuilder` for `SendEmail.send()`.
+ */
+interface EmailReplyMessageBuilder {
+ from: string | EmailAddress;
+ subject: string;
+ replyTo?: string | EmailAddress;
+ headers?: Record;
+ text?: string;
+ html?: string;
+ attachments?: EmailAttachment[];
+}
+/**
+ * Fields for composing an email without constructing raw MIME, for
+ * `SendEmail.send()`. Requires at least one of `to`, `cc`, or `bcc`.
+ */
+type EmailMessageBuilder = EmailReplyMessageBuilder & EmailDestinations;
/**
* A binding that allows a Worker to send email messages.
*/
interface SendEmail {
send(message: EmailMessage): Promise;
- send(builder: {
- from: string | EmailAddress;
- to: string | EmailAddress | (string | EmailAddress)[];
- subject: string;
- replyTo?: string | EmailAddress;
- cc?: string | EmailAddress | (string | EmailAddress)[];
- bcc?: string | EmailAddress | (string | EmailAddress)[];
- headers?: Record;
- text?: string;
- html?: string;
- attachments?: EmailAttachment[];
- }): Promise;
+ send(builder: EmailMessageBuilder): Promise;
}
declare abstract class EmailEvent extends ExtendableEvent {
readonly message: ForwardableEmailMessage;
@@ -13181,6 +13211,11 @@ declare namespace CloudflareWorkersModule {
export type WorkflowDurationLabel = 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year';
export type WorkflowSleepDuration = `${number} ${WorkflowDurationLabel}${'s' | ''}` | number;
export type WorkflowDelayDuration = WorkflowSleepDuration;
+ export type WorkflowDynamicDelayContext = {
+ ctx: WorkflowStepContext;
+ error: Error;
+ };
+ export type WorkflowDelayFunction = (input: WorkflowDynamicDelayContext) => WorkflowDelayDuration | Promise;
export type WorkflowTimeoutDuration = WorkflowSleepDuration;
export type WorkflowRetentionDuration = WorkflowSleepDuration;
export type WorkflowBackoff = 'constant' | 'linear' | 'exponential';
@@ -13188,7 +13223,7 @@ declare namespace CloudflareWorkersModule {
export type WorkflowStepConfig = {
retries?: {
limit: number;
- delay: WorkflowDelayDuration | number;
+ delay: WorkflowDelayDuration | number | WorkflowDelayFunction;
backoff?: WorkflowBackoff;
};
timeout?: WorkflowTimeoutDuration | number;
@@ -13214,13 +13249,22 @@ declare namespace CloudflareWorkersModule {
type: string;
sensitive?: WorkflowStepSensitivity;
};
- export type WorkflowStepContext = {
+ export type WorkflowStepContext = {
step: {
name: string;
count: number;
};
attempt: number;
- config: WorkflowStepConfig;
+ config: {
+ retries?: {
+ limit: number;
+ backoff?: WorkflowBackoff;
+ } & (Delay extends WorkflowDelayFunction ? {} : {
+ delay: WorkflowDelayDuration | number;
+ });
+ timeout?: WorkflowTimeoutDuration | number;
+ sensitive?: WorkflowStepSensitivity;
+ };
};
export type WorkflowRollbackContext = {
ctx: WorkflowStepContext;
@@ -13236,7 +13280,9 @@ declare namespace CloudflareWorkersModule {
};
export abstract class WorkflowStep {
do>(name: string, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise;
- do>(name: string, config: WorkflowStepConfig, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise;
+ do, const C extends WorkflowStepConfig>(name: string, config: C, callback: (ctx: WorkflowStepContext) => Promise, rollbackOptions?: WorkflowStepRollbackOptions): Promise;
sleep: (name: string, duration: WorkflowSleepDuration) => Promise;
sleepUntil: (name: string, timestamp: Date | number) => Promise;
waitForEvent>(name: string, options: {
@@ -14146,6 +14192,7 @@ declare namespace TailStream {
readonly dispatchNamespace?: string;
readonly entrypoint?: string;
readonly executionModel: string;
+ readonly durableObjectId?: string;
readonly scriptName?: string;
readonly scriptTags?: string[];
readonly scriptVersion?: ScriptVersion;
@@ -14700,6 +14747,13 @@ interface WorkflowError {
code?: number;
message: string;
}
+interface WorkflowInstanceTerminateOptions {
+ /**
+ * If true, run registered rollback handlers before terminating the instance.
+ * Only steps that registered rollback handlers are rolled back.
+ */
+ rollback?: boolean;
+}
interface WorkflowInstanceRestartOptions {
/**
* Restart from a specific step. If omitted, the instance restarts from the beginning.
@@ -14733,8 +14787,9 @@ declare abstract class WorkflowInstance {
public resume(): Promise;
/**
* Terminate the instance. If it is errored, terminated or complete, an error will be thrown.
+ * @param options Options for termination, including whether registered rollback handlers should run.
*/
- public terminate(): Promise;
+ public terminate(options?: WorkflowInstanceTerminateOptions): Promise;
/**
* Restart the instance. Optionally restart from a specific step, preserving
* cached results for all steps before it.
diff --git a/wrangler.jsonc b/wrangler.jsonc
index f11e1ff58..1eed3b57b 100644
--- a/wrangler.jsonc
+++ b/wrangler.jsonc
@@ -34,6 +34,9 @@
"binding": "AI",
"remote": true,
},
+ "images": {
+ "binding": "IMAGES",
+ },
"worker_loaders": [
{
"binding": "LOADER",
@@ -97,12 +100,6 @@
"instance_type": "standard-1",
"max_instances": 1,
},
- {
- "class_name": "ImageFileConverter",
- "image": "./containers/image-converter/Dockerfile",
- "instance_type": "lite",
- "max_instances": 1,
- },
{
"class_name": "WorkspaceFileProcessor",
"image": "./containers/liteparse/Dockerfile",
@@ -133,10 +130,6 @@
"name": "OFFICE_PDF_CONVERTER",
"class_name": "OfficePdfConverter",
},
- {
- "name": "IMAGE_FILE_CONVERTER",
- "class_name": "ImageFileConverter",
- },
{
"name": "WORKSPACE_FILE_PROCESSOR",
"class_name": "WorkspaceFileProcessor",
@@ -181,6 +174,10 @@
},
],
},
+ {
+ "tag": "v10_remove_image_file_converter",
+ "deleted_classes": ["ImageFileConverter"],
+ },
],
"d1_databases": [
{
@@ -229,6 +226,9 @@
"binding": "AI",
"remote": true,
},
+ "images": {
+ "binding": "IMAGES",
+ },
"worker_loaders": [
{
"binding": "LOADER",
@@ -269,12 +269,6 @@
"instance_type": "standard-1",
"max_instances": 2,
},
- {
- "class_name": "ImageFileConverter",
- "image": "./containers/image-converter/Dockerfile",
- "instance_type": "basic",
- "max_instances": 1,
- },
{
"class_name": "WorkspaceFileProcessor",
"image": "./containers/liteparse/Dockerfile",
@@ -304,10 +298,6 @@
"name": "OFFICE_PDF_CONVERTER",
"class_name": "OfficePdfConverter",
},
- {
- "name": "IMAGE_FILE_CONVERTER",
- "class_name": "ImageFileConverter",
- },
{
"name": "WORKSPACE_FILE_PROCESSOR",
"class_name": "WorkspaceFileProcessor",
@@ -360,6 +350,9 @@
"binding": "AI",
"remote": true,
},
+ "images": {
+ "binding": "IMAGES",
+ },
"worker_loaders": [
{
"binding": "LOADER",
@@ -400,12 +393,6 @@
"instance_type": "standard-1",
"max_instances": 2,
},
- {
- "class_name": "ImageFileConverter",
- "image": "./containers/image-converter/Dockerfile",
- "instance_type": "basic",
- "max_instances": 1,
- },
{
"class_name": "WorkspaceFileProcessor",
"image": "./containers/liteparse/Dockerfile",
@@ -436,10 +423,6 @@
"name": "OFFICE_PDF_CONVERTER",
"class_name": "OfficePdfConverter",
},
- {
- "name": "IMAGE_FILE_CONVERTER",
- "class_name": "ImageFileConverter",
- },
{
"name": "WORKSPACE_FILE_PROCESSOR",
"class_name": "WorkspaceFileProcessor",