Skip to content

Commit 64c8392

Browse files
committed
feat(files): preview HEIC photos in the file viewer
The agent can read HEIC since #6346, but the Files page still showed 'Preview not available' — an <img> pointed at the serve route got the stored HEIF under nosniff, which no browser outside Safari renders. The serve route now resolves a JPEG derivative for HEIF bytes, cached in the artifact store and keyed by the source's storage key. Workspace keys are regenerated on every content replacement, so the key is already a content version and using it avoids streaming the original just to hash it. Caching matters here in a way it did not for the vision path: a preview is re-fetched on every view and the WASM decode costs roughly a second for a phone photo. The original stays the stored object — downloads and raw=1 serve it untouched, so this never changes what a user gets back. compileDocumentIfNeeded becomes resolveServableBytes, since it now resolves images as well as generated documents. .tif/.tiff stay download-only: nothing decodes those on either side.
1 parent 2b35a3c commit 64c8392

5 files changed

Lines changed: 196 additions & 9 deletions

File tree

apps/sim/app/api/files/serve/[...path]/route.ts

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads'
1313
import type { StorageContext } from '@/lib/uploads/config'
1414
import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
1515
import { downloadFile } from '@/lib/uploads/core/storage-service'
16+
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
1617
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
1718
import { verifyFileAccess } from '@/app/api/files/authorization'
1819
import {
@@ -30,15 +31,23 @@ const logger = createLogger('FilesServeAPI')
3031
* {@link resolveServableDocBytes} (generated docs → compiled artifact). `raw=1`
3132
* bypasses resolution and serves the stored source as-is.
3233
*/
33-
async function compileDocumentIfNeeded(
34+
async function resolveServableBytes(
3435
buffer: Buffer,
3536
filename: string,
37+
storageKey: string,
3638
workspaceId: string | undefined,
3739
raw: boolean,
3840
ownerKey: string | undefined,
3941
signal: AbortSignal | undefined
4042
): Promise<{ buffer: Buffer; contentType: string }> {
4143
if (raw) return { buffer, contentType: getContentType(filename) }
44+
45+
// Images resolve first and independently of the document path: a HEIF has no
46+
// compiled-source concept, and its derivative is keyed by storage key rather
47+
// than by source hash.
48+
const image = await resolveServableImageBytes(buffer, storageKey)
49+
if (image) return image
50+
4251
return resolveServableDocBytes({
4352
rawBuffer: buffer,
4453
fileName: filename,
@@ -198,9 +207,10 @@ async function handleLocalFile(
198207
const segment = filename.split('/').pop() || filename
199208
const displayName = stripStorageKeyPrefix(segment)
200209
const workspaceId = getWorkspaceIdForCompile(filename)
201-
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
210+
const { buffer: fileBuffer, contentType } = await resolveServableBytes(
202211
rawBuffer,
203212
displayName,
213+
filename,
204214
workspaceId,
205215
raw,
206216
ownerKey,
@@ -260,9 +270,10 @@ async function handleCloudProxy(
260270
const segment = cloudKey.split('/').pop() || 'download'
261271
const displayName = stripStorageKeyPrefix(segment)
262272
const workspaceId = getWorkspaceIdForCompile(cloudKey)
263-
const { buffer: fileBuffer, contentType } = await compileDocumentIfNeeded(
273+
const { buffer: fileBuffer, contentType } = await resolveServableBytes(
264274
rawBuffer,
265275
displayName,
276+
cloudKey,
266277
workspaceId,
267278
raw,
268279
ownerKey,

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -227,13 +227,15 @@ describe('resolveFileCategory — formats accepted on upload must be previewable
227227
['image.bmp', 'image/bmp'],
228228
['image.avif', 'image/avif'],
229229
['favicon.ico', 'image/x-icon'],
230+
['photo.heic', 'image/heic'],
231+
['photo.heif', 'image/heif'],
230232
])('%s previews as an image', (filename, mimeType) => {
231233
expect(resolveFileCategory(mimeType, filename)).toBe('image-previewable')
232234
expect(resolveFileCategory('application/octet-stream', filename)).toBe('image-previewable')
233235
})
234236

235-
it.each(['image.tiff', 'photo.heic'])(
236-
'%s stays unsupported — no browser renders it in an <img>',
237+
it.each(['image.tiff', 'scan.tif'])(
238+
'%s stays unsupported — nothing decodes it on either side',
237239
(filename) => {
238240
expect(resolveFileCategory(null, filename)).toBe('unsupported')
239241
}

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-category.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ const IFRAME_PREVIEWABLE_MIME_TYPES = new Set([
4444
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4545

4646
/**
47-
* Image formats every supported browser decodes natively. `.tif`/`.tiff` and
48-
* `.heic`/`.heif` are accepted uploads but deliberately absent — no browser renders
49-
* them in an `<img>`, so they stay on the download-only path rather than showing a
50-
* broken image.
47+
* Formats the image viewer can show. Most are decoded by the browser directly;
48+
* `.heic`/`.heif` are not — no browser outside Safari renders them — but the serve
49+
* route transcodes them to JPEG, so an `<img>` pointed at it works.
50+
*
51+
* `.tif`/`.tiff` are accepted uploads and deliberately absent: nothing decodes them
52+
* on either side, so they stay download-only rather than showing a broken image.
5153
*/
5254
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
5355
'image/png',
@@ -58,6 +60,8 @@ const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
5860
'image/bmp',
5961
'image/x-icon',
6062
'image/vnd.microsoft.icon',
63+
'image/heic',
64+
'image/heif',
6165
])
6266
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
6367
'png',
@@ -68,6 +72,8 @@ const IMAGE_PREVIEWABLE_EXTENSIONS = new Set([
6872
'avif',
6973
'bmp',
7074
'ico',
75+
'heic',
76+
'heif',
7177
])
7278

7379
const AUDIO_PREVIEWABLE_MIME_TYPES = new Set([
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockDownloadFile, mockUploadFile, mockTranscode } = vi.hoisted(() => ({
7+
mockDownloadFile: vi.fn(),
8+
mockUploadFile: vi.fn(),
9+
mockTranscode: vi.fn(),
10+
}))
11+
12+
vi.mock('@/lib/uploads/core/storage-service', () => ({
13+
downloadFile: mockDownloadFile,
14+
uploadFile: mockUploadFile,
15+
}))
16+
17+
vi.mock('@/lib/uploads/server/heic', async (importOriginal) => ({
18+
...(await importOriginal<typeof import('@/lib/uploads/server/heic')>()),
19+
transcodeHeicToJpeg: mockTranscode,
20+
}))
21+
22+
import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative'
23+
24+
/** An ISO-BMFF `ftyp` box declaring a HEVC-coded HEIF still. */
25+
function heifBytes(): Buffer {
26+
const header = Buffer.alloc(16)
27+
header.writeUInt32BE(16, 0)
28+
header.write('ftyp', 4, 'ascii')
29+
header.write('heic', 8, 'ascii')
30+
return header
31+
}
32+
33+
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0])
34+
35+
describe('resolveServableImageBytes', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
mockDownloadFile.mockRejectedValue(new Error('not found'))
39+
mockUploadFile.mockResolvedValue(undefined)
40+
mockTranscode.mockResolvedValue(JPEG)
41+
})
42+
43+
it('leaves non-HEIF bytes untouched', async () => {
44+
expect(await resolveServableImageBytes(JPEG, 'workspace/ws/a.jpg')).toBeNull()
45+
expect(mockDownloadFile).not.toHaveBeenCalled()
46+
expect(mockTranscode).not.toHaveBeenCalled()
47+
})
48+
49+
it('transcodes and caches on a miss', async () => {
50+
const result = await resolveServableImageBytes(heifBytes(), 'workspace/ws/a.heic')
51+
52+
expect(result).toEqual({ buffer: JPEG, contentType: 'image/jpeg' })
53+
expect(mockTranscode).toHaveBeenCalledOnce()
54+
expect(mockUploadFile).toHaveBeenCalledOnce()
55+
})
56+
57+
it('serves the cached derivative without decoding again', async () => {
58+
const cached = Buffer.from([0xff, 0xd8, 0xca, 0xce])
59+
mockDownloadFile.mockResolvedValue(cached)
60+
61+
const result = await resolveServableImageBytes(heifBytes(), 'workspace/ws/a.heic')
62+
63+
expect(result).toEqual({ buffer: cached, contentType: 'image/jpeg' })
64+
expect(mockTranscode).not.toHaveBeenCalled()
65+
})
66+
67+
it('keys the derivative by storage key, so replaced content misses the old entry', async () => {
68+
await resolveServableImageBytes(heifBytes(), 'workspace/ws/111-a.heic')
69+
await resolveServableImageBytes(heifBytes(), 'workspace/ws/222-a.heic')
70+
71+
const [first, second] = mockUploadFile.mock.calls.map((call) => call[0].customKey)
72+
expect(first).not.toEqual(second)
73+
})
74+
75+
it('still serves the image when caching the derivative fails', async () => {
76+
mockUploadFile.mockRejectedValue(new Error('s3 down'))
77+
78+
const result = await resolveServableImageBytes(heifBytes(), 'workspace/ws/a.heic')
79+
80+
expect(result).toEqual({ buffer: JPEG, contentType: 'image/jpeg' })
81+
})
82+
83+
it('falls back to the stored bytes when the decode fails', async () => {
84+
mockTranscode.mockResolvedValue(null)
85+
86+
expect(await resolveServableImageBytes(heifBytes(), 'workspace/ws/a.heic')).toBeNull()
87+
expect(mockUploadFile).not.toHaveBeenCalled()
88+
})
89+
})
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { createHash } from 'node:crypto'
2+
import { createLogger } from '@sim/logger'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service'
5+
import { isHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic'
6+
7+
const logger = createLogger('ImageDerivative')
8+
9+
/**
10+
* Keyed by the source's storage key rather than a hash of its bytes. Workspace keys
11+
* are regenerated on every content replacement, so the key is already a content
12+
* version — using it avoids streaming the whole original just to hash it.
13+
*/
14+
function derivativeKey(storageKey: string): string {
15+
const hash = createHash('sha256').update(storageKey, 'utf-8').digest('hex')
16+
return `image-derivative/${hash}.jpg`
17+
}
18+
19+
async function loadDerivative(storageKey: string): Promise<Buffer | null> {
20+
try {
21+
return await downloadFile({ key: derivativeKey(storageKey), context: 'copilot' })
22+
} catch {
23+
return null
24+
}
25+
}
26+
27+
/**
28+
* Stores the derivative, swallowing failure.
29+
*
30+
* Unlike the compiled-doc store — which throws, because its serve path is load-only
31+
* and cannot rebuild a missing artifact — a miss here is fully recoverable: the next
32+
* read transcodes again. Failing the request would turn a cache problem into a broken
33+
* image for bytes we have already rendered successfully.
34+
*/
35+
async function storeDerivative(storageKey: string, jpeg: Buffer): Promise<void> {
36+
try {
37+
await uploadFile({
38+
file: jpeg,
39+
fileName: 'derivative.jpg',
40+
contentType: 'image/jpeg',
41+
context: 'copilot',
42+
customKey: derivativeKey(storageKey),
43+
preserveKey: true,
44+
})
45+
} catch (error) {
46+
logger.warn('Failed to store image derivative', {
47+
storageKey,
48+
error: getErrorMessage(error),
49+
})
50+
}
51+
}
52+
53+
/**
54+
* A browser-renderable form of stored image bytes, or `null` when the stored bytes
55+
* are already renderable and should be served untouched.
56+
*
57+
* Only HEIF needs this today: no browser outside Safari decodes it, and serving it
58+
* under `X-Content-Type-Options: nosniff` guarantees a broken image. The transcoded
59+
* JPEG is cached, because a preview is re-fetched on every view and the WebAssembly
60+
* decode costs roughly a second for a phone photo.
61+
*
62+
* The original always remains the stored object — downloads and `raw=1` serve it
63+
* untouched, so this never rewrites what a user gets back.
64+
*/
65+
export async function resolveServableImageBytes(
66+
buffer: Buffer,
67+
storageKey: string
68+
): Promise<{ buffer: Buffer; contentType: string } | null> {
69+
if (!isHeifContainer(buffer)) return null
70+
71+
const cached = await loadDerivative(storageKey)
72+
if (cached) return { buffer: cached, contentType: 'image/jpeg' }
73+
74+
const jpeg = await transcodeHeicToJpeg(buffer)
75+
if (!jpeg) return null
76+
77+
await storeDerivative(storageKey, jpeg)
78+
return { buffer: jpeg, contentType: 'image/jpeg' }
79+
}

0 commit comments

Comments
 (0)