affaan-m/ECC · error · ValueError

source fps must be finite and positive; provide --fps

Error message

source fps must be finite and positive; provide --fps

What it means

After probing the first take, forge() computes FPS as the explicit fps argument if given, else info0.fps from ffprobe. If the resulting frame rate is not finite and positive (probe returned 0, garbage, or the container lacks a usable rate), it raises ValueError telling the user to supply --fps manually.

Solutions

  1. Pass the frame rate explicitly: forge(..., fps=30) or --fps 30 on the CLI, exactly as the message suggests.
  2. Check the probe result: ffprobe -v error -select_streams v:0 -show_entries stream=r_frame_rate take0.mov — if it shows 0/0 the metadata is broken.
  3. Re-encode/remux the first take (ffmpeg -i in.mov -r 30 -c copy fixed.mov) to embed a sane frame rate.
  4. Verify the take file is not truncated or corrupt.

Example fix

// before (CLI)
$ python forge.py noir take1.mov take2.mov --out edit.mp4   # probe fps = 0

// after
$ python forge.py noir take1.mov take2.mov --out edit.mp4 --fps 30
Defensive patterns

Strategy: fallback

Validate before calling

import subprocess, json

def probe_fps(path, default=30.0):
    out = subprocess.run(['ffprobe', '-v', 'error', '-select_streams', 'v:0',
                          '-show_entries', 'stream=r_frame_rate', '-of', 'json', path],
                         capture_output=True, text=True)
    try:
        num, den = json.loads(out.stdout)['streams'][0]['r_frame_rate'].split('/')
        fps = float(num) / float(den)
        return fps if fps > 0 else default
    except Exception:
        return default

Try / catch

try:
    forge(genre, takes, out=out)
except ValueError as e:
    if 'source fps' in str(e):
        forge(genre, takes, out=out, fps=30.0)  # explicit fallback

Prevention

When it happens

Trigger: Calling forge without fps on a take whose container has a missing/zero frame rate (some screen recordings, raw streams, MKVs with unusual headers, or a corrupt file ffprobe misreads).

Common situations: Screen-capture or OBS recordings with variable/absent frame rate metadata; images or raw streams concatenated into containers without timing info; ffprobe version differences returning 0/0 for broken files.

Understand the failure class

Background: "Must be a positive integer", "Invalid value", "Unsupported": the invalid-argument-value error family, when a library rejects the value you pass — this error's family across 35 libraries.

Related errors


AI-assisted analysis of affaan-m/ECC@8321021c54 (2026-09-16). Data as JSON: /api/errors/6fcaa8dc120d734d. Report an issue: GitHub.

Appendix: source

Thrown at skills/taste-application/scripts/forge.py:89

        raise ValueError("all takes must be readable local files")
    if base_video and not Path(base_video).is_file():
        raise ValueError("base video must be a readable local file")
    if any(not Path(o).is_file() for o in (overlays or [])):
        raise ValueError("all overlays must be readable local files")
    if fps is not None and (not math.isfinite(fps) or fps <= 0):
        raise ValueError("fps must be finite and positive")
    sp = pack_mod.load(genre, root=root)
    tgt = grade_mod.load_stats(sp.grade_path)
    cad = cad_mod.load(sp.cadence_path)

    # Geometry comes from the first take unless overridden; everything else is
    # normalized to it so concat does not silently fail on a size mismatch.
    info0 = frame_mod.probe(takes[0])
    W = width or info0.width
    H = height or info0.height
    FPS = fps if fps is not None else info0.fps
    if not math.isfinite(FPS) or FPS <= 0:
        raise ValueError("source fps must be finite and positive; provide --fps")
    if W <= 0 or H <= 0:
        raise ValueError("output width and height must be positive")
    # Prior timelines reference these shot files. Each run owns a fresh child,
    # including failed runs, so retries cannot erase an existing edit.
    work_root = Path(work)
    work_root.mkdir(parents=True, exist_ok=True)
    work_dir = Path(tempfile.mkdtemp(prefix="run-", dir=work_root)).resolve()
    print(f"forging '{genre}'  ->  {W}x{H} @ {FPS:g}fps")
    print(f"  cadence: mean {cad.mean_shot:.2f}s, {cad.cuts_per_min:.0f} cuts/min, "
          f"variance {cad.rhythm_variance:.2f}")

    total_target = duration or sum(frame_mod.probe(t).duration for t in takes)
    # When apply.py generated these takes it already decided where the cuts
    # fall, and it told the model so ("cut into 6 pieces of ~0.8s"). Re-planning
    # here would silently cut somewhere else, against footage shot for the
    # original plan - so the caller's plan wins when there is one.
    if plan is None:
        # Plan PER TAKE against each take's own length, not by splitting the

View on GitHub (pinned to 8321021c54)