fix(app): stream previewed videos so they play right away (#57587)

This commit is contained in:
Kit Langton
2026-10-20 21:15:02 -07:00
committed by GitHub
Unverified
parent 25d8e19a52
commit d8bbf79ba5
9 changed files with 144 additions and 23 deletions
+30 -8
View File
@@ -850,7 +850,7 @@ test("image files load into the review panel and keep it painted while loading",
).toEqual([])
})
test("video files over 25 MB play in the viewer, expand into the theatre overlay, and collapse on Escape without remounting", async ({
test("video files stream into the viewer behind a loading state, expand into the theatre overlay, and collapse on Escape without remounting", async ({
page,
}) => {
const directory = "C:/OpenCode/ReviewVideo"
@@ -874,13 +874,29 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
panes: { ses_reviewvideo: { review: true } },
},
})
await page.route(`**/api/fs/read/${file}*`, (route) =>
route.fulfill({
status: 200,
headers: { "access-control-allow-origin": "*", "content-type": "application/octet-stream" },
body: mp4,
}),
)
// The app only checks the file with a HEAD; the video element streams it with range requests, held until released.
const ranged = Promise.withResolvers<void>()
const release = Promise.withResolvers<void>()
await page.route(`**/api/fs/read/${file}*`, async (route) => {
const headers = { "access-control-allow-origin": "*", "accept-ranges": "bytes", "content-type": "video/mp4" }
if (route.request().method() === "HEAD")
return route.fulfill({ status: 200, headers: { ...headers, "content-length": String(mp4.length) } })
const range = /^bytes=(\d+)-(\d*)$/.exec((await route.request().headerValue("range")) ?? "")
if (!range) return route.fulfill({ status: 200, headers, body: mp4 })
ranged.resolve()
await release.promise
const start = Number(range[1])
const end = Math.min(range[2] ? Number(range[2]) : mp4.length - 1, start + 4_000_000, mp4.length - 1)
return route.fulfill({
status: 206,
headers: { ...headers, "content-range": `bytes ${start}-${end}/${mp4.length}` },
body: mp4.subarray(start, end + 1),
})
})
const panel = page.locator("#review-panel")
await openFileLauncher(panel)
@@ -892,7 +908,13 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
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 })
const loading = stage.getByRole("status", { name: "Loading", exact: true })
await ranged.promise
await expect(loading).toBeVisible()
expect(await video.getAttribute("src")).toMatch(new RegExp(`^${SERVER}/api/fs/read/${file}\\?`))
release.resolve()
await expect(loading).toHaveCount(0)
await expect(video).toBeVisible()
await expect(toolbar).toContainText("64 × 36")
await expect(toolbar).toContainText("0:01")
+9 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { authFromToken, authTokenFromCredentials } from "./api"
import { authFromToken, authTokenFromCredentials, fileStreamUrl } from "./api"
test.each([
[btoa("opencode:secret"), { password: "secret" }],
@@ -15,3 +15,11 @@ test("authTokenFromCredentials ignores usernames in legacy saved credentials", (
const credentials = { username: "legacy", password: "secret" }
expect(authTokenFromCredentials(credentials)).toBe(btoa("opencode:secret"))
})
test("fileStreamUrl never puts a credential in a media URL", () => {
const input = { path: "clips/final cut.mp4", directory: "/work/app" }
expect(fileStreamUrl({ url: "http://127.0.0.1:4096", password: "secret" }, input)).toBeUndefined()
expect(fileStreamUrl({ url: "http://127.0.0.1:4096" }, input)).toBe(
"http://127.0.0.1:4096/api/fs/read/clips/final%20cut.mp4?location%5Bdirectory%5D=%2Fwork%2Fapp",
)
})
+17
View File
@@ -36,4 +36,21 @@ export function createApiForServer(input: {
})
}
/**
* The server's ranged read of one file, for media elements. They cannot send the Authorization header, and a credential
* never goes in a DOM URL, so a server that needs a password has none: its media downloads through the API instead.
*/
export function fileStreamUrl(server: ServerConnection.HttpBase, input: { path: string; directory: string }) {
if (server.password) return
const url = new URL(
`api/fs/read/${input.path.split("/").map(encodeURIComponent).join("/")}`,
server.url.endsWith("/") ? server.url : `${server.url}/`,
)
url.searchParams.set("location[directory]", input.directory)
return url.href
}
export type ServerApi = OpenCodeClient
+16 -2
View File
@@ -62,6 +62,8 @@ type ServerSDKBase = {
server: ServerConnection.Any
scope: ServerScope
url: string
/** The connection the API currently uses, which a managed server's reconnect may replace. */
http: ServerConnection.HttpBase
api: ServerApi
pty: ReturnType<typeof createPtyClient>
connection: {
@@ -110,6 +112,9 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
get url() {
return transport.url
},
get http() {
return transport.http
},
get api() {
return transport.api
},
@@ -121,12 +126,18 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS
}
}
export function createServerTransport(input: { http: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }): {
export type ServerTransport = {
update(http: ServerConnection.HttpBase): ServerApi
readonly url: string
readonly http: ServerConnection.HttpBase
readonly api: ServerApi
readonly pty: ReturnType<typeof createPtyClient>
} {
}
export function createServerTransport(input: {
http: ServerConnection.HttpBase
fetch?: typeof globalThis.fetch
}): ServerTransport {
const queue = createRequestQueue({ fetch: input.fetch ?? globalThis.fetch })
const build = (http: ServerConnection.HttpBase) => {
@@ -146,6 +157,9 @@ export function createServerTransport(input: { http: ServerConnection.HttpBase;
get url() {
return state.current.http.url
},
get http() {
return state.current.http
},
get api() {
return state.current.api
},
@@ -13,6 +13,18 @@ const binaryKinds = new Set<ArtifactKind>([
"presentation",
])
/** Kinds a media element reads straight from the server, so it shows the first frame before the rest arrives. */
export const streamedKinds = new Set<ArtifactKind>(["image", "audio", "video"])
/** Content for a streamed file from a HEAD of its URL. A changed file gets a new URL, so its element reloads. */
export function streamedFileContent(path: string, url: string, response: Response): FileContent {
const size = Number(response.headers.get("content-length") ?? 0)
const versioned = new URL(url)
versioned.searchParams.set("v", `${size}-${Date.parse(response.headers.get("last-modified") ?? "") || 0}`)
return { type: "binary", content: "", size, mimeType: artifactMime(path), url: versioned.href }
}
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
function isBinaryBytes(bytes: Uint8Array) {
return bytes.subarray(0, 8192).includes(0)
+23 -5
View File
@@ -9,7 +9,10 @@ import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
import { createPathHelpers } from "./path"
import { fileContentFromBytes } from "./artifact"
import { artifactKind } from "@opencode/util/artifact"
import { usePlatform } from "@/runtime/platform/platform"
import { fileStreamUrl } from "@/runtime/server/api"
import { fileContentFromBytes, streamedFileContent, streamedKinds } from "./artifact"
import {
approxBytes,
evictContentLru,
@@ -42,6 +45,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const sdk = useWorkspaceLocation()
const params = useParams()
const serverSDK = useServerSDK()
const platform = usePlatform()
const language = useLanguage()
const extensions = useExtensionAttachment()
@@ -164,6 +168,22 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
}
// Media plays from the server's ranged reads, so only a HEAD runs here, for the file's size. A failed HEAD has no
// body; the full read that follows reports the server's error.
const read = (file: string, request: { path: string; location: { directory: string } }) => {
const download = () => serverSDK.api.file.read(request).then((data) => fileContentFromBytes(file, data))
const url = streamedKinds.has(artifactKind(file))
? fileStreamUrl(serverSDK.http, { path: request.path, directory: request.location.directory })
: undefined
if (!url) return download()
return (platform.fetch ?? fetch)(url, { method: "HEAD" }).then((response) =>
response.ok ? streamedFileContent(file, url, response) : download(),
)
}
const load = (input: string, options?: { force?: boolean; silent?: boolean }) => {
const file = path.normalize(input)
@@ -199,11 +219,9 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
promise: Promise.resolve(),
}
entry.promise = serverSDK.api.file
.read(request)
.then((data) => {
entry.promise = read(file, request)
.then((content) => {
if (scope() !== directory) return
const content = fileContentFromBytes(file, data)
setLoaded(file, content)
touchFileContent(file, approxBytes(content))
evictContent(new Set([file]))
@@ -18,6 +18,7 @@ import { Button } from "@opencode/ui/button"
import { FileIcon } from "@opencode/ui/file-icon"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Spinner } from "@opencode/ui/spinner"
import { Markdown } from "@opencode/session-ui/markdown"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { artifactKind, type ArtifactKind } from "@opencode/util/artifact"
@@ -123,7 +124,7 @@ export default function ArtifactView(props: {
// A file that fails also drops the facts its viewer reported, such as a page count.
const fail = (reason?: string) => change({ undecodable: true, info: {}, reason })
const media = { onInfo: (info: ArtifactInfo) => change({ info }), onError: () => fail() }
const media = { onInfo: (info: ArtifactInfo) => change({ info }), onError: () => fail(ctx.t("view.loadFailed")) }
const size = () => formatBytes(locale.locale(), contentBytes(props.content))
@@ -295,8 +296,11 @@ function OpenInBrowserButton(props: { session: MountedSession; path: string }) {
)
}
function createBlobUrl(content: () => FileContent) {
function createMediaUrl(content: () => FileContent) {
return createMemo(() => {
const streamed = content().url
if (streamed) return streamed
const value = blobUrlFromContent(content())
onCleanup(() => URL.revokeObjectURL(value))
@@ -304,9 +308,24 @@ function createBlobUrl(content: () => FileContent) {
})
}
/** Holds the pane until the media's first frame, so a file still streaming never shows as blank. */
function MediaLoading() {
const ctx = useExtension()
return (
<div
role="status"
aria-label={ctx.t("common.loading")}
class="pointer-events-none absolute inset-0 flex items-center justify-center text-text-weak"
>
<Spinner class="size-4" />
</div>
)
}
/** Images and SVG previews: each decoded image owns its viewport. */
function ArtifactImage(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const url = createMediaUrl(() => props.content)
return (
<Show when={url()} keyed>
@@ -485,6 +504,9 @@ function ImageStage(props: MediaProps & { src: string }) {
zoomAt(1 / fit(), event.clientX, event.clientY)
}}
/>
<Show when={!view.imageWidth}>
<MediaLoading />
</Show>
<div class="absolute end-3 top-3">
<Button size="small" variant="neutral" disabled={view.zoom === 1} onClick={reset}>
{ctx.t("view.fit")}
@@ -496,9 +518,10 @@ function ImageStage(props: MediaProps & { src: string }) {
function ArtifactVideo(props: MediaProps) {
const ctx = useExtension()
const url = createBlobUrl(() => props.content)
const url = createMediaUrl(() => props.content)
// Fullscreen belongs to one video: a new file starts fitted in the pane.
const [expandedUrl, setExpandedUrl] = createSignal<string>()
const [loadedUrl, setLoadedUrl] = createSignal<string>()
const [paused, setPaused] = createSignal(true)
const [active, setActive] = createSignal(false)
const [box, setBox] = createStore({ left: 0, top: 0, ready: false })
@@ -629,7 +652,11 @@ function ArtifactVideo(props: MediaProps) {
props.onInfo({ width: el.videoWidth, height: el.videoHeight, duration: el.duration })
measure()
}}
onLoadedData={() => setLoadedUrl(url())}
/>
<Show when={loadedUrl() !== url()}>
<MediaLoading />
</Show>
<button
type="button"
data-slot="artifact-video-fullscreen"
@@ -676,7 +703,7 @@ function ArtifactVideo(props: MediaProps) {
}
function ArtifactAudio(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const url = createMediaUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
@@ -701,7 +728,7 @@ function ArtifactAudio(props: MediaProps) {
}
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
const url = createBlobUrl(() => props.content)
const url = createMediaUrl(() => props.content)
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
@@ -789,7 +816,7 @@ const specimenSizes = [12, 16, 24, 40, 64]
function ArtifactFont(props: { path: string; content: FileContent }) {
const ctx = useExtension()
const url = createBlobUrl(() => props.content)
const url = createMediaUrl(() => props.content)
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
// The document's font set holds the face while it shows.
@@ -10,6 +10,7 @@ export default {
"view.fullscreen": "Enter full screen",
"view.exitFullscreen": "Exit full screen",
"view.binary": "Binary file · {{size}}",
"view.loadFailed": "Couldn't load this file.",
"view.table.rows.one": "{{count}} row",
"view.table.rows.other": "{{count}} rows",
"view.table.columns.one": "{{count}} column",
@@ -106,6 +106,8 @@ export type FileContent = {
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
/** A URL the server streams the file from with range requests, set instead of `bytes` for images, audio and video. */
url?: string
}
/** A range of lines in a file, or in one side of its diff. */