diff --git a/packages/app/e2e/regression/review.spec.ts b/packages/app/e2e/regression/review.spec.ts index d0cc76e539b..06ced4a743a 100644 --- a/packages/app/e2e/regression/review.spec.ts +++ b/packages/app/e2e/regression/review.spec.ts @@ -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) diff --git a/packages/app/src/workspaces/files/artifact.test.ts b/packages/app/src/workspaces/files/artifact.test.ts index d28633f12eb..af6996e0362 100644 --- a/packages/app/src/workspaces/files/artifact.test.ts +++ b/packages/app/src/workspaces/files/artifact.test.ts @@ -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", () => { diff --git a/packages/app/src/workspaces/files/artifact.ts b/packages/app/src/workspaces/files/artifact.ts index c99a2be45a7..2f769f0c98e 100644 --- a/packages/app/src/workspaces/files/artifact.ts +++ b/packages/app/src/workspaces/files/artifact.ts @@ -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([ "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. diff --git a/packages/app/src/workspaces/files/content-cache.ts b/packages/app/src/workspaces/files/content-cache.ts index 10ea605e5aa..17be8fac7ff 100644 --- a/packages/app/src/workspaces/files/content-cache.ts +++ b/packages/app/src/workspaces/files/content-cache.ts @@ -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 } diff --git a/packages/app/src/workspaces/files/types.ts b/packages/app/src/workspaces/files/types.ts index 02d91403387..851243a684a 100644 --- a/packages/app/src/workspaces/files/types.ts +++ b/packages/app/src/workspaces/files/types.ts @@ -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" diff --git a/packages/gui-extensions/src/file/artifact.test.ts b/packages/gui-extensions/src/file/artifact.test.ts index 471d9222e1a..bf3d70804a3 100644 --- a/packages/gui-extensions/src/file/artifact.test.ts +++ b/packages/gui-extensions/src/file/artifact.test.ts @@ -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", () => { diff --git a/packages/gui-extensions/src/file/artifact.ts b/packages/gui-extensions/src/file/artifact.ts index b9a9913bd02..7fd0ad51ac7 100644 --- a/packages/gui-extensions/src/file/artifact.ts +++ b/packages/gui-extensions/src/file/artifact.ts @@ -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 })) diff --git a/packages/gui-extensions/src/sdk/host-apis.ts b/packages/gui-extensions/src/sdk/host-apis.ts index 9701939cf29..e1d73599c64 100644 --- a/packages/gui-extensions/src/sdk/host-apis.ts +++ b/packages/gui-extensions/src/sdk/host-apis.ts @@ -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. */