Skip to content

Commit 89b9a10

Browse files
committed
Fix recorder chapter anchor edge cases
1 parent 4dc0245 commit 89b9a10

3 files changed

Lines changed: 63 additions & 17 deletions

File tree

server/lib/recorder/ffmpeg.go

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,12 @@ type FFmpegRecorder struct {
7171
// chapter offsets to the media timeline; zero means never detected and
7272
// callers fall back to startTime (stamped before the process spawned).
7373
captureAnchor time.Time
74-
ffmpegErr error
75-
exitCode int
76-
exited chan struct{}
77-
deleted bool
78-
markers []Marker
79-
stz *scaletozero.Oncer
74+
ffmpegErr error
75+
exitCode int
76+
exited chan struct{}
77+
deleted bool
78+
markers []Marker
79+
stz *scaletozero.Oncer
8080

8181
// flight coordinates concurrent operations using different keys:
8282
// - "stop": prevents multiple SIGINTs from being sent to ffmpeg
@@ -520,12 +520,16 @@ func (fr *FFmpegRecorder) Mark(name string) (string, int64, error) {
520520
}
521521

522522
// watchCaptureStart stamps captureAnchor with the moment ffmpeg writes its
523-
// first output bytes. The muxer only writes them once the capture pipeline is
524-
// fully initialized (input opened, encoder ready, first frame in flight), so
525-
// this tracks media t=0 far more closely than startTime, which is stamped
526-
// before the process is even spawned. If no bytes ever appear the anchor
527-
// stays zero and callers fall back to startTime.
523+
// first output bytes for the current Start() session. The muxer only writes
524+
// them once the capture pipeline is fully initialized (input opened, encoder
525+
// ready, first frame in flight), so this tracks media t=0 far more closely
526+
// than startTime, which is stamped before the process is even spawned. If no
527+
// bytes ever appear the anchor stays zero and callers fall back to startTime.
528528
func (fr *FFmpegRecorder) watchCaptureStart() {
529+
fr.mu.Lock()
530+
sessionStart := fr.startTime
531+
fr.mu.Unlock()
532+
529533
ticker := time.NewTicker(10 * time.Millisecond)
530534
defer ticker.Stop()
531535
for {
@@ -534,6 +538,12 @@ func (fr *FFmpegRecorder) watchCaptureStart() {
534538
return
535539
case <-ticker.C:
536540
if info, err := os.Stat(fr.outputPath); err == nil && info.Size() > 0 {
541+
// Ignore stale bytes from a previous recording session that used the
542+
// same output path. We only anchor once this file has been updated
543+
// after the current Start() timestamp.
544+
if !sessionStart.IsZero() && info.ModTime().Before(sessionStart) {
545+
continue
546+
}
537547
now := time.Now()
538548
fr.mu.Lock()
539549
fr.captureAnchor = now

server/lib/recorder/markers.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,9 @@ const sentinelChapterName = "_recording_start"
2323

2424
// buildChapterMetadata writes an ffmetadata file describing one MP4 chapter per
2525
// marker and returns its path. Each marker's chapter starts at its offset from
26-
// startTime; markers before startTime or at/after durationMs are dropped.
27-
// durationMs is the recording length and becomes the END of the final chapter.
26+
// startTime; markers before startTime are clamped to 0 and markers at/after
27+
// durationMs are dropped. durationMs is the recording length and becomes the
28+
// END of the final chapter.
2829
// Chapter ENDs are clamped to never precede their START, since ffmpeg rejects
2930
// an END-before-START chapter outright and would fail the whole remux.
3031
//
@@ -40,9 +41,12 @@ func buildChapterMetadata(outputPath string, markers []Marker, startTime time.Ti
4041
chapters := make([]chapter, 0, len(markers))
4142
for _, m := range markers {
4243
startMs := m.At.Sub(startTime).Milliseconds()
43-
if startMs < 0 || startMs >= durationMs {
44+
if startMs >= durationMs {
4445
continue
4546
}
47+
if startMs < 0 {
48+
startMs = 0
49+
}
4650
chapters = append(chapters, chapter{startMs: startMs, title: m.Name})
4751
}
4852
if len(chapters) == 0 {

server/lib/recorder/markers_test.go

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ func TestBuildChapterMetadata_SentinelAndOrdering(t *testing.T) {
4747
assert.Equal(t, expected, meta)
4848
}
4949

50-
func TestBuildChapterMetadata_DropsNegativeOffsets(t *testing.T) {
50+
func TestBuildChapterMetadata_ClampsNegativeOffsetsToZero(t *testing.T) {
5151
start := time.Unix(1000, 0)
5252
out := filepath.Join(t.TempDir(), "rec.mp4")
5353
markers := []Marker{
@@ -60,9 +60,9 @@ func TestBuildChapterMetadata_DropsNegativeOffsets(t *testing.T) {
6060
require.True(t, ok)
6161

6262
meta := readMeta(t, path)
63-
assert.NotContains(t, meta, "before-start")
63+
assert.Contains(t, meta, "START=0\nEND=4000\ntitle=before-start")
6464
assert.Contains(t, meta, "title=kept")
65-
// Sentinel + one kept marker = two chapters.
65+
// Two usable chapters, no sentinel needed because the first starts at 0.
6666
assert.Equal(t, 2, strings.Count(meta, "[CHAPTER]"))
6767
}
6868

@@ -329,6 +329,38 @@ func TestWatchCaptureStart_AnchorsToFirstOutputBytes(t *testing.T) {
329329
rec.mu.Unlock()
330330
}
331331

332+
func TestWatchCaptureStart_IgnoresPreexistingOutputUntilCurrentSessionWrite(t *testing.T) {
333+
outputPath := filepath.Join(t.TempDir(), "anchor-stale.mp4")
334+
require.NoError(t, os.WriteFile(outputPath, []byte("stale"), 0o644))
335+
time.Sleep(20 * time.Millisecond) // ensure stale file mtime predates startTime
336+
337+
rec := &FFmpegRecorder{
338+
id: "anchor-stale",
339+
outputPath: outputPath,
340+
startTime: time.Now(),
341+
exited: make(chan struct{}),
342+
}
343+
defer close(rec.exited)
344+
go rec.watchCaptureStart()
345+
346+
time.Sleep(50 * time.Millisecond)
347+
rec.mu.Lock()
348+
assert.True(t, rec.captureAnchor.IsZero(), "stale output must not anchor this session")
349+
rec.mu.Unlock()
350+
351+
beforeRewrite := time.Now()
352+
require.NoError(t, os.WriteFile(outputPath, []byte("fresh"), 0o644))
353+
require.Eventually(t, func() bool {
354+
rec.mu.Lock()
355+
defer rec.mu.Unlock()
356+
return !rec.captureAnchor.IsZero()
357+
}, 2*time.Second, 5*time.Millisecond)
358+
359+
rec.mu.Lock()
360+
assert.False(t, rec.captureAnchor.Before(beforeRewrite), "anchor stamped at/after fresh session write")
361+
rec.mu.Unlock()
362+
}
363+
332364
func TestWatchCaptureStart_StopsOnExitWithoutOutput(t *testing.T) {
333365
rec := &FFmpegRecorder{
334366
id: "anchor-exit",

0 commit comments

Comments
 (0)