Conversation
`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
There was a problem hiding this comment.
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
| last_frame_epsilon = 0.05 | ||
| duration = probe_duration(video) | ||
| if duration is not None: | ||
| latest = max(0.0, duration - last_frame_epsilon) |
There was a problem hiding this comment.
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>
| if duration is not None: | ||
| latest = max(0.0, duration - last_frame_epsilon) | ||
| start = min(start, latest) | ||
| end = min(end, latest) |
There was a problem hiding this comment.
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>
| capture_output=True, text=True, check=True, | ||
| ) | ||
| return float(out.stdout.strip()) | ||
| except (subprocess.CalledProcessError, ValueError): |
There was a problem hiding this comment.
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>
| except (subprocess.CalledProcessError, ValueError): | |
| except (subprocess.CalledProcessError, ValueError, OSError): |
What breaks
timeline_view.pycrashes whenever the requested range reaches the end of the clip:extract_framesspaces N frames over[start, end]inclusive, so the last frame is requested at exactlyend. Seeking at or past the final frame makes ffmpeg exit 0 and write nothing, socheck=Truecannot catch it, and the failure only surfaces later when PIL opens the missing JPEG.Reproduction
Three synthetic clips, different duration, fps and resolution:
0 <duration>f_009f_009f_009ffmpeg -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 # fineAn
endpast the duration is worse:a.mp4 0 6loses several frames and fails earlier, atf_006. On the 4.0s/25fps clip it already fails from3.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=Truenever fires:Why it matters
Both paths come out of SKILL.md's own workflow:
timeline_view, so the duration is the obvious value to pass asend.timeline_viewon 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.5crashes.Both reproduced.
The fix
probe_duration()reads the container duration, matching the existing ffprobe style inrender.py, and both ends are clamped to just inside it.RuntimeErrornaming 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.pycrashing withFileNotFoundErrorwhenever 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 oncheck=Truelet the failure surface later as a missing JPEG.RuntimeErrornaming the timestamp when a source cannot be probed.tests/test_timeline_view_range.pycovering 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.