fix(overlay): defer autoplay release for untokenized startup priming

- Autoplay-ready gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback doesn't resume before the first tokenized cue
- Wire isTokenizationReady dep through main.ts and update gate/wiring tests accordingly
- Drop stale release/release-notes.md, add changelog entry under changes/
This commit is contained in:
2026-07-12 02:11:10 -07:00
parent 7d81342f0f
commit 14070acceb
6 changed files with 101 additions and 90 deletions
+2
View File
@@ -1275,6 +1275,8 @@ const autoplayReadyGate = createAutoplayReadyGate({
signalPluginAutoplayReady: () => {
sendMpvCommandRuntime(appState.mpvClient, ['script-message', 'subminer-autoplay-ready']);
},
// Deferred: isTokenizationWarmupReady is assigned during composeMpvRuntimeHandlers below.
isTokenizationReady: () => isTokenizationWarmupReady(),
requestOverlayPointerRecovery: () => {
if (process.platform !== 'darwin' || !overlayManager.getVisibleOverlayVisible()) {
return;
+9 -1
View File
@@ -278,7 +278,15 @@ test('startup autoplay release is tied to visible overlay measurement readiness'
assert.ok(gateBlock);
assert.match(gateBlock, /isSignalTargetReady:\s*\(signal\) =>/);
assert.doesNotMatch(gateBlock, /isTokenizationWarmupReady\(\)/);
// Untokenized signals are filtered by the gate's isTokenizationReady dep, not
// by the target-readiness predicate (which must stay warmup-free so warm and
// tokenized releases are never deferred behind the global warmup flag).
assert.match(gateBlock, /isTokenizationReady:\s*\(\) => isTokenizationWarmupReady\(\)/);
const signalTargetReadyBlock = gateBlock.match(
/isSignalTargetReady:\s*\(signal\) =>(?<body>[\s\S]*?)\n schedule:/,
)?.groups?.body;
assert.ok(signalTargetReadyBlock);
assert.doesNotMatch(signalTargetReadyBlock, /isTokenizationWarmupReady\(\)/);
assert.match(gateBlock, /isVisibleOverlayAutoplayTargetReady\(/);
assert.match(gateBlock, /getLatestVisibleMeasurement:/);
@@ -542,3 +542,79 @@ test('autoplay ready gate passes the pending subtitle signal to the readiness pr
[['script-message', 'subminer-autoplay-ready']],
);
});
function createTokenizationGateHarness(deps: { isTokenizationReady: () => boolean }) {
const commands: Array<Array<string | boolean>> = [];
const gate = createAutoplayReadyGate({
isAppOwnedFlowInFlight: () => false,
getCurrentMediaPath: () => '/media/video.mkv',
getCurrentVideoPath: () => null,
getPlaybackPaused: () => true,
getMpvClient: () =>
({
connected: true,
requestProperty: async () => true,
send: ({ command }: { command: Array<string | boolean> }) => {
commands.push(command);
},
}) as never,
signalPluginAutoplayReady: () => {
commands.push(['script-message', 'subminer-autoplay-ready']);
},
isTokenizationReady: deps.isTokenizationReady,
schedule: (callback) => {
queueMicrotask(callback);
return 1 as never;
},
logDebug: () => {},
});
return { gate, commands };
}
test('autoplay ready gate ignores untokenized signals while tokenization warmup is pending', async () => {
const { gate, commands } = createTokenizationGateHarness({
isTokenizationReady: () => false,
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
});
test('autoplay ready gate releases tokenized signals while tokenization warmup is pending', async () => {
const { gate, commands } = createTokenizationGateHarness({
isTokenizationReady: () => false,
});
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕', tokens: [{ surface: '字幕' }] as never },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
});
test('autoplay ready gate releases untokenized signals once tokenization warmup is ready', async () => {
let tokenizationReady = false;
const { gate, commands } = createTokenizationGateHarness({
isTokenizationReady: () => tokenizationReady,
});
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands, []);
tokenizationReady = true;
gate.maybeSignalPluginAutoplayReady({ text: '字幕', tokens: null }, { forceWhilePaused: true });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
});
+10
View File
@@ -26,6 +26,7 @@ export type AutoplayReadyGateDeps = {
getPlaybackPaused: () => boolean | null;
getMpvClient: () => MpvClientLike | null;
signalPluginAutoplayReady: () => void;
isTokenizationReady?: () => boolean;
requestOverlayPointerRecovery?: () => void;
onAutoplayReadyReleased?: (signal: AutoplayReadySignal) => void;
isSignalTargetReady?: (signal: AutoplayReadySignal) => boolean;
@@ -217,6 +218,15 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
if (!payload.text.trim()) {
return;
}
// Untokenized payloads (startup subtitle priming, tokenizer fallbacks) must
// not release the startup pause gate while tokenization warmup is pending —
// the tokenized delivery or the post-warmup release signals readiness later.
if (payload.tokens === null && deps.isTokenizationReady && !deps.isTokenizationReady()) {
deps.logDebug(
'[autoplay-ready] ignored untokenized signal while tokenization warmup is pending',
);
return;
}
maybeReleaseAutoplayReadySignal({
mediaPath: getSignalMediaPath(),