Skip to content

Commit 97dbece

Browse files
authored
Merge pull request #218 from pylon-code/upstream/2026-08-31-web-video-attachments
feat(web): play video attachments in chat
2 parents d095a6f + f9eb423 commit 97dbece

23 files changed

Lines changed: 898 additions & 120 deletions

apps/desktop/src/electron/ElectronProtocol.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ describe("ElectronProtocol", () => {
225225
"http:",
226226
"https:",
227227
]);
228+
assert.deepEqual(directives["media-src"], ["'self'", "pylon-code:", "blob:"]);
228229
assert.deepEqual(directives["font-src"], ["'self'", "pylon-code:", "data:"]);
229230
});
230231
});

apps/desktop/src/electron/ElectronProtocol.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export function makeDesktopContentSecurityPolicy(input: DesktopProtocolRegistrat
8787
`script-src ${scriptSources.join(" ")}`,
8888
`connect-src ${connectSources.join(" ")}`,
8989
`img-src 'self' ${input.scheme}: blob: data: http: https:`,
90+
`media-src 'self' ${input.scheme}: blob:`,
9091
"style-src 'self' 'unsafe-inline'",
9192
`font-src 'self' ${input.scheme}: data:`,
9293
"worker-src 'self' blob:",

apps/server/src/assets/AssetAccess.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,37 @@ describe("AssetAccess", () => {
208208
}).pipe(Effect.provide(testLayer)),
209209
);
210210

211+
it.effect("serves video attachments inline", () =>
212+
Effect.gen(function* () {
213+
const config = yield* ServerConfig.ServerConfig;
214+
const fileSystem = yield* FileSystem.FileSystem;
215+
const path = yield* Path.Path;
216+
const attachmentId = "thread-1-00000000-0000-4000-8000-000000000001-mp4";
217+
const attachmentPath = path.join(config.attachmentsDir, `${attachmentId}.mp4`);
218+
yield* fileSystem.makeDirectory(config.attachmentsDir, { recursive: true });
219+
yield* fileSystem.writeFile(attachmentPath, new Uint8Array([1, 2, 3]));
220+
221+
const result = yield* issueAssetUrl({
222+
resource: {
223+
_tag: "attachment",
224+
attachmentId,
225+
fileName: "demo.mp4",
226+
mimeType: 'video/mp4; codecs="avc1.42E01E"',
227+
},
228+
});
229+
const suffix = result.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length);
230+
const separatorIndex = suffix.indexOf("/");
231+
232+
expect(
233+
yield* resolveAsset(suffix.slice(0, separatorIndex), suffix.slice(separatorIndex + 1)),
234+
).toEqual({
235+
kind: "file",
236+
path: attachmentPath,
237+
fileName: "demo.mp4",
238+
mimeType: "video/mp4",
239+
});
240+
}).pipe(Effect.provide(testLayer)),
241+
);
211242
it.effect("issues project favicon capabilities with a signed fallback", () =>
212243
Effect.gen(function* () {
213244
const fileSystem = yield* FileSystem.FileSystem;

apps/server/src/assets/AssetAccess.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const SIGNING_SECRET_NAME = "asset-access-signing-key";
4848
const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000;
4949
const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000;
5050
const PROJECT_FAVICON_VERSION_PREFIX = "v";
51+
const INLINE_VIDEO_MIME_TYPE_PATTERN = /^video\/[\w!#$&^.+-]+$/i;
5152
const PREVIEW_ASSET_EXTENSIONS = new Set([
5253
...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS,
5354
...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS,
@@ -311,16 +312,20 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i
311312
});
312313
}
313314
// Generic files carry their extension inside the attachment id (that
314-
// shape resolves the on-disk path); images do not. Only generic files
315-
// download, images render inline in chat.
315+
// shape resolves the on-disk path); images do not. Videos and images
316+
// render inline; other generic files download.
316317
const isGenericFile = parseAttachmentFileExtension(input.resource.attachmentId) !== null;
318+
const videoMimeType = input.resource.mimeType?.split(";", 1)[0]?.trim() ?? "";
319+
const isVideo = INLINE_VIDEO_MIME_TYPE_PATTERN.test(videoMimeType);
317320
claims = {
318321
version: 1,
319322
kind: "attachment",
320323
attachmentId: input.resource.attachmentId,
321-
...(isGenericFile ? { download: true } : {}),
324+
...(isGenericFile && !isVideo ? { download: true } : {}),
322325
...(input.resource.fileName !== undefined ? { fileName: input.resource.fileName } : {}),
323-
...(input.resource.mimeType !== undefined ? { mimeType: input.resource.mimeType } : {}),
326+
...(input.resource.mimeType !== undefined
327+
? { mimeType: isVideo ? videoMimeType : input.resource.mimeType }
328+
: {}),
324329
expiresAt,
325330
};
326331
fileName = input.resource.fileName ?? path.basename(attachmentPath);

apps/server/src/http.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,17 @@ describe("assetResponseHeaders", () => {
5050
});
5151
});
5252

53+
it("serves inline videos with their declared mime type", () => {
54+
expect(
55+
assetResponseHeaders("/attachments/demo.bin", {
56+
mimeType: 'video/mp4; codecs="avc1.42E01E"',
57+
}),
58+
).toEqual({
59+
"Cache-Control": "private, max-age=3600",
60+
"Content-Type": "video/mp4",
61+
"X-Content-Type-Options": "nosniff",
62+
});
63+
});
5364
it("declares utf-8 for HTML assets so non-ASCII content renders correctly", () => {
5465
expect(assetResponseHeaders("/workspace/page.html")).toHaveProperty(
5566
"Content-Type",

apps/server/src/http.ts

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ const DOWNLOAD_MIME_TYPE_PATTERN = /^[\w!#$&^.+-]+\/[\w!#$&^.+-]+$/;
6262
const isSafeDownloadMimeType = (mimeType: string): boolean =>
6363
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) &&
6464
!/(?:^text\/html$|\/xml(?:$|-)|\+xml$)/i.test(mimeType.trim().toLowerCase());
65+
const isSafeInlineVideoMimeType = (mimeType: string): boolean =>
66+
DOWNLOAD_MIME_TYPE_PATTERN.test(mimeType) && mimeType.toLowerCase().startsWith("video/");
6567

6668
/** RFC 6266 disposition with an ASCII fallback name plus a UTF-8 `filename*`. */
6769
export function downloadContentDisposition(fileName?: string): string {
@@ -91,6 +93,7 @@ export function assetResponseHeaders(
9193
},
9294
): Record<string, string> {
9395
const lowerPath = filePath.toLowerCase();
96+
const inlineVideoMimeType = options?.mimeType?.split(";", 1)[0]?.trim();
9497
return {
9598
"Cache-Control": "private, max-age=3600",
9699
"X-Content-Type-Options": "nosniff",
@@ -103,9 +106,11 @@ export function assetResponseHeaders(
103106
? options.mimeType
104107
: "application/octet-stream",
105108
}
106-
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
107-
? { "Content-Type": "text/html; charset=utf-8" }
108-
: {}),
109+
: inlineVideoMimeType !== undefined && isSafeInlineVideoMimeType(inlineVideoMimeType)
110+
? { "Content-Type": inlineVideoMimeType }
111+
: lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")
112+
? { "Content-Type": "text/html; charset=utf-8" }
113+
: {}),
109114
...(!options?.download && lowerPath.endsWith(".svg")
110115
? { "Content-Security-Policy": SVG_CONTENT_SECURITY_POLICY }
111116
: {}),
@@ -281,9 +286,9 @@ export const assetRouteLayer = HttpRouter.add(
281286
status: 200,
282287
headers: assetResponseHeaders(
283288
asset.path,
284-
asset.download
289+
asset.download || asset.mimeType !== undefined
285290
? {
286-
download: true,
291+
...(asset.download ? { download: true } : {}),
287292
...(asset.fileName !== undefined ? { fileName: asset.fileName } : {}),
288293
...(asset.mimeType !== undefined ? { mimeType: asset.mimeType } : {}),
289294
}

apps/web/src/components/ChatView.logic.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import {
2222
dismissBranchMismatchForSession,
2323
ENVIRONMENT_RECONNECT_WARNING_GRACE_MS,
2424
getStartedThreadModelChangeBlockReason,
25+
loadVideoPreviewUrl,
26+
isVideoPreviewRequestCurrent,
2527
hasEnvironmentReconnectWarningGraceElapsed,
2628
hasServerAcknowledgedLocalDispatch,
2729
isBranchMismatchDismissedForSession,
@@ -42,6 +44,31 @@ import {
4244
shouldWriteThreadErrorToCurrentServerThread,
4345
} from "./ChatView.logic";
4446

47+
describe("loadVideoPreviewUrl", () => {
48+
it("loads video bytes into an object URL", async () => {
49+
const objectUrl = await loadVideoPreviewUrl("data:video/mp4;base64,AA==");
50+
expect(objectUrl).toMatch(/^blob:/);
51+
URL.revokeObjectURL(objectUrl);
52+
});
53+
54+
it("stops loading when the preview request is cancelled", async () => {
55+
const controller = new AbortController();
56+
controller.abort();
57+
58+
await expect(
59+
loadVideoPreviewUrl("data:video/mp4;base64,AA==", controller.signal),
60+
).rejects.toMatchObject({ name: "AbortError" });
61+
});
62+
});
63+
64+
describe("isVideoPreviewRequestCurrent", () => {
65+
it("rejects changed threads and replaced previews", () => {
66+
expect(isVideoPreviewRequestCurrent("thread-1", "thread-2", 1, 1)).toBe(false);
67+
expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 1, 2)).toBe(false);
68+
expect(isVideoPreviewRequestCurrent("thread-1", "thread-1", 2, 2)).toBe(true);
69+
});
70+
});
71+
4572
const environmentId = EnvironmentId.make("environment-local");
4673
const projectId = ProjectId.make("project-1");
4774
const threadId = ThreadId.make("thread-1");

apps/web/src/components/ChatView.logic.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,21 @@ export function revokeBlobPreviewUrl(previewUrl: string | undefined): void {
291291
URL.revokeObjectURL(previewUrl);
292292
}
293293

294+
export async function loadVideoPreviewUrl(url: string, signal?: AbortSignal): Promise<string> {
295+
const response = await fetch(url, signal ? { signal } : {});
296+
if (!response.ok) throw new Error(`Could not load video (${response.status}).`);
297+
return URL.createObjectURL(await response.blob());
298+
}
299+
300+
export function isVideoPreviewRequestCurrent(
301+
requestThreadKey: string,
302+
currentThreadKey: string,
303+
requestId: number,
304+
currentRequestId: number,
305+
): boolean {
306+
return requestThreadKey === currentThreadKey && requestId === currentRequestId;
307+
}
308+
294309
export function revokeUserMessagePreviewUrls(message: ChatMessage): void {
295310
if (message.role !== "user" || !message.attachments) {
296311
return;

0 commit comments

Comments
 (0)