diff --git a/.github/actions/shared_setup/action.yml b/.github/actions/shared_setup/action.yml index 30920cc1..1cb9d6d5 100644 --- a/.github/actions/shared_setup/action.yml +++ b/.github/actions/shared_setup/action.yml @@ -6,7 +6,10 @@ inputs: DENO_VERSION: default: '2.9' FFMPEG_VERSION: - default: '8.1' + 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`) 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 00000000..7a1d3fe1 Binary files /dev/null and b/lib/test/resources/nyan_cat.webp differ 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 a1471518..10646220 100644 --- a/packages/core/src/lib/file_processor.ts +++ b/packages/core/src/lib/file_processor.ts @@ -106,7 +106,11 @@ const FFProbeOutput = z.object({ FFProbeOutputSubtitleStream, FFProbeOutputAttachmentStream, FFProbeOutputUnknownStream, - ]).array().min(1) + ]).array().min(1), + packets: z.object({ + pts_time: z.coerce.number(), + duration_time: z.coerce.number(), + }).array().optional(), }).passthrough() @@ -232,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', @@ -239,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) @@ -298,7 +308,18 @@ class FileProcessor { width = rotated_size.width height = rotated_size.height - if (media_type === 'VIDEO' || stream.codec_name === 'gif') { + if (stream.codec_name === 'webp_anim') { + 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 + // 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) { duration = Math.max(duration, stream.duration) } @@ -324,7 +345,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))) @@ -349,9 +370,6 @@ 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`)) - } throw new errors.InvalidFileError(`Invalid file`, new Error(`width: ${width} and height: ${height} values cannot be zero`)) } diff --git a/packages/core/test/media.test.ts b/packages/core/test/media.test.ts index 5ed44990..4df909b1 100644 --- a/packages/core/test/media.test.ts +++ b/packages/core/test/media.test.ts @@ -1213,6 +1213,31 @@ 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_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) + 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']) + + // 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 => { using forager = new Forager(ctx.get_test_config()) forager.init()