Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ Everything below is opt-in. Notes for existing consumers:
- `StepError` messages are richer (multi-line, with artifact paths). If you parsed them, prefer the new structured `error.artifacts` field.
- The timing manifest gained optional fields (`lang`, `capture`). Old kept work dirs still post-process fine.

## Unreleased

- **More robust calibration-flash detection (#46).** Flash detection now accepts magenta frames across a wider chroma range, fixing intermittent misses when the raw recording is decoded as limited range (16–235) — common in CI and some ffmpeg builds. A missed flash previously fell back to clock-zero (degrading audio sync); detection is now reliable on those setups. Internal-only; no API change.

## 0.11.0 — teaching-first rendering

A pedagogy-focused release: the engine already nailed the mechanics (narration-first pacing, signaling, coherence), so this round makes the *tutorials it produces* teach better, acting on the instructional-designer review. All three additions are additive and opt-in/presence-driven — existing tutorials and adapters render unchanged, and no public API was removed or changed. Update both packages in lockstep.
Expand Down
22 changes: 18 additions & 4 deletions packages/core/src/post/ffmpeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,28 @@ export async function detectFlashOffsetMs(video: string, scanSeconds = 4): Promi
}
}

/**
* Minimum chroma average (on the 0–255 signalstats scale, neutral = 128) that
* BOTH U and V must clear for a frame to count as the magenta flash.
*
* Magenta (#ff00ff) is the only thing that pushes *both* chroma channels far
* above neutral at once — normal UI content lifts at most one (a blue button is
* high-U/low-V, red text low-U/high-V), so requiring both stays magenta-specific.
* A clean full-range flash measures UAVG≈201 / VAVG≈221, but the same frame
* decoded as **limited range** (16–235, which CI's webm pipeline often does)
* scales toward center to ≈177 / ≈195 — straddling the old 180/170 cutoff and
* causing intermittent misses (#46). 150 keeps a wide margin above neutral and
* above any single-channel UI colour while absorbing range/compression drift.
*/
export const FLASH_CHROMA_MIN = 150;

/**
* Parse signalstats metadata=print output. Frames arrive as
* frame:N pts:P pts_time:T
* lavfi.signalstats.UAVG=...
* lavfi.signalstats.VAVG=...
* A full-frame magenta (#ff00ff) flash measures UAVG≈201 / VAVG≈221 in
* Chromium's VP8 webm; we accept anything with both chroma averages far from
* neutral (128) in the magenta direction.
* Returns the time of the first frame whose chroma reads as magenta (see
* {@link FLASH_CHROMA_MIN}), i.e. the calibration flash.
*/
export function parseFlashFromMetadata(out: string): number | null {
let ptsTime: number | null = null;
Expand All @@ -106,7 +120,7 @@ export function parseFlashFromMetadata(out: string): number | null {
const v = /lavfi\.signalstats\.VAVG=([\d.]+)/.exec(line);
if (v) vavg = parseFloat(v[1]!);
if (ptsTime !== null && uavg !== null && vavg !== null) {
if (uavg > 180 && vavg > 170) return Math.round(ptsTime * 1000);
if (uavg > FLASH_CHROMA_MIN && vavg > FLASH_CHROMA_MIN) return Math.round(ptsTime * 1000);
uavg = vavg = null;
}
}
Expand Down
31 changes: 31 additions & 0 deletions packages/core/test/ffmpeg-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,4 +278,35 @@ describe('parseFlashFromMetadata', () => {
].join('\n');
expect(parseFlashFromMetadata(out)).toBeNull();
});

it('detects a limited-range / compression-dimmed magenta frame (#46 regression)', () => {
// Same flash decoded as limited range scales toward center (~177/195) and
// straddled the old 180/170 cutoff — the intermittent CI miss this fixes.
const out = [
'frame:0 pts:0 pts_time:0',
'lavfi.signalstats.UAVG=129.0',
'lavfi.signalstats.VAVG=127.0',
'frame:9 pts:300 pts_time:0.300',
'lavfi.signalstats.UAVG=177.0',
'lavfi.signalstats.VAVG=195.0',
].join('\n');
expect(parseFlashFromMetadata(out)).toBe(300);
});

it('stays magenta-specific: a single elevated chroma channel is not a flash', () => {
// A saturated blue (high U, low V) or red (low U, high V) UI frame must not
// be mistaken for the flash — magenta requires BOTH channels above neutral.
const blueish = [
'frame:3 pts:120 pts_time:0.120',
'lavfi.signalstats.UAVG=205.0',
'lavfi.signalstats.VAVG=120.0',
].join('\n');
const reddish = [
'frame:3 pts:120 pts_time:0.120',
'lavfi.signalstats.UAVG=120.0',
'lavfi.signalstats.VAVG=210.0',
].join('\n');
expect(parseFlashFromMetadata(blueish)).toBeNull();
expect(parseFlashFromMetadata(reddish)).toBeNull();
});
});
2 changes: 2 additions & 0 deletions packages/example-app/test/e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ try {
result.manifest.steps.some((s) => s.callouts.length > 0),
'at least one callout was captured',
);
// Relies on the range-tolerant flash threshold (#46) so this isn't flaky on
// CI's limited-range webm; a detected flash means clock calibration worked.
assert.ok(result.videoClockOffsetMs > 0, 'calibration flash was detected');
assert.equal(
await detectFlashOffsetMs(output, 2),
Expand Down
Loading