From 24c66494f6dd2a12ff9964c5123285a383d21b76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 24 Feb 2026 23:41:48 +0000 Subject: [PATCH 01/10] feat: add pure TypeScript animated WebP parser Adds a RIFF/WebP binary format parser (webp_parser.ts) that extracts metadata from WebP files without depending on FFmpeg, which currently lacks proper animated WebP support (see ffmpeg ticket #4907). The parser handles: - VP8X extended format (animated + still, with alpha detection) - VP8 lossy bitstream dimension extraction - VP8L lossless bitstream dimension extraction - ANIM chunk (loop count, background color) - ANMF frame chunks (per-frame duration, dimensions, blending/disposal) Integration into file_processor.ts: - Falls back to the WebP parser when ffprobe fails on .webp files - Falls back when ffprobe returns zero width/height for .webp files - Replaces the old hard error with a functional shim Tests use synthetic RIFF buffers to validate all chunk types and error cases. Stubs for real file tests are left as comments pending sample animated/non-animated WebP resource files. Co-authored-by: andykais-claude --- packages/core/src/lib/file_processor.ts | 37 +++- packages/core/src/lib/webp_parser.ts | 267 ++++++++++++++++++++++++ packages/core/test/webp_parser.test.ts | 254 ++++++++++++++++++++++ 3 files changed, 557 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/lib/webp_parser.ts create mode 100644 packages/core/test/webp_parser.test.ts diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index a1471518..078ce212 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -6,6 +6,7 @@ import z from 'zod' import { Context } from '~/context.ts' import { CODECS } from './codecs.ts' import * as errors from './errors.ts' +import { parse_webp_file } from './webp_parser.ts' interface FileInfoBase { @@ -252,6 +253,9 @@ class FileProcessor { const output = await cmd.output() if (!output.success) { + if (path.extname(this.#filepath) === '.webp') { + return this.#get_info_webp_fallback() + } const command = ffprobe_command.join(' ') const stdout = this.#decoder.decode(output.stdout) const stderr = this.#decoder.decode(output.stderr) @@ -350,7 +354,7 @@ class FileProcessor { if (media_type !== 'AUDIO' && width === 0 || height === 0) { if (path.extname(this.#filepath) === '.webp') { - throw new errors.InvalidFileError(`webp decoding is not yet supported by ffmpeg. See here https://trac.ffmpeg.org/ticket/4907`, new Error(`width: ${width} and height: ${height} values cannot be zero`)) + return this.#get_info_webp_fallback() } throw new errors.InvalidFileError(`Invalid file`, new Error(`width: ${width} and height: ${height} values cannot be zero`)) } @@ -372,6 +376,37 @@ class FileProcessor { return file_info } + /** + * Fallback for WebP files when ffprobe cannot decode them properly. + * Uses a pure TypeScript RIFF/WebP parser to extract metadata. + * See https://trac.ffmpeg.org/ticket/4907 + */ + async #get_info_webp_fallback(): Promise { + const webp_info = await parse_webp_file(this.#filepath) + const filename = path.basename(this.#filepath) + const codec_info = CODECS.get_codec('webp') + + const duration = webp_info.animated ? webp_info.total_duration_ms / 1000 : 0 + const framecount = webp_info.animated ? webp_info.framecount : 0 + const framerate = webp_info.animated && duration > 0 ? framecount / duration : 0 + + const file_info = { + filepath: this.#filepath, + filename, + ...codec_info, + width: webp_info.canvas_width, + height: webp_info.canvas_height, + animated: webp_info.animated, + duration, + framerate, + framecount, + audio: false, + } as FileInfo + + this.#error_context.file_info = file_info + return file_info + } + /* Our architecture stores files on disk outside of the database and uses a "Content-addressed storage" approach. * It is considered good practice when doing this to avoid a totally flat directory structure, mainly for OS performance reasons. * A happy medium is just grouping the files by the first two characters (gives us 256 possible subdirectories). diff --git a/packages/core/src/lib/webp_parser.ts b/packages/core/src/lib/webp_parser.ts new file mode 100644 index 00000000..830b19a9 --- /dev/null +++ b/packages/core/src/lib/webp_parser.ts @@ -0,0 +1,267 @@ +/** + * Pure TypeScript parser for WebP files (RIFF-based container format). + * This is a shim for animated WebP support until FFmpeg's libwebp decoding + * is fully functional. See https://trac.ffmpeg.org/ticket/4907 + * + * Implements parsing based on the WebP Container Specification: + * https://developers.google.com/speed/webp/docs/riff_container + */ + +import * as errors from './errors.ts' + + +interface WebPFrame { + x_offset: number + y_offset: number + width: number + height: number + duration_ms: number + blending: 'alpha' | 'no_alpha' + disposal: 'none' | 'background' +} + +interface WebPInfoBase { + canvas_width: number + canvas_height: number + has_alpha: boolean +} + +interface WebPStillInfo extends WebPInfoBase { + animated: false +} + +interface WebPAnimatedInfo extends WebPInfoBase { + animated: true + loop_count: number + background_color: { r: number; g: number; b: number; a: number } + frames: WebPFrame[] + total_duration_ms: number + framecount: number +} + +export type WebPInfo = WebPStillInfo | WebPAnimatedInfo + + +class WebPParseError extends errors.FileProcessingError { + override name = 'WebPParseError' + constructor(message: string) { + super(message) + } +} + + +function read_uint24_le(data: DataView, offset: number): number { + return data.getUint8(offset) | (data.getUint8(offset + 1) << 8) | (data.getUint8(offset + 2) << 16) +} + + +function ascii(data: DataView, offset: number, length: number): string { + const bytes = new Uint8Array(data.buffer, data.byteOffset + offset, length) + return String.fromCharCode(...bytes) +} + + +/** + * Parse a VP8 (lossy) bitstream header to extract width and height. + * The VP8 bitstream begins with a 3-byte frame tag, followed (for keyframes) + * by a 3-byte start code (0x9D 0x01 0x2A) and then width/height. + */ +function parse_vp8_dimensions(data: DataView, offset: number, size: number): { width: number; height: number } { + if (size < 10) { + throw new WebPParseError(`VP8 chunk too small to contain dimensions (size: ${size})`) + } + + const frame_tag_byte0 = data.getUint8(offset) + const is_keyframe = (frame_tag_byte0 & 0x01) === 0 + + if (!is_keyframe) { + throw new WebPParseError('VP8 chunk is not a keyframe, cannot extract dimensions') + } + + const start_code_0 = data.getUint8(offset + 3) + const start_code_1 = data.getUint8(offset + 4) + const start_code_2 = data.getUint8(offset + 5) + if (start_code_0 !== 0x9D || start_code_1 !== 0x01 || start_code_2 !== 0x2A) { + throw new WebPParseError(`Invalid VP8 start code: 0x${start_code_0.toString(16)} 0x${start_code_1.toString(16)} 0x${start_code_2.toString(16)}`) + } + + const width = data.getUint16(offset + 6, true) & 0x3FFF + const height = data.getUint16(offset + 8, true) & 0x3FFF + + return { width, height } +} + + +/** + * Parse a VP8L (lossless) bitstream header to extract width and height. + * The VP8L bitstream starts with a 1-byte signature (0x2F), followed by + * a 4-byte packed field containing width-1 (14 bits) and height-1 (14 bits). + */ +function parse_vp8l_dimensions(data: DataView, offset: number, size: number): { width: number; height: number } { + if (size < 5) { + throw new WebPParseError(`VP8L chunk too small to contain dimensions (size: ${size})`) + } + + const signature = data.getUint8(offset) + if (signature !== 0x2F) { + throw new WebPParseError(`Invalid VP8L signature: 0x${signature.toString(16)}`) + } + + const bits = data.getUint32(offset + 1, true) + const width = (bits & 0x3FFF) + 1 + const height = ((bits >> 14) & 0x3FFF) + 1 + + return { width, height } +} + + +/** + * Parse a WebP file buffer and extract metadata including animation info. + */ +export function parse_webp(buffer: ArrayBuffer): WebPInfo { + const data = new DataView(buffer) + + if (buffer.byteLength < 12) { + throw new WebPParseError(`File too small to be a valid WebP (${buffer.byteLength} bytes)`) + } + + const riff_tag = ascii(data, 0, 4) + if (riff_tag !== 'RIFF') { + throw new WebPParseError(`Missing RIFF tag, found "${riff_tag}"`) + } + + const file_size = data.getUint32(4, true) + 8 + const webp_tag = ascii(data, 8, 4) + if (webp_tag !== 'WEBP') { + throw new WebPParseError(`Missing WEBP tag, found "${webp_tag}"`) + } + + let canvas_width = 0 + let canvas_height = 0 + let has_alpha = false + let is_animated = false + let loop_count = 0 + let background_color = { r: 0, g: 0, b: 0, a: 0 } + const frames: WebPFrame[] = [] + let found_vp8x = false + + let pos = 12 + const end = Math.min(file_size, buffer.byteLength) + + while (pos + 8 <= end) { + const chunk_fourcc = ascii(data, pos, 4) + const chunk_size = data.getUint32(pos + 4, true) + const chunk_data_offset = pos + 8 + const padded_size = chunk_size + (chunk_size & 1) + + if (chunk_data_offset + chunk_size > end) { + break + } + + switch (chunk_fourcc) { + case 'VP8X': { + if (chunk_size < 10) { + throw new WebPParseError(`VP8X chunk too small (${chunk_size} bytes)`) + } + found_vp8x = true + const flags = data.getUint8(chunk_data_offset) + is_animated = (flags & 0x02) !== 0 + has_alpha = (flags & 0x10) !== 0 + + canvas_width = read_uint24_le(data, chunk_data_offset + 4) + 1 + canvas_height = read_uint24_le(data, chunk_data_offset + 7) + 1 + break + } + + case 'ANIM': { + if (chunk_size < 6) { + throw new WebPParseError(`ANIM chunk too small (${chunk_size} bytes)`) + } + background_color = { + b: data.getUint8(chunk_data_offset), + g: data.getUint8(chunk_data_offset + 1), + r: data.getUint8(chunk_data_offset + 2), + a: data.getUint8(chunk_data_offset + 3), + } + loop_count = data.getUint16(chunk_data_offset + 4, true) + break + } + + case 'ANMF': { + if (chunk_size < 16) { + throw new WebPParseError(`ANMF chunk too small (${chunk_size} bytes)`) + } + const frame_x = read_uint24_le(data, chunk_data_offset) * 2 + const frame_y = read_uint24_le(data, chunk_data_offset + 3) * 2 + const frame_width = read_uint24_le(data, chunk_data_offset + 6) + 1 + const frame_height = read_uint24_le(data, chunk_data_offset + 9) + 1 + const duration_ms = read_uint24_le(data, chunk_data_offset + 12) + const frame_flags = data.getUint8(chunk_data_offset + 15) + const blending = (frame_flags & 0x02) !== 0 ? 'no_alpha' as const : 'alpha' as const + const disposal = (frame_flags & 0x01) !== 0 ? 'background' as const : 'none' as const + + frames.push({ x_offset: frame_x, y_offset: frame_y, width: frame_width, height: frame_height, duration_ms, blending, disposal }) + break + } + + case 'VP8 ': { + if (!found_vp8x) { + const dims = parse_vp8_dimensions(data, chunk_data_offset, chunk_size) + canvas_width = dims.width + canvas_height = dims.height + } + break + } + + case 'VP8L': { + if (!found_vp8x) { + const dims = parse_vp8l_dimensions(data, chunk_data_offset, chunk_size) + canvas_width = dims.width + canvas_height = dims.height + has_alpha = true + } + break + } + } + + pos = chunk_data_offset + padded_size + } + + if (canvas_width === 0 || canvas_height === 0) { + throw new WebPParseError(`Could not determine WebP dimensions (width: ${canvas_width}, height: ${canvas_height})`) + } + + if (is_animated) { + if (frames.length === 0) { + throw new WebPParseError('VP8X indicates animation but no ANMF frames found') + } + const total_duration_ms = frames.reduce((sum, f) => sum + f.duration_ms, 0) + return { + canvas_width, + canvas_height, + has_alpha, + animated: true, + loop_count, + background_color, + frames, + total_duration_ms, + framecount: frames.length, + } + } + + return { + canvas_width, + canvas_height, + has_alpha, + animated: false, + } +} + + +/** + * Parse a WebP file from disk and return its metadata. + */ +export async function parse_webp_file(filepath: string): Promise { + const buffer = await Deno.readFile(filepath) + return parse_webp(buffer.buffer as ArrayBuffer) +} diff --git a/packages/core/test/webp_parser.test.ts b/packages/core/test/webp_parser.test.ts new file mode 100644 index 00000000..410e12d5 --- /dev/null +++ b/packages/core/test/webp_parser.test.ts @@ -0,0 +1,254 @@ +import { test } from 'forager-test' +import { parse_webp } from '~/lib/webp_parser.ts' + + +function build_riff_webp(chunks: Uint8Array): ArrayBuffer { + const riff_header = new Uint8Array(12) + const view = new DataView(riff_header.buffer) + riff_header.set(new TextEncoder().encode('RIFF'), 0) + view.setUint32(4, chunks.byteLength + 4, true) + riff_header.set(new TextEncoder().encode('WEBP'), 8) + + const result = new Uint8Array(riff_header.byteLength + chunks.byteLength) + result.set(riff_header, 0) + result.set(chunks, 12) + return result.buffer as ArrayBuffer +} + + +function build_chunk(fourcc: string, payload: Uint8Array): Uint8Array { + const header = new Uint8Array(8) + const view = new DataView(header.buffer) + header.set(new TextEncoder().encode(fourcc), 0) + view.setUint32(4, payload.byteLength, true) + + const padded_size = payload.byteLength + (payload.byteLength & 1) + const result = new Uint8Array(8 + padded_size) + result.set(header, 0) + result.set(payload, 8) + return result +} + + +function build_vp8x_chunk(opts: { animated?: boolean; alpha?: boolean; width: number; height: number }): Uint8Array { + const payload = new Uint8Array(10) + const view = new DataView(payload.buffer) + let flags = 0 + if (opts.animated) flags |= 0x02 + if (opts.alpha) flags |= 0x10 + view.setUint8(0, flags) + + const w = opts.width - 1 + const h = opts.height - 1 + payload[4] = w & 0xFF + payload[5] = (w >> 8) & 0xFF + payload[6] = (w >> 16) & 0xFF + payload[7] = h & 0xFF + payload[8] = (h >> 8) & 0xFF + payload[9] = (h >> 16) & 0xFF + + return build_chunk('VP8X', payload) +} + + +function build_anim_chunk(loop_count: number): Uint8Array { + const payload = new Uint8Array(6) + const view = new DataView(payload.buffer) + view.setUint16(4, loop_count, true) + return build_chunk('ANIM', payload) +} + + +function build_anmf_chunk(opts: { width: number; height: number; duration_ms: number }): Uint8Array { + const min_payload_size = 16 + const dummy_frame_data = new Uint8Array(8) + const payload = new Uint8Array(min_payload_size + dummy_frame_data.byteLength) + + const w = opts.width - 1 + const h = opts.height - 1 + payload[6] = w & 0xFF + payload[7] = (w >> 8) & 0xFF + payload[8] = (w >> 16) & 0xFF + payload[9] = h & 0xFF + payload[10] = (h >> 8) & 0xFF + payload[11] = (h >> 16) & 0xFF + payload[12] = opts.duration_ms & 0xFF + payload[13] = (opts.duration_ms >> 8) & 0xFF + payload[14] = (opts.duration_ms >> 16) & 0xFF + + payload.set(dummy_frame_data, min_payload_size) + return build_chunk('ANMF', payload) +} + + +function concat_uint8(...arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((sum, a) => sum + a.byteLength, 0) + const result = new Uint8Array(total) + let offset = 0 + for (const a of arrays) { + result.set(a, offset) + offset += a.byteLength + } + return result +} + + +test('webp_parser - synthetic buffers', async (ctx) => { + await ctx.subtest('parse non-animated VP8X webp', () => { + const vp8x = build_vp8x_chunk({ width: 800, height: 600 }) + const buffer = build_riff_webp(vp8x) + const info = parse_webp(buffer) + + ctx.assert.equals(info.canvas_width, 800) + ctx.assert.equals(info.canvas_height, 600) + ctx.assert.equals(info.animated, false) + ctx.assert.equals(info.has_alpha, false) + }) + + await ctx.subtest('parse non-animated VP8X with alpha', () => { + const vp8x = build_vp8x_chunk({ width: 1920, height: 1080, alpha: true }) + const buffer = build_riff_webp(vp8x) + const info = parse_webp(buffer) + + ctx.assert.equals(info.canvas_width, 1920) + ctx.assert.equals(info.canvas_height, 1080) + ctx.assert.equals(info.animated, false) + ctx.assert.equals(info.has_alpha, true) + }) + + await ctx.subtest('parse animated webp', () => { + const vp8x = build_vp8x_chunk({ animated: true, width: 320, height: 240 }) + const anim = build_anim_chunk(0) + const frame1 = build_anmf_chunk({ width: 320, height: 240, duration_ms: 100 }) + const frame2 = build_anmf_chunk({ width: 320, height: 240, duration_ms: 100 }) + const frame3 = build_anmf_chunk({ width: 320, height: 240, duration_ms: 100 }) + + const chunks = concat_uint8(vp8x, anim, frame1, frame2, frame3) + const buffer = build_riff_webp(chunks) + const info = parse_webp(buffer) + + ctx.assert.equals(info.animated, true) + if (!info.animated) throw new Error('unreachable') + ctx.assert.equals(info.canvas_width, 320) + ctx.assert.equals(info.canvas_height, 240) + ctx.assert.equals(info.framecount, 3) + ctx.assert.equals(info.total_duration_ms, 300) + ctx.assert.equals(info.loop_count, 0) + }) + + await ctx.subtest('parse animated webp with varying frame durations', () => { + const vp8x = build_vp8x_chunk({ animated: true, width: 640, height: 480 }) + const anim = build_anim_chunk(5) + const frame1 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 50 }) + const frame2 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 200 }) + const frame3 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 150 }) + const frame4 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 100 }) + + const chunks = concat_uint8(vp8x, anim, frame1, frame2, frame3, frame4) + const buffer = build_riff_webp(chunks) + const info = parse_webp(buffer) + + ctx.assert.equals(info.animated, true) + if (!info.animated) throw new Error('unreachable') + ctx.assert.equals(info.framecount, 4) + ctx.assert.equals(info.total_duration_ms, 500) + ctx.assert.equals(info.loop_count, 5) + }) + + await ctx.subtest('rejects non-RIFF data', () => { + const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) + ctx.assert.throws(() => parse_webp(garbage.buffer as ArrayBuffer), Error, 'Missing RIFF tag') + }) + + await ctx.subtest('rejects file too small', () => { + const tiny = new Uint8Array(4) + ctx.assert.throws(() => parse_webp(tiny.buffer as ArrayBuffer), Error, 'too small') + }) + + await ctx.subtest('rejects RIFF without WEBP tag', () => { + const data = new Uint8Array(12) + const view = new DataView(data.buffer) + data.set(new TextEncoder().encode('RIFF'), 0) + view.setUint32(4, 4, true) + data.set(new TextEncoder().encode('AVI '), 8) + ctx.assert.throws(() => parse_webp(data.buffer as ArrayBuffer), Error, 'Missing WEBP tag') + }) + + await ctx.subtest('VP8X animated flag set but no ANMF frames', () => { + const vp8x = build_vp8x_chunk({ animated: true, width: 100, height: 100 }) + const buffer = build_riff_webp(vp8x) + ctx.assert.throws(() => parse_webp(buffer), Error, 'no ANMF frames found') + }) +}) + + +test('webp_parser - VP8 lossy still image', async (ctx) => { + await ctx.subtest('parse VP8 dimensions', () => { + const frame_tag = new Uint8Array(3) + frame_tag[0] = 0x00 + + const start_code = new Uint8Array([0x9D, 0x01, 0x2A]) + const dims = new Uint8Array(4) + const dims_view = new DataView(dims.buffer) + dims_view.setUint16(0, 400, true) + dims_view.setUint16(2, 300, true) + + const padding = new Uint8Array(3) + const vp8_payload = concat_uint8(frame_tag, start_code, dims, padding) + const vp8_chunk = build_chunk('VP8 ', vp8_payload) + const buffer = build_riff_webp(vp8_chunk) + const info = parse_webp(buffer) + + ctx.assert.equals(info.canvas_width, 400) + ctx.assert.equals(info.canvas_height, 300) + ctx.assert.equals(info.animated, false) + }) +}) + + +test('webp_parser - VP8L lossless still image', async (ctx) => { + await ctx.subtest('parse VP8L dimensions', () => { + const payload = new Uint8Array(5) + const view = new DataView(payload.buffer) + payload[0] = 0x2F + + const width = 512 + const height = 256 + const bits = ((width - 1) & 0x3FFF) | (((height - 1) & 0x3FFF) << 14) + view.setUint32(1, bits, true) + + const vp8l_chunk = build_chunk('VP8L', payload) + const buffer = build_riff_webp(vp8l_chunk) + const info = parse_webp(buffer) + + ctx.assert.equals(info.canvas_width, 512) + ctx.assert.equals(info.canvas_height, 256) + ctx.assert.equals(info.animated, false) + ctx.assert.equals(info.has_alpha, true) + }) +}) + + +// TODO: Add tests with real WebP files once sample animated and non-animated webp resources are provided. +// These tests will exercise the full parse_webp_file() path and validate against known file properties. +// +// test('webp_parser - real animated webp file', async (ctx) => { +// await ctx.subtest('parse animated webp from disk', async () => { +// const info = await parse_webp_file(ctx.resources.media_files['animated_sample.webp']) +// ctx.assert.equals(info.animated, true) +// if (!info.animated) throw new Error('unreachable') +// ctx.assert.equals(info.canvas_width, EXPECTED_WIDTH) +// ctx.assert.equals(info.canvas_height, EXPECTED_HEIGHT) +// // ctx.assert.equals(info.total_duration_ms, EXPECTED_DURATION_MS) +// // ctx.assert.equals(info.framecount, EXPECTED_FRAME_COUNT) +// }) +// }) +// +// test('webp_parser - real non-animated webp file', async (ctx) => { +// await ctx.subtest('parse still webp from disk', async () => { +// const info = await parse_webp_file(ctx.resources.media_files['still_sample.webp']) +// ctx.assert.equals(info.animated, false) +// ctx.assert.equals(info.canvas_width, EXPECTED_WIDTH) +// ctx.assert.equals(info.canvas_height, EXPECTED_HEIGHT) +// }) +// }) From 65f10a60144ef5a1d4d898067985178668e55437 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 22:40:20 +0000 Subject: [PATCH 02/10] refactor: address PR review feedback - Rename webp_parser.ts to file_processor_webp_shim.ts - Move FileInfo conversion logic into the shim (get_webp_file_info) so it returns FileInfo directly, no webp-specific types exposed - Remove zero-dimension webp fallback in file_processor.ts since ffprobe will never get that far when parsing animated webp - Export FileInfo type from file_processor.ts for the shim to use - Update test imports to match renamed file Co-authored-by: andykais-claude --- packages/core/src/lib/file_processor.ts | 39 ++----------------- ..._parser.ts => file_processor_webp_shim.ts} | 36 ++++++++++++++--- packages/core/test/webp_parser.test.ts | 2 +- 3 files changed, 35 insertions(+), 42 deletions(-) rename packages/core/src/lib/{webp_parser.ts => file_processor_webp_shim.ts} (86%) diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index 078ce212..fe1b6bbd 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -6,7 +6,7 @@ import z from 'zod' import { Context } from '~/context.ts' import { CODECS } from './codecs.ts' import * as errors from './errors.ts' -import { parse_webp_file } from './webp_parser.ts' +import { get_webp_file_info } from './file_processor_webp_shim.ts' interface FileInfoBase { @@ -254,7 +254,7 @@ class FileProcessor { if (!output.success) { if (path.extname(this.#filepath) === '.webp') { - return this.#get_info_webp_fallback() + return get_webp_file_info(this.#filepath) } const command = ffprobe_command.join(' ') const stdout = this.#decoder.decode(output.stdout) @@ -353,9 +353,6 @@ class FileProcessor { } if (media_type !== 'AUDIO' && width === 0 || height === 0) { - if (path.extname(this.#filepath) === '.webp') { - return this.#get_info_webp_fallback() - } throw new errors.InvalidFileError(`Invalid file`, new Error(`width: ${width} and height: ${height} values cannot be zero`)) } @@ -376,37 +373,6 @@ class FileProcessor { return file_info } - /** - * Fallback for WebP files when ffprobe cannot decode them properly. - * Uses a pure TypeScript RIFF/WebP parser to extract metadata. - * See https://trac.ffmpeg.org/ticket/4907 - */ - async #get_info_webp_fallback(): Promise { - const webp_info = await parse_webp_file(this.#filepath) - const filename = path.basename(this.#filepath) - const codec_info = CODECS.get_codec('webp') - - const duration = webp_info.animated ? webp_info.total_duration_ms / 1000 : 0 - const framecount = webp_info.animated ? webp_info.framecount : 0 - const framerate = webp_info.animated && duration > 0 ? framecount / duration : 0 - - const file_info = { - filepath: this.#filepath, - filename, - ...codec_info, - width: webp_info.canvas_width, - height: webp_info.canvas_height, - animated: webp_info.animated, - duration, - framerate, - framecount, - audio: false, - } as FileInfo - - this.#error_context.file_info = file_info - return file_info - } - /* Our architecture stores files on disk outside of the database and uses a "Content-addressed storage" approach. * It is considered good practice when doing this to avoid a totally flat directory structure, mainly for OS performance reasons. * A happy medium is just grouping the files by the first two characters (gives us 256 possible subdirectories). @@ -709,3 +675,4 @@ class FileProcessor { } export { FileProcessor } +export type { FileInfo } diff --git a/packages/core/src/lib/webp_parser.ts b/packages/core/src/lib/file_processor_webp_shim.ts similarity index 86% rename from packages/core/src/lib/webp_parser.ts rename to packages/core/src/lib/file_processor_webp_shim.ts index 830b19a9..fea4bb46 100644 --- a/packages/core/src/lib/webp_parser.ts +++ b/packages/core/src/lib/file_processor_webp_shim.ts @@ -7,7 +7,10 @@ * https://developers.google.com/speed/webp/docs/riff_container */ +import * as path from '@std/path' +import { CODECS } from './codecs.ts' import * as errors from './errors.ts' +import type { FileInfo } from './file_processor.ts' interface WebPFrame { @@ -39,7 +42,7 @@ interface WebPAnimatedInfo extends WebPInfoBase { framecount: number } -export type WebPInfo = WebPStillInfo | WebPAnimatedInfo +type WebPInfo = WebPStillInfo | WebPAnimatedInfo class WebPParseError extends errors.FileProcessingError { @@ -259,9 +262,32 @@ export function parse_webp(buffer: ArrayBuffer): WebPInfo { /** - * Parse a WebP file from disk and return its metadata. + * Parse a WebP file from disk and return FileInfo compatible with FileProcessor. + * This is the primary entry point used by FileProcessor as a fallback when + * ffprobe cannot handle WebP files. */ -export async function parse_webp_file(filepath: string): Promise { - const buffer = await Deno.readFile(filepath) - return parse_webp(buffer.buffer as ArrayBuffer) +export async function get_webp_file_info(filepath: string): Promise { + const resolved_filepath = path.resolve(filepath) + const buffer = await Deno.readFile(resolved_filepath) + const webp_info = parse_webp(buffer.buffer as ArrayBuffer) + + const filename = path.basename(resolved_filepath) + const codec_info = CODECS.get_codec('webp') + + const duration = webp_info.animated ? webp_info.total_duration_ms / 1000 : 0 + const framecount = webp_info.animated ? webp_info.framecount : 0 + const framerate = webp_info.animated && duration > 0 ? framecount / duration : 0 + + return { + filepath: resolved_filepath, + filename, + ...codec_info, + width: webp_info.canvas_width, + height: webp_info.canvas_height, + animated: webp_info.animated, + duration, + framerate, + framecount, + audio: false, + } as FileInfo } diff --git a/packages/core/test/webp_parser.test.ts b/packages/core/test/webp_parser.test.ts index 410e12d5..7c216720 100644 --- a/packages/core/test/webp_parser.test.ts +++ b/packages/core/test/webp_parser.test.ts @@ -1,5 +1,5 @@ import { test } from 'forager-test' -import { parse_webp } from '~/lib/webp_parser.ts' +import { parse_webp } from '~/lib/file_processor_webp_shim.ts' function build_riff_webp(chunks: Uint8Array): ArrayBuffer { From 96932b49ecd52abe12f8635bbbd8ddf8c136f5c0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 22:46:24 +0000 Subject: [PATCH 03/10] test: add animated WebP test resource and real file tests Add nyan_cat.webp (400x400, 12 frames, 840ms, animated with alpha) as a test resource and integrate it into the WebP parser test suite. New test suite 'webp_parser - animated webp file from disk': - parse_webp: validates canvas dimensions, frame count, total duration, alpha flag, loop count, and per-frame durations from a real file - get_webp_file_info: validates the full FileInfo output including codec, media_type, content_type, framerate, and filename Co-authored-by: andykais-claude --- lib/test/lib/util.ts | 1 + lib/test/resources/nyan_cat.webp | Bin 0 -> 37342 bytes packages/core/test/webp_parser.test.ts | 57 ++++++++++++++----------- 3 files changed, 34 insertions(+), 24 deletions(-) create mode 100644 lib/test/resources/nyan_cat.webp diff --git a/lib/test/lib/util.ts b/lib/test/lib/util.ts index 324e4a5f..682549cb 100644 --- a/lib/test/lib/util.ts +++ b/lib/test/lib/util.ts @@ -29,6 +29,7 @@ const media_files = resource_file_mapper([ 'cat_cronch.mp4', 'blink.gif', 'music_snippet.mp3', + 'nyan_cat.webp', ] as const) if (!import.meta.dirname) throw new Error(`unexpected value in import.meta.dirname`) diff --git a/lib/test/resources/nyan_cat.webp b/lib/test/resources/nyan_cat.webp new file mode 100644 index 0000000000000000000000000000000000000000..7a1d3fe146ae49b89f0cf8ff33864c3053562960 GIT binary patch literal 37342 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 literal 0 HcmV?d00001 diff --git a/packages/core/test/webp_parser.test.ts b/packages/core/test/webp_parser.test.ts index 7c216720..531a60a0 100644 --- a/packages/core/test/webp_parser.test.ts +++ b/packages/core/test/webp_parser.test.ts @@ -1,5 +1,5 @@ import { test } from 'forager-test' -import { parse_webp } from '~/lib/file_processor_webp_shim.ts' +import { parse_webp, get_webp_file_info } from '~/lib/file_processor_webp_shim.ts' function build_riff_webp(chunks: Uint8Array): ArrayBuffer { @@ -229,26 +229,35 @@ test('webp_parser - VP8L lossless still image', async (ctx) => { }) -// TODO: Add tests with real WebP files once sample animated and non-animated webp resources are provided. -// These tests will exercise the full parse_webp_file() path and validate against known file properties. -// -// test('webp_parser - real animated webp file', async (ctx) => { -// await ctx.subtest('parse animated webp from disk', async () => { -// const info = await parse_webp_file(ctx.resources.media_files['animated_sample.webp']) -// ctx.assert.equals(info.animated, true) -// if (!info.animated) throw new Error('unreachable') -// ctx.assert.equals(info.canvas_width, EXPECTED_WIDTH) -// ctx.assert.equals(info.canvas_height, EXPECTED_HEIGHT) -// // ctx.assert.equals(info.total_duration_ms, EXPECTED_DURATION_MS) -// // ctx.assert.equals(info.framecount, EXPECTED_FRAME_COUNT) -// }) -// }) -// -// test('webp_parser - real non-animated webp file', async (ctx) => { -// await ctx.subtest('parse still webp from disk', async () => { -// const info = await parse_webp_file(ctx.resources.media_files['still_sample.webp']) -// ctx.assert.equals(info.animated, false) -// ctx.assert.equals(info.canvas_width, EXPECTED_WIDTH) -// ctx.assert.equals(info.canvas_height, EXPECTED_HEIGHT) -// }) -// }) +test('webp_parser - animated webp file from disk', async (ctx) => { + await ctx.subtest('parse_webp extracts correct metadata', async () => { + const buffer = await Deno.readFile(ctx.resources.media_files['nyan_cat.webp']) + const info = parse_webp(buffer.buffer as ArrayBuffer) + + ctx.assert.equals(info.animated, true) + if (!info.animated) throw new Error('unreachable') + ctx.assert.equals(info.canvas_width, 400) + ctx.assert.equals(info.canvas_height, 400) + ctx.assert.equals(info.has_alpha, true) + ctx.assert.equals(info.framecount, 12) + ctx.assert.equals(info.total_duration_ms, 840) + ctx.assert.equals(info.loop_count, 0) + ctx.assert.equals(info.frames.length, 12) + ctx.assert.equals(info.frames[0].duration_ms, 70) + }) + + await ctx.subtest('get_webp_file_info returns FileInfo', async () => { + const file_info = await get_webp_file_info(ctx.resources.media_files['nyan_cat.webp']) + + ctx.assert.equals(file_info.width, 400) + ctx.assert.equals(file_info.height, 400) + ctx.assert.equals(file_info.animated, true) + ctx.assert.equals(file_info.codec, 'webp') + ctx.assert.equals(file_info.media_type, 'IMAGE') + ctx.assert.equals(file_info.content_type, 'image/webp') + ctx.assert.equals(file_info.duration, 0.84) + ctx.assert.equals(file_info.framecount, 12) + ctx.assert.equals(file_info.audio, false) + ctx.assert.equals(file_info.filename, 'nyan_cat.webp') + }) +}) From 1c66e69a572ece7bf74e6ca291074cbb1f920278 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 26 Feb 2026 23:28:30 +0000 Subject: [PATCH 04/10] test: move get_webp_file_info test to integrated media.test.ts Replace the unit-level get_webp_file_info test in webp_parser.test.ts with an end-to-end test in media.test.ts that validates animated WebP properties through forager.media.create. The integration test revealed two issues that needed fixing: - ffprobe succeeds on animated WebP but returns width=0/height=0, so the zero-dimension fallback to the webp shim is needed after all - ffmpeg cannot generate thumbnails for animated WebP, so create_thumbnails now skips thumbnail generation for animated webp (returning 0 thumbnails) instead of failing the entire media creation Co-authored-by: andykais-claude --- packages/core/src/lib/file_processor.ts | 15 +++++++++++++++ packages/core/test/media.test.ts | 19 +++++++++++++++++++ packages/core/test/webp_parser.test.ts | 16 +--------------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index fe1b6bbd..05220b47 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -353,6 +353,9 @@ class FileProcessor { } if (media_type !== 'AUDIO' && width === 0 || height === 0) { + if (path.extname(this.#filepath) === '.webp') { + return get_webp_file_info(this.#filepath) + } throw new errors.InvalidFileError(`Invalid file`, new Error(`width: ${width} and height: ${height} values cannot be zero`)) } @@ -402,6 +405,18 @@ class FileProcessor { // TODO make previews a supported field // assuming that 1/4 of the way into a video is a good preview position + // FFmpeg cannot decode animated WebP files, so we skip thumbnail generation. + // See https://trac.ffmpeg.org/ticket/4907 + if (file_info.codec === 'webp' && file_info.animated) { + const tmp_folder = await Deno.makeTempDir({prefix: 'forager-thumbnails-'}) + const thumbnail_destination_folder = path.join(this.#ctx.config.thumbnails.folder, this.get_storage_folder(checksum)) + return { + source_folder: tmp_folder, + destination_folder: thumbnail_destination_folder, + thumbnails: [], + } + } + const tmp_folder = await Deno.makeTempDir({prefix: 'forager-thumbnails-'}) const thumbnail_timestamps: number[] = [] const tmp_thumbnail_filepath = path.join(tmp_folder, `%0${this.#THUMBNAILS_FILENAME_ZERO_PAD_SIZE}d.jpg`) diff --git a/packages/core/test/media.test.ts b/packages/core/test/media.test.ts index 5ed44990..75926f46 100644 --- a/packages/core/test/media.test.ts +++ b/packages/core/test/media.test.ts @@ -1213,6 +1213,25 @@ test('video media', async ctx => { }) }) +test('animated webp media', async ctx => { + using forager = new Forager(ctx.get_test_config()) + forager.init() + + const media_nyan = await forager.media.create(ctx.resources.media_files['nyan_cat.webp'], {title: 'Nyan Cat'}, ['meme']) + ctx.assert.equals(media_nyan.media_file.media_type, 'IMAGE') + ctx.assert.equals(media_nyan.media_file.content_type, 'image/webp') + ctx.assert.equals(media_nyan.media_file.codec, 'webp') + ctx.assert.equals(media_nyan.media_file.width, 400) + ctx.assert.equals(media_nyan.media_file.height, 400) + ctx.assert.equals(media_nyan.media_file.animated, true) + ctx.assert.equals(media_nyan.media_file.duration, 0.84) + ctx.assert.equals(media_nyan.media_file.framecount, 12) + ctx.assert.equals(media_nyan.media_file.audio, false) + ctx.assert.equals(media_nyan.media_file.filepath, ctx.resources.media_files['nyan_cat.webp']) + // ffmpeg cannot generate thumbnails for animated webp + ctx.assert.equals(media_nyan.thumbnails.total, 0) +}) + test('search query.animated', async ctx => { using forager = new Forager(ctx.get_test_config()) forager.init() diff --git a/packages/core/test/webp_parser.test.ts b/packages/core/test/webp_parser.test.ts index 531a60a0..429176db 100644 --- a/packages/core/test/webp_parser.test.ts +++ b/packages/core/test/webp_parser.test.ts @@ -1,5 +1,5 @@ import { test } from 'forager-test' -import { parse_webp, get_webp_file_info } from '~/lib/file_processor_webp_shim.ts' +import { parse_webp } from '~/lib/file_processor_webp_shim.ts' function build_riff_webp(chunks: Uint8Array): ArrayBuffer { @@ -246,18 +246,4 @@ test('webp_parser - animated webp file from disk', async (ctx) => { ctx.assert.equals(info.frames[0].duration_ms, 70) }) - await ctx.subtest('get_webp_file_info returns FileInfo', async () => { - const file_info = await get_webp_file_info(ctx.resources.media_files['nyan_cat.webp']) - - ctx.assert.equals(file_info.width, 400) - ctx.assert.equals(file_info.height, 400) - ctx.assert.equals(file_info.animated, true) - ctx.assert.equals(file_info.codec, 'webp') - ctx.assert.equals(file_info.media_type, 'IMAGE') - ctx.assert.equals(file_info.content_type, 'image/webp') - ctx.assert.equals(file_info.duration, 0.84) - ctx.assert.equals(file_info.framecount, 12) - ctx.assert.equals(file_info.audio, false) - ctx.assert.equals(file_info.filename, 'nyan_cat.webp') - }) }) From 5f7f2d7be57900c9aa33be076e8a3bf3bb5e58b9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 22:47:12 +0000 Subject: [PATCH 05/10] ci: bump ffmpeg to 9.0 ffmpeg 9.0 adds native animated WebP decoding and demuxing. Co-authored-by: andykais-ai --- .github/actions/shared_setup/action.yml | 2 +- .../core/src/lib/file_processor_webp_shim.ts | 293 ------------------ packages/core/test/webp_parser.test.ts | 249 --------------- 3 files changed, 1 insertion(+), 543 deletions(-) delete mode 100644 packages/core/src/lib/file_processor_webp_shim.ts delete mode 100644 packages/core/test/webp_parser.test.ts diff --git a/.github/actions/shared_setup/action.yml b/.github/actions/shared_setup/action.yml index 30920cc1..bbbb1846 100644 --- a/.github/actions/shared_setup/action.yml +++ b/.github/actions/shared_setup/action.yml @@ -6,7 +6,7 @@ inputs: DENO_VERSION: default: '2.9' FFMPEG_VERSION: - default: '8.1' + default: '9.0' DENO_DIR: default: 'deno_dependency_cache' diff --git a/packages/core/src/lib/file_processor_webp_shim.ts b/packages/core/src/lib/file_processor_webp_shim.ts deleted file mode 100644 index fea4bb46..00000000 --- a/packages/core/src/lib/file_processor_webp_shim.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * Pure TypeScript parser for WebP files (RIFF-based container format). - * This is a shim for animated WebP support until FFmpeg's libwebp decoding - * is fully functional. See https://trac.ffmpeg.org/ticket/4907 - * - * Implements parsing based on the WebP Container Specification: - * https://developers.google.com/speed/webp/docs/riff_container - */ - -import * as path from '@std/path' -import { CODECS } from './codecs.ts' -import * as errors from './errors.ts' -import type { FileInfo } from './file_processor.ts' - - -interface WebPFrame { - x_offset: number - y_offset: number - width: number - height: number - duration_ms: number - blending: 'alpha' | 'no_alpha' - disposal: 'none' | 'background' -} - -interface WebPInfoBase { - canvas_width: number - canvas_height: number - has_alpha: boolean -} - -interface WebPStillInfo extends WebPInfoBase { - animated: false -} - -interface WebPAnimatedInfo extends WebPInfoBase { - animated: true - loop_count: number - background_color: { r: number; g: number; b: number; a: number } - frames: WebPFrame[] - total_duration_ms: number - framecount: number -} - -type WebPInfo = WebPStillInfo | WebPAnimatedInfo - - -class WebPParseError extends errors.FileProcessingError { - override name = 'WebPParseError' - constructor(message: string) { - super(message) - } -} - - -function read_uint24_le(data: DataView, offset: number): number { - return data.getUint8(offset) | (data.getUint8(offset + 1) << 8) | (data.getUint8(offset + 2) << 16) -} - - -function ascii(data: DataView, offset: number, length: number): string { - const bytes = new Uint8Array(data.buffer, data.byteOffset + offset, length) - return String.fromCharCode(...bytes) -} - - -/** - * Parse a VP8 (lossy) bitstream header to extract width and height. - * The VP8 bitstream begins with a 3-byte frame tag, followed (for keyframes) - * by a 3-byte start code (0x9D 0x01 0x2A) and then width/height. - */ -function parse_vp8_dimensions(data: DataView, offset: number, size: number): { width: number; height: number } { - if (size < 10) { - throw new WebPParseError(`VP8 chunk too small to contain dimensions (size: ${size})`) - } - - const frame_tag_byte0 = data.getUint8(offset) - const is_keyframe = (frame_tag_byte0 & 0x01) === 0 - - if (!is_keyframe) { - throw new WebPParseError('VP8 chunk is not a keyframe, cannot extract dimensions') - } - - const start_code_0 = data.getUint8(offset + 3) - const start_code_1 = data.getUint8(offset + 4) - const start_code_2 = data.getUint8(offset + 5) - if (start_code_0 !== 0x9D || start_code_1 !== 0x01 || start_code_2 !== 0x2A) { - throw new WebPParseError(`Invalid VP8 start code: 0x${start_code_0.toString(16)} 0x${start_code_1.toString(16)} 0x${start_code_2.toString(16)}`) - } - - const width = data.getUint16(offset + 6, true) & 0x3FFF - const height = data.getUint16(offset + 8, true) & 0x3FFF - - return { width, height } -} - - -/** - * Parse a VP8L (lossless) bitstream header to extract width and height. - * The VP8L bitstream starts with a 1-byte signature (0x2F), followed by - * a 4-byte packed field containing width-1 (14 bits) and height-1 (14 bits). - */ -function parse_vp8l_dimensions(data: DataView, offset: number, size: number): { width: number; height: number } { - if (size < 5) { - throw new WebPParseError(`VP8L chunk too small to contain dimensions (size: ${size})`) - } - - const signature = data.getUint8(offset) - if (signature !== 0x2F) { - throw new WebPParseError(`Invalid VP8L signature: 0x${signature.toString(16)}`) - } - - const bits = data.getUint32(offset + 1, true) - const width = (bits & 0x3FFF) + 1 - const height = ((bits >> 14) & 0x3FFF) + 1 - - return { width, height } -} - - -/** - * Parse a WebP file buffer and extract metadata including animation info. - */ -export function parse_webp(buffer: ArrayBuffer): WebPInfo { - const data = new DataView(buffer) - - if (buffer.byteLength < 12) { - throw new WebPParseError(`File too small to be a valid WebP (${buffer.byteLength} bytes)`) - } - - const riff_tag = ascii(data, 0, 4) - if (riff_tag !== 'RIFF') { - throw new WebPParseError(`Missing RIFF tag, found "${riff_tag}"`) - } - - const file_size = data.getUint32(4, true) + 8 - const webp_tag = ascii(data, 8, 4) - if (webp_tag !== 'WEBP') { - throw new WebPParseError(`Missing WEBP tag, found "${webp_tag}"`) - } - - let canvas_width = 0 - let canvas_height = 0 - let has_alpha = false - let is_animated = false - let loop_count = 0 - let background_color = { r: 0, g: 0, b: 0, a: 0 } - const frames: WebPFrame[] = [] - let found_vp8x = false - - let pos = 12 - const end = Math.min(file_size, buffer.byteLength) - - while (pos + 8 <= end) { - const chunk_fourcc = ascii(data, pos, 4) - const chunk_size = data.getUint32(pos + 4, true) - const chunk_data_offset = pos + 8 - const padded_size = chunk_size + (chunk_size & 1) - - if (chunk_data_offset + chunk_size > end) { - break - } - - switch (chunk_fourcc) { - case 'VP8X': { - if (chunk_size < 10) { - throw new WebPParseError(`VP8X chunk too small (${chunk_size} bytes)`) - } - found_vp8x = true - const flags = data.getUint8(chunk_data_offset) - is_animated = (flags & 0x02) !== 0 - has_alpha = (flags & 0x10) !== 0 - - canvas_width = read_uint24_le(data, chunk_data_offset + 4) + 1 - canvas_height = read_uint24_le(data, chunk_data_offset + 7) + 1 - break - } - - case 'ANIM': { - if (chunk_size < 6) { - throw new WebPParseError(`ANIM chunk too small (${chunk_size} bytes)`) - } - background_color = { - b: data.getUint8(chunk_data_offset), - g: data.getUint8(chunk_data_offset + 1), - r: data.getUint8(chunk_data_offset + 2), - a: data.getUint8(chunk_data_offset + 3), - } - loop_count = data.getUint16(chunk_data_offset + 4, true) - break - } - - case 'ANMF': { - if (chunk_size < 16) { - throw new WebPParseError(`ANMF chunk too small (${chunk_size} bytes)`) - } - const frame_x = read_uint24_le(data, chunk_data_offset) * 2 - const frame_y = read_uint24_le(data, chunk_data_offset + 3) * 2 - const frame_width = read_uint24_le(data, chunk_data_offset + 6) + 1 - const frame_height = read_uint24_le(data, chunk_data_offset + 9) + 1 - const duration_ms = read_uint24_le(data, chunk_data_offset + 12) - const frame_flags = data.getUint8(chunk_data_offset + 15) - const blending = (frame_flags & 0x02) !== 0 ? 'no_alpha' as const : 'alpha' as const - const disposal = (frame_flags & 0x01) !== 0 ? 'background' as const : 'none' as const - - frames.push({ x_offset: frame_x, y_offset: frame_y, width: frame_width, height: frame_height, duration_ms, blending, disposal }) - break - } - - case 'VP8 ': { - if (!found_vp8x) { - const dims = parse_vp8_dimensions(data, chunk_data_offset, chunk_size) - canvas_width = dims.width - canvas_height = dims.height - } - break - } - - case 'VP8L': { - if (!found_vp8x) { - const dims = parse_vp8l_dimensions(data, chunk_data_offset, chunk_size) - canvas_width = dims.width - canvas_height = dims.height - has_alpha = true - } - break - } - } - - pos = chunk_data_offset + padded_size - } - - if (canvas_width === 0 || canvas_height === 0) { - throw new WebPParseError(`Could not determine WebP dimensions (width: ${canvas_width}, height: ${canvas_height})`) - } - - if (is_animated) { - if (frames.length === 0) { - throw new WebPParseError('VP8X indicates animation but no ANMF frames found') - } - const total_duration_ms = frames.reduce((sum, f) => sum + f.duration_ms, 0) - return { - canvas_width, - canvas_height, - has_alpha, - animated: true, - loop_count, - background_color, - frames, - total_duration_ms, - framecount: frames.length, - } - } - - return { - canvas_width, - canvas_height, - has_alpha, - animated: false, - } -} - - -/** - * Parse a WebP file from disk and return FileInfo compatible with FileProcessor. - * This is the primary entry point used by FileProcessor as a fallback when - * ffprobe cannot handle WebP files. - */ -export async function get_webp_file_info(filepath: string): Promise { - const resolved_filepath = path.resolve(filepath) - const buffer = await Deno.readFile(resolved_filepath) - const webp_info = parse_webp(buffer.buffer as ArrayBuffer) - - const filename = path.basename(resolved_filepath) - const codec_info = CODECS.get_codec('webp') - - const duration = webp_info.animated ? webp_info.total_duration_ms / 1000 : 0 - const framecount = webp_info.animated ? webp_info.framecount : 0 - const framerate = webp_info.animated && duration > 0 ? framecount / duration : 0 - - return { - filepath: resolved_filepath, - filename, - ...codec_info, - width: webp_info.canvas_width, - height: webp_info.canvas_height, - animated: webp_info.animated, - duration, - framerate, - framecount, - audio: false, - } as FileInfo -} diff --git a/packages/core/test/webp_parser.test.ts b/packages/core/test/webp_parser.test.ts deleted file mode 100644 index 429176db..00000000 --- a/packages/core/test/webp_parser.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { test } from 'forager-test' -import { parse_webp } from '~/lib/file_processor_webp_shim.ts' - - -function build_riff_webp(chunks: Uint8Array): ArrayBuffer { - const riff_header = new Uint8Array(12) - const view = new DataView(riff_header.buffer) - riff_header.set(new TextEncoder().encode('RIFF'), 0) - view.setUint32(4, chunks.byteLength + 4, true) - riff_header.set(new TextEncoder().encode('WEBP'), 8) - - const result = new Uint8Array(riff_header.byteLength + chunks.byteLength) - result.set(riff_header, 0) - result.set(chunks, 12) - return result.buffer as ArrayBuffer -} - - -function build_chunk(fourcc: string, payload: Uint8Array): Uint8Array { - const header = new Uint8Array(8) - const view = new DataView(header.buffer) - header.set(new TextEncoder().encode(fourcc), 0) - view.setUint32(4, payload.byteLength, true) - - const padded_size = payload.byteLength + (payload.byteLength & 1) - const result = new Uint8Array(8 + padded_size) - result.set(header, 0) - result.set(payload, 8) - return result -} - - -function build_vp8x_chunk(opts: { animated?: boolean; alpha?: boolean; width: number; height: number }): Uint8Array { - const payload = new Uint8Array(10) - const view = new DataView(payload.buffer) - let flags = 0 - if (opts.animated) flags |= 0x02 - if (opts.alpha) flags |= 0x10 - view.setUint8(0, flags) - - const w = opts.width - 1 - const h = opts.height - 1 - payload[4] = w & 0xFF - payload[5] = (w >> 8) & 0xFF - payload[6] = (w >> 16) & 0xFF - payload[7] = h & 0xFF - payload[8] = (h >> 8) & 0xFF - payload[9] = (h >> 16) & 0xFF - - return build_chunk('VP8X', payload) -} - - -function build_anim_chunk(loop_count: number): Uint8Array { - const payload = new Uint8Array(6) - const view = new DataView(payload.buffer) - view.setUint16(4, loop_count, true) - return build_chunk('ANIM', payload) -} - - -function build_anmf_chunk(opts: { width: number; height: number; duration_ms: number }): Uint8Array { - const min_payload_size = 16 - const dummy_frame_data = new Uint8Array(8) - const payload = new Uint8Array(min_payload_size + dummy_frame_data.byteLength) - - const w = opts.width - 1 - const h = opts.height - 1 - payload[6] = w & 0xFF - payload[7] = (w >> 8) & 0xFF - payload[8] = (w >> 16) & 0xFF - payload[9] = h & 0xFF - payload[10] = (h >> 8) & 0xFF - payload[11] = (h >> 16) & 0xFF - payload[12] = opts.duration_ms & 0xFF - payload[13] = (opts.duration_ms >> 8) & 0xFF - payload[14] = (opts.duration_ms >> 16) & 0xFF - - payload.set(dummy_frame_data, min_payload_size) - return build_chunk('ANMF', payload) -} - - -function concat_uint8(...arrays: Uint8Array[]): Uint8Array { - const total = arrays.reduce((sum, a) => sum + a.byteLength, 0) - const result = new Uint8Array(total) - let offset = 0 - for (const a of arrays) { - result.set(a, offset) - offset += a.byteLength - } - return result -} - - -test('webp_parser - synthetic buffers', async (ctx) => { - await ctx.subtest('parse non-animated VP8X webp', () => { - const vp8x = build_vp8x_chunk({ width: 800, height: 600 }) - const buffer = build_riff_webp(vp8x) - const info = parse_webp(buffer) - - ctx.assert.equals(info.canvas_width, 800) - ctx.assert.equals(info.canvas_height, 600) - ctx.assert.equals(info.animated, false) - ctx.assert.equals(info.has_alpha, false) - }) - - await ctx.subtest('parse non-animated VP8X with alpha', () => { - const vp8x = build_vp8x_chunk({ width: 1920, height: 1080, alpha: true }) - const buffer = build_riff_webp(vp8x) - const info = parse_webp(buffer) - - ctx.assert.equals(info.canvas_width, 1920) - ctx.assert.equals(info.canvas_height, 1080) - ctx.assert.equals(info.animated, false) - ctx.assert.equals(info.has_alpha, true) - }) - - await ctx.subtest('parse animated webp', () => { - const vp8x = build_vp8x_chunk({ animated: true, width: 320, height: 240 }) - const anim = build_anim_chunk(0) - const frame1 = build_anmf_chunk({ width: 320, height: 240, duration_ms: 100 }) - const frame2 = build_anmf_chunk({ width: 320, height: 240, duration_ms: 100 }) - const frame3 = build_anmf_chunk({ width: 320, height: 240, duration_ms: 100 }) - - const chunks = concat_uint8(vp8x, anim, frame1, frame2, frame3) - const buffer = build_riff_webp(chunks) - const info = parse_webp(buffer) - - ctx.assert.equals(info.animated, true) - if (!info.animated) throw new Error('unreachable') - ctx.assert.equals(info.canvas_width, 320) - ctx.assert.equals(info.canvas_height, 240) - ctx.assert.equals(info.framecount, 3) - ctx.assert.equals(info.total_duration_ms, 300) - ctx.assert.equals(info.loop_count, 0) - }) - - await ctx.subtest('parse animated webp with varying frame durations', () => { - const vp8x = build_vp8x_chunk({ animated: true, width: 640, height: 480 }) - const anim = build_anim_chunk(5) - const frame1 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 50 }) - const frame2 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 200 }) - const frame3 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 150 }) - const frame4 = build_anmf_chunk({ width: 640, height: 480, duration_ms: 100 }) - - const chunks = concat_uint8(vp8x, anim, frame1, frame2, frame3, frame4) - const buffer = build_riff_webp(chunks) - const info = parse_webp(buffer) - - ctx.assert.equals(info.animated, true) - if (!info.animated) throw new Error('unreachable') - ctx.assert.equals(info.framecount, 4) - ctx.assert.equals(info.total_duration_ms, 500) - ctx.assert.equals(info.loop_count, 5) - }) - - await ctx.subtest('rejects non-RIFF data', () => { - const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]) - ctx.assert.throws(() => parse_webp(garbage.buffer as ArrayBuffer), Error, 'Missing RIFF tag') - }) - - await ctx.subtest('rejects file too small', () => { - const tiny = new Uint8Array(4) - ctx.assert.throws(() => parse_webp(tiny.buffer as ArrayBuffer), Error, 'too small') - }) - - await ctx.subtest('rejects RIFF without WEBP tag', () => { - const data = new Uint8Array(12) - const view = new DataView(data.buffer) - data.set(new TextEncoder().encode('RIFF'), 0) - view.setUint32(4, 4, true) - data.set(new TextEncoder().encode('AVI '), 8) - ctx.assert.throws(() => parse_webp(data.buffer as ArrayBuffer), Error, 'Missing WEBP tag') - }) - - await ctx.subtest('VP8X animated flag set but no ANMF frames', () => { - const vp8x = build_vp8x_chunk({ animated: true, width: 100, height: 100 }) - const buffer = build_riff_webp(vp8x) - ctx.assert.throws(() => parse_webp(buffer), Error, 'no ANMF frames found') - }) -}) - - -test('webp_parser - VP8 lossy still image', async (ctx) => { - await ctx.subtest('parse VP8 dimensions', () => { - const frame_tag = new Uint8Array(3) - frame_tag[0] = 0x00 - - const start_code = new Uint8Array([0x9D, 0x01, 0x2A]) - const dims = new Uint8Array(4) - const dims_view = new DataView(dims.buffer) - dims_view.setUint16(0, 400, true) - dims_view.setUint16(2, 300, true) - - const padding = new Uint8Array(3) - const vp8_payload = concat_uint8(frame_tag, start_code, dims, padding) - const vp8_chunk = build_chunk('VP8 ', vp8_payload) - const buffer = build_riff_webp(vp8_chunk) - const info = parse_webp(buffer) - - ctx.assert.equals(info.canvas_width, 400) - ctx.assert.equals(info.canvas_height, 300) - ctx.assert.equals(info.animated, false) - }) -}) - - -test('webp_parser - VP8L lossless still image', async (ctx) => { - await ctx.subtest('parse VP8L dimensions', () => { - const payload = new Uint8Array(5) - const view = new DataView(payload.buffer) - payload[0] = 0x2F - - const width = 512 - const height = 256 - const bits = ((width - 1) & 0x3FFF) | (((height - 1) & 0x3FFF) << 14) - view.setUint32(1, bits, true) - - const vp8l_chunk = build_chunk('VP8L', payload) - const buffer = build_riff_webp(vp8l_chunk) - const info = parse_webp(buffer) - - ctx.assert.equals(info.canvas_width, 512) - ctx.assert.equals(info.canvas_height, 256) - ctx.assert.equals(info.animated, false) - ctx.assert.equals(info.has_alpha, true) - }) -}) - - -test('webp_parser - animated webp file from disk', async (ctx) => { - await ctx.subtest('parse_webp extracts correct metadata', async () => { - const buffer = await Deno.readFile(ctx.resources.media_files['nyan_cat.webp']) - const info = parse_webp(buffer.buffer as ArrayBuffer) - - ctx.assert.equals(info.animated, true) - if (!info.animated) throw new Error('unreachable') - ctx.assert.equals(info.canvas_width, 400) - ctx.assert.equals(info.canvas_height, 400) - ctx.assert.equals(info.has_alpha, true) - ctx.assert.equals(info.framecount, 12) - ctx.assert.equals(info.total_duration_ms, 840) - ctx.assert.equals(info.loop_count, 0) - ctx.assert.equals(info.frames.length, 12) - ctx.assert.equals(info.frames[0].duration_ms, 70) - }) - -}) From cbfa52b88fc84b3305a5d226e86a0c5e66e04cc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 22:47:12 +0000 Subject: [PATCH 06/10] feat(core): use ffmpeg 9 for animated webp, drop the webp shim ffmpeg 9.0 decodes animated WebP natively, so the pure TypeScript shim and its fallbacks in FileProcessor are no longer needed. This also gives animated WebP real thumbnails instead of none. - register the new 'webp_anim' codec that ffprobe reports - treat webp_anim as animated like gif - ffprobe gives no stream/format duration for webp_anim, so read duration and framecount from the packet timestamps (one packet per frame) and skip the framerate*duration framecount estimate, which undercounts because ffprobe rounds the framerate - media.test.ts asserts all 12 thumbnails and their timestamps Co-authored-by: andykais-ai --- packages/core/src/lib/codecs.ts | 1 + packages/core/src/lib/file_processor.ts | 68 +++++++++++++++++-------- packages/core/test/media.test.ts | 12 +++-- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/packages/core/src/lib/codecs.ts b/packages/core/src/lib/codecs.ts index 43c8753e..be12dafe 100644 --- a/packages/core/src/lib/codecs.ts +++ b/packages/core/src/lib/codecs.ts @@ -44,6 +44,7 @@ CODECS.add_codec('VIDEO', 'h264', 'video/mp4') CODECS.add_codec('VIDEO', 'hevc', 'video/mp4') CODECS.add_codec('IMAGE', 'gif', 'image/gif') CODECS.add_codec('IMAGE', 'webp', 'image/webp') +CODECS.add_codec('IMAGE', 'webp_anim', 'image/webp') CODECS.add_codec('IMAGE', 'tiff', 'image/png', ['tif', 'tiff']) CODECS.add_codec('IMAGE', 'png', 'image/png') CODECS.add_codec('IMAGE', 'apng', 'image/apng') diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index 05220b47..d8e6008e 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -6,7 +6,6 @@ import z from 'zod' import { Context } from '~/context.ts' import { CODECS } from './codecs.ts' import * as errors from './errors.ts' -import { get_webp_file_info } from './file_processor_webp_shim.ts' interface FileInfoBase { @@ -110,6 +109,13 @@ const FFProbeOutput = z.object({ ]).array().min(1) }).passthrough() +const FFProbePacketsOutput = z.object({ + packets: z.object({ + pts_time: z.coerce.number(), + duration_time: z.coerce.number(), + }).array(), +}) + function throw_contextually() { return (method: Function, _context: ClassMemberDecoratorContext) => { @@ -253,9 +259,6 @@ class FileProcessor { const output = await cmd.output() if (!output.success) { - if (path.extname(this.#filepath) === '.webp') { - return get_webp_file_info(this.#filepath) - } const command = ffprobe_command.join(' ') const stdout = this.#decoder.decode(output.stdout) const stderr = this.#decoder.decode(output.stderr) @@ -302,7 +305,15 @@ class FileProcessor { width = rotated_size.width height = rotated_size.height - if (media_type === 'VIDEO' || stream.codec_name === 'gif') { + if (stream.codec_name === 'webp_anim') { + // ffprobe reports no stream or format duration for animated webp, only per-frame timestamps. + // Each frame is exactly one packet, so the packets give us an exact duration and framecount + const packets = await this.#get_packets_info() + animated = true + duration = packets.duration + framecount = packets.count + framerate = framecount / duration + } else if (media_type === 'VIDEO' || stream.codec_name === 'gif') { if (stream.duration !== undefined) { duration = Math.max(duration, stream.duration) } @@ -328,7 +339,7 @@ class FileProcessor { if (animated && ffprobe_data.format.duration) { duration = Math.max(duration, ffprobe_data.format.duration) } - if (animated) { + if (animated && codec_info.codec !== 'webp_anim') { const framecount_guess = Math.floor(framerate * duration) framecount = Math.min(...[framecount, framecount_guess].filter(n => !Number.isNaN(n))) @@ -353,9 +364,6 @@ class FileProcessor { } if (media_type !== 'AUDIO' && width === 0 || height === 0) { - if (path.extname(this.#filepath) === '.webp') { - return get_webp_file_info(this.#filepath) - } throw new errors.InvalidFileError(`Invalid file`, new Error(`width: ${width} and height: ${height} values cannot be zero`)) } @@ -376,6 +384,35 @@ class FileProcessor { return file_info } + async #get_packets_info(): Promise<{ duration: number; count: number }> { + const command = [ + 'ffprobe', + '-v', 'error', + '-pattern_type', 'none', + '-select_streams', 'v:0', + '-print_format', 'json', + '-show_entries', 'packet=pts_time,duration_time', + '-i', this.#filepath, + ] + this.#error_context.commands_ran.push(command) + const output = await new Deno.Command('ffprobe', { + args: command.slice(1), + stdout: 'piped', + stderr: 'piped', + }).output() + if (!output.success) { + throw new errors.SubprocessError(output, 'reading packet durations failed') + } + const { packets } = FFProbePacketsOutput.parse(JSON.parse(this.#decoder.decode(output.stdout))) + const last_packet = packets.at(-1) + if (last_packet === undefined) { + throw new errors.UnExpectedError(`No packets found for ${this.#filepath}`) + } + // ffprobe prints timestamps with microsecond precision, so we round away float error from the addition + const duration = Number((last_packet.pts_time + last_packet.duration_time).toFixed(6)) + return { duration, count: packets.length } + } + /* Our architecture stores files on disk outside of the database and uses a "Content-addressed storage" approach. * It is considered good practice when doing this to avoid a totally flat directory structure, mainly for OS performance reasons. * A happy medium is just grouping the files by the first two characters (gives us 256 possible subdirectories). @@ -405,18 +442,6 @@ class FileProcessor { // TODO make previews a supported field // assuming that 1/4 of the way into a video is a good preview position - // FFmpeg cannot decode animated WebP files, so we skip thumbnail generation. - // See https://trac.ffmpeg.org/ticket/4907 - if (file_info.codec === 'webp' && file_info.animated) { - const tmp_folder = await Deno.makeTempDir({prefix: 'forager-thumbnails-'}) - const thumbnail_destination_folder = path.join(this.#ctx.config.thumbnails.folder, this.get_storage_folder(checksum)) - return { - source_folder: tmp_folder, - destination_folder: thumbnail_destination_folder, - thumbnails: [], - } - } - const tmp_folder = await Deno.makeTempDir({prefix: 'forager-thumbnails-'}) const thumbnail_timestamps: number[] = [] const tmp_thumbnail_filepath = path.join(tmp_folder, `%0${this.#THUMBNAILS_FILENAME_ZERO_PAD_SIZE}d.jpg`) @@ -690,4 +715,3 @@ class FileProcessor { } export { FileProcessor } -export type { FileInfo } diff --git a/packages/core/test/media.test.ts b/packages/core/test/media.test.ts index 75926f46..4df909b1 100644 --- a/packages/core/test/media.test.ts +++ b/packages/core/test/media.test.ts @@ -1220,7 +1220,7 @@ test('animated webp media', async ctx => { const media_nyan = await forager.media.create(ctx.resources.media_files['nyan_cat.webp'], {title: 'Nyan Cat'}, ['meme']) ctx.assert.equals(media_nyan.media_file.media_type, 'IMAGE') ctx.assert.equals(media_nyan.media_file.content_type, 'image/webp') - ctx.assert.equals(media_nyan.media_file.codec, 'webp') + ctx.assert.equals(media_nyan.media_file.codec, 'webp_anim') ctx.assert.equals(media_nyan.media_file.width, 400) ctx.assert.equals(media_nyan.media_file.height, 400) ctx.assert.equals(media_nyan.media_file.animated, true) @@ -1228,8 +1228,14 @@ test('animated webp media', async ctx => { ctx.assert.equals(media_nyan.media_file.framecount, 12) ctx.assert.equals(media_nyan.media_file.audio, false) ctx.assert.equals(media_nyan.media_file.filepath, ctx.resources.media_files['nyan_cat.webp']) - // ffmpeg cannot generate thumbnails for animated webp - ctx.assert.equals(media_nyan.thumbnails.total, 0) + + // fewer frames than the thumbnail cap, so every frame becomes a thumbnail + const thumbnails = forager.media.get({media_reference_id: media_nyan.media_reference.id}).thumbnails + ctx.assert.equals(thumbnails.total, 12) + ctx.assert.equals( + thumbnails.results.map(thumbnail => thumbnail.media_timestamp), + [0, 0.07, 0.14, 0.21, 0.28, 0.35, 0.42, 0.49, 0.56, 0.63, 0.7, 0.77], + ) }) test('search query.animated', async ctx => { From f505845e0157e063997310bbcb63ae7bab4d6f3e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 23:00:07 +0000 Subject: [PATCH 07/10] refactor(core): read animated webp packet timestamps in the main ffprobe call Co-authored-by: andykais-ai --- packages/core/src/lib/file_processor.ts | 59 ++++++++----------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/packages/core/src/lib/file_processor.ts b/packages/core/src/lib/file_processor.ts index d8e6008e..10646220 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -106,15 +106,12 @@ const FFProbeOutput = z.object({ FFProbeOutputSubtitleStream, FFProbeOutputAttachmentStream, FFProbeOutputUnknownStream, - ]).array().min(1) -}).passthrough() - -const FFProbePacketsOutput = z.object({ + ]).array().min(1), packets: z.object({ pts_time: z.coerce.number(), duration_time: z.coerce.number(), - }).array(), -}) + }).array().optional(), +}).passthrough() function throw_contextually() { @@ -239,6 +236,12 @@ class FileProcessor { if (!filepath_exists) { throw new errors.FileNotFound(`Cannot process file because it does not exist`) } + let show_entries = 'format=duration,stream=duration,width,height,display_aspect_ratio,codec_type,codec_name,nb_read_packets,r_frame_rate,avg_frame_rate:stream_tags=rotate' + if (path.extname(this.#filepath).toLowerCase() === '.webp') { + // ffprobe reports no stream or format duration for animated webp, only per-frame timestamps. + // We only request packets for webp because printing every packet of a long video is very large + show_entries += ':packet=pts_time,duration_time' + } const ffprobe_command = [ 'ffprobe', '-v', 'error', @@ -246,7 +249,7 @@ class FileProcessor { '-print_format', 'json', '-show_streams', '-count_packets', - '-show_entries', 'format=duration,stream=duration,width,height,display_aspect_ratio,codec_type,codec_name,nb_read_packets,r_frame_rate,avg_frame_rate:stream_tags=rotate', + '-show_entries', show_entries, '-i', this.#filepath ] this.#error_context.commands_ran.push(ffprobe_command) @@ -306,12 +309,15 @@ class FileProcessor { height = rotated_size.height if (stream.codec_name === 'webp_anim') { - // ffprobe reports no stream or format duration for animated webp, only per-frame timestamps. - // Each frame is exactly one packet, so the packets give us an exact duration and framecount - const packets = await this.#get_packets_info() + const last_packet = ffprobe_data.packets?.at(-1) + if (last_packet === undefined) { + throw new errors.UnExpectedError(`No packets found for animated webp ${this.#filepath}`) + } animated = true - duration = packets.duration - framecount = packets.count + // ffprobe prints timestamps with microsecond precision, so we round away float error from the addition + duration = Number((last_packet.pts_time + last_packet.duration_time).toFixed(6)) + // each animated webp frame is exactly one packet + framecount = stream.nb_read_packets framerate = framecount / duration } else if (media_type === 'VIDEO' || stream.codec_name === 'gif') { if (stream.duration !== undefined) { @@ -384,35 +390,6 @@ class FileProcessor { return file_info } - async #get_packets_info(): Promise<{ duration: number; count: number }> { - const command = [ - 'ffprobe', - '-v', 'error', - '-pattern_type', 'none', - '-select_streams', 'v:0', - '-print_format', 'json', - '-show_entries', 'packet=pts_time,duration_time', - '-i', this.#filepath, - ] - this.#error_context.commands_ran.push(command) - const output = await new Deno.Command('ffprobe', { - args: command.slice(1), - stdout: 'piped', - stderr: 'piped', - }).output() - if (!output.success) { - throw new errors.SubprocessError(output, 'reading packet durations failed') - } - const { packets } = FFProbePacketsOutput.parse(JSON.parse(this.#decoder.decode(output.stdout))) - const last_packet = packets.at(-1) - if (last_packet === undefined) { - throw new errors.UnExpectedError(`No packets found for ${this.#filepath}`) - } - // ffprobe prints timestamps with microsecond precision, so we round away float error from the addition - const duration = Number((last_packet.pts_time + last_packet.duration_time).toFixed(6)) - return { duration, count: packets.length } - } - /* Our architecture stores files on disk outside of the database and uses a "Content-addressed storage" approach. * It is considered good practice when doing this to avoid a totally flat directory structure, mainly for OS performance reasons. * A happy medium is just grouping the files by the first two characters (gives us 256 possible subdirectories). From e06e05451209101e29a2b193f49e6ad802417718 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 13 Oct 2026 23:57:53 +0000 Subject: [PATCH 08/10] ci: download and cache macOS ffmpeg 9 with retries to survive evermeet rate limits Co-authored-by: andykais-ai --- .github/actions/shared_setup/action.yml | 30 +++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/.github/actions/shared_setup/action.yml b/.github/actions/shared_setup/action.yml index bbbb1846..f031303c 100644 --- a/.github/actions/shared_setup/action.yml +++ b/.github/actions/shared_setup/action.yml @@ -19,10 +19,40 @@ runs: deno-version: "${{ inputs.DENO_VERSION }}" - name: Setup FFmpeg + if: runner.os != 'macOS' uses: AnimMouse/setup-ffmpeg@v1 with: version: "${{ inputs.FFMPEG_VERSION }}" + # AnimMouse/setup-ffmpeg downloads macOS builds from evermeet.cx without retrying, and evermeet's mirror + # rate limits back to back downloads (HTTP 429), so we download, retry, and cache the macOS binaries ourselves + - name: Cache FFmpeg (macOS) + if: runner.os == 'macOS' + id: ffmpeg_cache_macos + uses: actions/cache@v6 + with: + path: ~/ffmpeg_bin + key: "ffmpeg:${{ inputs.FFMPEG_VERSION }}:${{ runner.os }}:${{ runner.arch }}" + + - name: Download FFmpeg (macOS) + if: runner.os == 'macOS' && steps.ffmpeg_cache_macos.outputs.cache-hit != 'true' + shell: bash + run: | + mkdir -p ~/ffmpeg_bin + for binary in ffmpeg ffprobe; do + curl --fail --silent --show-error --location --retry 10 --retry-delay 30 --retry-all-errors \ + --output "$RUNNER_TEMP/$binary.zip" "https://evermeet.cx/$binary/$binary-${{ inputs.FFMPEG_VERSION }}.zip" + unzip -o "$RUNNER_TEMP/$binary.zip" "$binary" -d ~/ffmpeg_bin + done + chmod +x ~/ffmpeg_bin/ffmpeg ~/ffmpeg_bin/ffprobe + + - name: Add FFmpeg to PATH (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + echo ~/ffmpeg_bin >> "$GITHUB_PATH" + ~/ffmpeg_bin/ffprobe -version | head -n 1 + - name: Cache Deno dependencies uses: actions/cache@v6 with: From 07aa2e3e19e448bd6bc959e8f604540c8033ec14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 01:57:09 +0000 Subject: [PATCH 09/10] Revert "ci: download and cache macOS ffmpeg 9 with retries to survive evermeet rate limits" This reverts commit e06e05451209101e29a2b193f49e6ad802417718. --- .github/actions/shared_setup/action.yml | 30 ------------------------- 1 file changed, 30 deletions(-) diff --git a/.github/actions/shared_setup/action.yml b/.github/actions/shared_setup/action.yml index f031303c..bbbb1846 100644 --- a/.github/actions/shared_setup/action.yml +++ b/.github/actions/shared_setup/action.yml @@ -19,40 +19,10 @@ runs: deno-version: "${{ inputs.DENO_VERSION }}" - name: Setup FFmpeg - if: runner.os != 'macOS' uses: AnimMouse/setup-ffmpeg@v1 with: version: "${{ inputs.FFMPEG_VERSION }}" - # AnimMouse/setup-ffmpeg downloads macOS builds from evermeet.cx without retrying, and evermeet's mirror - # rate limits back to back downloads (HTTP 429), so we download, retry, and cache the macOS binaries ourselves - - name: Cache FFmpeg (macOS) - if: runner.os == 'macOS' - id: ffmpeg_cache_macos - uses: actions/cache@v6 - with: - path: ~/ffmpeg_bin - key: "ffmpeg:${{ inputs.FFMPEG_VERSION }}:${{ runner.os }}:${{ runner.arch }}" - - - name: Download FFmpeg (macOS) - if: runner.os == 'macOS' && steps.ffmpeg_cache_macos.outputs.cache-hit != 'true' - shell: bash - run: | - mkdir -p ~/ffmpeg_bin - for binary in ffmpeg ffprobe; do - curl --fail --silent --show-error --location --retry 10 --retry-delay 30 --retry-all-errors \ - --output "$RUNNER_TEMP/$binary.zip" "https://evermeet.cx/$binary/$binary-${{ inputs.FFMPEG_VERSION }}.zip" - unzip -o "$RUNNER_TEMP/$binary.zip" "$binary" -d ~/ffmpeg_bin - done - chmod +x ~/ffmpeg_bin/ffmpeg ~/ffmpeg_bin/ffprobe - - - name: Add FFmpeg to PATH (macOS) - if: runner.os == 'macOS' - shell: bash - run: | - echo ~/ffmpeg_bin >> "$GITHUB_PATH" - ~/ffmpeg_bin/ffprobe -version | head -n 1 - - name: Cache Deno dependencies uses: actions/cache@v6 with: From 6096fce72a6c7bee4900b68fab7053c1b1374df5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 14 Oct 2026 03:09:15 +0000 Subject: [PATCH 10/10] ci: run macOS on apple silicon (macos-15) with ffmpeg 9 from setup-ffmpeg Co-authored-by: andykais-ai --- .github/actions/shared_setup/action.yml | 5 ++++- .github/workflows/cli.yml | 6 +----- .github/workflows/core.yml | 2 +- AGENTS.md | 4 ++-- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/actions/shared_setup/action.yml b/.github/actions/shared_setup/action.yml index bbbb1846..1cb9d6d5 100644 --- a/.github/actions/shared_setup/action.yml +++ b/.github/actions/shared_setup/action.yml @@ -7,6 +7,9 @@ inputs: default: '2.9' FFMPEG_VERSION: default: '9.0' + # macOS arm builds come from osxexperts.net, which names releases differently (e.g. ffmpeg9arm.zip) + FFMPEG_VERSION_MACOS: + default: '9' DENO_DIR: default: 'deno_dependency_cache' @@ -21,7 +24,7 @@ runs: - name: Setup FFmpeg uses: AnimMouse/setup-ffmpeg@v1 with: - version: "${{ inputs.FFMPEG_VERSION }}" + version: "${{ runner.os == 'macOS' && inputs.FFMPEG_VERSION_MACOS || inputs.FFMPEG_VERSION }}" - name: Cache Deno dependencies uses: actions/cache@v6 diff --git a/.github/workflows/cli.yml b/.github/workflows/cli.yml index d85f6cf7..402ee7f8 100644 --- a/.github/workflows/cli.yml +++ b/.github/workflows/cli.yml @@ -23,11 +23,7 @@ jobs: # TODO actually build this on different OS's, and compile the binaries separately os: - ubuntu-latest - - ################################################################################################################################################## - # MacOS arm builds are currently not supported with setup-ffmpeg action, so we are sticking to macos-15 x86 builds # - ################################################################################################################################################## - - macos-15-intel + - macos-15 ################################################################################################################################################## # Windows builds are currently disabled because path resolution doesn't seem to work # diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index ce86064c..2e684f9f 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -19,7 +19,7 @@ jobs: test: strategy: matrix: - os: [ubuntu-latest, macos-15-intel, windows-latest] + os: [ubuntu-latest, macos-15, windows-latest] runs-on: ${{ matrix.os }} diff --git a/AGENTS.md b/AGENTS.md index c3c43f94..4c35f878 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -650,12 +650,12 @@ web: **Core CI** (`.github/workflows/core.yml`) - Triggers: Push/PR to main -- Matrix: Ubuntu (latest), macOS (15-intel), Windows (latest) +- Matrix: Ubuntu (latest), macOS (15), Windows (latest) - Jobs: Lint, Test, Publishing checks (dry-run), Publish to JSR **CLI CI** (`.github/workflows/cli.yml`) - Triggers: Push/PR to main, tags -- Matrix: Ubuntu (latest), macOS (15-intel) - Windows disabled due to path resolution +- Matrix: Ubuntu (latest), macOS (15) - Windows disabled due to path resolution - Jobs: Test, Compile binaries, Create releases **Web CI** (`.github/workflows/web.yml`)