Skip to content

fix(timeline_view): clamp the frame range to the last decodable frame - #156

Open
AIWerk wants to merge 1 commit into
browser-use:mainfrom
AIWerk:fix/timeline-view-end-clamp
Open

AIWerk wants to merge 1 commit into
browser-use:mainfrom
AIWerk:fix/timeline-view-end-clamp

Conversation

@AIWerk

@AIWerk AIWerk commented Sep 7, 2026

Copy link
Copy Markdown

What breaks

timeline_view.py crashes whenever the requested range reaches the end of the clip:

FileNotFoundError: [Errno 2] No such file or directory: '/tmp/tmpr95yn3ou/f_009.jpg'

extract_frames spaces N frames over [start, end] inclusive, so the last frame is requested at exactly end. Seeking at or past the final frame makes ffmpeg exit 0 and write nothing, so check=True cannot catch it, and the failure only surfaces later when PIL opens the missing JPEG.

Reproduction

Three synthetic clips, different duration, fps and resolution:

clip 0 <duration> just below
4.0s / 25fps / 640x360 crash at f_009 renders
7.3s / 30fps / 1280x720 crash at f_009 renders
12.5s / 24fps / 854x480 crash at f_009 renders
ffmpeg -f lavfi -i testsrc=duration=4:size=640x360:rate=25 \
       -f lavfi -i sine=frequency=440:duration=4 \
       -c:v libx264 -c:a aac -shortest a.mp4
python helpers/timeline_view.py a.mp4 0 4 -o out.png   # FileNotFoundError
python helpers/timeline_view.py a.mp4 0 3.9 -o out.png # fine

An end past the duration is worse: a.mp4 0 6 loses several frames and fails earlier, at f_006. On the 4.0s/25fps clip it already fails from 3.999, so the broken window is everything after the last frame's PTS, not just the exact duration.

Seek behaviour on its own, which is why check=True never fires:

-ss 3.960 -> exit 0, 8419 byte file
-ss 3.999 -> exit 0, no file
-ss 4.000 -> exit 0, no file

Why it matters

Both paths come out of SKILL.md's own workflow:

  • Step 1 ffprobes every source and then samples a timeline_view, so the duration is the obvious value to pass as end.
  • Step 7 runs timeline_view on the rendered output at every cut boundary with a +/-1.5s window, which reaches past the end at the final cut. On a 12.5s render with the last cut at 12.0s, 10.5 13.5 crashes.

Both reproduced.

The fix

  • probe_duration() reads the container duration, matching the existing ffprobe style in render.py, and both ends are clamped to just inside it.
  • Every extracted frame is checked for existence instead of trusting ffmpeg's exit code, so a source ffprobe cannot read raises a clear RuntimeError naming the timestamp rather than a missing-file traceback.

Behaviour inside the clip is unchanged; only ranges that ran off the end are affected.

Tests

tests/test_timeline_view_range.py, following the mocked-subprocess style of the existing tests. Covers clamping at the duration, past the duration, the single-frame path, an untouched interior range, and the missing-frame error. The suite errors out against the unfixed code, so the tests can actually fail. Full suite: 23 passing.

🤖 Generated with Claude Code

https://claude.ai/code/session_015GmL9znFuS9JLUWxA5dn1C


Summary by cubic

Fixes timeline_view.py crashing with FileNotFoundError whenever a frame range reaches or passes the end of a clip. ffmpeg exits 0 without writing a file when seeking at or past the last frame, so the old reliance on check=True let the failure surface later as a missing JPEG.

  • Clamps both range ends to 0.05s before the container duration; interior ranges are untouched.
  • Verifies each extracted frame exists and raises a RuntimeError naming the timestamp when a source cannot be probed.
  • Adds tests/test_timeline_view_range.py covering clamping at and past the duration, the single-frame path, interior ranges, and the missing-frame error; the suite fails without the fix.

Written for commit d0c1d23. Summary will update on new commits.

Review in cubic

`extract_frames` spaces N frames over [start, end] inclusive, so the last
frame is requested at exactly `end`. Seeking at or past the final frame
makes ffmpeg exit 0 without writing a file, so `check=True` cannot catch
it and the caller dies later on a missing JPEG:

    FileNotFoundError: [Errno 2] No such file or directory: '.../f_009.jpg'

Reproduced on three clips (4.0s/25fps/640x360, 7.3s/30fps/1280x720,
12.5s/24fps/854x480): each one crashes on `timeline_view.py clip 0
<duration>` and renders fine just below it. An `end` past the duration
loses several frames and fails earlier still. On the 4.0s/25fps clip it
already fails from 3.999, i.e. anywhere after the last frame's PTS.

Both of these come straight out of the documented workflow in SKILL.md:
step 1 ffprobes every source and then samples a timeline_view, so the
duration is the obvious value to pass as `end`; and step 7 runs
timeline_view on the rendered output at every cut boundary with a
+/-1.5s window, which reaches past the end at the final cut.

Probe the duration once and clamp both ends to just inside it, and check
that each extracted frame actually exists rather than trusting ffmpeg's
exit code, so a source ffprobe cannot read fails with a clear message
instead of a missing-file traceback.

Tests cover both halves and fail without the fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015GmL9znFuS9JLUWxA5dn1C

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="helpers/timeline_view.py">

<violation number="1" location="helpers/timeline_view.py:47">
P3: If the ffprobe binary is missing, subprocess.run raises FileNotFoundError, which `probe_duration` does not catch, so it raises instead of returning None as its docstring promises. Catch OSError (covers FileNotFoundError) alongside CalledProcessError/ValueError, matching the pattern in helpers/grade.py.</violation>

<violation number="2" location="helpers/timeline_view.py:63">
P1: When the video stream ends more than 50 ms before the container duration, this clamp still seeks past the last decodable frame and raises the same missing-frame error. Derive the bound from video-frame timestamps or retry at an earlier timestamp when extraction produces no file.</violation>

<violation number="3" location="helpers/timeline_view.py:65">
P2: When a requested range runs past the clip, `extract_frames` clamps only its local copies, so the composite still labels and renders the waveform for the unclamped range. Propagate the effective clamped range to `render_timeline` and use it for all timeline annotations and audio extraction.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Fix all with cubic | Re-trigger cubic

Comment thread helpers/timeline_view.py
last_frame_epsilon = 0.05
duration = probe_duration(video)
if duration is not None:
latest = max(0.0, duration - last_frame_epsilon)

@cubic-dev-ai cubic-dev-ai Bot Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When the video stream ends more than 50 ms before the container duration, this clamp still seeks past the last decodable frame and raises the same missing-frame error. Derive the bound from video-frame timestamps or retry at an earlier timestamp when extraction produces no file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/timeline_view.py, line 63:

<comment>When the video stream ends more than 50 ms before the container duration, this clamp still seeks past the last decodable frame and raises the same missing-frame error. Derive the bound from video-frame timestamps or retry at an earlier timestamp when extraction produces no file.</comment>

<file context>
@@ -34,11 +34,38 @@
+    last_frame_epsilon = 0.05
+    duration = probe_duration(video)
+    if duration is not None:
+        latest = max(0.0, duration - last_frame_epsilon)
+        start = min(start, latest)
+        end = min(end, latest)
</file context>
Fix with cubic

Comment thread helpers/timeline_view.py
if duration is not None:
latest = max(0.0, duration - last_frame_epsilon)
start = min(start, latest)
end = min(end, latest)

@cubic-dev-ai cubic-dev-ai Bot Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a requested range runs past the clip, extract_frames clamps only its local copies, so the composite still labels and renders the waveform for the unclamped range. Propagate the effective clamped range to render_timeline and use it for all timeline annotations and audio extraction.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/timeline_view.py, line 65:

<comment>When a requested range runs past the clip, `extract_frames` clamps only its local copies, so the composite still labels and renders the waveform for the unclamped range. Propagate the effective clamped range to `render_timeline` and use it for all timeline annotations and audio extraction.</comment>

<file context>
@@ -34,11 +34,38 @@
+    if duration is not None:
+        latest = max(0.0, duration - last_frame_epsilon)
+        start = min(start, latest)
+        end = min(end, latest)
+        if end < start:
+            start, end = end, end
</file context>
Fix with cubic

Comment thread helpers/timeline_view.py
capture_output=True, text=True, check=True,
)
return float(out.stdout.strip())
except (subprocess.CalledProcessError, ValueError):

@cubic-dev-ai cubic-dev-ai Bot Sep 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: If the ffprobe binary is missing, subprocess.run raises FileNotFoundError, which probe_duration does not catch, so it raises instead of returning None as its docstring promises. Catch OSError (covers FileNotFoundError) alongside CalledProcessError/ValueError, matching the pattern in helpers/grade.py.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At helpers/timeline_view.py, line 47:

<comment>If the ffprobe binary is missing, subprocess.run raises FileNotFoundError, which `probe_duration` does not catch, so it raises instead of returning None as its docstring promises. Catch OSError (covers FileNotFoundError) alongside CalledProcessError/ValueError, matching the pattern in helpers/grade.py.</comment>

<file context>
@@ -34,11 +34,38 @@
+            capture_output=True, text=True, check=True,
+        )
+        return float(out.stdout.strip())
+    except (subprocess.CalledProcessError, ValueError):
+        return None
+
</file context>
Suggested change
except (subprocess.CalledProcessError, ValueError):
except (subprocess.CalledProcessError, ValueError, OSError):
Fix with cubic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant