mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-07-29 19:21:33 -07:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
73af1451b7
|
|||
| 36a3704815 | |||
| 359cb0a301 |
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: mining
|
||||||
|
|
||||||
|
- Normalized generated card audio by default during media extraction, with `ankiConnect.media.normalizeAudio` available to keep raw source loudness when needed.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: youtube
|
||||||
|
|
||||||
|
- Fixed direct YouTube stream media extraction by parsing mpv EDL stream URLs with their byte-length guards, preventing trailing EDL segment options from corrupting signed googlevideo URLs and causing ffmpeg 403 errors.
|
||||||
@@ -559,6 +559,7 @@
|
|||||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
|
|||||||
@@ -161,13 +161,14 @@ Audio is extracted from the video file using the subtitle's start and end timest
|
|||||||
"ankiConnect": {
|
"ankiConnect": {
|
||||||
"media": {
|
"media": {
|
||||||
"generateAudio": true,
|
"generateAudio": true,
|
||||||
|
"normalizeAudio": true, // normalize generated clip loudness
|
||||||
"audioPadding": 0, // optional seconds before and after subtitle timing
|
"audioPadding": 0, // optional seconds before and after subtitle timing
|
||||||
"maxMediaDuration": 30 // cap total duration in seconds
|
"maxMediaDuration": 30 // cap total duration in seconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream.
|
Output format: MP3 at 44100 Hz. If the video has multiple audio streams, SubMiner uses the active stream. Generated sentence audio is loudness-normalized by default during extraction; set `normalizeAudio` to `false` to keep raw source loudness.
|
||||||
|
|
||||||
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
The audio is uploaded to Anki's media folder and inserted as `[sound:audio_<timestamp>.mp3]`.
|
||||||
|
|
||||||
@@ -347,6 +348,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
"imageType": "static",
|
"imageType": "static",
|
||||||
"imageFormat": "jpg",
|
"imageFormat": "jpg",
|
||||||
"imageQuality": 92,
|
"imageQuality": 92,
|
||||||
|
"normalizeAudio": true,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"maxMediaDuration": 30,
|
"maxMediaDuration": 30,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -951,6 +951,7 @@ Enable automatic Anki card creation and updates with media generation:
|
|||||||
"animatedMaxWidth": 640,
|
"animatedMaxWidth": 640,
|
||||||
"animatedMaxHeight": 0,
|
"animatedMaxHeight": 0,
|
||||||
"animatedCrf": 35,
|
"animatedCrf": 35,
|
||||||
|
"normalizeAudio": true,
|
||||||
"audioPadding": 0,
|
"audioPadding": 0,
|
||||||
"fallbackDuration": 3,
|
"fallbackDuration": 3,
|
||||||
"maxMediaDuration": 30
|
"maxMediaDuration": 30
|
||||||
@@ -1001,6 +1002,7 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.model` | string | Optional model override for Anki AI translation/enrichment flows. |
|
||||||
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
| `ankiConnect.ai.systemPrompt` | string | Optional system prompt override for Anki AI translation/enrichment flows. |
|
||||||
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
| `media.generateAudio` | `true`, `false` | Generate audio clips from video (default: `true`) |
|
||||||
|
| `media.normalizeAudio` | `true`, `false` | Normalize generated sentence-audio loudness during media extraction (default: `true`). Set to `false` to keep raw source loudness. |
|
||||||
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
| `media.generateImage` | `true`, `false` | Generate image/animation screenshots (default: `true`) |
|
||||||
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
| `media.imageType` | `"static"`, `"avif"` | Image type: static screenshot or animated AVIF (default: `"static"`) |
|
||||||
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
| `media.imageFormat` | `"jpg"`, `"png"`, `"webp"` | Image format (default: `"jpg"`) |
|
||||||
|
|||||||
@@ -559,6 +559,7 @@
|
|||||||
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
"animatedMaxHeight": 0, // Maximum height for animated AVIF captures, in pixels. Set to 0 to preserve aspect ratio.
|
||||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||||
|
"normalizeAudio": true, // Normalize generated sentence audio loudness during media extraction. Values: true | false
|
||||||
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
"audioPadding": 0, // Seconds of padding appended to both ends of generated sentence audio and animated AVIF clips.
|
||||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- **YouTube Background Cache:** Fixed Windows background media cache startup for YouTube URLs opened directly in mpv.
|
|
||||||
- Resolved stream URLs are now tracked even when mpv still exposes the original YouTube playlist entry.
|
|
||||||
- Queued Anki media updates can append audio and images after the cache finishes instead of staying text-only.
|
|
||||||
|
|
||||||
- **YouTube Subtitle Picker Notifications:** Manual subtitle picker requests now show immediate status while SubMiner probes tracks and opens the modal.
|
|
||||||
- Subtitle download progress is replaced with a transient success notification after tracks load.
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- feat(youtube): notify on manual picker open and show success after track load by @ksyasuda in #133
|
|
||||||
- fix(youtube): recover source URL for background media cache on direct mpv open by @ksyasuda in #132
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -860,13 +860,18 @@ test('AnkiIntegration queues YouTube media updates against recovered source URLs
|
|||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube audio', async () => {
|
test('AnkiIntegration passes audio normalization config for ready cached YouTube audio', async () => {
|
||||||
const audioCalls: Array<{ path: string; audioStreamIndex?: number }> = [];
|
const audioCalls: Array<{
|
||||||
|
path: string;
|
||||||
|
audioStreamIndex?: number;
|
||||||
|
normalizeAudio?: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
const integration = new AnkiIntegration(
|
const integration = new AnkiIntegration(
|
||||||
{
|
{
|
||||||
media: {
|
media: {
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
|
normalizeAudio: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{} as never,
|
{} as never,
|
||||||
@@ -896,13 +901,21 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
|
normalizeAudio?: boolean,
|
||||||
) => Promise<Buffer>;
|
) => Promise<Buffer>;
|
||||||
};
|
};
|
||||||
generateAudio: () => Promise<Buffer | null>;
|
generateAudio: () => Promise<Buffer | null>;
|
||||||
};
|
};
|
||||||
internals.mediaGenerator = {
|
internals.mediaGenerator = {
|
||||||
generateAudio: async (path, _startTime, _endTime, _audioPadding, audioStreamIndex) => {
|
generateAudio: async (
|
||||||
audioCalls.push({ path: path.path, audioStreamIndex });
|
path,
|
||||||
|
_startTime,
|
||||||
|
_endTime,
|
||||||
|
_audioPadding,
|
||||||
|
audioStreamIndex,
|
||||||
|
normalizeAudio,
|
||||||
|
) => {
|
||||||
|
audioCalls.push({ path: path.path, audioStreamIndex, normalizeAudio });
|
||||||
return Buffer.from('audio');
|
return Buffer.from('audio');
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -913,6 +926,7 @@ test('AnkiIntegration does not use mpv stream indexes for ready cached YouTube a
|
|||||||
{
|
{
|
||||||
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
path: '/tmp/subminer-youtube-media-cache/media.mkv',
|
||||||
audioStreamIndex: undefined,
|
audioStreamIndex: undefined,
|
||||||
|
normalizeAudio: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -348,6 +348,7 @@ export class AnkiIntegration {
|
|||||||
endTime,
|
endTime,
|
||||||
audioPadding,
|
audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
|
this.config.media?.normalizeAudio !== false,
|
||||||
),
|
),
|
||||||
generateScreenshot: (videoPath, timestamp, options) =>
|
generateScreenshot: (videoPath, timestamp, options) =>
|
||||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||||
@@ -502,6 +503,7 @@ export class AnkiIntegration {
|
|||||||
endTime,
|
endTime,
|
||||||
audioPadding,
|
audioPadding,
|
||||||
audioStreamIndex,
|
audioStreamIndex,
|
||||||
|
this.config.media?.normalizeAudio !== false,
|
||||||
),
|
),
|
||||||
generateScreenshot: (videoPath, timestamp, options) =>
|
generateScreenshot: (videoPath, timestamp, options) =>
|
||||||
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
this.mediaGenerator.generateScreenshot(videoPath, timestamp, options),
|
||||||
@@ -996,6 +998,7 @@ export class AnkiIntegration {
|
|||||||
endTime,
|
endTime,
|
||||||
this.config.media?.audioPadding,
|
this.config.media?.audioPadding,
|
||||||
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
resolveAudioStreamIndexForMediaGeneration(videoPath, this.mpvClient.currentAudioStreamIndex),
|
||||||
|
this.config.media?.normalizeAudio !== false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
@@ -269,9 +270,11 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
const imagePaths: string[] = [];
|
const imagePaths: string[] = [];
|
||||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||||
|
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||||
|
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||||
const edlSource = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
'!global_tags,title=test',
|
'!global_tags,title=test',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
@@ -354,8 +357,8 @@ test('manual clipboard subtitle update uses resolved mpv stream URLs for remote
|
|||||||
|
|
||||||
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
await service.updateLastAddedFromClipboard('一行目\n\n二行目');
|
||||||
|
|
||||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
assert.deepEqual(audioPaths, [audioUrl]);
|
||||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
assert.deepEqual(imagePaths, [videoUrl]);
|
||||||
assert.equal(storedMedia.length, 2);
|
assert.equal(storedMedia.length, 2);
|
||||||
assert.equal(updatedFields.length, 1);
|
assert.equal(updatedFields.length, 1);
|
||||||
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
assert.equal(updatedFields[0]?.Sentence, '一行目 二行目');
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig } from '../types/anki';
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
|
|
||||||
@@ -290,9 +291,11 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
const imagePaths: string[] = [];
|
const imagePaths: string[] = [];
|
||||||
const recordMediaPath = (mediaInput: MediaInput): string =>
|
const recordMediaPath = (mediaInput: MediaInput): string =>
|
||||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||||
|
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||||
|
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||||
const edlSource = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
'!global_tags,title=test',
|
'!global_tags,title=test',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
@@ -397,8 +400,8 @@ test('CardCreationService uses stream-open-filename for remote media generation'
|
|||||||
const created = await service.createSentenceCard('テスト', 0, 1);
|
const created = await service.createSentenceCard('テスト', 0, 1);
|
||||||
|
|
||||||
assert.equal(created, true);
|
assert.equal(created, true);
|
||||||
assert.deepEqual(audioPaths, ['https://audio.example/videoplayback?mime=audio%2Fwebm']);
|
assert.deepEqual(audioPaths, [audioUrl]);
|
||||||
assert.deepEqual(imagePaths, ['https://video.example/videoplayback?mime=video%2Fmp4']);
|
assert.deepEqual(imagePaths, [videoUrl]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
test('CardCreationService does not use mpv stream indexes for ready cached YouTube media', async () => {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ interface CardCreationMediaGenerator {
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
audioPadding?: number,
|
audioPadding?: number,
|
||||||
audioStreamIndex?: number,
|
audioStreamIndex?: number,
|
||||||
|
normalizeAudio?: boolean,
|
||||||
): Promise<Buffer | null>;
|
): Promise<Buffer | null>;
|
||||||
generateScreenshot(
|
generateScreenshot(
|
||||||
path: MediaInput,
|
path: MediaInput,
|
||||||
@@ -842,6 +843,7 @@ export class CardCreationService {
|
|||||||
videoPath,
|
videoPath,
|
||||||
mpvClient.currentAudioStreamIndex ?? undefined,
|
mpvClient.currentAudioStreamIndex ?? undefined,
|
||||||
),
|
),
|
||||||
|
this.deps.getConfig().media?.normalizeAudio !== false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import * as mediaSource from './media-source';
|
import * as mediaSource from './media-source';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
|
|
||||||
const { resolveMediaGenerationInputPath } = mediaSource;
|
const { resolveMediaGenerationInputPath } = mediaSource;
|
||||||
|
|
||||||
@@ -53,9 +54,11 @@ test('resolveMediaGenerationInputPath prefers stream-open-filename for remote me
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video', async () => {
|
||||||
|
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||||
|
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||||
const edlSource = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
'!global_tags,title=test',
|
'!global_tags,title=test',
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
@@ -74,8 +77,52 @@ test('resolveMediaGenerationInputPath unwraps mpv edl source for audio and video
|
|||||||
'video',
|
'video',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(audioResult, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
assert.equal(audioResult, audioUrl);
|
||||||
assert.equal(videoResult, 'https://video.example/videoplayback?mime=video%2Fmp4');
|
assert.equal(videoResult, videoUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInputPath strips mpv edl segment options from unwrapped streams', async () => {
|
||||||
|
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||||
|
const signedVideoUrl =
|
||||||
|
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||||
|
const edlSource = [
|
||||||
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(signedVideoUrl)},title=clip,length=73,timestamps=chapters`,
|
||||||
|
'!global_tags,title=test',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
const result = await resolveMediaGenerationInputPath(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => edlSource,
|
||||||
|
},
|
||||||
|
'video',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result, signedVideoUrl);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveMediaGenerationInputPath ignores length-guarded URLs in mpv edl headers', async () => {
|
||||||
|
const initUrl = 'https://init.example/init.mp4';
|
||||||
|
const audioUrl = 'https://audio.example/stream';
|
||||||
|
const videoUrl = 'https://video.example/stream';
|
||||||
|
const edlSource = [
|
||||||
|
`edl://!mp4_dash,init=${toMpvEdlValue(initUrl)}`,
|
||||||
|
'!new_stream',
|
||||||
|
toMpvEdlValue(audioUrl),
|
||||||
|
'!new_stream',
|
||||||
|
toMpvEdlValue(videoUrl),
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
const audioResult = await resolveMediaGenerationInputPath(
|
||||||
|
{
|
||||||
|
currentVideoPath: 'https://www.youtube.com/watch?v=abc123',
|
||||||
|
requestProperty: async () => edlSource,
|
||||||
|
},
|
||||||
|
'audio',
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(audioResult, audioUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
test('resolveMediaGenerationInputPath falls back to currentVideoPath when stream-open-filename fails', async () => {
|
||||||
@@ -97,9 +144,11 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
|||||||
).resolveMediaGenerationInput;
|
).resolveMediaGenerationInput;
|
||||||
assert.equal(typeof resolver, 'function');
|
assert.equal(typeof resolver, 'function');
|
||||||
|
|
||||||
|
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||||
|
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||||
const edlSource = [
|
const edlSource = [
|
||||||
'edl://!new_stream;!no_clip;!no_chapters;%70%https://audio.example/videoplayback?mime=audio%2Fwebm',
|
`edl://!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(audioUrl)}`,
|
||||||
'!new_stream;!no_clip;!no_chapters;%69%https://video.example/videoplayback?mime=video%2Fmp4',
|
`!new_stream;!no_clip;!no_chapters;${toMpvEdlValue(videoUrl)}`,
|
||||||
].join(';');
|
].join(';');
|
||||||
|
|
||||||
const result = await resolver!(
|
const result = await resolver!(
|
||||||
@@ -117,7 +166,7 @@ test('resolveMediaGenerationInput returns single-stream metadata for mpv EDL URL
|
|||||||
'audio',
|
'audio',
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(result?.path, 'https://audio.example/videoplayback?mime=audio%2Fwebm');
|
assert.equal(result?.path, audioUrl);
|
||||||
assert.equal(result?.singleResolvedStream, true);
|
assert.equal(result?.singleResolvedStream, true);
|
||||||
assert.equal(result?.inputOptions?.reconnect, true);
|
assert.equal(result?.inputOptions?.reconnect, true);
|
||||||
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
assert.equal(result?.inputOptions?.userAgent, 'Mozilla/5.0');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isRemoteMediaPath } from '../jimaku/utils';
|
import { isRemoteMediaPath } from '../jimaku/utils';
|
||||||
import type { MediaInput, MediaInputOptions } from '../media-input';
|
import type { MediaInput, MediaInputOptions } from '../media-input';
|
||||||
import type { MpvClient } from '../types/runtime';
|
import type { MpvClient } from '../types/runtime';
|
||||||
|
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||||
|
|
||||||
export type MediaGenerationKind = 'audio' | 'video';
|
export type MediaGenerationKind = 'audio' | 'video';
|
||||||
export type MediaGenerationInputSource =
|
export type MediaGenerationInputSource =
|
||||||
@@ -73,9 +74,8 @@ function normalizeHeaderName(value: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
function extractUrlsFromMpvEdlSource(source: string): string[] {
|
||||||
const matches = source.matchAll(/%\d+%(https?:\/\/.*?)(?=;!new_stream|;!global_tags|$)/gms);
|
return extractFileUrlsFromMpvEdlSource(source)
|
||||||
return [...matches]
|
.map((value) => trimToNonEmptyString(value))
|
||||||
.map((match) => trimToNonEmptyString(match[1]))
|
|
||||||
.filter((value): value is string => value !== null);
|
.filter((value): value is string => value !== null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function toMpvEdlValue(value: string): string {
|
||||||
|
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { extractFileUrlsFromMpvEdlSource } from './mpv-edl';
|
||||||
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
|
|
||||||
|
test('extractFileUrlsFromMpvEdlSource honors length-guarded file values', () => {
|
||||||
|
const url =
|
||||||
|
'https://rr1---sn.example.googlevideo.com/videoplayback?mime=video%2Fmp4&mn=sn-a,sn-b&lsig=abc%3D';
|
||||||
|
const source = `edl://!new_stream;${toMpvEdlValue(url)},title=clip,length=73`;
|
||||||
|
|
||||||
|
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [url]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractFileUrlsFromMpvEdlSource reads file parameters', () => {
|
||||||
|
const initUrl = 'https://init.example/init.mp4';
|
||||||
|
const fileUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||||
|
const source = `edl://!mp4_dash,init=${toMpvEdlValue(initUrl)};file=${toMpvEdlValue(
|
||||||
|
fileUrl,
|
||||||
|
)},length=42`;
|
||||||
|
|
||||||
|
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [fileUrl]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extractFileUrlsFromMpvEdlSource aggregates file URLs across entries', () => {
|
||||||
|
const audioUrl = 'https://audio.example/videoplayback?mime=audio%2Fwebm';
|
||||||
|
const videoUrl = 'https://video.example/videoplayback?mime=video%2Fmp4';
|
||||||
|
const source = [
|
||||||
|
'edl://!new_stream',
|
||||||
|
toMpvEdlValue(audioUrl),
|
||||||
|
'!new_stream',
|
||||||
|
`file=${toMpvEdlValue(videoUrl)},length=50`,
|
||||||
|
'!global_tags,title=test',
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
assert.deepEqual(extractFileUrlsFromMpvEdlSource(source), [audioUrl, videoUrl]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
const EDL_URI_PREFIX = 'edl://';
|
||||||
|
|
||||||
|
const BYTE_COMMA = ','.charCodeAt(0);
|
||||||
|
const BYTE_CR = '\r'.charCodeAt(0);
|
||||||
|
const BYTE_EQUALS = '='.charCodeAt(0);
|
||||||
|
const BYTE_EXCLAMATION = '!'.charCodeAt(0);
|
||||||
|
const BYTE_LF = '\n'.charCodeAt(0);
|
||||||
|
const BYTE_PERCENT = '%'.charCodeAt(0);
|
||||||
|
const BYTE_SEMICOLON = ';'.charCodeAt(0);
|
||||||
|
|
||||||
|
function isDigitByte(value: number | undefined): value is number {
|
||||||
|
return value !== undefined && value >= 48 && value <= 57;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEntrySeparator(value: number | undefined): boolean {
|
||||||
|
return value === BYTE_SEMICOLON || value === BYTE_LF || value === BYTE_CR;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isParamSeparator(value: number | undefined): boolean {
|
||||||
|
return value === BYTE_COMMA || isEntrySeparator(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBytes(buffer: Buffer, start: number, end: number): string {
|
||||||
|
return buffer.subarray(start, end).toString('utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
return /^https?:\/\//i.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toEdlDataBuffer(source: string): Buffer {
|
||||||
|
const data = source.startsWith(EDL_URI_PREFIX) ? source.slice(EDL_URI_PREFIX.length) : source;
|
||||||
|
return Buffer.from(data, 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLengthGuardedValue(
|
||||||
|
buffer: Buffer,
|
||||||
|
position: number,
|
||||||
|
): { value: string; end: number } | null {
|
||||||
|
if (buffer[position] !== BYTE_PERCENT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cursor = position + 1;
|
||||||
|
if (!isDigitByte(buffer[cursor])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let byteLength = 0;
|
||||||
|
while (true) {
|
||||||
|
const digit = buffer[cursor];
|
||||||
|
if (!isDigitByte(digit)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
byteLength = byteLength * 10 + (digit - 48);
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[cursor] !== BYTE_PERCENT) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const valueStart = cursor + 1;
|
||||||
|
const valueEnd = valueStart + byteLength;
|
||||||
|
if (valueEnd > buffer.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
value: decodeBytes(buffer, valueStart, valueEnd),
|
||||||
|
end: valueEnd,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipEntrySeparators(buffer: Buffer, position: number): number {
|
||||||
|
let cursor = position;
|
||||||
|
while (cursor < buffer.length && isEntrySeparator(buffer[cursor])) {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipEntry(buffer: Buffer, position: number): number {
|
||||||
|
let cursor = position;
|
||||||
|
while (cursor < buffer.length) {
|
||||||
|
const guardedValue = parseLengthGuardedValue(buffer, cursor);
|
||||||
|
if (guardedValue) {
|
||||||
|
cursor = guardedValue.end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isEntrySeparator(buffer[cursor])) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
return cursor;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRawValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||||
|
let cursor = position;
|
||||||
|
while (
|
||||||
|
cursor < buffer.length &&
|
||||||
|
!isParamSeparator(buffer[cursor]) &&
|
||||||
|
buffer[cursor] !== BYTE_EXCLAMATION
|
||||||
|
) {
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
value: decodeBytes(buffer, position, cursor),
|
||||||
|
end: cursor,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseParamValue(buffer: Buffer, position: number): { value: string; end: number } {
|
||||||
|
return parseLengthGuardedValue(buffer, position) ?? parseRawValue(buffer, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalParamName(
|
||||||
|
buffer: Buffer,
|
||||||
|
position: number,
|
||||||
|
): { name: string | null; valueStart: number } {
|
||||||
|
let cursor = position;
|
||||||
|
while (
|
||||||
|
cursor < buffer.length &&
|
||||||
|
!isParamSeparator(buffer[cursor]) &&
|
||||||
|
buffer[cursor] !== BYTE_PERCENT &&
|
||||||
|
buffer[cursor] !== BYTE_EXCLAMATION
|
||||||
|
) {
|
||||||
|
if (buffer[cursor] === BYTE_EQUALS) {
|
||||||
|
return {
|
||||||
|
name: decodeBytes(buffer, position, cursor),
|
||||||
|
valueStart: cursor + 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
cursor += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: null, valueStart: position };
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSegmentEntry(buffer: Buffer, position: number): { urls: string[]; end: number } {
|
||||||
|
const urls: string[] = [];
|
||||||
|
let cursor = position;
|
||||||
|
let unnamedParamIndex = 0;
|
||||||
|
|
||||||
|
while (cursor < buffer.length && !isEntrySeparator(buffer[cursor])) {
|
||||||
|
const { name, valueStart } = parseOptionalParamName(buffer, cursor);
|
||||||
|
const value = parseParamValue(buffer, valueStart);
|
||||||
|
const lowerName = name?.toLowerCase() ?? null;
|
||||||
|
const isFileParam = lowerName === 'file' || (lowerName === null && unnamedParamIndex === 0);
|
||||||
|
|
||||||
|
if (isFileParam && isHttpUrl(value.value)) {
|
||||||
|
urls.push(value.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lowerName === null) {
|
||||||
|
unnamedParamIndex += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = value.end;
|
||||||
|
if (buffer[cursor] === BYTE_COMMA) {
|
||||||
|
cursor += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!isEntrySeparator(buffer[cursor])) {
|
||||||
|
cursor = skipEntry(buffer, cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { urls, end: cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractFileUrlsFromMpvEdlSource(source: string): string[] {
|
||||||
|
const buffer = toEdlDataBuffer(source);
|
||||||
|
const urls: string[] = [];
|
||||||
|
let cursor = 0;
|
||||||
|
|
||||||
|
while (cursor < buffer.length) {
|
||||||
|
cursor = skipEntrySeparators(buffer, cursor);
|
||||||
|
if (cursor >= buffer.length) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buffer[cursor] === BYTE_EXCLAMATION) {
|
||||||
|
cursor = skipEntry(buffer, cursor);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const segment = parseSegmentEntry(buffer, cursor);
|
||||||
|
urls.push(...segment.urls);
|
||||||
|
cursor = segment.end;
|
||||||
|
}
|
||||||
|
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
@@ -272,6 +272,7 @@ export class PendingYoutubeMediaQueue {
|
|||||||
job.endTime,
|
job.endTime,
|
||||||
config.media?.audioPadding,
|
config.media?.audioPadding,
|
||||||
undefined,
|
undefined,
|
||||||
|
config.media?.normalizeAudio !== false,
|
||||||
);
|
);
|
||||||
if (audioBuffer) {
|
if (audioBuffer) {
|
||||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
model: '',
|
model: '',
|
||||||
systemPrompt: '',
|
systemPrompt: '',
|
||||||
});
|
});
|
||||||
|
assert.equal(config.ankiConnect.media.normalizeAudio, true);
|
||||||
assert.equal(config.startupWarmups.lowPowerMode, false);
|
assert.equal(config.startupWarmups.lowPowerMode, false);
|
||||||
assert.equal(config.startupWarmups.mecab, true);
|
assert.equal(config.startupWarmups.mecab, true);
|
||||||
assert.equal(config.startupWarmups.yomitanExtension, true);
|
assert.equal(config.startupWarmups.yomitanExtension, true);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
animatedMaxHeight: 0,
|
animatedMaxHeight: 0,
|
||||||
animatedCrf: 35,
|
animatedCrf: 35,
|
||||||
syncAnimatedImageToWordAudio: true,
|
syncAnimatedImageToWordAudio: true,
|
||||||
|
normalizeAudio: true,
|
||||||
audioPadding: 0,
|
audioPadding: 0,
|
||||||
fallbackDuration: 3.0,
|
fallbackDuration: 3.0,
|
||||||
maxMediaDuration: 30,
|
maxMediaDuration: 30,
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'subtitleStyle.nameMatchEnabled',
|
'subtitleStyle.nameMatchEnabled',
|
||||||
|
'ankiConnect.media.normalizeAudio',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
|
|||||||
@@ -181,6 +181,12 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
defaultValue: defaultConfig.ankiConnect.media.generateAudio,
|
||||||
description: 'Generate sentence audio for mined cards.',
|
description: 'Generate sentence audio for mined cards.',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ankiConnect.media.normalizeAudio',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.ankiConnect.media.normalizeAudio,
|
||||||
|
description: 'Normalize generated sentence audio loudness during media extraction.',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'ankiConnect.media.generateImage',
|
path: 'ankiConnect.media.generateImage',
|
||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
|
|||||||
@@ -1206,6 +1206,7 @@ export function createStatsApp(
|
|||||||
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
const mediaGen = options?.createMediaGenerator?.() ?? new MediaGenerator();
|
||||||
|
|
||||||
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
const audioPadding = ankiConfig.media?.audioPadding ?? 0;
|
||||||
|
const normalizeAudio = ankiConfig.media?.normalizeAudio !== false;
|
||||||
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
const maxMediaDuration = ankiConfig.media?.maxMediaDuration ?? 30;
|
||||||
|
|
||||||
const startSec = startMs / 1000;
|
const startSec = startMs / 1000;
|
||||||
@@ -1228,7 +1229,14 @@ export function createStatsApp(
|
|||||||
|
|
||||||
const audioPromise = generateAudio
|
const audioPromise = generateAudio
|
||||||
? timeMiningPhase(mode, 'generateAudio', () =>
|
? timeMiningPhase(mode, 'generateAudio', () =>
|
||||||
mediaGen.generateAudio(sourcePath, startSec, clampedEndSec, audioPadding),
|
mediaGen.generateAudio(
|
||||||
|
sourcePath,
|
||||||
|
startSec,
|
||||||
|
clampedEndSec,
|
||||||
|
audioPadding,
|
||||||
|
null,
|
||||||
|
normalizeAudio,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: Promise.resolve(null);
|
: Promise.resolve(null);
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,24 @@ test('generateAudio defaults to unpadded sentence timing', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('generateAudio normalizes sentence audio by default', async () => {
|
||||||
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
|
await generator.generateAudio('/video.mp4', 10, 12);
|
||||||
|
|
||||||
|
const args = readFfmpegArgs(argsPath);
|
||||||
|
assert.equal(args[args.indexOf('-af') + 1], 'loudnorm=I=-23:TP=-2:LRA=11');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generateAudio can preserve raw sentence audio loudness', async () => {
|
||||||
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
|
await generator.generateAudio('/video.mp4', 10, 12, 0, null, false);
|
||||||
|
|
||||||
|
const args = readFfmpegArgs(argsPath);
|
||||||
|
assert.equal(args.includes('-af'), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
test('generateAudio clips leading padding without adding it to trailing duration', async () => {
|
||||||
await withStubbedFfmpeg(async (generator, argsPath) => {
|
await withStubbedFfmpeg(async (generator, argsPath) => {
|
||||||
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
await generator.generateAudio('/video.mp4', 0.2, 1.2, 0.5);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { createLogger } from './logger';
|
|||||||
import { normalizeMediaInput, type MediaInput } from './media-input';
|
import { normalizeMediaInput, type MediaInput } from './media-input';
|
||||||
|
|
||||||
const log = createLogger('media');
|
const log = createLogger('media');
|
||||||
|
const AUDIO_NORMALIZATION_FILTER = 'loudnorm=I=-23:TP=-2:LRA=11';
|
||||||
|
|
||||||
export type { MediaInput, MediaInputOptions } from './media-input';
|
export type { MediaInput, MediaInputOptions } from './media-input';
|
||||||
|
|
||||||
@@ -264,6 +265,7 @@ export class MediaGenerator {
|
|||||||
endTime: number,
|
endTime: number,
|
||||||
padding: number = 0,
|
padding: number = 0,
|
||||||
audioStreamIndex: number | null = null,
|
audioStreamIndex: number | null = null,
|
||||||
|
normalizeAudio = true,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
const safePadding = Number.isFinite(padding) ? Math.max(0, padding) : 0;
|
||||||
const start = Math.max(0, startTime - safePadding);
|
const start = Math.max(0, startTime - safePadding);
|
||||||
@@ -293,7 +295,11 @@ export class MediaGenerator {
|
|||||||
args.push('-map', `0:${audioStreamIndex}`);
|
args.push('-map', `0:${audioStreamIndex}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
args.push('-vn', '-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
args.push('-vn');
|
||||||
|
if (normalizeAudio) {
|
||||||
|
args.push('-af', AUDIO_NORMALIZATION_FILTER);
|
||||||
|
}
|
||||||
|
args.push('-acodec', 'libmp3lame', '-q:a', '2', '-ar', '44100', '-y', outputPath);
|
||||||
|
|
||||||
this.logMediaDebug(
|
this.logMediaDebug(
|
||||||
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
`audio start ${inputDescription} start=${start} duration=${duration} padding=${safePadding}`,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface AnkiConnectConfig {
|
|||||||
animatedMaxHeight?: number;
|
animatedMaxHeight?: number;
|
||||||
animatedCrf?: number;
|
animatedCrf?: number;
|
||||||
syncAnimatedImageToWordAudio?: boolean;
|
syncAnimatedImageToWordAudio?: boolean;
|
||||||
|
normalizeAudio?: boolean;
|
||||||
audioPadding?: number;
|
audioPadding?: number;
|
||||||
fallbackDuration?: number;
|
fallbackDuration?: number;
|
||||||
maxMediaDuration?: number;
|
maxMediaDuration?: number;
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ export interface ResolvedConfig {
|
|||||||
animatedMaxHeight?: number;
|
animatedMaxHeight?: number;
|
||||||
animatedCrf: number;
|
animatedCrf: number;
|
||||||
syncAnimatedImageToWordAudio: boolean;
|
syncAnimatedImageToWordAudio: boolean;
|
||||||
|
normalizeAudio: boolean;
|
||||||
audioPadding: number;
|
audioPadding: number;
|
||||||
fallbackDuration: number;
|
fallbackDuration: number;
|
||||||
maxMediaDuration: number;
|
maxMediaDuration: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user