Skip to content

0.23.0: video-clipping summary template + clips.json cut list - #35

Merged
pretyflaco merged 3 commits into
mainfrom
video-clipping
Oct 31, 2026
Merged

pretyflaco merged 3 commits into
mainfrom
video-clipping

Conversation

@pretyflaco

Copy link
Copy Markdown
Owner

Adds a third summary template, video-clipping. The model reads a video's transcript and proposes stand-alone clips in two kinds, short (15-60 s) and long (1-3 min). millet turns that list into <base>.clips.json, a cut list an agent can feed to ffmpeg.

Part of a three-repo change. Release order: this PR → vezir → vezir-android (vezir's floor becomes millet-pipeline>=0.23.0).

What's in it

  • Prompts (millet/prompts/summarize_video_clipping_{system,user}.md):
    • Markdown sections: ## Overview / ## Short Clips / ## Long Clips.
    • The JSON block keeps the standard keys, so frontmatter validation and indexing don't change. It adds clips: [{kind, start, end, title, hook, reason, score, tags}].
  • millet/clips.py (stdlib only, no LLM). The LLM's timestamps are second-granular and sometimes mis-copied, so they're treated as cues. build_clips:
    • Snaps each boundary to a real segment boundary, in this order: an exact copy of a floored timestamp, then the segment containing it, then the nearest boundary within 5 s.
    • Pads 0.25 s before / 0.5 s after, but only into silence, never into a neighbouring segment's speech, and clamps to the duration.
    • Enforces kind windows after snapping: short 10-75 s, long 45-240 s.
    • Resolves same-kind overlap by keeping the higher-scored clip. A short clip inside a long one is fine.
    • Caps the list at 15 short / 8 long.
    • Never guesses. A clip that fails a check goes into dropped with a reason. A broken JSON contract gives clips: [] plus error, so a consumer can tell "nothing worth clipping" from "the model failed".
  • Output: <base>.clips.json (.clips.<lang>.json for a language-override retry).
    • When it's written: after transcribe and after a label --apply-json summary regeneration.
    • Per clip: id, start_s/end_s/duration_s, start_ts/end_ts, segments, speakers, the verbatim text, words rebased to the clip start (for captions), and ffmpeg_args (a frame-accurate re-encode with {input}/{output} placeholders).
    • Timeline: all times are on the transcript timeline. vezir adds the video offset, because millet never sees the video.
    • No top-level duration, so vezir's duration probe can't mistake the file for the transcript.
    • Relabeling: a relabel without regeneration renames speakers inside the clips; the boundaries don't move.
    • Failure isolation: if writing the clips file fails, the summary still succeeds.

Fixes along the way

  • _find_session_files now skips .clips. and .meta. JSONs when looking for the transcript. <base>.clips.json sorts before <base>.json, and template .meta.json sidecars had the same latent problem. It only showed up when the directory name differed from the basename.
  • sync pushes the cut list as clips[.<lang>].json. Before this, it would have claimed transcript.json.

Verification

  • tests/test_clips.py: 43 tests covering prompt wiring, timestamp parsing, every snapping and drop path, writing, relabel, apply_labels integration, the transcript-glob regression, and sync naming. Added to the CI subset.
  • The CI subset plus test_summary_template.py: 448 passed. ruff check is clean.
  • Live run on the default backend (deepseek-v4-1-flash, Tinfoil TEE) with a real 30-minute recording: valid JSON, 11 clips (8 short / 3 long), 0 dropped, every boundary snapped. The first draft treated the 15/8 ceiling as a target and returned exactly 15/8. The prompt now scales the count to the video's length.

Worth a reviewer's eye

  • The snapping order and the 5 s tolerance in _snap.
  • The kind windows are wider than the prompt's ranges to absorb padding and snapping. Clips outside them are dropped, not re-classified.
  • The frame-accurate re-encode default in ffmpeg_args. Stream copy would cut on keyframes.

@pretyflaco

Copy link
Copy Markdown
Owner Author

Related: pretyflaco/vezir#19 (needs this release first), pretyflaco/vezir-android#5.

…n-finite numbers

Addresses the review of #35:
- use SummaryConfig.template (arg > MILLET_SUMMARY_TEMPLATE, lowercased)
  for the artifact name and the clips decision, in both the initial
  summary and label --apply-json regeneration
- write_clips_doc returns the document; the whole clip step is inside the
  best-effort guard, so it can never fail a saved summary
- reject non-finite timestamps/scores, sanitize echoed model values,
  serialize with allow_nan=False
- tests: env-configured template (both names), case normalization,
  initial-summary integration, failure isolation with/without preset,
  additional-language coexistence, non-finite inputs, defensive branches
@pretyflaco

Copy link
Copy Markdown
Owner Author

Review addressed in 77e5ca1 (a new commit; the reviewed head c74bec8 is unchanged).

1. Medium: an environment-configured template wrote no clips.json. Fixed.

  • _generate_summary and apply_labels' regeneration path now take the template from SummaryConfig.template for both the artifact name and the clips decision. That value is the explicit argument if given, otherwise MILLET_SUMMARY_TEMPLATE or the legacy MEETSCRIBE_SUMMARY_TEMPLATE, lowercased.
  • The same mismatch had a wider effect than the clips file: any environment-configured template (including iteration-plan) overwrote <base>.summary.md instead of writing <base>.<template>.md. That is fixed too, and an explicit Video-Clipping now names its files in lowercase.
  • vezir is unaffected, because it always passes the template explicitly.
  • Tests:
    • TestResolvedTemplate: initial generation with the template set only through the environment, under both env names; case normalization; no template means no clips file; regeneration with the env-only template, leaving the primary .summary.md untouched.
    • Additional language through apply_labels(summary_language="de", summary_template="video-clipping"), checking that the de pair sits alongside the unchanged primary pair.

2. Low: reading the clips file back could fail a saved summary. Fixed.

  • write_clips_doc() returns (path, document), so nothing is read back. The import, write and report are all inside the best-effort guard, in a new _write_clips_best_effort in label.py and the reworked _write_clips_if_template in _helpers.py.
  • Tests (TestClipFailureIsolation): a write failure with and without a preset returns the original MeetingSummary and keeps the saved summary file; a failure in the post-write report step is isolated; regeneration with a preset and a failing clip write still succeeds.

3. Low: non-finite numbers broke the JSON contract. Fixed.

  • parse_timestamp and _coerce_score reject non-finite values with math.isfinite.
  • Model values echoed into requested/dropped go through _json_safe: non-finite numbers become null, and long or nested values are truncated or turned into a repr.
  • write_clips and relabel_clips_file serialize with allow_nan=False.
  • Tests (TestNonFinite): 1e309, NaN and "inf" as score and as start/end, plus junk entries containing 1e309. Each output is parsed strictly, rejecting any non-standard constant. A separate test checks the serialization guard.

Coverage. Changed-line and branch coverage was measured locally with coverage.py --branch over test_clips.py, test_label.py and test_sync_core.py. It found no uncovered changed lines or branches in millet/clips.py, millet/cli/_helpers.py, millet/label.py or millet/sync.py. Closing the remaining defensive branches took tests for dict/invalid segments, unusable scores, word filtering, an empty transcript, relabel edge cases, a relabel failure, and an unrelated label map. One unreachable guard (if duration:) was removed.

Verification (local, on 77e5ca1): ruff check millet/ tests/ is clean. The CI subset plus test_summary_template.py gives 479 passed (test_clips.py: 74). The pyproject.toml and millet/__init__.py versions agree at 0.23.0. Remote CI is pending at the time of writing.

Still unverified independently: the live Tinfoil run. It was author-run on c74bec8 and these fixes don't touch the prompt or the snapping.

Noted, not changed: transcribe.post_process (the GUI path) has the same raw-versus-resolved pattern. It never passes a template, so it predates this PR and isn't made worse by it; left out of scope.

A 400-digit JSON integer (or a string timestamp with a huge hour field)
made float()/math.isfinite raise OverflowError and aborted build_clips.
parse_timestamp and _coerce_score now return None on overflow (and on
Python 3.11+'s int_max_str_digits ValueError); _json_safe applies
isfinite to floats only and echoes ints that fit in 64 bits, null
otherwise.
@pretyflaco

Copy link
Copy Markdown
Owner Author

Re-review addressed in 30fcfbe.

Low: arbitrarily large JSON integers. Fixed.

  • parse_timestamp wraps its whole parse and returns None on OverflowError. It also returns None on ValueError, which is how Python 3.11+ fails on an integer string past int_max_str_digits.
  • The re-review missed one path, now covered: a string timestamp with a huge hour field ("1000…0:00:00"). It matches the HH:MM:SS regex, and h * 3600 + … + sec overflows when the integer is added to a float. The result is also checked with math.isfinite.
  • _coerce_score catches OverflowError and returns None.
  • _json_safe applies math.isfinite to floats only. An integer is echoed as-is if it fits in 64 bits, and as null otherwise, so a huge number can't get around the echo cap either.

Tests (TestHugeIntegers, 7 new):

  • parse_timestamp returns None for a 400-digit integer, its negative, a 400-digit hour field, a 5000-digit hour field, and a 400-digit numeric string.
  • A huge integer as start or as end: the clip is dropped as unparseable start/end timestamp and the echoed value is null.
  • A huge hour string: dropped, and the echo is truncated to 200 characters.
  • A huge integer score: null, and the clip is kept.
  • A huge integer as a non-object entry, inside a list entry and as kind: echoed as null.
  • Ordinary integers, including ±(2⁶³−1), are echoed unchanged.

Every test output is parsed strictly, rejecting any non-standard constant.

Verification (author-run, local, on 30fcfbe):

  • ruff check millet/ tests/ is clean.
  • The CI test subset plus test_summary_template.py gives 486 passed (test_clips.py: 81).
  • coverage.py --branch: no uncovered changed lines or branches in millet/clips.py.
  • Spot check on Python 3.12.3, which takes the int_max_str_digits path: every value above returns None, build_clips produces its error document, and strict serialization succeeds.

Remote CI: green on 3.10, 3.11 and 3.12.

Still unverified: the live Tinfoil run on the current head. The prompt and the snapping logic are unchanged since c74bec8.

@pretyflaco
pretyflaco merged commit 7e6499d into main Oct 31, 2026
3 checks passed
@pretyflaco
pretyflaco deleted the video-clipping branch October 31, 2026 08:17
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