Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/actions/shared_setup/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down
6 changes: 1 addition & 5 deletions .github/workflows/cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 #
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/core.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
1 change: 1 addition & 0 deletions lib/test/lib/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
Binary file added lib/test/resources/nyan_cat.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/core/src/lib/codecs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
32 changes: 25 additions & 7 deletions packages/core/src/lib/file_processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()


Expand Down Expand Up @@ -232,14 +236,20 @@ 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',
'-pattern_type', 'none', // necessary for files that contain "%" in the name
'-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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)))

Expand All @@ -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`))
}

Expand Down
25 changes: 25 additions & 0 deletions packages/core/test/media.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading