fix(app): retain raw media bytes up to 256 MiB for artifact previews (#55018)

This commit is contained in:
Kit Langton
2026-10-13 15:52:31 -07:00
committed by GitHub
Unverified
parent 3ec856b6c2
commit 46e4695b96
8 changed files with 76 additions and 19 deletions
+16 -5
View File
@@ -737,17 +737,23 @@ test("image files load into the review panel and keep it painted while loading",
).toEqual([])
})
test("video files expand into an in-window theatre overlay and collapse on Escape without remounting", async ({
test("video files over 25 MB play in the viewer, expand into the theatre overlay, and collapse on Escape without remounting", async ({
page,
}) => {
const directory = "C:/OpenCode/ReviewVideo"
const file = "demo.webm"
const file = "arcade-update-draft.mp4"
const webm = Buffer.from(
"GkXfo59ChoEBQveBAULygQRC84EIQoKEd2VibUKHgQJChYECGFOAZwEAAAAAAAH5EU2bdLpNu4tTq4QVSalmU6yBoU27i1OrhBZUrmtTrIHWTbuMU6uEElTDZ1OsggEyTbuMU6uEHFO7a1OsggHj7AEAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVSalmsCrXsYMPQkBNgIxMYXZmNjMuMS4xMDJXQYxMYXZmNjMuMS4xMDJEiYhAj0AAAAAAABZUrmvXrgEAAAAAAABO14EBc8WIiQeUklk95R2cgQAitZyDdW5kiIEAhoVWX1ZQOIOBASPjg4Q7msoA4JCwgUC6gSSagQJVsIRVuYEBVe6BAOwBAAAAAAAAAgAAElTDZ/pzc59jwIBnyJlFo4dFTkNPREVSRIeMTGF2ZjYzLjEuMTAyc3PVY8CLY8WIiQeUklk95R1nyKBFo4dFTkNPREVSRIeTTGF2YzYzLjEuMTAyIGxpYnZweGfIoUWjiERVUkFUSU9ORIeTMDA6MDA6MDEuMDAwMDAwMDAwAB9DtnWt54EAo6iBAACAsAIAnQEqQAAkAABHCIWFiJmEiAICAAaOT8zHm/FYAP7/q1CAHFO7a5G7j7OBALeK94EB8YIBsfCBAw==",
const head = Buffer.from(
"AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAAMm1kYXSCSYNCAAPwAjYAOCQcGEIAADBgAABYS///mpmB/////7FMC////+OkgAAAAAL+bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAil0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAAAkAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAGhbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABTG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAQxzdGJsAAAAqHN0c2QAAAAAAAAAAQAAAJh2cDA5AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAJABIAAAASAAAAAAAAAABF0xhdmM2My4xLjEwMiBsaWJ2cHgtdnA5AAAAAAAAAAAAGP//AAAAFHZwY0MBAAAAAAqCAgICAAAAAAAKZmllbAEAAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAJxAAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAEAAEAAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAABAAAAAQAAABRzdHN6AAAAAAAAACoAAAABAAAAFHN0Y28AAAAAAAAAAQAAACwAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYzLjEuMTAy",
"base64",
)
const mp4 = Buffer.alloc(38_400_000)
head.copy(mp4)
mp4.writeUInt32BE(mp4.length - head.length, head.length)
mp4.write("free", head.length + 4, "ascii")
await openSession(page, {
name: "ReviewVideo",
fileList: (path) => (path ? [] : [fileNode(directory, file)]),
@@ -759,7 +765,7 @@ test("video files expand into an in-window theatre overlay and collapse on Escap
route.fulfill({
status: 200,
headers: { "access-control-allow-origin": "*", "content-type": "application/octet-stream" },
body: webm,
body: mp4,
}),
)
@@ -768,12 +774,17 @@ test("video files expand into an in-window theatre overlay and collapse on Escap
await panel.getByRole("button", { name: file }).click()
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
const toolbar = panel.locator('[data-slot="artifact-toolbar"]')
const stage = panel.locator('[data-slot="artifact-stage"]')
const video = stage.locator('video[data-slot="artifact-media"]')
const fullscreen = stage.getByRole("button", { name: "Enter full screen", exact: true })
const exitFullscreen = stage.getByRole("button", { name: "Exit full screen", exact: true })
await expect(video).toBeVisible()
await expect(toolbar).toContainText("64 × 36")
await expect(toolbar).toContainText("0:01")
await expect(toolbar).toContainText("38.4 MB")
await expect(panel.getByText(/Binary file/)).toHaveCount(0)
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
const inlineBounds = (await stage.boundingBox())!
expect(inlineBounds.width).toBeLessThan(1000)
@@ -1,10 +1,26 @@
import { describe, expect, test } from "bun:test"
import { fileContentFromBytes, MAX_MEDIA_BYTES } from "./artifact"
import { approxBytes } from "./content-cache"
describe("fileContentFromBytes", () => {
test("keeps media as base64 with a mime type", () => {
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
test("keeps media bytes with a mime type, including videos past 25 MiB", () => {
const png = new Uint8Array([137, 80, 78, 71])
expect(fileContentFromBytes("a.png", png)).toEqual({
type: "binary",
content: "",
bytes: png,
mimeType: "image/png",
})
const video = new Uint8Array(40 * 1024 * 1024)
const content = fileContentFromBytes("arcade-update-draft.mp4", video)
expect(content).toEqual({
type: "binary",
content: "",
bytes: video,
mimeType: "video/mp4",
})
expect(approxBytes(content)).toBe(video.byteLength)
})
test("decodes text and svg with a mime type", () => {
@@ -1,8 +1,7 @@
import { artifactKind, artifactMime, type ArtifactKind } from "@opencode/util/artifact"
import { bytesToBase64 } from "@opencode/util/base64"
import type { FileContent } from "@/runtime/server/types"
import type { FileContent } from "@opencode/gui-extensions/sdk"
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
/** Kinds whose raw bytes are kept so media elements and binary viewers can read them without a text round trip. */
const binaryKinds = new Set<ArtifactKind>([
"image",
"audio",
@@ -19,8 +18,8 @@ function isBinaryBytes(bytes: Uint8Array) {
return bytes.subarray(0, 8192).includes(0)
}
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
/** Media above this stays a placeholder so the in-memory file cache stays bounded. */
export const MAX_MEDIA_BYTES = 256 * 1024 * 1024
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
const kind = artifactKind(path)
@@ -29,7 +28,7 @@ export function fileContentFromBytes(path: string, bytes: Uint8Array): FileConte
if (binaryKinds.has(kind)) {
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
return { type: "binary", content: "", bytes, mimeType }
}
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
@@ -1,4 +1,4 @@
import type { FileContent } from "@/runtime/server/types"
import type { FileContent } from "@opencode/gui-extensions/sdk"
const MAX_FILE_CONTENT_ENTRIES = 40
@@ -15,7 +15,7 @@ export function approxBytes(content: FileContent) {
}, 0) ?? 0
// V8 keeps an ASCII string such as base64 at one byte per character; other text may take two.
const body = content.content.length * (content.encoding === "base64" ? 1 : 2)
const body = content.bytes?.byteLength ?? content.content.length * (content.encoding === "base64" ? 1 : 2)
return body + ((content.diff?.length ?? 0) + patchBytes) * 2
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { FileContent } from "@/runtime/server/types"
import type { FileContent } from "@opencode/gui-extensions/sdk"
import { Schema } from "effect"
import { Persistence } from "@/runtime/persistence/schema"
@@ -1,5 +1,19 @@
import { describe, expect, test } from "bun:test"
import { parseDelimited, resolveArtifactPath } from "./artifact"
import { bytesFromContent, contentBytes, parseDelimited, resolveArtifactPath } from "./artifact"
describe("binary content helpers", () => {
test("reads size and independent byte copies from raw bytes", () => {
const bytes = new Uint8Array([0, 1, 2, 3])
const content = { type: "binary" as const, content: "", bytes, mimeType: "video/mp4" }
expect(contentBytes(content)).toBe(4)
const copy = bytesFromContent(content)
expect(copy).toEqual(bytes)
copy[0] = 9
expect(content.bytes[0]).toBe(0)
})
})
describe("parseDelimited", () => {
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
@@ -5,6 +5,8 @@ import type { FileContent } from "../sdk"
export function contentBytes(content: FileContent) {
if (content.size !== undefined) return content.size
if (content.bytes !== undefined) return content.bytes.byteLength
if (content.encoding === "base64") {
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
@@ -87,6 +89,9 @@ export function parseDelimited(text: string, delimiter: string, limit = 1000) {
/** The raw bytes of loaded content. */
export function bytesFromContent(content: FileContent) {
// A copy, as a viewer may move the returned buffer to a worker.
if (content.bytes !== undefined) return content.bytes.slice()
if (content.encoding !== "base64") return new TextEncoder().encode(content.content)
return base64ToBytes(content.content)
@@ -95,6 +100,16 @@ export function bytesFromContent(content: FileContent) {
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
export function blobUrlFromContent(content: FileContent) {
const type = content.mimeType ?? "application/octet-stream"
const bytes = content.bytes
if (bytes !== undefined) {
const view =
bytes.buffer instanceof ArrayBuffer
? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength)
: bytes.slice()
return URL.createObjectURL(new Blob([view], { type }))
}
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
+3 -1
View File
@@ -65,11 +65,13 @@ export type FileContent = {
/**
* How to read `content`.
* - `text`: `content` is the text.
* - `binary`: `content` is base64 when `encoding` says so, or empty when only `size` is known.
* - `binary`: `bytes` holds the raw bytes, `content` is base64 when `encoding` says so, or empty when only `size` is known.
*/
type: "text" | "binary"
/** The text, or the base64 bytes of a binary file. */
content: string
/** The raw bytes of a binary file, when kept without base64 encoding. */
bytes?: Uint8Array
/** A unified diff of the file's uncommitted change, when there is one. */
diff?: string
/** The same change, parsed. */