Repository navigation
concat_videos match_levels levels a multi-shot input as one unit: its inner seam_level_step survives the join, undocumented and unwarned #876
Description
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)
assess_output(name="asset:qa-cast/ep167-episode.mp4")→seam_level_step4.93 dB at seam 1 (shot@lock|shot@code). That is a 2-shot H3dialogue-shortcut, 32 kHz.- 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"}}shotis a for_each of 2templates/ltx2/image-to-videoshots (41 f and 33 f, 960×544). After the join:loop_audio(ep68-score)→mix_audio→normalize_audio(-3, -16 LUFS, limit)→pair_audio. - 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. assess_outputon the final episode →seam_level_stepseam 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_levelslevels each inner shot over itsstart_sample/num_samplesspan. This could be opt-in, e.g.match_levels_scope: "shots". The shot map is already there, since the output'smedia.shotsreproduces it exactly. - (b) At minimum, the
match_levelsdescription says a multi-shot input is levelled as a whole. Ajob.warningsentry also names an input whose inner shots differ by more than assess'sseam_level_stepthreshold (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
fix(mcp): #876 - warn when match_levels levels a multi-shot input as …
Merge fix/876-inner-level-step: #876
dkackman commentedon Oct 14, 2026
Implemented option (b) as model claude-sonnet-5-5 (anthropic). Merged to
developat 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 secondconcat_videos).match_levelsdescription inget_task("concat_videos")now says each input is levelled as one unit. An input that is itself a join (it carriesmedia.shots) keeps the level steps between its own shots.match_levelsset, each input that carriesmedia.shotshas its shots measured over theirstart_sample/num_samplesspans. If their levels, in the chosen measure, differ by more than 3 dB, ajob.warningsentry of kindinner_level_stepnames the input and the spread. The text suggeststrim_videoper shot and re-joining.concat_videos. I left it for Don to decide whether to approve it.To verify, rerun the repro join (
ep167-episode.mp4plus two shots,match_levels: rms).job.warningsshould hold aninner_level_stepentry forep167-episode.mp4with 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.dkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(725764d775..c38f8af6dc: dw/tasks/concat_videos.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.1 remaining item
fix(mcp): #876 - one owner for the inner-step threshold, place shots …
Merge fix/876-arch-review: #876
dkackman commentedon Oct 14, 2026
Addressed the architecture review as model claude-sonnet-5-5 (anthropic). Merged to
developat df2ca06; the driver deploys it to mini-ai after this session, so I haven't run it on the server.INNER_LEVEL_STEP_WARN_DBnow lives indw/tasks/joins.pybesideLEVEL_SPREAD_WARN_DB. Theseam_level_steprule indw/assessment_rules.pyreads it, and that module's docstring names both shared thresholds._warn_inner_level_stepsnow places shots withassess.sample_span, so a trimmed multi-shot input (no recordedstart_sample) still gets measured.pair_audioreplaces the join's track (audio_replaced_downstream, Join level-spread warning fires when pair_audio replaces the track downstream #800), aslevel_spreadis.tests/test_concat_videos.pyandtests/test_assessment_rules.pypass. 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 nopair_audioafter it.dkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(c38f8af6dc..df2ca06187: dw/assessment_rules.py dw/tasks/concat_videos.py dw/tasks/joins.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.dkackman commentedon Oct 14, 2026
Architecture review: passed. Reviewer model
claude-opus-5-5via theanthropicprovider. I readgit diff c38f8af6..df2ca061underdw/anddw_mcp/(commit 68f9efc:dw/assessment_rules.py,dw/tasks/concat_videos.py,dw/tasks/joins.py) againstdocs/ARCHITECTURE.mdand item 3 ofstage-c-guardrails.md. I found nothing that blocks.The three points from the earlier bounce are resolved:
INNER_LEVEL_STEP_WARN_DBis now defined once, indw/tasks/joins.py:512, next toLEVEL_SPREAD_WARN_DB. Theseam_level_steprule 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._warn_inner_level_stepsnow places shots withassess.sample_span(dw/tasks/concat_videos.py:257), the owner the Script check row names, instead of reading the spans itself.assessdoesn't importconcat_videos, so this adds no import cycle.get_context().audio_replaced_downstreamgate thatwarn_on_level_spreaduses. The flag still comes fromstep_cacheand 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: noCLAUDE.mdchange.Two notes that don't block:
match_levelsdescription (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.level_spreadas using the gate. A later map edit could addinner_level_stepto 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 inpair_audio, so the warning only shows when the join runs without it.dkackman commentedon Oct 14, 2026
Verified on the mini-ai server (mps on mini-ai.lan, develop @ 61838a9) by model
claude-opus-5-5via theanthropicprovider.asset:qa-cast/ep167-episode.mp4doesn't exist on this server, so I built an equivalent multi-shot input from a common asset (workspaceqa-876).quiet=gain_audio(asset:qa-cast/ep62-shot2-deflect.mp4, gain_db=-10), thenquiet_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 carryingmedia.shots.edit=concat_videos([previous_result:inner, <asset>], fps=24, match_levels="rms", match_levels_dbfs=-24).get_task("concat_videos")→match_levelsnow 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". ✔507fdd4895ac):job.warningsholdsedit: 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). ✔98724ce1656f): no inner-step warning. The only warning is the shared-name one. ✔pair_audioreplaces the join's track (job1935c2210ca0): theeditstep's inner-step warning is suppressed, as the Join level-spread warning fires when pair_audio replaces the track downstream #800 gate does forlevel_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_stepthat the description says it's "reported as". That matches howmatch_levels_heldis 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.