revert(01): reopen Phase 1 — D-13 multi-EBML-concat is unplayable
REQ-video-ring-buffer flipped from [x] back to [ ]. ROADMAP.md Phase 1 row reverted from [x] Closed 2026-05-15 to [ ] reopened 2026-05-16. STATE.md status flipped phase_complete → phase_reopened with full historical narrative preserved. Root cause (confirmed at byte level by gsd-debugger 2026-05-16): D-13's concat-of-self-contained-WebM-segments architecture produces a 3-EBML-header WebM that standards-compliant Matroska parsers (mpv, ffmpeg, Chrome HTMLMediaElement) play only as the first segment (~9.94 s) and silently drop the remaining 2 segments. Confirmed via operator mpv drag-drop test of BOTH the canonical 2026-05-15 closure fixture and the 2026-05-16 UAT-produced fixture — both exhibit the same broken playback. The 2026-05-15 "operator-confirmed clean Chrome playback" assessment was insufficient: it verified the file plays without freezing but did not measure total duration. Phase 1's primary deliverable (REQ-video-ring-buffer / SPEC §10 #7) is therefore NOT satisfied. Fix path chosen by user: ts-ebml (parse) + webm-muxer (write) to replace mergeVideoSegments file-concat with real single-EBML remux. Will land as Plan 01-08 via fresh /gsd-plan-phase ceremony. RED test landed in tests/offscreen/webm-playback.test.ts (2 new assertions on container-format-duration + ffmpeg-full-decode-duration). 2 failures, 53 baseline tests still GREEN. Option C port-lifecycle refactor (debug session empty-archive-port-race, commits 674c415..f0871c0) DID land cleanly and is retained — that fix was orthogonal and correctly resolved the silent-empty-archive symptom that previously masked this deeper bug. Debug session: .planning/debug/d13-multi-ebml-concat-unplayable.md Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,27 @@
|
||||
// Skip discipline: if ffmpeg is missing from the environment the test
|
||||
// auto-skips rather than failing. CI ships ffmpeg per `smoke.sh` so this is
|
||||
// a developer-convenience fence, not a behavioural softening.
|
||||
//
|
||||
// --- 2026-05-16 amendment: D-13 architecture failure RED tests ---
|
||||
//
|
||||
// Debug session `.planning/debug/d13-multi-ebml-concat-unplayable.md` proved
|
||||
// the existing two assertions ABOVE pass under D-13 only because they check
|
||||
// structural validity (ffmpeg null-decode tolerates the multi-EBML-header
|
||||
// concat by silently reading segments 1+2 and dropping segment 3, and by
|
||||
// collapsing all segments onto seg1's local timestamp axis so no muxer
|
||||
// "File ended prematurely" warning fires). Players that respect Matroska's
|
||||
// segment-info Duration element (mpv, Chrome's HTMLMediaElement, ffprobe's
|
||||
// `format=duration`) read 9.94 s — the FIRST segment's metadata duration —
|
||||
// and stop. The committed 1.6 MB fixture contains ~30 s of valid VP9 frames
|
||||
// but presents as ~10 s of content to operators and tests.
|
||||
//
|
||||
// The "container-level playable duration" describe block below adds the
|
||||
// assertion the closure check missed on 2026-05-15: that ffprobe-reported
|
||||
// format duration EXCEEDS 25_000 ms for the canonical fixture. This is
|
||||
// RED today under D-13 and stays RED until the multi-EBML concat at
|
||||
// src/background/index.ts mergeVideoSegments() is replaced with a true
|
||||
// remux that writes a single EBML header whose Info.Duration covers the
|
||||
// whole ~30 s span.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
@@ -43,12 +64,21 @@ import { dirname, resolve } from 'node:path';
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURE_PATH = resolve(here, '..', 'fixtures', 'last_30sec.webm');
|
||||
const FFMPEG_BIN = '/usr/bin/ffmpeg';
|
||||
const FFPROBE_BIN = '/usr/bin/ffprobe';
|
||||
|
||||
// Cap: a clean 30-second WebM decoded with `-f null` finishes well under
|
||||
// 10 s on commodity hardware. If we ever exceed this we want a hard failure,
|
||||
// not a hung CI job.
|
||||
const FFMPEG_TIMEOUT_MS = 30_000;
|
||||
|
||||
// Playable-duration floor. The recorder rotates every 10 s and keeps 3
|
||||
// segments (D-13 / SEGMENT_DURATION_MS × MAX_SEGMENTS = 30_000 ms). The
|
||||
// rotation lifecycle can drop a partial sub-second at each boundary so the
|
||||
// final remux file is bounded by [~27_000, ~30_000] ms in steady state. We
|
||||
// gate at 25_000 ms to keep slack for boundary noise but still firmly above
|
||||
// the broken-architecture failure mode (9_940 ms — first segment only).
|
||||
const MIN_PLAYABLE_DURATION_MS = 25_000;
|
||||
|
||||
function ffmpegAvailable(): boolean {
|
||||
try {
|
||||
return existsSync(FFMPEG_BIN) && statSync(FFMPEG_BIN).isFile();
|
||||
@@ -57,6 +87,14 @@ function ffmpegAvailable(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function ffprobeAvailable(): boolean {
|
||||
try {
|
||||
return existsSync(FFPROBE_BIN) && statSync(FFPROBE_BIN).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface DecodeResult {
|
||||
stderr: string;
|
||||
packetErrorCount: number;
|
||||
@@ -113,6 +151,44 @@ function decodeDryRunStrict(fixturePath: string): DecodeResult {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the container-level `format=duration` value from a WebM file via
|
||||
* ffprobe. This is the value that mpv, Chrome's HTMLMediaElement, and most
|
||||
* Matroska parsers honor when deciding "how long is this file?" — they pick
|
||||
* up the first Segment's Info.Duration EBML element and stop seeking past
|
||||
* the EBML header's reported length.
|
||||
*
|
||||
* Returns NaN on parse failure (ffprobe missing input track, malformed
|
||||
* float, etc.) so the assertion downstream can produce a precise error
|
||||
* message rather than masking a probe-side failure as a duration check.
|
||||
*
|
||||
* @param fixturePath - Absolute path to the WebM file under test.
|
||||
* @returns Container-level duration in milliseconds.
|
||||
*/
|
||||
function probeContainerDurationMs(fixturePath: string): number {
|
||||
const proc = spawnSync(
|
||||
FFPROBE_BIN,
|
||||
[
|
||||
'-v', 'error',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'default=noprint_wrappers=1:nokey=1',
|
||||
'-i', fixturePath,
|
||||
],
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
timeout: FFMPEG_TIMEOUT_MS,
|
||||
maxBuffer: 1 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (proc.signal !== null) {
|
||||
throw new Error(`ffprobe was killed by signal ${proc.signal}`);
|
||||
}
|
||||
const stdout = (proc.stdout ?? '').trim();
|
||||
const seconds = parseFloat(stdout);
|
||||
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : Number.NaN;
|
||||
}
|
||||
|
||||
describe('webm playback (RED — confirms webm-playback-freeze bug)', () => {
|
||||
it.skipIf(!ffmpegAvailable())(
|
||||
'ffmpeg dry-run on last_30sec.webm produces zero decoder packet errors',
|
||||
@@ -151,3 +227,90 @@ describe('webm playback (RED — confirms webm-playback-freeze bug)', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('webm playable duration (RED — confirms d13-multi-ebml-concat-unplayable bug)', () => {
|
||||
it.skipIf(!ffprobeAvailable())(
|
||||
'container-level format=duration on last_30sec.webm exceeds 25 s',
|
||||
() => {
|
||||
// SPEC §10 #7 requires last_30sec.webm to "play back in a browser"
|
||||
// covering the most recent ~30 s. Both mpv and Chrome's HTMLMediaElement
|
||||
// honor the first Segment's Info.Duration EBML element — which under
|
||||
// D-13's multi-EBML concat is hardcoded to the FIRST segment's local
|
||||
// duration (~9.94 s for the canonical fixture). That bug means the
|
||||
// canonical Phase 1 closure fixture (committed 2026-05-15) presents
|
||||
// as ~10 s of content to any standards-compliant Matroska parser,
|
||||
// even though segments 2+3 are physically present in the bytes.
|
||||
//
|
||||
// The fix is a true WebM REMUX of the concatenated segments: parse
|
||||
// each segment's clusters via an EBML library, extract the VP9
|
||||
// frame payloads with their keyframe/delta flags, and re-mux into
|
||||
// a single-EBML-header WebM whose clusters carry monotonically
|
||||
// increasing timestamps. The resulting file's Info.Duration will
|
||||
// span the full ~30 s window.
|
||||
//
|
||||
// Floor of MIN_PLAYABLE_DURATION_MS (25_000) accommodates the
|
||||
// ~3 s boundary slack from segment rotation while remaining well
|
||||
// above the broken-architecture failure mode (9_940 ms).
|
||||
expect(existsSync(FIXTURE_PATH)).toBe(true);
|
||||
const durationMs = probeContainerDurationMs(FIXTURE_PATH);
|
||||
expect(
|
||||
durationMs,
|
||||
`ffprobe reported container duration=${durationMs} ms for ${FIXTURE_PATH}. ` +
|
||||
`Under SPEC §10 #7 the file must present at least ${MIN_PLAYABLE_DURATION_MS} ms ` +
|
||||
`of playable content to standards-compliant Matroska parsers (mpv, Chrome). ` +
|
||||
`If this value is ~9_940 ms the file is a multi-EBML-header concat (D-13 raw output) ` +
|
||||
`where players honor only the first segment's local Info.Duration metadata. ` +
|
||||
`Fix: replace mergeVideoSegments() in src/background/index.ts with a true WebM remux ` +
|
||||
`(parse + rewrite into a single-EBML-headered WebM with adjusted monotonic timestamps).`,
|
||||
).toBeGreaterThanOrEqual(MIN_PLAYABLE_DURATION_MS);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(!ffmpegAvailable())(
|
||||
'ffmpeg full decode of last_30sec.webm reaches at least 25 s of timeline',
|
||||
() => {
|
||||
// Defense-in-depth: even if a future ffprobe quirk computes
|
||||
// format=duration by summing all reachable cluster timestamps,
|
||||
// ffmpeg's full null-decode of the concatenated file collapses
|
||||
// segments 2..N onto the first segment's local timestamp axis
|
||||
// (verified empirically 2026-05-16: 601 frames decoded, time=09.96)
|
||||
// because the multi-EBML format provides no segment-level offset.
|
||||
// The remux fix will produce a stream whose decoded `time=...`
|
||||
// reaches at least 25 s end-to-end.
|
||||
expect(existsSync(FIXTURE_PATH)).toBe(true);
|
||||
const proc = spawnSync(
|
||||
FFMPEG_BIN,
|
||||
['-nostdin', '-v', 'error', '-stats', '-i', FIXTURE_PATH, '-f', 'null', '-'],
|
||||
{
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
encoding: 'utf-8',
|
||||
timeout: FFMPEG_TIMEOUT_MS,
|
||||
maxBuffer: 4 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
if (proc.signal !== null) {
|
||||
throw new Error(`ffmpeg was killed by signal ${proc.signal}`);
|
||||
}
|
||||
const stderr = proc.stderr ?? '';
|
||||
// ffmpeg's `-stats` line on the final frame looks like:
|
||||
// frame= 601 fps=0.0 q=-0.0 Lsize=N/A time=00:00:09.96 bitrate=N/A ...
|
||||
// We want the LAST time= match (subsequent stats lines overwrite the
|
||||
// earlier ones with monotonically increasing time values).
|
||||
const timeMatches = [...stderr.matchAll(/time=(\d{2}):(\d{2}):(\d{2})\.(\d{2})/g)];
|
||||
const last = timeMatches[timeMatches.length - 1];
|
||||
const decodedMs = last
|
||||
? (parseInt(last[1], 10) * 3600 + parseInt(last[2], 10) * 60 + parseInt(last[3], 10)) * 1000 +
|
||||
parseInt(last[4], 10) * 10
|
||||
: Number.NaN;
|
||||
expect(
|
||||
decodedMs,
|
||||
`ffmpeg decoded only ${decodedMs} ms of timeline from ${FIXTURE_PATH}. ` +
|
||||
`SPEC §10 #7 requires at least ${MIN_PLAYABLE_DURATION_MS} ms of decoded content. ` +
|
||||
`If decoded duration is ~9_960 ms the multi-EBML concat is collapsing all segments ` +
|
||||
`onto seg1's local timestamp axis (the timestamp-collision symptom). ` +
|
||||
`Fix: real WebM remux per d13-multi-ebml-concat-unplayable debug session. ` +
|
||||
`Full ffmpeg stderr:\n${stderr}`,
|
||||
).toBeGreaterThanOrEqual(MIN_PLAYABLE_DURATION_MS);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user