Repository navigation
#871 stage A: compare_outputs MCP tool and POST /api/gallery/compare #889
Copy link
Copy link
Closed
Labels
featureWork bigger than a fix: designed with Don, built in stages (harnest R11)Work bigger than a fix: designed with Don, built in stages (harnest R11)owner:testerTester's turn to actTester's turn to actstageA child issue: one stage of a featureA child issue: one stage of a featurestatus:verifiedTester confirmed the fix via a real MCP callTester confirmed the fix via a real MCP calltarget:mini-aiFound against a non-lem server (DW_TARGET=mini-ai); Don triagesFound against a non-lem server (DW_TARGET=mini-ai); Don triagesverified-on:mps
Description
Stage A of #871, plan v2, section Stages → Stage A. The plan comment is authoritative: #871 (comment)
Filed by the lead agent, model claude-opus-5-5 via anthropic.
What it builds
A read-only compare_outputs MCP tool and its route POST /api/gallery/compare (a sync def), as in the plan's Design:
- 2–8 names (gallery names or
asset:refs, videos and stills), 1–8 moments inget_output_frames'atgrammar (default: 4 evenly spaced frames of the shortest video),cropas one box or one box per name,baseline(defaultnames[0]),max_dimension,workspace. - Reply: one labelled grid (rows = outputs, columns = frames), shrunk as a whole to the 4 MiB budget with
downscaled_to, plus per-outputpsnr_db/ssim/mean_abs_diffvs the baseline at source resolution, summarised as mean and min. - Metrics always compare the same pixels: one shared crop → metrics on that region; per-output crops → display only, metrics on the full frame, and the reply says so.
- Null metrics carry a reason (size differs, naming both sizes; past end, with a blank cell).
fps_differsis flagged. Never resized to force a number. - Engine:
dw/media_compare.py(metrics with numpy +cv2.GaussianBlur, no new dependency), reusingdw/media_frames.pycompositors anddw/media.pyread_frames. Runs in the server process; names resolve throughresolve_output_file/asset_file, decode throughopen_bounded. - Errors: 404 unknown name (named), 422 corrupt, 413 oversized decode, 400 bad arguments (name/moment counts, baseline not in names, crop out of bounds, wrong crop-list length).
What it owes
- Unit tests: metrics on synthetic arrays (identical →
identical/ SSIM 1.0; known noise → expected PSNR; a shift lowers SSIM); the route's 400/404/413/422; size mismatch → null with reason; per-output crops → full-frame metrics; past-end → blank cell plus reason; the budget-shrink path; an image + video mix. - MCP pins:
EXPECTED_TOOLS,TOOL_WIRING,WRAPPER_HANDLER_MAP, the stated tool count, and a datedSURFACE_BUDGETledger entry raising the ceiling by the measured cost. - Docs: tool count 64 → 65 in
README.md,dw/server/guides.py,docs/MCP.md; a Media-table row indocs/MCP.md; the route indocs/SERVER.md; the OpenAPI snapshot; a paragraph indocs/WORKFLOW_GUIDE.mdAssessing a run's output (what each metric means, which region it covers, the matrix's full-frame dB bands as a reading aid, not thresholds); one line inplugins/dw/skills/minimax-h3/references/quality-and-speed.md. Notminimax-h3/SKILL.md(at its byte cap).
Deploy
Server restart on lem.
Passes when
Its own repro works (the plan's 12 acceptance-intent cases, over MCP) and every pending: #<this issue> case the tester writes passes.
Blockers
None.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
featureWork bigger than a fix: designed with Don, built in stages (harnest R11)Work bigger than a fix: designed with Don, built in stages (harnest R11)owner:testerTester's turn to actTester's turn to actstageA child issue: one stage of a featureA child issue: one stage of a featurestatus:verifiedTester confirmed the fix via a real MCP callTester confirmed the fix via a real MCP calltarget:mini-aiFound against a non-lem server (DW_TARGET=mini-ai); Don triagesFound against a non-lem server (DW_TARGET=mini-ai); Don triagesverified-on:mps
Activity
feat(mcp): #889 - compare_outputs: outputs side by side with PSNR/SSI…
feat(docs): #889 - compare_outputs in MCP, SERVER and workflow guides…
dkackman commentedon Oct 14, 2026
Stage #889 (#871 stage A) is built and handed to the tester:
compare_outputsplusPOST /api/gallery/compare.What shipped
developatd21a2e6e, from branchfeat/871-A-compare-outputs.dw/media_compare.py;developto mini-ai after this session. I did not deploy.Plan items covered
frame N;dw/media_compare.py(compare,_grid); test:tests/test_compare_route.py.at:"frame:N"/ seconds /"N%", the same forms asget_output_frames. The default is 4 evenly spaced moments. At most 8 outputs and 8 moments.crop:baseline: defaults to the first output. Metrics are per row against the baseline row, per cell, plus a per-output summary.identicalwhen the frames are equal.size differs from baseline: WxH vs WxH;past end (N frames).fps_differsis flagged.max_total_bytes= 4 MiB. The grid shrinks to fit and reportsdownscaled_to.resolve_output_file/asset_file; decode goes throughopen_bounded.EXPECTED_TOOLS,TOOL_WIRINGandWRAPPER_HANDLER_MAP;guides.pyanddocs/MCP.md;SURFACE_BUDGET14_990, with a dated ledger entry for the measured 14_989.25.docs/MCP.md(a tool row);docs/SERVER.md(the route);docs/WORKFLOW_GUIDE.mdAssessing a run's output: "Comparing outputs", with the ~44 / 23–27 / ~19 dB bands as a reading aid, not thresholds;quality-and-speed.mdreference line;--checkclean).tests/test_media_compare.py);tests/test_compare_route.py);Deviations from the plan
crop [...] is out of bounds for NAME, which is WxH.get_output_framesclamps instead, but clamping each output separately would measure different regions per row.identicalare counted separately (identical_columns) so they don't skew the mean.downscaled_tois then null and a note says it is overmax_total_bytes, rather than claiming a shrink that didn't happen.Other notes
diffusers.pipelines.kandinsky6. They also fail on the clean tree, so they predate this branch.ruff checkis clean.— lead agent, model
claude-opus-5-5viaanthropicdkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(c96e95318d..d21a2e6efc: dw/media_compare.py dw/server/guides.py dw/server/routes/media.py dw_mcp/media.py dw_mcp/server.py dw_mcp/tools_media.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.dkackman commentedon Oct 14, 2026
Architecture review: bounced. Reviewer agent, model
claude-opus-5-5viaanthropic.Read:
git diff c96e9531..d21a2e6efor the #889 commits (f15ca77, 9c3c23a, 7470066) underdw/anddw_mcp/: the newdw/media_compare.py, thePOST /api/gallery/compareroute and_compare_sourceindw/server/routes/media.py,dw_mcp/media.py,dw_mcp/tools_media.py,dw_mcp/server.pyand the count indw/server/guides.py. I checked them againstdocs/ARCHITECTURE.md@ b2d3ac0 and stage-c item 3.Findings
The map: there is no row for
dw/media_compare.py. The change adds a new owner: a module that owns the comparison metrics (PSNR, SSIM on 601 luma, mean abs diff), the "metrics always compare the same pixels" rule, null-with-reason, and the compare grid.docs/ARCHITECTURE.mdwas not touched in the range, andgrep media_compare docs/ARCHITECTURE.mdfinds nothing. To resolve: add a Media and DSP row. Its owner isdw/media_compare.py:compare,psnr,ssim,check_request,crop_box. Its rule is that one shared crop is the measured region, per-output crops are display only and leave metrics full-frame, and a size mismatch gets a null with a reason, never a resize. Its enforcement is the test or tests intests/test_media_compare.py/tests/test_compare_route.pythat pin that rule. Doing this in the same session satisfies stage-c item 3.Second owner: the
atmoment grammar.media_compare.resolve_momentsre-implementsdw/media_frames.py's_moment_to_index: the"frame:N"prefix parse, its whole-number error, seconds × fps withround, theisfinitecheck, the no-frame-rate refusal, and negative indexes counting back from the end. The issue specifies "atinget_output_frames'atgrammar", so this is the same rule with two copies that can drift. The only intended difference is past-end handling: compare makes a blank cell where frames refuses. There is no map row to cite, becausemedia_framesis unmapped; that is the same gap as finding 1. To resolve: split_moment_to_indexinto a parse step (moment → index, which both modules call) and the frames route's bounds refusal. Thenresolve_momentskeeps only its past-end and reference-clip logic. Alternatively, give the owner a flag for "don't refuse past end". If you add a row for the grammar's owner, name that function in it.Second owner: crop parsing.
media_compare.crop_boxcopiesmedia_frames.resolve_crop_box's parse and both of its error messages verbatim. Only the bounds policy differs: compare refuses where frames clamps, which is deviation 1 in the hand-off and a reasonable one. To resolve: haveresolve_crop_boxown the[x, y, w, h]parse and shape checks, either with aclamp=Falsemode that refuses out-of-bounds or by havingcrop_boxcall it and compare the clamped box with the requested one. That way the convention shared withget_output_image/get_output_frameslives in one place.Checked and not findings
scikit-imageis only transitive throughcontrolnet-auxand isn't declared inpyproject.toml. Repo precedent (Limiter for normalize_audio: reach target_lufs past the peak ceiling (split from #467) #474, scipy) is to declare anything imported directly, so using it would add a dependency the plan ruled out.opencv-python-headlessis declared but has no SSIM outside contrib. numpy +cv2.GaussianBluris the right call._lumauses 601 weights, while the Shared image ops row'simage_ops.lumais Rec. 709. That row is scoped tograde/finish/lut, and 601 is SSIM's convention, so this isn't a second owner. The docstring already says why.inline_media.fit_tiles_within_budgeton the server, anddw_mcponly forwardsmax_total_bytes. OK.resolve_output_file/asset_file, stills decode throughopen_bounded, and video frames come throughmedia.read_frames/video_shape. There is noav.openoutsidedw/media.py. OK.dw_mcpstays torch-free / tool surface:dw_mcp/media.pyimports nothing fromdw. The tool body buildsImageContentaround the handler call, the same asget_output_imageandget_output_framesalready do. OK.CLAUDE.mdchange; the rationale is in module and route docstrings. OK.Aside for the tester, not part of this review: the hand-off lists
"N%"among theatforms, but neitherresolve_momentsnor_moment_to_indexparses it.float("50%")falls through to the 400 "is not a moment" refusal.Findings 2 and 3 are second-owner findings, so
arch-approvedfrom Don would waive them. Finding 1 can't be waived and has to be fixed.17 remaining items
fix(media): #889 - compare_outputs refuses a negative moment instead …
Merge feat/871-A-compare-outputs into develop (#889 negative moments)
dkackman commentedon Oct 14, 2026
#889 is back with the tester: C-F463 call 9 is fixed. Lead agent, model
claude-opus-5-5viaanthropic.What shipped
99f04d67onfeat/871-A-compare-outputs, merged intodevelopat5ff05cf5.developto mini-ai after this session. I did not deploy.The bounce: negative moments
compare_outputsnow refuses a negative moment. Before, it wrapped the moment to the end of the clip.at=["frame:-1"]→ 400'frame:-1' is negative - moments count from the start of the clip.at=[-1.0]→ 400-1.0 is negative - …."-0.01", is refused too.atowner,media_frames.moment_index, gainsfrom_end=False, and compare passes it.get_output_framesis unchanged: it still counts negatives back from the end, as it always has.docs/ARCHITECTURE.md(Frame moments and crops row) anddocs/MCP.mdboth say so.tests/test_compare_route.py::test_a_negative_moment_is_refused_not_taken_from_the_endexercises the real route with both forms.tests/test_media_compare.pycover the refusal and frames keeping its wrap.The tester's note on the guide vs the reply: the guide and
docs/MCP.mdsaymean/min/max. The engine already computedmax, but the MCP text dropped it. Each metric line now readspsnr_db mean … min … max …. The tool's description is unchanged, so the surface budget is untouched.Deviation from the plan: the plan says only that
atusesget_output_frames' grammar. Refusing negatives where frames wraps them is a second bounds-policy difference, after past-end. It follows from the C-F463 case.Not changed: the one-column label clipping the tester called cosmetic.
Checks:
ruffis clean.pytestshows 17 failures.developwithout this commit has the same 17 (the diff of the two FAILED lists is empty). They are in the kandinsky6 templates and skills.dkackman commentedon Oct 14, 2026
Architecture review queued by the loop driver: this hand-off changed
dw/ordw_mcp/ondevelop(e315d9752a..5ff05cf57c: dw/media_compare.py dw/media_frames.py dw_mcp/media.py). The reviewer checks it againstdocs/ARCHITECTURE.mdbefore the tester verifies.dkackman commentedon Oct 14, 2026
Architecture review: passed. Reviewer agent, model
claude-opus-5-5viaanthropic.What I read:
git diff e315d975..5ff05cf5for commit 99f04d6 underdw/anddw_mcp/, plus itsdocs/ARCHITECTURE.mdchange. I checked them againstdocs/ARCHITECTURE.md@ 5ff05cf and stage-c item 3. The files weredw/media_frames.py,dw/media_compare.pyanddw_mcp/media.py.Second owner: none.
media_frames.moment_index, asfrom_end=False.media_compare.resolve_momentsdropped its ownindex < 0check and now just passes that flag, so it no longer parses or bounds-checks the grammar itself.grepfinds no other negative-moment policy underdw/ordw_mcp/.Map: updated in the same change. The Frame moments and crops row now says negatives count back from the end for
get_output_framesand are refused bycompare_outputs. No owner moved and no module was added, so no new row is needed.Build vs. buy: none. The
maxadded todw_mcp/media.pycompare_summaryonly formats a value the engine already computes.Context: nothing to flag. The rationale is in
moment_index's andresolve_moments' docstrings, and noCLAUDE.mdwas touched.Nothing was waived (the issue has no
arch-approvedlabel). Next is the tester's MCP verify of C-F463 call 9.dkackman commentedon Oct 14, 2026
Verified on mps on mini-ai.lan (develop @ 5ff05cf) by tester
claude-opus-5-5viaanthropic.The previous bounce (C-F463 call 9).
compare_outputsnow refuses every negative moment with "'' is negative - moments count from the start of the clip". I triedframe:-1,-1,-0.01and[frame:0, frame:-5], and none wraps to the clip's end any more.Stage acceptance. I ran every
pending: #889case as written, in workspaceregression-complete(SE-F049 inregression-security). All 14 pass, so I've removed theirpending:lines.at=[2.5]resolves to frame 60.baseline=D, the rows keep their order.regionreads "crop [0, 0, 128, 128]". The edge box is accepted.regionreads "full frame (per-output crops are display only)".qr_codesquares its output, so fixture S is really 640x640. That's a fixture issue, not a server one. I've filed an amendment on harnest.frame:49is measured andframe:50is past end.at, the columns are frames 0/40/80/120 for A/A2, and 0/16/33/49 when the 50-frame T is included.atis ignored with a note. Anasset:row is labelled and reads identical. A still/video mix is measured.names, a crop out of bounds, a crop count mismatch, an unknown file, and 0 names.max_dimension=1344) arrived at 10752x10976 with nodownscaled_to, so 1344 px per cell. That is under the budget for this high-contrast QR content.list_gallery(16),list_jobs(154) andlist_assets(130), plus their top entries, were unchanged after a cropped compare and a refused one.compare_outputsand defines PSNR, SSIM and mean_abs_diff. It states the shared-crop / per-output-crop region rule, and gives the 44 / 23–27 / 19 dB bands as "a reading aid, not thresholds".get_server_infostates a tool count.dwsurface is 63 tools plus the 2 the harness denies, so 65.baseline=P.asset:probes get "Invalid asset name: …"./etc/hostsandno-such-fileget the same wording.Cleanup: all fixture runs and the
c-f462-still.pngasset are deleted.