mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-12 09:12:50 +03:00
fix(app): retain raw media bytes up to 256 MiB for artifact previews (#55018)
This commit is contained in:
committed by
GitHub
Unverified
parent
3ec856b6c2
commit
46e4695b96
@@ -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,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 }))
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user