From c243fbd4bcecca1a67c10d06393839e85d1c4a1e Mon Sep 17 00:00:00 2001 From: sudacode Date: Sat, 23 May 2026 12:26:56 -0700 Subject: [PATCH] fix: start animated AVIF motion at sentence start, not audio padding - Removed leading padding from AVIF clip start so pre-sentence frames are not shown - Duration now extends by trailing padding only - Added regression test verifying -ss and -t args against a stub ffmpeg --- changes/animated-avif-padding-freeze.md | 4 ++ src/media-generator.test.ts | 57 ++++++++++++++++++++++++- src/media-generator.ts | 8 ++-- 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 changes/animated-avif-padding-freeze.md diff --git a/changes/animated-avif-padding-freeze.md b/changes/animated-avif-padding-freeze.md new file mode 100644 index 00000000..18eccb19 --- /dev/null +++ b/changes/animated-avif-padding-freeze.md @@ -0,0 +1,4 @@ +type: fixed +area: anki + +- Kept animated AVIF card images from showing pre-sentence motion on multi-line mines without delaying motion after sentence audio starts. diff --git a/src/media-generator.test.ts b/src/media-generator.test.ts index fb1cfab4..54ec709a 100644 --- a/src/media-generator.test.ts +++ b/src/media-generator.test.ts @@ -1,7 +1,10 @@ import assert from 'node:assert/strict'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import test from 'node:test'; -import { buildAnimatedImageVideoFilter } from './media-generator'; +import { buildAnimatedImageVideoFilter, MediaGenerator } from './media-generator'; test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in is provided', () => { assert.equal( @@ -13,3 +16,55 @@ test('buildAnimatedImageVideoFilter prepends a cloned first frame when lead-in i 'tpad=start_duration=1.25:start_mode=clone,fps=10,scale=w=640:h=-2', ); }); + +test('generateAnimatedImage starts motion with sentence audio instead of delaying for audio padding', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-media-generator-test-')); + const binDir = path.join(root, 'bin'); + const tempDir = path.join(root, 'media'); + const argsPath = path.join(root, 'ffmpeg-args.txt'); + fs.mkdirSync(binDir, { recursive: true }); + const ffmpegPath = path.join(binDir, 'ffmpeg'); + fs.writeFileSync( + ffmpegPath, + [ + '#!/bin/sh', + 'if [ "$1" = "-hide_banner" ] && [ "$2" = "-encoders" ]; then', + ' echo " V..... libaom-av1"', + ' exit 0', + 'fi', + 'printf "%s\\n" "$@" > "$SUBMINER_TEST_FFMPEG_ARGS"', + 'out=""', + 'for arg in "$@"; do out="$arg"; done', + 'printf avif > "$out"', + ].join('\n'), + 'utf8', + ); + fs.chmodSync(ffmpegPath, 0o755); + + const originalPath = process.env.PATH; + const originalArgsPath = process.env.SUBMINER_TEST_FFMPEG_ARGS; + process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`; + process.env.SUBMINER_TEST_FFMPEG_ARGS = argsPath; + const generator = new MediaGenerator(tempDir); + + try { + await generator.generateAnimatedImage('/video.mp4', 10, 12, 0.5, { + fps: 10, + maxWidth: 640, + }); + + const args = fs.readFileSync(argsPath, 'utf8').trim().split('\n'); + assert.equal(args[args.indexOf('-ss') + 1], '10'); + assert.equal(args[args.indexOf('-t') + 1], '2.5'); + assert.equal(args[args.indexOf('-vf') + 1], 'fps=10,scale=w=640:h=-2'); + } finally { + generator.cleanup(); + process.env.PATH = originalPath; + if (originalArgsPath === undefined) { + delete process.env.SUBMINER_TEST_FFMPEG_ARGS; + } else { + process.env.SUBMINER_TEST_FFMPEG_ARGS = originalArgsPath; + } + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/src/media-generator.ts b/src/media-generator.ts index 479b98a3..1e16ef87 100644 --- a/src/media-generator.ts +++ b/src/media-generator.ts @@ -319,9 +319,11 @@ export class MediaGenerator { leadingStillDuration?: number; } = {}, ): Promise { - const start = Math.max(0, startTime - padding); - const duration = endTime - startTime + 2 * padding; const { fps = 10, maxWidth = 640, maxHeight, crf = 35, leadingStillDuration = 0 } = options; + const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0; + const start = Math.max(0, startTime); + const duration = endTime - startTime + safePadding; + const totalLeadingStillDuration = Math.max(0, leadingStillDuration); const clampedCrf = Math.max(0, Math.min(63, crf)); @@ -359,7 +361,7 @@ export class MediaGenerator { fps, maxWidth, maxHeight, - leadingStillDuration, + leadingStillDuration: totalLeadingStillDuration, }), ...encoderArgs, '-y',