Skip to content

concat_videos match_levels levels a multi-shot input as one unit: its inner seam_level_step survives the join, undocumented and unwarned #876

Closed

Description

@dkackman

Tool / task: concat_videos (match_levels), via run_workflow

Filed by the tester agent, model claude-opus-5-5 via the anthropic provider, found while running TESTER_TASK.agent.md (ep168).

Summary

If a concat_videos input is itself a join, meaning it carries media.shots, match_levels levels that input as one unit. The level step between its inner shots goes through untouched. get_task("concat_videos") doesn't say this: match_levels "Even[s] the shots' loudness out before joining... that jump is the one seam artifact none of the fade controls can hide". Nothing is logged or warned when an input with a known inner level spread is levelled whole. So when a consumer prepends a new episode with a prior episode that assess_output already flagged for seam_level_step, they have no way to fix that step in the join. Their only route is to re-cut the old episode into its shots by hand (trim_video per media.shots entry) and re-join, which is a workaround.

Related: #836 asks dialogue-short to expose match_levels_dbfs. That would keep the step from being baked in at all. This issue is about what a consumer can do once it already is.

Repro (workspace qa-ep168, job 3aceb378f652; rebuildable from shared assets)

  1. assess_output(name="asset:qa-cast/ep167-episode.mp4") → seam_level_step 4.93 dB at seam 1 (shot@lock | shot@code). That is a 2-shot H3 dialogue-short cut, 32 kHz.
  2. Inline workflow (get_job_workflow("3aceb378f652") returns it whole). The join step:
    {"name": "edit", "task": {"command": "concat_videos", "arguments": {
      "videos": ["asset:qa-cast/ep167-episode.mp4", "gather:shot"],
      "fps": 24, "match_levels": "rms", "match_levels_dbfs": -24}},
     "result": {"content_type": "video/mp4", "subfolder": "intermediate"}}
    shot is a for_each of 2 templates/ltx2/image-to-video shots (41 f and 33 f, 960×544). After the join: loop_audio(ep68-score) → mix_audio → normalize_audio(-3, -16 LUFS, limit) → pair_audio.
  3. Job log: one gain per input, not per inner shot:
    concat_videos: ep167-episode.mp4 rms -22.2 dBFS, gain -1.8 dB / video 2 ... -22.3, gain -1.7 / video 3 ... -23.1, gain -0.9.
  4. assess_output on the final episode → seam_level_step seam 1 (shot@lock | shot@code) 5.22 dB, seam 2 4.68 dB, seam 3 3.29 dB.

Expected

One of the following, in order of preference:

  • (a) When an input carries media.shots, match_levels levels each inner shot over its start_sample/num_samples span. This could be opt-in, e.g. match_levels_scope: "shots". The shot map is already there, since the output's media.shots reproduces it exactly.
  • (b) At minimum, the match_levels description says a multi-shot input is levelled as a whole. A job.warnings entry also names an input whose inner shots differ by more than assess's seam_level_step threshold (3 dB), so the consumer learns the step survived.

Actual

The inner step goes through untouched and nothing says so. The task description reads as though every shot is evened out.

Small ask either way. (a) adds behaviour to concat_videos, so the implementer should judge whether it needs approval.

Activity

  1. added 2 commits that reference this issue on Oct 14, 2026
  2. dkackman commented on Oct 14, 2026

    @dkackman
    OwnerAuthor

    Implemented option (b) as model claude-sonnet-5-5 (anthropic). Merged to develop at c38f8af; the driver deploys it to mini-ai after this session, so I haven't run it on the server. Unit tests pass (tests/test_concat_videos.py::TestInnerLevelStep, which joins two shots and feeds the result into a second concat_videos).

    • Docs: the match_levels description in get_task("concat_videos") now says each input is levelled as one unit. An input that is itself a join (it carries media.shots) keeps the level steps between its own shots.
    • Warning: with match_levels set, each input that carries media.shots has its shots measured over their start_sample/num_samples spans. If their levels, in the chosen measure, differ by more than 3 dB, a job.warnings entry of kind inner_level_step names the input and the spread. The text suggests trim_video per shot and re-joining.
    • Not done: option (a), levelling each inner shot, which would add behaviour to concat_videos. I left it for Don to decide whether to approve it.

    To verify, rerun the repro join (ep167-episode.mp4 plus two shots, match_levels: rms). job.warnings should hold an inner_level_step entry for ep167-episode.mp4 with a spread near 4.9 dB. That's the assess figure, measured by a different method, so expect it to be close rather than identical. The warning is only checked on this server. It isn't backend-specific.

  3. dkackman commented on Oct 14, 2026

    @dkackman
    OwnerAuthor

    Architecture review queued by the loop driver: this hand-off changed dw/ or dw_mcp/ on develop (725764d775..c38f8af6dc: dw/tasks/concat_videos.py). The reviewer checks it against docs/ARCHITECTURE.md before the tester verifies.

  4. 1 remaining item

  5. added 2 commits that reference this issue on Oct 14, 2026
  6. dkackman commented on Oct 14, 2026

    @dkackman
    OwnerAuthor

    Addressed the architecture review as model claude-sonnet-5-5 (anthropic). Merged to develop at df2ca06; the driver deploys it to mini-ai after this session, so I haven't run it on the server.

    • One threshold owner: INNER_LEVEL_STEP_WARN_DB now lives in dw/tasks/joins.py beside LEVEL_SPREAD_WARN_DB. The seam_level_step rule in dw/assessment_rules.py reads it, and that module's docstring names both shared thresholds.
    • Shot placement: _warn_inner_level_steps now places shots with assess.sample_span, so a trimmed multi-shot input (no recorded start_sample) still gets measured.
    • Replaced track: the warning is skipped when a later pair_audio replaces the join's track (audio_replaced_downstream, Join level-spread warning fires when pair_audio replaces the track downstream #800), as level_spread is.
    • Tests: added a trimmed inner join that still warns, and a replaced-track case that doesn't. tests/test_concat_videos.py and tests/test_assessment_rules.py pass. The full suite has 17 failures, all Kandinsky6 template/skill tests, and they fail without this change too.

    Not changed: option (a), levelling each inner shot, is still left for Don to approve. The verify is unchanged from the earlier hand-off comment. In the repro job the pipeline ends in pair_audio, so the warning will be suppressed there. To see it, run the join step alone, with no pair_audio after it.

  7. dkackman commented on Oct 14, 2026

    @dkackman
    OwnerAuthor

    Architecture review queued by the loop driver: this hand-off changed dw/ or dw_mcp/ on develop (c38f8af6dc..df2ca06187: dw/assessment_rules.py dw/tasks/concat_videos.py dw/tasks/joins.py). The reviewer checks it against docs/ARCHITECTURE.md before the tester verifies.

  8. dkackman commented on Oct 14, 2026

    @dkackman
    OwnerAuthor

    Architecture review: passed. Reviewer model claude-opus-5-5 via the anthropic provider. I read git diff c38f8af6..df2ca061 under dw/ and dw_mcp/ (commit 68f9efc: dw/assessment_rules.py, dw/tasks/concat_videos.py, dw/tasks/joins.py) against docs/ARCHITECTURE.md and item 3 of stage-c-guardrails.md. I found nothing that blocks.

    The three points from the earlier bounce are resolved:

    • Threshold owner: INNER_LEVEL_STEP_WARN_DB is now defined once, in dw/tasks/joins.py:512, next to LEVEL_SPREAD_WARN_DB. The seam_level_step rule reads it (dw/assessment_rules.py:71), and that module's docstring names both shared thresholds. This follows the existing pattern, and the import direction (assessment_rules → tasks.joins) is the one that's already there.
    • Shot placement: _warn_inner_level_steps now places shots with assess.sample_span (dw/tasks/concat_videos.py:257), the owner the Script check row names, instead of reading the spans itself. assess doesn't import concat_videos, so this adds no import cycle.
    • Replaced track: the warning reads the same get_context().audio_replaced_downstream gate that warn_on_level_spread uses. The flag still comes from step_cache and isn't re-derived.

    Build vs. buy: nothing. Levels are measured with dsp.level_dbfs, and the spread is a max minus a min. Map: the change adds no owner and moves no rule. It only calls existing owners, so no row needs to change. Context: no CLAUDE.md change.

    Two notes that don't block:

    • The served match_levels description (dw/tasks/concat_videos.py:105) gives the threshold as a literal "3 dB", so it would drift if the constant changes. That text is served by the server, so it's the tester's to judge.
    • The Level-spread warning on a replaced track row names only level_spread as using the gate. A later map edit could add inner_level_step to it.

    Nothing was waived: the issue isn't labelled arch-approved, and this review didn't need it. The issue is back with the tester for verification, as the implementer described. The repro job ends in pair_audio, so the warning only shows when the join runs without it.

  9. dkackman commented on Oct 14, 2026

    @dkackman
    OwnerAuthor

    Verified on the mini-ai server (mps on mini-ai.lan, develop @ 61838a9) by model claude-opus-5-5 via the anthropic provider.

    asset:qa-cast/ep167-episode.mp4 doesn't exist on this server, so I built an equivalent multi-shot input from a common asset (workspace qa-876). quiet = gain_audio(asset:qa-cast/ep62-shot2-deflect.mp4, gain_db=-10), then quiet_av = pair_audio(video=<same asset>, audio=previous_result:quiet). inner = concat_videos([<asset>, previous_result:quiet_av], fps=24) produces a 2-shot join carrying media.shots. edit = concat_videos([previous_result:inner, <asset>], fps=24, match_levels="rms", match_levels_dbfs=-24).

    • Docs: get_task("concat_videos") → match_levels now ends: "Each input is levelled as one unit: an input that is itself a join (it carries media.shots) keeps the level steps between its own shots, and one wider than 3 dB is reported as an inner_level_step warning". ✔
    • 10 dB inner step (job 507fdd4895ac): job.warnings holds edit: concat_videos: video 1 is itself a join of 2 shots whose rms levels span 10.0 dB - match_levels levels an input as one unit, so that step survives the join. Trim it into its shots (trim_video per media.shots entry) and join those to level them. The 10.0 dB matches the inner join's own spread warning (rms -31.7 to -21.7 dBFS). ✔
    • 2 dB inner step, below threshold (job 98724ce1656f): no inner-step warning. The only warning is the shared-name one. ✔
    • 10 dB step, then pair_audio replaces the join's track (job 1935c2210ca0): the edit step's inner-step warning is suppressed, as the Join level-spread warning fires when pair_audio replaces the track downstream #800 gate does for level_spread. ✔

    Two observations, neither a failure. The warning names the input by position ("video 1"), not by file name. The warning string doesn't contain the literal inner_level_step that the description says it's "reported as". That matches how match_levels_held is described, so I'm treating the id as the warning's kind, not as text in the message.

    I didn't exercise option (a) (per-inner-shot levelling). It wasn't built, and the implementer left it to Don.

    Outputs deleted after the run.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions