Compare commits

..
Author SHA1 Message Date
sudacode d1e356f53f fix(youtube): preserve explicit 3000ms caption durations
- Distinguish generated rolling cues from plain-text cues
- Keep explicit sound-cue spans intact
2026-08-27 23:21:58 -07:00
sudacode fa73aea2f9 fix(youtube): keep auto captions timed and paged correctly
- Page oversized rolling captions within YouTube’s row and column limits
- Preserve explicit durations for sound cues
2026-08-27 19:06:59 -07:00
sudacode f8ca8681dc fix(youtube): keep auto captions on screen for their full span
YouTube's sentence-level ASR emits long caption rows with a placeholder
d="3000", while the caption actually displays until the next window
event. Trusting `d` made long lines vanish mid-speech and leave a blank
gap until the next cue.

Rolling auto-caption documents (rows with a="1") now end each cue at the
next event timestamp instead of t + d, matching YouTube's own display
timing. Manual and non-rolling TimedText keep duration-based timing so
real silence gaps are preserved.
2026-08-26 00:07:42 -07:00
sudacode c2c25c0da6 fix(anki): keep overlay progress visible through card updates (#218) 2026-08-25 20:27:58 -07:00
sudacode 556de61756 chore(changelog): split release details into nested bullets
- Update changelog generation guidance and tests
- Reformat current release notes and document the new style
2026-08-25 12:39:45 -07:00
21 changed files with 525 additions and 106 deletions
+16 -3
View File
@@ -10,9 +10,22 @@
- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC. - **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC.
### Fixed ### Fixed
- **Subtitle & Karaoke Duplication**: Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, or repeated animation events. Lines are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved. Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. Secondary subtitles now go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second. - **Subtitle & Karaoke Duplication**:
- **Character Dictionary Reliability**: Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries. Snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. Dictionaries are reused instead of regenerated when MeCab finds no name splits, and cached portraits now restore correctly after the portrait index finishes loading post-tokenization. Desktop progress notifications on Linux AppImage installs now update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper. - Karaoke and animated signs are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved, instead of flooding the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, and repeated animation events.
- **Overlay Startup & Modals**: Fixed several causes of the overlay getting stuck on "Overlay loading": the macOS window-tracking helper now targets macOS 12.0+ instead of requiring the build machine's exact macOS version (previously crashed on older systems like Ventura), and mpv IPC connection attempts now time out and retry, showing an actionable error if content still isn't ready after 30 seconds. Dedicated overlay modals are also prewarmed on macOS and Windows so shortcuts open them promptly, and on macOS reused modals and the stats window now open above fullscreen mpv on its current Space instead of jumping to another desktop. - Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly.
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container.
- Secondary subtitles go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines.
- Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
- **Character Dictionary Reliability**:
- Generation, merged rebuilds, and imports no longer freeze the app on large dictionaries; snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path.
- Dictionaries are reused instead of regenerated when MeCab finds no name splits.
- Cached portraits restore correctly after the portrait index finishes loading post-tokenization.
- Desktop progress notifications on Linux AppImage installs update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
- **Overlay Startup & Modals**:
- The macOS window-tracking helper targets macOS 12.0+ instead of requiring the build machine's exact macOS version, fixing crashes on older systems like Ventura that left the overlay stuck on "Overlay loading".
- mpv IPC connection attempts time out and retry, showing an actionable error if content still isn't ready after 30 seconds.
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly.
- On macOS, reused modals and the stats window open above fullscreen mpv on its current Space instead of jumping to another desktop.
- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv. - **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups. - **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups.
- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later. - **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later.
+1 -1
View File
@@ -42,7 +42,7 @@ How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that. - At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries. - The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
- GitHub release notes and prerelease notes use short top-level items with nested bullets for the change, user benefit, and any useful action note. The stable `CHANGELOG.md` can stay in compact single-line bullets. - `CHANGELOG.md`, GitHub release notes, and prerelease notes all use short top-level items with one nested bullet per distinct change, instead of packing a release's worth of detail into a single paragraph bullet. An item with only one thing to say stays inline on the top-level bullet. Release notes and prerelease notes additionally cover user benefit and any useful action note in their nested bullets.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely. - `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something. - The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Kept the Anki card update spinner visible until audio and image updates finish.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: youtube
- YouTube auto-generated captions now follow their intended timing and two-row roll-up layout: long speech is paged instead of covering the video with a wall of text, while explicitly timed sound cues such as `[音楽]` no longer cover later dialogue.
+16 -3
View File
@@ -10,9 +10,22 @@
- **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC. - **Prerelease Release Notes**: Prerelease notes now open with a "Changes since" section listing only what changed versus the previous beta/RC of the same version, above the cumulative highlights, and CI rejects prerelease tags whose committed notes were generated for a different beta/RC.
**Fixed** **Fixed**
- **Subtitle & Karaoke Duplication**: Typeset ASS karaoke and animated signs no longer flood the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, or repeated animation events. Lines are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved. Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly. Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container. Secondary subtitles now go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines. Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second. - **Subtitle & Karaoke Duplication**:
- **Character Dictionary Reliability**: Character dictionary generation, merged rebuilds, and imports no longer freeze the app on large dictionaries. Snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path. Dictionaries are reused instead of regenerated when MeCab finds no name splits, and cached portraits now restore correctly after the portrait index finishes loading post-tokenization. Desktop progress notifications on Linux AppImage installs now update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper. - Karaoke and animated signs are reconstructed once from their authored text and shown only while actually sung, with original word spacing preserved, instead of flooding the overlay, subtitle sidebar, immersion history, mining, or stats with glyph fragments, per-frame color phases, and repeated animation events.
- **Overlay Startup & Modals**: Fixed several causes of the overlay getting stuck on "Overlay loading": the macOS window-tracking helper now targets macOS 12.0+ instead of requiring the build machine's exact macOS version (previously crashed on older systems like Ventura), and mpv IPC connection attempts now time out and retry, showing an actionable error if content still isn't ready after 30 seconds. Dedicated overlay modals are also prewarmed on macOS and Windows so shortcuts open them promptly, and on macOS reused modals and the stats window now open above fullscreen mpv on its current Space instead of jumping to another desktop. - Decorative layers (highlight sweeps, glow/shadow copies, symbol-font decoration, particle swarms, hidden or zero-scaled text) stay out of published text, while ordinary repeated dialogue, positioned signs, wrapped lyric rows, and multi-row CC-style blocks still display correctly.
- Embedded subtitle tracks on network-mounted (SMB/NFS) media are extracted and parsed again instead of falling back to live-text-only, restoring karaoke reconstruction, sidebar cues, and mining for releases that only ship subtitles inside the container.
- Secondary subtitles go through the same deduplication pipeline as primary subtitles and no longer clip display after about four lines.
- Event-heavy karaoke files that previously stalled subtitle loading for several seconds now parse in well under a second.
- **Character Dictionary Reliability**:
- Generation, merged rebuilds, and imports no longer freeze the app on large dictionaries; snapshot I/O, archive building, and image/name lookup caches moved off the UI's critical path.
- Dictionaries are reused instead of regenerated when MeCab finds no name splits.
- Cached portraits restore correctly after the portrait index finishes loading post-tokenization.
- Desktop progress notifications on Linux AppImage installs update in place instead of flickering, fixing a bug where the AppImage's bundled libraries broke the system notification helper.
- **Overlay Startup & Modals**:
- The macOS window-tracking helper targets macOS 12.0+ instead of requiring the build machine's exact macOS version, fixing crashes on older systems like Ventura that left the overlay stuck on "Overlay loading".
- mpv IPC connection attempts time out and retry, showing an actionable error if content still isn't ready after 30 seconds.
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly.
- On macOS, reused modals and the stats window open above fullscreen mpv on its current Space instead of jumping to another desktop.
- **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv. - **Wayland File Drop**: Fixed native Wayland drag-and-drop from file managers such as Thunar, so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
- **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups. - **Windows Mouse Lag**: Fixed system-wide mouse lag on Windows while SubMiner is running, caused by the overlay's global mouse hook for click-through forwarding and by the mpv window tracker blocking the app on repeated PowerShell lookups.
- **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later. - **Sentence Mining Audio & Clips**: Sentence-audio generation no longer times out on slow network-mounted media with many subtitle/font streams (bounded FFmpeg probing, two-minute extraction budget, clearer error reporting), and mined audio/animated AVIF clips now capture the subtitle line that was actually mined by snapshotting the clip range at lookup time instead of reading live mpv state later.
-78
View File
@@ -1,78 +0,0 @@
## Highlights
### Added
- **Library Duplicate & Misfiled Episode Tools**
- Merge duplicate show cards from the Library grid: select cards and use "Merge Selected" to combine sessions, mined cards, and watch time onto one entry while keeping remembered title aliases.
- Reassign a misfiled episode to the correct show with the "→" button on an episode row; the fix survives later filename parsing, Jellyfin refreshes, and season repair.
- Exact AniList matches merge automatically, while likely (fuzzy) matches surface as a dismissible "Possible duplicate" suggestion instead of merging without confirmation.
- **Stats Duplicate-Line Cleanup Tool**
- The Vocabulary tab's new Duplicates button scans a chosen time window for old karaoke/animation duplicate bursts and collapses each one to a single line after you confirm, without touching watch time or lines-seen totals.
- The same cleanup is available from the terminal via `subminer stats cleanup --duplicate-lines`, with `--dry-run` and `--lookback-days` options.
### Changed
- **Prerelease Notes "Changes Since" Section**
- Prerelease release notes now open with a "Changes since" section listing only what changed since the previous beta/RC of the same version, shown above the full cumulative highlights.
### Fixed
- **Subtitle Deduplication & Karaoke Reconstruction**
- Typeset ASS karaoke and animated signs are reconstructed into their authored line and shown once, instead of flooding the overlay, subtitle sidebar, immersion history, sentence mining, and stats with per-frame glyph fragments and repeated lyric bursts (a lyric could previously pin itself to the top of "Top Repeated Words").
- The same deduplication now applies consistently everywhere, including embedded subtitles extracted from network-mounted (SMB/NFS) media and the secondary subtitle overlay, while ordinary repeated dialogue, signs, and rewatches remain unaffected.
- Secondary subtitle overlays no longer clip long lines after about four rows, and no longer show scattered-letter or duplicated text while embedded subtitles are still being extracted.
- **Character Dictionary Reliability & Notifications**
- Character dictionary generation, rebuilds, and imports no longer freeze the app or trigger "not responding" dialogs on large dictionaries; the heavy work now runs off the main UI thread.
- Dictionaries are reused instead of being regenerated on every launch when no name splits were found, and portraits reappear correctly once the cached portrait index finishes loading.
- Linux desktop progress notifications, including on AppImage installs, now update in place instead of flickering closed and reopening.
- **Overlay Startup Reliability**
- The overlay no longer gets stuck on an endless "Overlay loading" screen when mpv's connection stalls at startup; connections now time out and retry, and a clear error appears if content still isn't ready after 30 seconds.
- **Overlay Modal Windows (macOS & Windows)**
- Modal windows such as Settings prewarm so shortcuts open them promptly on first press.
- On Windows, the hidden modal renderer now refreshes between sessions so later modals stay interactive.
- On macOS, reused modals and the stats window open above fullscreen mpv on the correct Space instead of jumping to another desktop; the overlay-attach helper also now supports macOS 12.0+, fixing "Overlay loading" getting stuck on older macOS versions.
- **Windows Mouse Lag**
- Fixed system-wide mouse lag while SubMiner is running: the overlay no longer installs a global mouse hook, and the mpv window tracker no longer blocks the app with repeated command-line lookups.
- **Linux Overlay & Launcher Fixes**
- Native Wayland drag-and-drop from file managers such as Thunar now works, so subtitle and video files dropped on the overlay reach mpv.
- Fixed missing MKV thumbnails in the rofi file picker on systems that only advertise legacy Matroska MIME aliases.
- **Sentence Mining Audio & Clip Accuracy**
- Sentence-audio generation no longer times out on slow network-mounted MKV files with many subtitle/font streams; probing is now bounded with a two-minute extraction budget and a clear error instead of a raw failure.
- Mined audio and animated clips now capture the exact subtitle line that was mined, instead of whatever line was on screen after audio extraction finished, fixing too-short or misaligned clips.
- **Stats Reliability & Performance**
- Fixed transient database-lock errors when multiple stats workers wrote at once.
- Stats deletes, library merges, video moves, and AniList reassignments no longer freeze the dashboard or rebuild lifetime totals from scratch, so they're fast and preserve lifetime totals older than the recent session-retention window; session deletes on large databases dropped from minutes to milliseconds.
- **Vocabulary Tab Accuracy**
- Vocabulary totals and charts now count all tracked vocabulary instead of only the first page, with new-word history rebuilt from corrected daily rollups to match.
- Calendar charts keep the correct local date in time zones west of UTC, and vocabulary cards/charts now refresh automatically and retry after the word exclusion list changes.
## What's Changed
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
- fix(stats): report complete vocabulary totals and new-word history by @ksyasuda in #202
- fix(mpv): recover from stalled IPC connects by @ksyasuda in #204
- fix(dictionary): prevent freezes and restore AppImage notifications by @ksyasuda in #205
- fix(subtitles): recover canonical lines from ASS animation by @ksyasuda in #207
- fix(overlay): deduplicate secondary subtitle rendering by @ksyasuda in #208
- fix(launcher): restore Matroska thumbnails in Linux rofi picker by @ksyasuda in #210
- fix(character-dictionary): cache completed MeCab refreshes by @ksyasuda in #212
- fix(subtitles): improve secondary subtitle extraction and display by @ksyasuda in #215
- feat(release): track prerelease deltas and validate committed notes by @ksyasuda in #216
- fix(subtitles): recover positioned ASS word spacing and drop control debris by @ksyasuda in #217
## 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`.
+13 -4
View File
@@ -44,14 +44,22 @@ function fragmentTypesInPrompt(input: string): string[] {
.map((line) => line.slice('type: '.length).trim()); .map((line) => line.slice('type: '.length).trim());
} }
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void { function assertPromptRequestsNestedBullets(input: string): void {
assert.match(input, /In MODE: release-notes, use short top-level change bullets/); assert.match(input, /In both modes, split every item into one nested bullet per distinct change/);
assert.match(input, /Nested bullets should cover the change, user benefit, and any user action/); assert.match(input, /Never stack several distinct changes into one long paragraph-shaped bullet/);
assert.match(input, /Do not require the exact nested labels/);
assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/); assert.match(input, /Keep nested bullets short, concrete, and readable by non-technical users/);
assert.match(input, /Avoid paragraph-style release-note bullets/); assert.match(input, /Avoid paragraph-style release-note bullets/);
} }
function assertReleaseNotesPromptRequestsNestedBullets(input: string): void {
assertPromptRequestsNestedBullets(input);
assert.match(
input,
/In MODE: release-notes, nested bullets should also cover user benefit and any user action/,
);
assert.match(input, /Do not require the exact nested labels/);
}
function defaultPolishedBody(input: string): string { function defaultPolishedBody(input: string): string {
const mode = modeFromPrompt(input); const mode = modeFromPrompt(input);
const types = fragmentTypesInPrompt(input); const types = fragmentTypesInPrompt(input);
@@ -446,6 +454,7 @@ test('writeChangelogArtifacts prompts Claude to summarize the final stable outco
prompt, prompt,
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/, /Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
); );
assertPromptRequestsNestedBullets(prompt);
} }
const releaseNotesPrompt = stub.calls.find( const releaseNotesPrompt = stub.calls.find(
+8 -3
View File
@@ -480,10 +480,15 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five. - Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users. - Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them. - Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
5. In MODE: changelog, each item may be a conventional single-level bullet, e.g. "- Playlist Browser: Adds faster saved-show browsing." 5. In both modes, split every item into one nested bullet per distinct change. Write a short bold name on the top-level bullet, then indent the details two spaces:
6. In MODE: release-notes, use short top-level change bullets with two or three nested bullets when an item needs explanation. - **Playlist Browser**:
Nested bullets should cover the change, user benefit, and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed. - Saved shows now open without rescanning the library.
- The picker remembers the last folder you browsed between launches.
Each nested bullet covers exactly one change, behavior, or user-visible outcome. Never stack several distinct changes into one long paragraph-shaped bullet.
Aim for two to five nested bullets per item. When an item genuinely has only one thing to say, put it inline on the top-level bullet ("- **Playlist Browser**: Saved shows now open without rescanning the library.") instead of emitting a single nested bullet.
Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets. Keep nested bullets short, concrete, and readable by non-technical users. Avoid paragraph-style release-note bullets.
Bullets inside the Internal section may stay single-level.
6. In MODE: release-notes, nested bullets should also cover user benefit and any user action or compatibility note when useful. Do not require the exact nested labels; natural phrasing is fine. Omit the action bullet when no action is needed.
7. Do not invent features. Every bullet must be grounded in the input fragments. 7. Do not invent features. Every bullet must be grounded in the input fragments.
8. Do not include the version heading (## v...) — that wrapper is added by the caller. 8. Do not include the version heading (## v...) — that wrapper is added by the caller.
+78
View File
@@ -11,10 +11,12 @@ import type { MediaInput } from './media-input';
import { AnkiConnectConfig } from './types'; import { AnkiConnectConfig } from './types';
type TestOverlayNotificationPayload = { type TestOverlayNotificationPayload = {
id?: string;
title: string; title: string;
body?: string; body?: string;
image?: string; image?: string;
variant?: string; variant?: string;
persistent?: boolean;
actions?: Array<{ id: string; label: string; noteId?: number }>; actions?: Array<{ id: string; label: string; noteId?: number }>;
}; };
@@ -1182,6 +1184,82 @@ test('AnkiIntegration embeds generated notification image on overlay mined-card
assert.deepEqual(cleanupPaths, [notificationIconPath]); assert.deepEqual(cleanupPaths, [notificationIconPath]);
}); });
test('AnkiIntegration keeps overlay card-update progress visible until the terminal notification', async () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'overlay',
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
showNotification: (noteId: number, label: string | number) => Promise<void>;
};
updateNotifications.beginUpdateProgress('Updating card');
await updateNotifications.showNotification(42, '食べる');
assert.deepEqual(
overlayNotifications.map(({ id, variant, persistent }) => ({ id, variant, persistent })),
[
{ id: 'anki-update-progress', variant: 'progress', persistent: true },
{ id: 'anki-update-progress', variant: 'success', persistent: false },
],
);
});
test('AnkiIntegration dismisses persistent overlay update progress when no terminal notification replaces it', () => {
const overlayNotifications: TestOverlayNotificationPayload[] = [];
const dismissedIds: string[] = [];
const integration = new AnkiIntegration(
{
behavior: {
notificationType: 'overlay',
},
},
{} as never,
{} as never,
undefined,
undefined,
undefined,
undefined,
{},
undefined,
(payload) => {
overlayNotifications.push(payload);
},
undefined,
undefined,
undefined,
(id) => {
dismissedIds.push(id);
},
);
const updateNotifications = integration as unknown as {
beginUpdateProgress: (message: string) => void;
endUpdateProgress: () => void;
};
updateNotifications.beginUpdateProgress('Updating card');
updateNotifications.endUpdateProgress();
assert.equal(overlayNotifications[0]?.persistent, true);
assert.deepEqual(dismissedIds, ['anki-update-progress']);
});
test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => { test('AnkiIntegration keeps overlay notification image when temp icon write fails', async () => {
const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = []; const desktopNotifications: Array<{ title: string; body?: string; icon?: string }> = [];
const overlayNotifications: TestOverlayNotificationPayload[] = []; const overlayNotifications: TestOverlayNotificationPayload[] = [];
+14 -2
View File
@@ -218,6 +218,8 @@ export class AnkiIntegration {
null; null;
private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null = private overlayNotificationCallback: ((payload: OverlayNotificationPayload) => void) | null =
null; null;
private overlayNotificationDismissCallback: ((id: string) => void) | null = null;
private overlayUpdateProgressActive = false;
private updateInProgress = false; private updateInProgress = false;
private uiFeedbackState: UiFeedbackState = createUiFeedbackState(); private uiFeedbackState: UiFeedbackState = createUiFeedbackState();
private parseWarningKeys = new Set<string>(); private parseWarningKeys = new Set<string>();
@@ -265,6 +267,7 @@ export class AnkiIntegration {
getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'], getCachedMediaPath?: MediaGenerationInputResolverOptions['getCachedMediaPath'],
shouldRequireRemoteMediaCache?: () => boolean, shouldRequireRemoteMediaCache?: () => boolean,
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined, getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined,
overlayNotificationDismissCallback?: (id: string) => void,
) { ) {
this.config = normalizeAnkiIntegrationConfig(config); this.config = normalizeAnkiIntegrationConfig(config);
this.aiConfig = { ...aiConfig }; this.aiConfig = { ...aiConfig };
@@ -280,6 +283,7 @@ export class AnkiIntegration {
this.getCachedMediaPath = getCachedMediaPath ?? null; this.getCachedMediaPath = getCachedMediaPath ?? null;
this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null; this.shouldRequireRemoteMediaCache = shouldRequireRemoteMediaCache ?? null;
this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null; this.getYoutubeMediaSourceUrl = getYoutubeMediaSourceUrl ?? null;
this.overlayNotificationDismissCallback = overlayNotificationDismissCallback ?? null;
this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue(); this.pendingYoutubeMediaQueue = this.createPendingYoutubeMediaQueue();
this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath); this.knownWordCache = this.createKnownWordCache(knownWordCacheStatePath);
this.pollingRunner = this.createPollingRunner(); this.pollingRunner = this.createPollingRunner();
@@ -1203,12 +1207,13 @@ export class AnkiIntegration {
private beginUpdateProgress(initialMessage: string): void { private beginUpdateProgress(initialMessage: string): void {
if (!this.shouldUseOsdNotifications()) { if (!this.shouldUseOsdNotifications()) {
if (this.shouldUseOverlayNotifications()) { if (this.shouldUseOverlayNotifications()) {
this.overlayUpdateProgressActive = true;
this.overlayNotificationCallback?.({ this.overlayNotificationCallback?.({
id: 'anki-update-progress', id: 'anki-update-progress',
title: 'Anki update', title: 'Anki update',
body: initialMessage, body: initialMessage,
variant: 'progress', variant: 'progress',
persistent: false, persistent: true,
}); });
} }
return; return;
@@ -1220,6 +1225,10 @@ export class AnkiIntegration {
private endUpdateProgress(): void { private endUpdateProgress(): void {
if (!this.shouldUseOsdNotifications()) { if (!this.shouldUseOsdNotifications()) {
if (this.overlayUpdateProgressActive) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationDismissCallback?.('anki-update-progress');
}
return; return;
} }
endUpdateProgress(this.uiFeedbackState, (timer) => { endUpdateProgress(this.uiFeedbackState, (timer) => {
@@ -1243,18 +1252,20 @@ export class AnkiIntegration {
if (!this.shouldUseOsdNotifications()) { if (!this.shouldUseOsdNotifications()) {
this.updateInProgress = true; this.updateInProgress = true;
if (this.shouldUseOverlayNotifications()) { if (this.shouldUseOverlayNotifications()) {
this.overlayUpdateProgressActive = true;
this.overlayNotificationCallback?.({ this.overlayNotificationCallback?.({
id: 'anki-update-progress', id: 'anki-update-progress',
title: 'Anki update', title: 'Anki update',
body: initialMessage, body: initialMessage,
variant: 'progress', variant: 'progress',
persistent: false, persistent: true,
}); });
} }
try { try {
return await action(); return await action();
} finally { } finally {
this.updateInProgress = false; this.updateInProgress = false;
this.endUpdateProgress();
} }
} }
return withUpdateProgress( return withUpdateProgress(
@@ -1353,6 +1364,7 @@ export class AnkiIntegration {
: undefined; : undefined;
if (shouldShowOverlayNotification && this.overlayNotificationCallback) { if (shouldShowOverlayNotification && this.overlayNotificationCallback) {
this.overlayUpdateProgressActive = false;
this.overlayNotificationCallback({ this.overlayNotificationCallback({
id: 'anki-update-progress', id: 'anki-update-progress',
title: 'Anki Card Updated', title: 'Anki Card Updated',
+2
View File
@@ -65,6 +65,7 @@ export interface AnkiJimakuIpcRuntimeOptions {
getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined; getYoutubeMediaSourceUrl?: () => Promise<string | null | undefined> | string | null | undefined;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -166,6 +167,7 @@ export function registerAnkiJimakuIpcRuntime(
options.getCachedMediaPath, options.getCachedMediaPath,
options.shouldRequireRemoteMediaCache, options.shouldRequireRemoteMediaCache,
options.getYoutubeMediaSourceUrl, options.getYoutubeMediaSourceUrl,
options.dismissOverlayNotification,
); );
integration.start(); integration.start();
options.setAnkiIntegration(integration); options.setAnkiIntegration(integration);
@@ -21,6 +21,7 @@ type CreateAnkiIntegrationArgs = {
mpvClient: { send?: (payload: { command: string[] }) => void }; mpvClient: { send?: (payload: { command: string[] }) => void };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -74,6 +75,7 @@ function createDefaultAnkiIntegration(args: CreateAnkiIntegrationArgs): AnkiInte
args.getCachedMediaPath, args.getCachedMediaPath,
args.shouldRequireRemoteMediaCache, args.shouldRequireRemoteMediaCache,
args.getYoutubeMediaSourceUrl, args.getYoutubeMediaSourceUrl,
args.dismissOverlayNotification,
); );
} }
@@ -137,6 +139,7 @@ export function initializeOverlayRuntime(
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -177,6 +180,7 @@ export function initializeOverlayAnkiIntegration(options: {
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -219,6 +223,7 @@ export function initializeOverlayAnkiIntegration(options: {
mpvClient, mpvClient,
showDesktopNotification: options.showDesktopNotification, showDesktopNotification: options.showDesktopNotification,
showOverlayNotification: options.showOverlayNotification, showOverlayNotification: options.showOverlayNotification,
dismissOverlayNotification: options.dismissOverlayNotification,
createFieldGroupingCallback: options.createFieldGroupingCallback, createFieldGroupingCallback: options.createFieldGroupingCallback,
knownWordCacheStatePath: options.getKnownWordCacheStatePath(), knownWordCacheStatePath: options.getKnownWordCacheStatePath(),
...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}), ...(options.getCachedMediaPath ? { getCachedMediaPath: options.getCachedMediaPath } : {}),
+112
View File
@@ -39,6 +39,118 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
); );
}); });
test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => {
// Real-world shape of YouTube's sentence-level auto captions: window-append
// filler rows (a="1", sometimes without d) mark the display timeline, while
// long text rows carry a placeholder d="3000" far shorter than the speech.
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext><body>',
'<p t="98550" d="3010" w="1" a="1">\n</p>',
'<p t="98560" d="3000" w="1"><s ac="0">ありがとうって言えないよね。こんなんじゃ。</s></p>',
'<p t="106950" w="1" a="1">\n</p>',
'<p t="106960" d="3799" w="1"><s ac="0">私だったら無理だよ。</s></p>',
'</body></timedtext>',
].join('\n'),
);
assert.equal(
result,
[
'WEBVTT',
'',
'00:01:38.560 --> 00:01:46.950',
'ありがとうって言えないよね。こんなんじゃ。',
'',
'00:01:46.960 --> 00:01:50.759',
'私だったら無理だよ。',
'',
].join('\n'),
);
});
test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => {
const text =
'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな';
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext format="3">',
'<head>',
'<ws id="1" mh="2" ju="0" sd="3"/>',
'<wp id="1" ap="6" ah="20" av="100" rc="2" cc="40"/>',
'</head>',
'<body>',
'<w t="0" id="1" wp="1" ws="1"/>',
`<p t="60440" d="3000" w="1"><s ac="0">${text}</s></p>`,
'<p t="72695" w="1" a="1">\n</p>',
'</body>',
'</timedtext>',
].join('\n'),
);
const cues = result
.trim()
.split(/\n\n/)
.filter((block) => block.includes('-->'));
const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n'));
assert.equal(cues.length, 2);
assert.deepEqual(
cues.map((cue) => cue.split('\n')[0]),
['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'],
);
assert.ok(cueText.every((page) => [...page].length <= 80));
assert.equal(cueText.join(''), text);
});
test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => {
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext format="3">',
'<head>',
'<ws id="1" mh="0"/>',
'<wp id="1" rc="2" cc="4"/>',
'</head>',
'<body>',
'<w t="0" id="1" wp="1" ws="1"/>',
'<p t="1000" d="3000" w="1">abcdefghijklmnopqrst</p>',
'</body>',
'</timedtext>',
].join('\n'),
);
assert.equal(
result,
['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'),
);
});
test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => {
const result = convertYoutubeTimedTextToVtt(
[
'<timedtext><body>',
'<p t="20305" d="3000" w="1">[音楽]</p>',
'<p t="26269" w="1" a="1">\n</p>',
'<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
'</body></timedtext>',
].join('\n'),
);
assert.equal(
result,
[
'WEBVTT',
'',
'00:00:20.305 --> 00:00:23.305',
'[音楽]',
'',
'00:00:26.279 --> 00:00:29.279',
'じゃあ、君からお願いします。',
'',
].join('\n'),
);
});
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => { test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
const result = normalizeYoutubeAutoVtt( const result = normalizeYoutubeAutoVtt(
[ [
+228 -12
View File
@@ -2,9 +2,31 @@ interface YoutubeTimedTextRow {
startMs: number; startMs: number;
durationMs: number; durationMs: number;
text: string; text: string;
isGenerated: boolean;
rollingWindow: YoutubeRollingWindow | null;
}
interface YoutubeRollingWindow {
rowCount: number;
columnCount: number;
}
interface YoutubeTimedTextWindowDefinitions {
rollingStyleIds: Set<string>;
positions: Map<string, YoutubeRollingWindow>;
windows: Map<string, YoutubeRollingWindow>;
}
interface YoutubeTimedTextDocument {
rows: YoutubeTimedTextRow[];
// Start times of every <p> event, including empty window-append fillers.
// Rolling speech rows with a 3000ms placeholder display until the next event.
eventStartsMs: number[];
hasRollingWindowEvents: boolean;
} }
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']); const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000;
function decodeNumericEntity(match: string, codePoint: number): string { function decodeNumericEntity(match: string, codePoint: number): string {
if ( if (
@@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map<string, string> {
return attrs; return attrs;
} }
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] { function parsePositiveInteger(value: string | undefined): number | null {
if (value === undefined) {
return null;
}
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
}
function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions {
const rollingStyleIds = new Set<string>();
for (const match of xml.matchAll(/<ws\b([^>]*)\/?\s*>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const id = attrs.get('id');
if (id !== undefined && attrs.get('mh') === '2') {
rollingStyleIds.add(id);
}
}
const positions = new Map<string, YoutubeRollingWindow>();
for (const match of xml.matchAll(/<wp\b([^>]*)\/?\s*>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const id = attrs.get('id');
const rowCount = parsePositiveInteger(attrs.get('rc'));
const columnCount = parsePositiveInteger(attrs.get('cc'));
if (id !== undefined && rowCount !== null && columnCount !== null) {
positions.set(id, { rowCount, columnCount });
}
}
const windows = new Map<string, YoutubeRollingWindow>();
for (const match of xml.matchAll(/<w\b([^>]*)\/?\s*>/g)) {
const attrs = parseAttributeMap(match[1] ?? '');
const id = attrs.get('id');
const styleId = attrs.get('ws');
const positionId = attrs.get('wp');
const position = positionId === undefined ? undefined : positions.get(positionId);
if (
id !== undefined &&
styleId !== undefined &&
rollingStyleIds.has(styleId) &&
position !== undefined
) {
windows.set(id, position);
}
}
return { rollingStyleIds, positions, windows };
}
function resolveRollingWindow(
attrs: Map<string, string>,
definitions: YoutubeTimedTextWindowDefinitions,
): YoutubeRollingWindow | null {
const windowId = attrs.get('w');
if (windowId !== undefined) {
return definitions.windows.get(windowId) ?? null;
}
const styleId = attrs.get('ws');
const positionId = attrs.get('wp');
if (
styleId === undefined ||
positionId === undefined ||
!definitions.rollingStyleIds.has(styleId)
) {
return null;
}
return definitions.positions.get(positionId) ?? null;
}
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
const rows: YoutubeTimedTextRow[] = []; const rows: YoutubeTimedTextRow[] = [];
const eventStartsMs: number[] = [];
let hasRollingWindowEvents = false;
const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml);
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) { for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
const attrs = parseAttributeMap(match[1] ?? ''); const attrs = parseAttributeMap(match[1] ?? '');
const startMs = Number(attrs.get('t')); const startMs = Number(attrs.get('t'));
if (!Number.isFinite(startMs)) {
continue;
}
eventStartsMs.push(startMs);
if (attrs.get('a') === '1') {
hasRollingWindowEvents = true;
}
const durationMs = Number(attrs.get('d')); const durationMs = Number(attrs.get('d'));
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) { if (!Number.isFinite(durationMs)) {
continue; continue;
} }
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, ''); const rawInner = match[2] ?? '';
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
const text = decodeHtmlEntities(inner).trim(); const text = decodeHtmlEntities(inner).trim();
if (!text) { if (!text) {
continue; continue;
} }
rows.push({ startMs, durationMs, text }); rows.push({
startMs,
durationMs,
text,
isGenerated: /<s\b/.test(rawInner),
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
});
} }
return rows; eventStartsMs.sort((a, b) => a - b);
return { rows, eventStartsMs, hasRollingWindowEvents };
}
function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined {
for (const startMs of eventStartsMs) {
if (startMs > afterMs) {
return startMs;
}
}
return undefined;
}
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
} }
function formatVttTimestamp(ms: number): string { function formatVttTimestamp(ms: number): string {
@@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string {
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`; return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
} }
const ROLLING_PAGE_BREAK_PATTERN = /[\s!?]/u;
// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so
// the overlay keeps their bounded presentation without changing authored cues.
function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] {
const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount;
const characters = [...text];
if (
!Number.isSafeInteger(pageCapacity) ||
pageCapacity <= 0 ||
characters.length <= pageCapacity
) {
return [text];
}
const pages: string[] = [];
let pageStart = 0;
while (pageStart < characters.length) {
let pageEnd = Math.min(pageStart + pageCapacity, characters.length);
if (pageEnd < characters.length) {
const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6);
for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) {
if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) {
pageEnd = index + 1;
break;
}
}
}
pages.push(characters.slice(pageStart, pageEnd).join(''));
pageStart = pageEnd;
}
return pages;
}
interface TimedCaptionPage {
startMs: number;
endMs: number;
text: string;
}
function timeCaptionPages(input: {
text: string;
pages: string[];
startMs: number;
endMs: number;
}): TimedCaptionPage[] {
const durationMs = input.endMs - input.startMs;
if (input.pages.length === 1 || durationMs < input.pages.length) {
return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }];
}
const totalCharacters = [...input.text].length;
const timedPages: TimedCaptionPage[] = [];
let consumedCharacters = 0;
let pageStartMs = input.startMs;
// Automatic captions often omit span offsets, so distribute the known cue
// duration by page length while guaranteeing every page at least one ms.
for (let index = 0; index < input.pages.length; index += 1) {
const page = input.pages[index]!;
consumedCharacters += [...page].length;
const remainingPages = input.pages.length - index - 1;
const proportionalEndMs =
input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters);
const pageEndMs =
remainingPages === 0
? input.endMs
: Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages);
timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page });
pageStartMs = pageEndMs;
}
return timedPages;
}
export function isYoutubeTimedTextExtension(value: string | undefined): boolean { export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
if (!value) { if (!value) {
return false; return false;
@@ -79,7 +276,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
} }
export function convertYoutubeTimedTextToVtt(xml: string): string { export function convertYoutubeTimedTextToVtt(xml: string): string {
const rows = extractYoutubeTimedTextRows(xml); const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
if (rows.length === 0) { if (rows.length === 0) {
return 'WEBVTT\n'; return 'WEBVTT\n';
} }
@@ -90,10 +287,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
const row = rows[index]!; const row = rows[index]!;
const nextRow = rows[index + 1]; const nextRow = rows[index + 1];
const unclampedEnd = row.startMs + row.durationMs; const unclampedEnd = row.startMs + row.durationMs;
// YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
// Plain-text cues can explicitly use the same duration and must keep it.
const nextEventStart =
isGeneratedRollingCue(row, hasRollingWindowEvents) &&
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
? findNextEventStartMs(eventStartsMs, row.startMs)
: undefined;
const clampedEnd = const clampedEnd =
nextRow && unclampedEnd > nextRow.startMs nextEventStart !== undefined
? Math.max(row.startMs, nextRow.startMs - 1) ? nextEventStart
: unclampedEnd; : nextRow && unclampedEnd > nextRow.startMs
? Math.max(row.startMs, nextRow.startMs - 1)
: unclampedEnd;
if (clampedEnd <= row.startMs) { if (clampedEnd <= row.startMs) {
continue; continue;
} }
@@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
if (!text) { if (!text) {
continue; continue;
} }
blocks.push( const pages = row.rollingWindow
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`, ? splitRollingCaptionIntoPages(text, row.rollingWindow)
); : [text];
for (const page of timeCaptionPages({
text,
pages,
startMs: row.startMs,
endMs: clampedEnd,
})) {
blocks.push(
`${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`,
);
}
} }
return `WEBVTT\n\n${blocks.join('\n\n')}\n`; return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
+4
View File
@@ -5925,6 +5925,8 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
showDesktopNotification, showDesktopNotification,
showOverlayNotification: (payload) => showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload), overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
broadcastRuntimeOptionsChanged: () => broadcastRuntimeOptionsChanged: () =>
overlayVisibilityComposer.broadcastRuntimeOptionsChanged(), overlayVisibilityComposer.broadcastRuntimeOptionsChanged(),
@@ -6415,6 +6417,8 @@ const { initializeOverlayRuntime: initializeOverlayRuntimeHandler } =
showDesktopNotification, showDesktopNotification,
showOverlayNotification: (payload) => showOverlayNotification: (payload) =>
overlayNotificationsRuntime.showOverlayNotification(payload), overlayNotificationsRuntime.showOverlayNotification(payload),
dismissOverlayNotification: (id) =>
overlayNotificationsRuntime.dismissOverlayNotification(id),
createFieldGroupingCallback: () => createFieldGroupingCallback(), createFieldGroupingCallback: () => createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'), getKnownWordCacheStatePath: () => path.join(USER_DATA_PATH, 'known-words-cache.json'),
getCachedMediaPath: (currentVideoPath, kind) => getCachedMediaPath: (currentVideoPath, kind) =>
+2
View File
@@ -132,6 +132,7 @@ export interface AnkiJimakuIpcRuntimeServiceDepsParams {
getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl']; getYoutubeMediaSourceUrl?: AnkiJimakuIpcRuntimeOptions['getYoutubeMediaSourceUrl'];
showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification']; showDesktopNotification: AnkiJimakuIpcRuntimeOptions['showDesktopNotification'];
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback']; createFieldGroupingCallback: AnkiJimakuIpcRuntimeOptions['createFieldGroupingCallback'];
broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged']; broadcastRuntimeOptionsChanged: AnkiJimakuIpcRuntimeOptions['broadcastRuntimeOptionsChanged'];
getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver']; getFieldGroupingResolver: AnkiJimakuIpcRuntimeOptions['getFieldGroupingResolver'];
@@ -334,6 +335,7 @@ export function createAnkiJimakuIpcRuntimeServiceDeps(
: {}), : {}),
showDesktopNotification: params.showDesktopNotification, showDesktopNotification: params.showDesktopNotification,
showOverlayNotification: params.showOverlayNotification, showOverlayNotification: params.showOverlayNotification,
dismissOverlayNotification: params.dismissOverlayNotification,
createFieldGroupingCallback: params.createFieldGroupingCallback, createFieldGroupingCallback: params.createFieldGroupingCallback,
broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged, broadcastRuntimeOptionsChanged: params.broadcastRuntimeOptionsChanged,
getFieldGroupingResolver: params.getFieldGroupingResolver, getFieldGroupingResolver: params.getFieldGroupingResolver,
@@ -26,6 +26,7 @@ type InitializeOverlayRuntimeCore = (options: {
} | null; } | null;
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -33,6 +33,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
getOverlayWindows: () => [], getOverlayWindows: () => [],
getResolvedConfig: () => ({}), getResolvedConfig: () => ({}),
showDesktopNotification: () => calls.push('notify'), showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({ createFieldGroupingCallback: () => async () => ({
keepNoteId: 1, keepNoteId: 1,
deleteNoteId: 2, deleteNoteId: 2,
@@ -57,6 +59,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
deps.refreshCurrentSubtitle?.(); deps.refreshCurrentSubtitle?.();
deps.syncOverlayShortcuts(); deps.syncOverlayShortcuts();
deps.showDesktopNotification('title', {}); deps.showDesktopNotification('title', {});
deps.showOverlayNotification?.({ title: 'title' });
deps.dismissOverlayNotification?.('notification-id');
const tracker = { const tracker = {
close: () => {}, close: () => {},
@@ -73,6 +77,8 @@ test('overlay runtime main deps builder maps runtime state and callbacks', () =>
'refresh-subtitle', 'refresh-subtitle',
'sync-shortcuts', 'sync-shortcuts',
'notify', 'notify',
'show-overlay',
'dismiss-overlay',
]); ]);
assert.equal(appState.windowTracker, tracker); assert.equal(appState.windowTracker, tracker);
assert.deepEqual(appState.ankiIntegration, { id: 'anki' }); assert.deepEqual(appState.ankiIntegration, { id: 'anki' });
@@ -39,6 +39,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig }; getResolvedConfig: () => { ankiConnect?: AnkiConnectConfig };
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback']; createFieldGroupingCallback: OverlayRuntimeOptionsMainDeps['createFieldGroupingCallback'];
getKnownWordCacheStatePath: () => string; getKnownWordCacheStatePath: () => string;
getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath']; getCachedMediaPath?: OverlayRuntimeOptionsMainDeps['getCachedMediaPath'];
@@ -78,6 +79,7 @@ export function createBuildInitializeOverlayRuntimeMainDepsHandler(deps: {
}, },
showDesktopNotification: deps.showDesktopNotification, showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification, showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: () => deps.createFieldGroupingCallback(), createFieldGroupingCallback: () => deps.createFieldGroupingCallback(),
getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(), getKnownWordCacheStatePath: () => deps.getKnownWordCacheStatePath(),
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),
@@ -22,6 +22,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
getRuntimeOptionsManager: () => null, getRuntimeOptionsManager: () => null,
setAnkiIntegration: () => calls.push('set-anki'), setAnkiIntegration: () => calls.push('set-anki'),
showDesktopNotification: () => calls.push('notify'), showDesktopNotification: () => calls.push('notify'),
showOverlayNotification: () => calls.push('show-overlay'),
dismissOverlayNotification: () => calls.push('dismiss-overlay'),
createFieldGroupingCallback: () => async () => ({ createFieldGroupingCallback: () => async () => ({
keepNoteId: 1, keepNoteId: 1,
deleteNoteId: 2, deleteNoteId: 2,
@@ -47,6 +49,8 @@ test('build initialize overlay runtime options maps dependencies', () => {
options.setWindowTracker(null); options.setWindowTracker(null);
options.setAnkiIntegration(null); options.setAnkiIntegration(null);
options.showDesktopNotification('title', {}); options.showDesktopNotification('title', {});
options.showOverlayNotification?.({ title: 'title' });
options.dismissOverlayNotification?.('notification-id');
assert.deepEqual(calls, [ assert.deepEqual(calls, [
'create-main', 'create-main',
@@ -58,5 +62,7 @@ test('build initialize overlay runtime options maps dependencies', () => {
'set-tracker', 'set-tracker',
'set-anki', 'set-anki',
'notify', 'notify',
'show-overlay',
'dismiss-overlay',
]); ]);
}); });
@@ -33,6 +33,7 @@ type OverlayRuntimeOptions = {
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -73,6 +74,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
setAnkiIntegration: (integration: unknown | null) => void; setAnkiIntegration: (integration: unknown | null) => void;
showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void; showDesktopNotification: (title: string, options: { body?: string; icon?: string }) => void;
showOverlayNotification?: (payload: OverlayNotificationPayload) => void; showOverlayNotification?: (payload: OverlayNotificationPayload) => void;
dismissOverlayNotification?: (id: string) => void;
createFieldGroupingCallback: () => ( createFieldGroupingCallback: () => (
data: KikuFieldGroupingRequestData, data: KikuFieldGroupingRequestData,
) => Promise<KikuFieldGroupingChoice>; ) => Promise<KikuFieldGroupingChoice>;
@@ -107,6 +109,7 @@ export function createBuildInitializeOverlayRuntimeOptionsHandler(deps: {
setAnkiIntegration: deps.setAnkiIntegration, setAnkiIntegration: deps.setAnkiIntegration,
showDesktopNotification: deps.showDesktopNotification, showDesktopNotification: deps.showDesktopNotification,
showOverlayNotification: deps.showOverlayNotification, showOverlayNotification: deps.showOverlayNotification,
dismissOverlayNotification: deps.dismissOverlayNotification,
createFieldGroupingCallback: deps.createFieldGroupingCallback, createFieldGroupingCallback: deps.createFieldGroupingCallback,
getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath, getKnownWordCacheStatePath: deps.getKnownWordCacheStatePath,
...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}), ...(deps.getCachedMediaPath ? { getCachedMediaPath: deps.getCachedMediaPath } : {}),