affaan-m/ECC · error · ValueError
fps must be positive, got
Error message
fps must be positive, got {fps!r} What it means
timeline.py raises ValueError('fps must be positive, got {fps!r}') in fps_fraction when the supplied frame rate is zero or negative after conversion to float. The function normalizes any fps into an exact Fraction (with NTSC 29.97/23.976-style snap), so a non-positive value is meaningless.
Solutions
- Pass a positive fps value (e.g. 24, 30, 29.97).
- Validate the fps field before building the timeline: `if not fps or fps <= 0: raise/skip`.
- Fix the source config/data where fps was 0 or negative.
- Coerce numeric strings ('30') to float before calling; non-numeric will fail earlier in float() conversion.
Example fix
// before
frames_to_timecode(90, fps=0)
// after
assert fps and fps > 0, f'fps must be positive, got {fps!r}'
frames_to_timecode(90, fps=24) Defensive patterns
Strategy: validation
Validate before calling
from fractions import Fraction
def validate_fps(fps):
if fps is None:
raise ValueError('fps is required')
if isinstance(fps, Fraction):
return fps
f = float(fps)
if f <= 0 or f != f: # also reject NaN
raise ValueError(f'fps must be positive, got {fps!r}')
return f Type guard
def is_valid_fps(fps) -> bool:
try:
f = float(fps)
except (TypeError, ValueError):
return False
return f > 0 and f == f Try / catch
try:
duration = frame_duration(frames, fps)
except ValueError as e:
if 'fps must be positive' in str(e):
logger.error('bad fps from config: %r', fps)
fps = 30.0 # documented default Prevention
- Never default fps to 0 in config files; use None to mean 'unset'
- Validate fps at config load time
- Coerce numeric strings to float before passing
- Be aware NaN passes float() but fails positivity — check explicitly
When it happens
Trigger: Calling fps_fraction (directly or via frame_duration, seconds_to_frames, frames_to_timecode) with 0, a negative number, NaN, or an fps field parsed as 0 from missing config.
Common situations: fps loaded from a config file defaulting to 0; division result used as fps becoming 0; typos like fps=-30; JSON config where 'fps': 0 was copied as a placeholder.
Understand the failure class
Background: "value must be between 0 and 1" / "out of range" / "must not be negative" errors: fixing range-validation failures across open-source libraries — this error's family across 42 libraries.
Related errors
- target timeline fps mismatch
- base_track_count exceeds target video tracks
- candidate fps/duration/retime ambiguity
- clip has no 'path
- clip has no 'path
AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16).
Data as JSON: /api/errors/d0f50ab2d816020f.
Report an issue: GitHub.
Appendix: source
Thrown at skills/taste-application/scripts/taste/timeline.py:90
# CMX3600 signals drop-frame with the `FCM:` header line rather than with the
# timecode separator; some houses also swap ':' for ';'. We emit the spec form
# (FCM header, ':' separators) because that is what Resolve's EDL parser keys on.
EDL_DROP_SEPARATOR = ":"
def fps_fraction(fps: float | Fraction) -> Fraction:
"""Exact frame rate as a :class:`Fraction`, snapping NTSC decimals.
>>> fps_fraction(29.97)
Fraction(30000, 1001)
>>> fps_fraction(24)
Fraction(24, 1)
"""
if isinstance(fps, Fraction):
return fps
fps = float(fps)
if fps <= 0:
raise ValueError(f"fps must be positive, got {fps!r}")
for nominal, exact in _NTSC.items():
if abs(fps - nominal) < _NTSC_TOL:
return exact
if abs(fps - round(fps)) < 1e-9:
return Fraction(int(round(fps)), 1)
return Fraction(fps).limit_denominator(100000)
def frame_duration(fps: float | Fraction) -> Fraction:
"""Duration of one frame, in seconds, as an exact fraction."""
return 1 / fps_fraction(fps)
def seconds_to_frames(seconds: float, fps: float | Fraction) -> int:
"""Quantise ``seconds`` to the nearest whole frame at ``fps``.
Rounds half away from zero rather than using banker's rounding, so a clip
asked for at exactly half a frame does not silently vanish.View on GitHub (pinned to 8321021c54)