An entry for the streamplace vod showcase
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(vod): add ffmpeg wrapper, thumbnail/sprite generation, and profile endpoint

- Add Deno.Command-based ffmpeg/ffprobe wrapper (no fluent-ffmpeg dependency)
- Add thumbnail and sprite sheet generation for video previews
- Add getProfile endpoint to fetch Bluesky user profiles
- Add videoMetadata endpoint returning thumbnail, spriteSheet, and VTT
- Rewrite playlist URLs to absolute for local ffmpeg access

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

+371 -27
+1 -1
apps/vod/package.json
··· 6 6 "build": "bun build src/index.ts --bundle --target=browser --outfile=dist/bundle.js" 7 7 }, 8 8 "dependencies": { 9 + "@at-run/runtime": "workspace:*", 9 10 "@atproto/api": "^0.19.5", 10 11 "@atproto/identity": "^0.4.12", 11 12 "@atproto/syntax": "^0.5.2", 12 - "@at-run/runtime": "workspace:*", 13 13 "m3u8-parser": "^7.2.0" 14 14 }, 15 15 "devDependencies": {
+87
apps/vod/src/ffmpeg.ts
··· 1 + /** 2 + * FFmpeg/FFprobe wrapper using Deno.Command 3 + * Works in the Deno sandbox with run: ["ffmpeg", "ffprobe"] permission 4 + */ 5 + 6 + declare const Deno: { 7 + Command: new (cmd: string, options: { 8 + args: string[] 9 + stdout: "piped" | "inherit" | "null" 10 + stderr: "piped" | "inherit" | "null" 11 + }) => { 12 + output(): Promise<{ 13 + success: boolean 14 + stdout: Uint8Array 15 + stderr: Uint8Array 16 + }> 17 + } 18 + } 19 + 20 + interface CommandResult { 21 + success: boolean 22 + stdout: string 23 + stderr: string 24 + } 25 + 26 + export async function ffmpeg(args: string[]): Promise<CommandResult> { 27 + const cmd = new Deno.Command("ffmpeg", { 28 + args, 29 + stdout: "piped", 30 + stderr: "piped", 31 + }) 32 + 33 + const { success, stdout, stderr } = await cmd.output() 34 + 35 + return { 36 + success, 37 + stdout: new TextDecoder().decode(stdout), 38 + stderr: new TextDecoder().decode(stderr), 39 + } 40 + } 41 + 42 + export async function ffprobe(args: string[]): Promise<CommandResult> { 43 + const cmd = new Deno.Command("ffprobe", { 44 + args, 45 + stdout: "piped", 46 + stderr: "piped", 47 + }) 48 + 49 + const { success, stdout, stderr } = await cmd.output() 50 + 51 + return { 52 + success, 53 + stdout: new TextDecoder().decode(stdout), 54 + stderr: new TextDecoder().decode(stderr), 55 + } 56 + } 57 + 58 + export interface ProbeResult { 59 + format: { 60 + duration: number 61 + size: number 62 + bit_rate: number 63 + } 64 + streams: Array<{ 65 + codec_type: string 66 + codec_name: string 67 + width?: number 68 + height?: number 69 + }> 70 + } 71 + 72 + export async function probe(input: string): Promise<ProbeResult> { 73 + const result = await ffprobe([ 74 + "-protocol_whitelist", "file,http,https,tcp,tls,crypto", 75 + "-v", "quiet", 76 + "-print_format", "json", 77 + "-show_format", 78 + "-show_streams", 79 + input, 80 + ]) 81 + 82 + if (!result.success) { 83 + throw new Error(`ffprobe failed: ${result.stderr}`) 84 + } 85 + 86 + return JSON.parse(result.stdout) 87 + }
+158 -26
apps/vod/src/index.ts
··· 5 5 */ 6 6 7 7 import { endpoint, manifest, v } from "@at-run/runtime" 8 - 9 - // Re-export v for sandbox validation 10 - export { v } 11 8 import { AtpAgent } from "@atproto/api" 12 9 import { IdResolver } from "@atproto/identity" 13 10 import { AtUri } from "@atproto/syntax" 14 11 import { Parser } from "m3u8-parser" 12 + import { generateSpriteWithVtt } from "./vtt" 13 + import { generateThumbnail } from "./thumbnail" 14 + 15 + // Re-export v for sandbox validation 16 + export { v } 17 + 18 + // Deno API declarations (available in sandbox) 19 + declare const Deno: { 20 + readTextFile(path: string): Promise<string> 21 + writeTextFile(path: string, content: string): Promise<void> 22 + readFile(path: string): Promise<Uint8Array> 23 + mkdir(path: string, options?: { recursive?: boolean }): Promise<void> 24 + stat(path: string): Promise<{ isFile: boolean; isDirectory: boolean }> 25 + } 15 26 16 27 const STREAM_PLACE_DID = "did:plc:rbvrr34edl5ddpuwcubjiost" 17 28 const STREAM_PLACE_PDS = "https://iameli.com" ··· 30 41 "vod-beta.stream.place", // HLS playlists and segments 31 42 "iameli.com", // stream.place PDS 32 43 "plc.directory", // DID resolution 44 + "public.api.bsky.app", // Bluesky public API for profiles 33 45 ], 46 + write: ["/tmp/vod-cache/"], 47 + read: ["/tmp/vod-cache/"], 48 + run: ["ffmpeg", "ffprobe"] 34 49 }, 35 50 }) 36 51 ··· 40 55 // Identity resolver for DID -> PDS resolution 41 56 const idResolver = new IdResolver() 42 57 58 + async function sha256(data: string | Uint8Array): Promise<string> { 59 + const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data 60 + const hash = await crypto.subtle.digest("SHA-256", bytes.buffer as ArrayBuffer) 61 + return [...new Uint8Array(hash)].map(b => b.toString(16).padStart(2, "0")).join("") 62 + } 63 + 64 + async function exists(path: string): Promise<boolean> { 65 + try { 66 + await Deno.stat(path) 67 + return true 68 + } catch { 69 + return false 70 + } 71 + } 72 + 43 73 /** 44 74 * Get an AtpAgent for a given DID's PDS 45 75 */ ··· 90 120 } 91 121 92 122 /** 123 + * Rewrite relative URIs in m3u8 content to absolute URLs 124 + */ 125 + function rewritePlaylistUrls(content: string, baseUrl: string): string { 126 + return content 127 + .split("\n") 128 + .map((line) => { 129 + const trimmed = line.trim() 130 + // Skip empty lines, comments, and tags (except URI= attributes) 131 + if (!trimmed || trimmed.startsWith("#")) { 132 + // Handle URI= attributes in tags like #EXT-X-MEDIA 133 + if (trimmed.includes('URI="') && !trimmed.includes('URI="http')) { 134 + return trimmed.replace(/URI="([^"]+)"/, `URI="${baseUrl}$1"`) 135 + } 136 + return line 137 + } 138 + // Non-comment, non-empty lines are URIs - make them absolute if relative 139 + if (!trimmed.startsWith("http")) { 140 + return baseUrl + trimmed 141 + } 142 + return line 143 + }) 144 + .join("\n") 145 + } 146 + 147 + /** 93 148 * Fetch and parse the master HLS playlist for a video 94 149 */ 95 150 async function fetchMasterPlaylist(videoUri: string): Promise<any> { 96 - const playlistUrl = `${VOD_XRPC_BASE}place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}` 151 + let masterPlaylist = "" 152 + const basePath = `/tmp/vod-cache/${await sha256(videoUri)}` 153 + const playlistPath = `${basePath}/master.m3u8` 154 + 155 + if (!(await exists(playlistPath))) { 156 + // Ensure cache directory exists 157 + await Deno.mkdir(basePath, { recursive: true }) 97 158 98 - const res = await fetch(playlistUrl) 99 - if (!res.ok) { 100 - throw new Error(`Failed to fetch playlist: ${res.status}`) 101 - } 159 + const playlistUrl = `${VOD_XRPC_BASE}place.stream.playback.getVideoPlaylist?uri=${encodeURIComponent(videoUri)}` 102 160 103 - const masterPlaylist = await res.text() 161 + const res = await fetch(playlistUrl) 162 + if (!res.ok) { 163 + throw new Error(`Failed to fetch playlist: ${res.status}`) 164 + } 165 + 166 + const rawPlaylist = await res.text() 167 + // Rewrite relative URIs to absolute URLs for local ffmpeg access 168 + masterPlaylist = rewritePlaylistUrls(rawPlaylist, VOD_XRPC_BASE) 169 + await Deno.writeTextFile(playlistPath, masterPlaylist) 170 + } else { 171 + masterPlaylist = await Deno.readTextFile(playlistPath) 172 + } 104 173 105 174 const parser = new Parser() 106 175 parser.push(masterPlaylist) ··· 220 289 audioTracks: Array<{ name: string; default: boolean }> 221 290 } 222 291 292 + const DidSchema = v.object({ 293 + did: v.pipe(v.string(), v.startsWith("did:", "Must be a valid DID")), 294 + }) 295 + 296 + interface Profile { 297 + did: string 298 + handle: string 299 + displayName?: string 300 + avatar?: string 301 + description?: string 302 + } 303 + 223 304 // ============================================================================ 224 305 // Endpoints 225 306 // ============================================================================ ··· 352 433 }, 353 434 }) 354 435 355 - export const pollTranscoding = endpoint<v.InferOutput<typeof GetPlaylistSchema>, { status: "pending" | "complete"; playlistUri: string }>({ 356 - input: GetPlaylistSchema, 357 - permissions: { 358 - run: ["ffmpeg", "ffprobe"], 359 - write: ["/tmp/vod-cache/"], 360 - read: ["/tmp/vod-cache/"] 436 + export const videoMetadata = endpoint<v.InferOutput<typeof UriOnlySchema>, { thumbnail: string; spriteSheet: string; vtt: string }>({ 437 + input: UriOnlySchema, 438 + limits: { 439 + timeoutMs: 240 * 1000 440 + }, 441 + async handler({ uri }) { 442 + const basePath = `/tmp/vod-cache/${await sha256(uri)}` 443 + const masterPlaylistPath = `${basePath}/master.m3u8` 444 + const metadataPath = `${basePath}/metadata` 445 + 446 + await fetchMasterPlaylist(uri) 447 + 448 + // Ensure metadata directory exists 449 + if (!(await exists(metadataPath))) { 450 + await Deno.mkdir(metadataPath, { recursive: true }) 451 + } 452 + 453 + // Generate sprite sheet and VTT if not cached 454 + console.log("VTT: " + masterPlaylistPath) 455 + const vttPath = `${metadataPath}/sprites.vtt` 456 + if (!(await exists(vttPath))) { 457 + await generateSpriteWithVtt(masterPlaylistPath, metadataPath) 458 + } 459 + 460 + // Generate thumbnail if not cached 461 + console.log("THUMB") 462 + const thumbnailPath = `${metadataPath}/thumbnail.jpg` 463 + if (!(await exists(thumbnailPath))) { 464 + await generateThumbnail(masterPlaylistPath, thumbnailPath) 465 + } 466 + 467 + // Read and encode as base64 468 + const thumbnailBytes = await Deno.readFile(thumbnailPath) 469 + const spriteSheetPath = `${metadataPath}/sprites.jpg` 470 + const spriteSheetBytes = await Deno.readFile(spriteSheetPath) 471 + const vttText = await Deno.readTextFile(vttPath) 472 + 473 + return { 474 + thumbnail: btoa(String.fromCharCode(...thumbnailBytes)), 475 + spriteSheet: btoa(String.fromCharCode(...spriteSheetBytes)), 476 + vtt: btoa(vttText), 477 + } 361 478 }, 362 - async handler({ uri, quality }) { 363 - const atUri = new AtUri(uri) 364 - const agent = await getAgentForDid(atUri.host) 479 + }) 365 480 366 - const response = await agent.com.atproto.repo.getRecord({ 367 - repo: atUri.host, 368 - collection: atUri.collection, 369 - rkey: atUri.rkey, 370 - }) 481 + /** 482 + * Get user profile from Bluesky 483 + */ 484 + export const getProfile = endpoint<v.InferOutput<typeof DidSchema>, Profile>({ 485 + input: DidSchema, 486 + async handler({ did }) { 487 + const url = `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 371 488 372 - console.log(response.data) 489 + const res = await fetch(url) 490 + if (!res.ok) { 491 + throw new Error(`Failed to fetch profile: ${res.status}`) 492 + } 493 + 494 + const data = await res.json() as { 495 + did: string 496 + handle: string 497 + displayName?: string 498 + avatar?: string 499 + description?: string 500 + } 501 + 373 502 return { 374 - status: "pending", 375 - playlistUri: "", 503 + did: data.did, 504 + handle: data.handle, 505 + displayName: data.displayName, 506 + avatar: data.avatar, 507 + description: data.description, 376 508 } 377 509 }, 378 510 })
+26
apps/vod/src/thumbnail.ts
··· 1 + /** 2 + * Video thumbnail generation 3 + */ 4 + 5 + import { ffmpeg } from "./ffmpeg" 6 + 7 + export async function generateThumbnail( 8 + input: string, 9 + output: string, 10 + time = "00:00:03" 11 + ): Promise<void> { 12 + const result = await ffmpeg([ 13 + "-protocol_whitelist", "file,http,https,tcp,tls,crypto", 14 + "-i", input, 15 + "-ss", time, 16 + "-vframes", "1", 17 + "-vf", "scale=320:240", 18 + "-q:v", "3", 19 + "-y", 20 + output, 21 + ]) 22 + 23 + if (!result.success) { 24 + throw new Error(`Failed to generate thumbnail: ${result.stderr}`) 25 + } 26 + }
+99
apps/vod/src/vtt.ts
··· 1 + /** 2 + * Sprite sheet and WebVTT generation for video thumbnails 3 + */ 4 + 5 + import { ffmpeg, probe } from "./ffmpeg" 6 + 7 + interface ThumbnailData { 8 + start: number 9 + end: number 10 + x: number 11 + y: number 12 + w: number 13 + h: number 14 + } 15 + 16 + declare const Deno: { 17 + writeTextFile(path: string, content: string): Promise<void> 18 + } 19 + 20 + export async function generateSpriteWithVtt( 21 + input: string, 22 + basePath: string 23 + ): Promise<void> { 24 + // Get video duration 25 + const meta = await probe(input) 26 + const duration = meta.format.duration 27 + 28 + const frameCount = 8 29 + const interval = duration / frameCount 30 + const thumbW = 160 31 + const thumbH = 90 32 + const cols = 4 33 + const rows = 2 34 + 35 + // Build VTT data 36 + const thumbs: ThumbnailData[] = [] 37 + 38 + for (let i = 0; i < frameCount; i++) { 39 + const start = i * interval 40 + const end = Math.min((i + 1) * interval, duration) 41 + const col = i % cols 42 + const row = Math.floor(i / cols) 43 + 44 + thumbs.push({ 45 + start, 46 + end, 47 + x: col * thumbW, 48 + y: row * thumbH, 49 + w: thumbW, 50 + h: thumbH, 51 + }) 52 + } 53 + 54 + // Generate sprite sheet 55 + const fps = frameCount / duration 56 + const spriteOutput = `${basePath}/sprites.jpg` 57 + 58 + console.log("SPRITE: " + spriteOutput) 59 + console.log("INPUT: " + input) 60 + 61 + const result = await ffmpeg([ 62 + "-protocol_whitelist", "file,http,https,tcp,tls,crypto", 63 + "-i", input, 64 + "-vf", `fps=${fps},scale=${thumbW}:${thumbH},tile=${cols}x${rows}`, 65 + "-frames:v", "1", 66 + "-q:v", "3", 67 + "-y", 68 + spriteOutput, 69 + ]) 70 + 71 + if (!result.success) { 72 + throw new Error(`Failed to generate sprite: ${result.stderr}`) 73 + } 74 + 75 + // Write WebVTT file 76 + const vtt = buildWebVtt(thumbs, "sprites.jpg") 77 + await Deno.writeTextFile(`${basePath}/sprites.vtt`, vtt) 78 + } 79 + 80 + function formatTime(seconds: number): string { 81 + const hrs = Math.floor(seconds / 3600) 82 + const mins = Math.floor((seconds % 3600) / 60) 83 + const secs = Math.floor(seconds % 60) 84 + const ms = Math.floor((seconds % 1) * 1000) 85 + 86 + return `${String(hrs).padStart(2, "0")}:${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}.${String(ms).padStart(3, "0")}` 87 + } 88 + 89 + function buildWebVtt(thumbs: ThumbnailData[], spriteUrl: string): string { 90 + const lines = ["WEBVTT", ""] 91 + 92 + for (const t of thumbs) { 93 + lines.push(`${formatTime(t.start)} --> ${formatTime(t.end)}`) 94 + lines.push(`${spriteUrl}#xywh=${t.x},${t.y},${t.w},${t.h}`) 95 + lines.push("") 96 + } 97 + 98 + return lines.join("\n") 99 + }