From 9ec992bce0e39a1bb07b5d27018a87beed1633d6 Mon Sep 17 00:00:00 2001 From: Jeff Crouse Date: Sat, 19 Sep 2026 10:20:53 -0400 Subject: [PATCH] =?UTF-8?q?feat(gate1):=20publish=20a=20real=20review=20ro?= =?UTF-8?q?und=20+=20the=20measurement=20protocol=20=E2=80=94=20gate=201?= =?UTF-8?q?=20/=20PR=204?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mcp/gate1/publish_review.py plays the agent for one honest round on a project folder: save + snapshot the baseline, record the brief (rev 1 + log), render the baseline excerpt with export_av, then for each direction copy the project to an ISOLATED sibling folder, load the copy, apply the edits, save, render the same excerpt, and snapshot the copy INTO the original's work/versions (ADR-0062 §2 — the foreground document is never edited); write the candidate records with provenance (the edits, the working copy) and evidence (export result, dropped frames, measured RMS for the audition-only level match, "intent evaluator unavailable"); reload the original and publish one review item + review_opened. Refuses to publish an excerpt whose export dropped frames. The whole round on song-sketch: 28 s, 3 × 465 frames. Control server + MCP (gate-1 slice; full families are gate 2): - work_status — records dir, has_records, preferred_version, dirty, brief_rev, open reviews. - work_snapshot_version {label, source, parent, brief_rev, into} — a version of the LOADED folder, optionally into another project's records; refuses an unsaved document (reads disk). - work::snapshot_version_into() behind it (+ test: the copy's own work/ is never content, nothing lands in the copy). Fixes found on the way: - save_project over MCP never cleared the dirty flag / autosave slot (the GUI save does): an agent's save left the edited-dot on and the quit prompt armed. Now mark_saved() + autosave::clear(). - Leaving Review appended review_visited into ANY folder project, creating work/ where none existed (it wrote into examples/song-sketch). Now only when work/ already exists. docs/roadmap/gate1-measurement.md is the protocol for the gate itself: the creator returns cold, two minutes, no transcript, eight silent observations, the ADR-0064 probes, and a Result section to fill in. The plan doc's PR 4 section reflects what shipped. Verified live: the published round appears in Review (badge 1) with three lockstep tiles, level match enabled from the measured RMS, brief + protections shown. Full headless ctest green (91); MCP name + argument parity green; layering guard green. Co-Authored-By: Claude Opus 5 (1M context) --- app/CMakeLists.txt | 1 + app/src/app/review_workspace.cpp | 6 +- app/src/cli/control_handlers.h | 1 + app/src/cli/control_handlers_project.cpp | 6 + app/src/cli/control_handlers_work.cpp | 83 +++++ app/src/cli/control_server.cpp | 1 + app/src/work/work_records.cpp | 10 +- app/src/work/work_records.h | 5 + app/tests/test_work_records.cpp | 16 + .../continuous-creative-workflow-gate1.md | 20 +- docs/roadmap/gate1-measurement.md | 84 +++++ mcp/gate1/publish_review.py | 298 ++++++++++++++++++ mcp/vivid_mcp.py | 27 ++ 13 files changed, 546 insertions(+), 12 deletions(-) create mode 100644 app/src/cli/control_handlers_work.cpp create mode 100644 docs/roadmap/gate1-measurement.md create mode 100644 mcp/gate1/publish_review.py diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index 9d35f8ec..28bd3fce 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -321,6 +321,7 @@ add_executable(vivid MACOSX_BUNDLE src/cli/project_recovery.cpp src/cli/control_handlers_packages.cpp src/cli/control_handlers_edit.cpp + src/cli/control_handlers_work.cpp src/cli/control_handlers_music_eval.cpp src/cli/control_handlers_visual_eval.cpp src/cli/control_handlers_video_export.cpp diff --git a/app/src/app/review_workspace.cpp b/app/src/app/review_workspace.cpp index 35ea930c..6821b2cc 100644 --- a/app/src/app/review_workspace.cpp +++ b/app/src/app/review_workspace.cpp @@ -209,7 +209,11 @@ void ReviewWorkspace::leave(Window& win) { (void)win; audition_.pause(); comment_active_ = false; - if (has_project_) work::append_event(project_dir_, { "", "review_visited", json() }); + // Anchor "since your last review" — but only for a project that already HAS records. Leaving + // Review must not create a work/ folder in a project that never had one. + std::error_code ec; + if (has_project_ && fs::is_directory(work::work_dir(project_dir_), ec)) + work::append_event(project_dir_, { "", "review_visited", json() }); } int ReviewWorkspace::pending_count() const { diff --git a/app/src/cli/control_handlers.h b/app/src/cli/control_handlers.h index c20365ca..cae9a19f 100644 --- a/app/src/cli/control_handlers.h +++ b/app/src/cli/control_handlers.h @@ -24,6 +24,7 @@ void register_audio_handlers(Handlers& handlers_); // authoring + clip void register_project_handlers(Handlers& handlers_); // session author/persist + project workflow void register_package_handlers(Handlers& handlers_); // ADR-0024 Phase 7: operator-package authoring void register_edit_handlers(Handlers& handlers_); // ADR-0017 undo/redo +void register_work_handlers(Handlers& handlers_); // ADR-0061/0062 work records (gate-1 slice: status + snapshot) void register_video_export_handlers(Handlers& handlers_); // realtime AV video export (File > Export Video / MCP) void register_audio_export_handlers(Handlers& handlers_); // ADR-0032: offline master-mix bounce to .wav (File > Export Audio / MCP) void register_audio_io_handlers(Handlers& handlers_); // ADR-0032 Phase A: output device enumerate/select diff --git a/app/src/cli/control_handlers_project.cpp b/app/src/cli/control_handlers_project.cpp index c021788b..e2db0ec8 100644 --- a/app/src/cli/control_handlers_project.cpp +++ b/app/src/cli/control_handlers_project.cpp @@ -5,6 +5,8 @@ #include "app/project_io.h" // folder-aware save/load + project-local operators #include "app/project_paths.h" // is_folder_project / session_json_path #include "app/app.h" +#include "app/edit_gateway.h" // ADR-0018: mark_saved +#include "app/autosave.h" // ADR-0018: clear the recovery slot after a clean save #include "app/mcp_bridge.h" // ADR-0040: the bundled MCP bridge location + setup command #include "audio/plugin_catalog.h" // check_tutorial_prereqs: installed plugin readiness #include "ui/node_graph.h" @@ -516,6 +518,10 @@ void register_project_handlers(Handlers& handlers_) { if (path.empty()) return err(code::kBadArg, "need path for first save"); auto sr = project_io::save(*c.app, *c.graph, *c.win_w, *c.win_h, *c.split_x, *c.dock_h, path); if (!sr.ok) return err(code::kIoError, sr.error); + // ADR-0018: the document is now clean — same as the GUI's save (file_actions::save_path). Without + // this an agent's save left the edited-dot on, the quit prompt armed, and the autosave slot live. + if (c.app->edit_gateway) c.app->edit_gateway->mark_saved(); + autosave::clear(); json r = ok(); r["path"] = c.app->project.current_project_path; r["session_file"] = sr.session_file; return r; }; handlers_["load_project"] = [](const ControlCtx& c, const json& b) { diff --git a/app/src/cli/control_handlers_work.cpp b/app/src/cli/control_handlers_work.cpp new file mode 100644 index 00000000..bf725f65 --- /dev/null +++ b/app/src/cli/control_handlers_work.cpp @@ -0,0 +1,83 @@ +#include "cli/control_handlers_internal.h" + +#include "app/app.h" +#include "app/edit_gateway.h" +#include "app/project_paths.h" +#include "audio/vst3_host.h" +#include "work/work_records.h" + +#include +#include + +namespace vivid { + +namespace fs = std::filesystem; + +// ADR-0061/0062 — the WORK RECORDS surface, gate-1 slice: enough for an agent to publish honest +// candidates for the Review workspace without reimplementing the records layer. The records +// themselves are plain JSON under /work/ that an agent reads/writes directly (that is the +// point of the format); these handlers cover the two things only the app can do well: +// work_status — where the records are, what the document derives from, what is open +// work_snapshot_version — an immutable version of the LOADED project folder (complete copy + +// SHA-256 per dependency + plugin identities), optionally INTO another +// project's records (the candidate-copy flow: edit a copy, snapshot it into +// the original's work/, never touching the foreground document) +// The full work/version/review/execution tool families are gate 2 (ADR-0063). +void register_work_handlers(Handlers& handlers_) { + handlers_["work_status"] = [](const ControlCtx& c, const json&) { + if (!c.app) return err(code::kInternal, "no app"); + json r = ok(); + const std::string& p = c.app->project.current_project_path; + const bool folder = !p.empty() && project_paths::is_folder_project(p); + r["project_dir"] = folder ? p : ""; + r["is_folder_project"] = folder; + r["preferred_version"] = c.session ? std::string(session::session_preferred_version(c.session)) : std::string(); + r["dirty"] = c.app->edit_gateway ? c.app->edit_gateway->dirty() : false; + if (!folder) { r["has_records"] = false; r["summary"] = "no folder project open — work records need a project folder"; return r; } + std::error_code ec; + const bool has = fs::is_directory(work::work_dir(p), ec); + r["has_records"] = has; + if (has) { + work::Brief b; r["brief_rev"] = work::load_brief(p, b) ? b.rev : 0; + int open = 0; json reviews = json::array(); + for (const auto& rv : work::list_reviews(p)) { + if (rv.status == "open") ++open; + reviews.push_back({ {"id", rv.id}, {"status", rv.status}, {"question", rv.question}, {"candidates", rv.candidates} }); + } + r["open_reviews"] = open; r["reviews"] = reviews; + r["versions"] = work::list_versions(p).size(); + r["candidates"] = work::list_candidates(p).size(); + } + return r; + }; + + // {label, source?="manual", parent?="", brief_rev?=0, into?=} -> {id, dependencies, plugins}. + // Reads project.json from DISK, so the loaded document must be saved first: an unsaved document + // is refused rather than snapshotting something the creator has not seen written. + handlers_["work_snapshot_version"] = [](const ControlCtx& c, const json& b) { + if (!c.app) return err(code::kInternal, "no app"); + const std::string& p = c.app->project.current_project_path; + if (p.empty() || !project_paths::is_folder_project(p)) return err(code::kBadArg, "the loaded project is not a folder project"); + if (c.app->edit_gateway && c.app->edit_gateway->dirty()) + return err(code::kConflict, "the document has unsaved changes — save_project first (a snapshot reads project.json on disk)"); + work::SnapshotOptions opt; + opt.label = b.value("label", std::string()); + opt.source = b.value("source", std::string("manual")); + opt.parent = b.value("parent", std::string()); + opt.brief_rev = b.value("brief_rev", 0); + const std::string into = b.value("into", p); + if (!into.empty() && into != p) { + std::error_code ec; + if (!fs::is_regular_file(fs::path(into) / "project.json", ec)) return err(code::kBadArg, "into: not a project folder: " + into); + } + std::string e; + const std::string id = work::snapshot_version_into(p, into, opt, &e); + if (id.empty()) return err(code::kIoError, e); + work::Version v; + json r = ok(); r["id"] = id; r["records_dir"] = into; + if (work::load_version(into, id, v)) { r["dependencies"] = v.dependencies.size(); r["plugins"] = v.plugins.size(); r["document_sha256"] = v.document_sha256; } + return r; + }; +} + +} // namespace vivid diff --git a/app/src/cli/control_server.cpp b/app/src/cli/control_server.cpp index 040f607c..bcbe13d6 100644 --- a/app/src/cli/control_server.cpp +++ b/app/src/cli/control_server.cpp @@ -163,6 +163,7 @@ void ControlServer::register_handlers() { register_package_handlers(handlers_); // ---- ADR-0024 Phase 7: operator-package authoring ---- register_edit_handlers(handlers_); // ---- ADR-0017 undo/redo ---- + register_work_handlers(handlers_); // ---- ADR-0061/0062 work records: status + version snapshot ---- register_video_export_handlers(handlers_); // ---- realtime AV video export ---- register_audio_export_handlers(handlers_); // ---- ADR-0032: offline master-mix WAV bounce ---- diff --git a/app/src/work/work_records.cpp b/app/src/work/work_records.cpp index 9c999da7..949afc96 100644 --- a/app/src/work/work_records.cpp +++ b/app/src/work/work_records.cpp @@ -273,6 +273,10 @@ static std::vector plugins_from_document(const json& doc) { } std::string snapshot_version(const fs::path& project_dir, const SnapshotOptions& opt, std::string* err) { + return snapshot_version_into(project_dir, project_dir, opt, err); +} + +std::string snapshot_version_into(const fs::path& project_dir, const fs::path& records_dir, const SnapshotOptions& opt, std::string* err) { std::error_code ec; const fs::path doc_src = project_dir / "project.json"; if (!fs::is_regular_file(doc_src, ec)) { set_err(err, "no project.json in " + project_dir.string()); return {}; } @@ -288,11 +292,11 @@ std::string snapshot_version(const fs::path& project_dir, const SnapshotOptions& v.document_sha256 = Sha256::of(doc_text); v.plugins = plugins_from_document(doc); - const fs::path dst = version_dir(project_dir, v.id); + const fs::path dst = version_dir(records_dir, v.id); fs::create_directories(dst, ec); if (ec) { set_err(err, "cannot create " + dst.string()); return {}; } - const fs::path work = work_dir(project_dir); + const fs::path work = work_dir(project_dir); // the source's own records (if any) are never content for (fs::recursive_directory_iterator it(project_dir, fs::directory_options::skip_permission_denied, ec), end; it != end && !ec; it.increment(ec)) { const fs::path& p = it->path(); @@ -320,7 +324,7 @@ std::string snapshot_version(const fs::path& project_dir, const SnapshotOptions& if (ec) { set_err(err, "walk failed: " + ec.message()); return {}; } std::sort(v.dependencies.begin(), v.dependencies.end(), [](const Dependency& a, const Dependency& b) { return a.path < b.path; }); - if (!save_version(project_dir, v, err)) return {}; + if (!save_version(records_dir, v, err)) return {}; return v.id; } diff --git a/app/src/work/work_records.h b/app/src/work/work_records.h index ae578a45..91e74a59 100644 --- a/app/src/work/work_records.h +++ b/app/src/work/work_records.h @@ -103,6 +103,11 @@ struct SnapshotOptions { }; std::string snapshot_version(const std::filesystem::path& project_dir, const SnapshotOptions& opt, std::string* err = nullptr); +// Same, but the version lands in ANOTHER project's records (`records_dir`/work/versions/): the +// gate-1 candidate flow edits an isolated COPY of the project and snapshots it into the original's +// work/ (ADR-0062 §2 — candidate writes never touch the foreground document or its folder). +std::string snapshot_version_into(const std::filesystem::path& project_dir, const std::filesystem::path& records_dir, + const SnapshotOptions& opt, std::string* err = nullptr); // Is `rel` a build output the snapshot leaves behind? (Exposed for the tests.) bool is_build_output(const std::filesystem::path& rel); diff --git a/app/tests/test_work_records.cpp b/app/tests/test_work_records.cpp index b0f14909..72b5f6a0 100644 --- a/app/tests/test_work_records.cpp +++ b/app/tests/test_work_records.cpp @@ -144,6 +144,22 @@ static void test_snapshot_version() { const auto all = list_versions(proj); CHECK(all.size() == 2); CHECK(all[1].parent == id || all[0].parent == id); + // Snapshot an isolated candidate COPY into the original's records (ADR-0062 §2). + { + const fs::path copy = fresh_dir("copy"); + write_file(copy / "project.json", std::string(kDoc) + "\n"); + write_file(copy / "aurora.glsl", "// shader v1"); + write_file(copy / "work" / "junk.json", "{}"); // the copy's own records are not content + SnapshotOptions o; o.label = "candidate C"; o.source = "candidate"; o.parent = id; + const std::string cid = snapshot_version_into(copy, proj, o, &err); + CHECK(!cid.empty()); + CHECK(fs::exists(version_dir(proj, cid) / "project.json")); // landed in the ORIGINAL's work/ + CHECK(!fs::exists(version_dir(proj, cid) / "work")); + CHECK(!fs::exists(copy / "work" / "versions")); // nothing written into the copy's records + Version cv; CHECK(load_version(proj, cid, cv)); + CHECK(cv.parent == id && cv.dependencies.size() == 2); + CHECK(list_versions(proj).size() == 3); + } // No project.json → clean failure. CHECK(snapshot_version(fresh_dir("empty"), opt, &err).empty()); CHECK(err.find("project.json") != std::string::npos); diff --git a/docs/roadmap/continuous-creative-workflow-gate1.md b/docs/roadmap/continuous-creative-workflow-gate1.md index 357e7ad2..ff3abb39 100644 --- a/docs/roadmap/continuous-creative-workflow-gate1.md +++ b/docs/roadmap/continuous-creative-workflow-gate1.md @@ -98,14 +98,18 @@ and reports what is missing or changed; it never substitutes content (ADR-0062 - Every action writes `work/` records and works with no control-server client connected. ### PR 4 — real media + the measurement -- `mcp/tools/` (or `examples/`) script: load `song-sketch`, snapshot the baseline version, write a - brief with two protections (drums, bass) and one preference (expansive chorus, visuals open up), - apply two directed edits on isolated copies, `export_av` each with a recorded recipe, write the - candidates and one review item. -- The measurement: the creator returns cold, two minutes, no transcript. Record in ADR-0064's - acceptance section: understood the change (y/n, seconds), chose or rejected (which), left a - passage-specific comment (y/n), what was confusing. Also test: no agent connection, feedback on - an older preview, choosing neither, returning to Create without losing selection. +- `mcp/gate1/publish_review.py`: loads a project folder, saves + snapshots the baseline, records the + brief, renders the baseline excerpt (`export_av`), renders two directions on **isolated copies** + (`load_project` the copy → edits → `save_project` → `export_av` → `work_snapshot_version … into=`), + writes candidate records with provenance + evidence (export result, measured RMS for the level match), + reloads the original, publishes one review item. Nothing in `work/` is hand-written. +- Control server + MCP: `work_status`, `work_snapshot_version` (the two things only the app can do + well; the full tool families are gate 2). `save_project` over MCP now clears the dirty flag like + the GUI save (it never did). +- Leaving Review no longer creates a `work/` folder in a project that never had records. +- `docs/roadmap/gate1-measurement.md`: the protocol — the creator returns cold, two minutes, no + transcript; eight observations, the ADR-0064 probes (no agent, undo/redo, feedback on an older + preview, neither, back to Create, save prompt); the result goes into ADR-0064's acceptance section. ## Out of scope for gate 1 - Any runner, worker, job, lease, or background execution (gate 3). diff --git a/docs/roadmap/gate1-measurement.md b/docs/roadmap/gate1-measurement.md new file mode 100644 index 00000000..b1cb7bfb --- /dev/null +++ b/docs/roadmap/gate1-measurement.md @@ -0,0 +1,84 @@ +# Gate 1 measurement — can a returning creator steer in two minutes? + +Status: protocol ready; **result pending** (fill in the Result section after the session) + +Date: 2026-09-19 + +The acceptance evidence ADR-0064 asks for: *use real audio and moving visuals to compare a baseline +and two alternatives; measure whether a returning creator can understand the change, choose or +reject it, and leave passage-specific feedback within two minutes without reading a transcript.* +This is a measurement, not a demo: the creator has **not** seen the candidates before, and the +person running the protocol says nothing until the clock stops. + +## Setup (the agent, before the creator sits down) + +1. Build `main` and launch Vivid. Keep the display awake (`caffeinate -dimsu`) and the window frontmost + while exporting (a sleeping display or occluded window collapses the frame loop). +2. Publish the round on a **writable copy** of the song sketch: + ```sh + uv run --directory mcp python gate1/publish_review.py \ + --project ~/Music/vivid-gate1/song-sketch --from-example --scene 0 --bars 8 + ``` + This snapshots the baseline, records the brief, renders the baseline excerpt, renders two + directions on isolated copies (snapshotted into the original's `work/`), and publishes one review + item. Nothing in `work/` is hand-written. +3. Leave the app in **Create** on the original project, with the pending badge showing `1` on the + `Create | Review` switch. Do not open Review. Quit any MCP client; the creator must not read a + transcript. +4. Start a stopwatch when the creator first touches the mouse or keyboard. + +## The session (the creator) + +Scenario given aloud, once, before the clock starts (this is the brief the creator gave earlier — +it is also on screen under *Direction & protections*): + +> "Yesterday you asked for the chorus to feel more expansive, with the visuals opening up, and for the +> drums and the chord part to stay as they are. Two directions came back. Decide." + +Then nothing. The creator switches to Review and does whatever they do. Stop the clock when they say +they are done, or at **2:00**, whichever comes first. + +## What is recorded (the observer, silently) + +| # | Question | Record | +|---|---|---| +| 1 | Did they find the decision without help? | yes / no · seconds until the review item was on screen | +| 2 | Did they play the comparison? Did they switch between Baseline / A / B while it played? | yes / no · which sources were heard · used tiles, keys, or both | +| 3 | Could they say what changed, in their own words, without looking at anything but the screen? | verbatim | +| 4 | Did they make a choice — Use / Keep / Revise / Dismiss / Neither? | which · seconds | +| 5 | Did they leave a passage-specific comment (a bar, not "the whole thing")? | yes / no · verbatim · the bar the record carries | +| 6 | Did they open anything that was not the review (Create, a graph, a log) to understand the change? | what | +| 7 | Did anything confuse them? Asked aloud or visibly hesitated? | verbatim / where | +| 8 | Total time to done | m:ss | + +Pass = 1, 3, 4 and 5 all yes within 2:00 and nothing in 6 was *needed* to answer 3. +Anything else is a fail that names the cause — that is the useful outcome. + +## The follow-up probes (after the clock, in this order) + +These are the ADR-0064 edge cases; each is one action and one observation. + +- **No agent connected.** Nothing was connected during the session. Confirm the choice and the comment + are in `work/feedback.jsonl` and `work/reviews/.json` (`resolution`), written by the app alone. +- **Undo.** ⌘Z. Did the *Current version* card and the *Since your last review* list tell the truth + (pointer back, candidate back to *kept*, `promotion_undone` event)? Redo. Same question. +- **Feedback on an older preview.** Select the resolved review, comment at a bar. Does the record target + the candidate's version, not the promoted document? +- **Neither.** (If they chose one.) Ask: "if you had liked neither?" — do they find *Neither — dismiss + all* without help? +- **Back to Create.** Does the session grid / graph come back exactly as they left it (selection, + dock, preview)? +- **Save prompt.** ⌘Q. The promotion left the document dirty; the prompt should appear. Don't Save. + +## Result + +_Fill in after the session. Copy the table with the observations, the verbatim answers to 3, 5 and 7, +and one paragraph: what the layout gets right, what it gets wrong, and what changes before the exact +layout is fixed (ADR-0064: "exact layout should follow this interaction proof")._ + +- Date / build: +- Creator: +- Pass / fail: +- Observations: +- Probes: +- Changes to make: diff --git a/mcp/gate1/publish_review.py b/mcp/gate1/publish_review.py new file mode 100644 index 00000000..44ede9e6 --- /dev/null +++ b/mcp/gate1/publish_review.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Gate 1 / PR 4 — publish ONE review with real media for the Review workspace. + +The proof gate of docs/roadmap/continuous-creative-workflow-gate1.md: a brief, a baseline, two rendered +alternatives, one question. This script plays the "agent" for that round, honestly: + + 1. loads the project folder, saves it, and snapshots it as the BASELINE version + 2. records the brief (the creator's direction, protections, preferences) in work/brief.json + 3. renders the baseline excerpt with the deterministic offline export (export_av) + 4. for each direction: copies the project to an ISOLATED sibling folder, loads the copy, applies the + direction's edits, saves, renders the same excerpt, snapshots the copy INTO the original's + work/versions (ADR-0062 §2 — the foreground document is never edited), writes the candidate record + with provenance + evidence (export result, measured loudness for the audition-only level match) + 5. reloads the original, writes the review item (one question, baseline media + candidates, passage) + and appends the review_opened event + +The Review workspace in the app picks the new decision up within ~5 s. Nothing here schedules or +executes background work (ADR-0063 is gate 3); this is the persistence + review proof, run by hand. + +Usage (app running; `uv run --directory mcp python gate1/publish_review.py ...`): + publish_review.py --project ~/Music/vivid-gate1/song-sketch --from-example + publish_review.py --project --scene 0 --bars 8 --directions my_directions.json + +--directions is a JSON list of {"label","purpose","edits":[{"method":"set_node_param","args":{...}}, ...]}; +the default is two visual directions for examples/song-sketch (AuroraField / Feedback / Blur). +""" +import argparse +import datetime +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import time +import urllib.error +import urllib.request + +PORT = int(os.environ.get("VIVID_PORT", "9876")) +REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + +DEFAULT_BRIEF = { + "direction": "Keep the drums and the chord part as they are. Make the chorus feel more expansive, and let the " + "visuals open up with it. Give me two directions.", + "protections": [{"id": "p1", "text": "Keep the drums and the chord part", "hard": True, + "scope": {"tracks": ["EZdrummer 3", "Pigments"], "note": "notes, patterns, and their plugin patches"}}], + "preferences": [{"id": "q1", "text": "the chorus feels more expansive; the visuals open up with it"}], +} +DEFAULT_QUESTION = "Which direction opens the chorus up better?" +# Two visual directions for examples/song-sketch: node 4 = AuroraField (warp/hue/density/glow), +# node 1 = Feedback (decay), node 2 = Blur (radius). Params are 0..1. +DEFAULT_DIRECTIONS = [ + {"label": "A", "purpose": "open field — sparse, bright aurora with long trails", + "edits": [{"method": "set_node_param", "args": {"node_id": 4, "name": "density", "value": 0.12}}, + {"method": "set_node_param", "args": {"node_id": 4, "name": "glow", "value": 1.0}}, + {"method": "set_node_param", "args": {"node_id": 1, "name": "decay", "value": 0.97}}]}, + {"label": "B", "purpose": "slow bloom — cooler hue, soft focus, barely any warp", + "edits": [{"method": "set_node_param", "args": {"node_id": 4, "name": "hue", "value": 0.55}}, + {"method": "set_node_param", "args": {"node_id": 4, "name": "warp", "value": 0.05}}, + {"method": "set_node_param", "args": {"node_id": 2, "name": "radius", "value": 0.85}}]}, +] + + +class ControlError(RuntimeError): + pass + + +def call(method, timeout=120, **args): + req = urllib.request.Request(f"http://127.0.0.1:{PORT}/{method}", data=json.dumps(args).encode(), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=timeout) as r: + out = json.loads(r.read()) + except urllib.error.URLError as e: + raise ControlError(f"{method}: no app on port {PORT} ({e})") from None + if not out.get("ok", True): + raise ControlError(f"{method} failed: {out.get('code')}: {out.get('error')}") + return out + + +def call_patient(method, **args): + """A call whose handler may outlast the server's own timeout (load_project compiles project-local + operators): on 'main loop not draining' wait until the app answers again, then continue.""" + try: + return call(method, **args) + except ControlError as e: + if "not draining" not in str(e): + raise + for _ in range(180): + time.sleep(1) + try: + call("get_health") + return {"ok": True, "late": True} + except ControlError: + pass + raise + + +def wait_settled(timeout=60): + """The load's undo baseline settles over a few frames (async plugin rebinds); wait until the + document is stable (dirty == False and preferred/version state readable).""" + t0 = time.time() + while time.time() - t0 < timeout: + try: + st = call("work_status") + if not st.get("dirty", True): + return st + except ControlError: + pass + time.sleep(0.5) + return call("work_status") + + +def now(): + return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def make_id(prefix, seed): + stamp = datetime.datetime.now(datetime.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + return f"{prefix}-{stamp}-{hashlib.sha256((seed + str(time.time_ns())).encode()).hexdigest()[:6]}" + + +def write_json(path, obj): + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(obj, f, indent=2) + f.write("\n") + os.replace(tmp, path) + + +def append_jsonl(path, obj): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "a") as f: + f.write(json.dumps(obj) + "\n") + + +def export_excerpt(path, bars, fps): + os.makedirs(os.path.dirname(path), exist_ok=True) + call("export_av", path=path, bars=bars, fps=fps) + t0 = time.time() + while time.time() - t0 < 1800: + try: + st = call("av_export_status", timeout=30) + except ControlError as e: + if "not draining" in str(e): + time.sleep(1) + continue + raise + if st.get("done") and not st.get("active"): + if st.get("dropped_frames", 0): + raise ControlError(f"export dropped {st['dropped_frames']} frames — refusing to publish an incomplete excerpt") + return st + time.sleep(0.5) + raise ControlError("export timed out") + + +def measure_rms_db(path): + """Integrated RMS (dBFS) of the excerpt's audio via ffmpeg volumedetect — the evidence the Review + workspace's audition-only level match uses. None when ffmpeg is unavailable.""" + if not shutil.which("ffmpeg"): + return None + try: + out = subprocess.run(["ffmpeg", "-v", "info", "-i", path, "-af", "volumedetect", "-f", "null", "-"], + capture_output=True, text=True, timeout=120).stderr + m = re.search(r"mean_volume:\s*(-?[\d.]+) dB", out) + return float(m.group(1)) if m else None + except Exception: + return None + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--project", required=True, help="project FOLDER that receives the records (work/)") + ap.add_argument("--from-example", action="store_true", help="create --project from examples/song-sketch if missing") + ap.add_argument("--scene", type=int, default=0, help="scene to launch before rendering (the passage)") + ap.add_argument("--bars", type=float, default=8.0, help="excerpt length in bars") + ap.add_argument("--fps", type=float, default=30.0) + ap.add_argument("--question", default=DEFAULT_QUESTION) + ap.add_argument("--brief", help="JSON file with {direction, protections, preferences} (default: song-sketch brief)") + ap.add_argument("--directions", help="JSON file with the candidate directions (default: two visual directions)") + ap.add_argument("--keep-copies", action="store_true", help="keep the isolated candidate copies next to the project") + a = ap.parse_args() + + project = os.path.abspath(os.path.expanduser(a.project)) + if not os.path.isfile(os.path.join(project, "project.json")): + if not a.from_example: + sys.exit(f"{project} is not a project folder (no project.json); pass --from-example to create it from song-sketch") + src = os.path.join(REPO, "examples", "song-sketch") + shutil.copytree(src, project, ignore=shutil.ignore_patterns("*.dylib", "work")) + print(f"created {project} from examples/song-sketch") + brief_in = json.load(open(a.brief)) if a.brief else DEFAULT_BRIEF + directions = json.load(open(a.directions)) if a.directions else DEFAULT_DIRECTIONS + work = os.path.join(project, "work") + + call("get_health") + st = call("status") + bpm = float(st.get("bpm", 120.0)) + beats_per_bar = 4 + + # 1. Baseline: load, settle, save (so disk == document), snapshot. + print(f"loading {project}") + call_patient("load_project", path=project) + wait_settled() + call("save_project", path=project) + base = call("work_snapshot_version", label="baseline", source="manual", brief_rev=1) + base_v = base["id"] + print(f"baseline version {base_v} ({base['dependencies']} files, {base['plugins']} plugins)") + + # 2. The brief (rev 1) + its log. + brief = {"schema": 1, "rev": 1, "updated": now(), "direction": brief_in["direction"], + "protections": brief_in.get("protections", []), "preferences": brief_in.get("preferences", []), + "interpretation": {"focus": "chorus", "domains": ["audio", "visual"], "excerpt": {"scene": a.scene, "bars": a.bars}}} + write_json(os.path.join(work, "brief.json"), brief) + append_jsonl(os.path.join(work, "brief.log.jsonl"), brief) + + # 3. The baseline excerpt. + recipe = {"scene": a.scene, "start_bar": 1, "bars": a.bars, "fps": a.fps, "bpm": bpm, "beats_per_bar": beats_per_bar, + "warmup_sec": 0, "renderer": "export_av (deterministic offline, current arming from local beat 0)"} + call("launch_scene", scene=a.scene) + base_media_path = os.path.join(work, "media", "baseline", "excerpt.mp4") + st = export_excerpt(base_media_path, a.bars, a.fps) + base_rms = measure_rms_db(base_media_path) + base_media = {"path": "work/media/baseline/excerpt.mp4", "kind": "av", + "recipe": dict(recipe, **({"rms_db": base_rms} if base_rms is not None else {}))} + print(f"baseline excerpt: {st['frames']} frames, {st['duration_sec']:.2f}s, peak {st['peak']:.2f}, rms {base_rms}") + + # 4. Candidates, each on an isolated copy. + cands = [] + for d in directions: + cid = make_id("c", d["label"]) + copy = f"{project}-candidate-{d['label']}" + if os.path.exists(copy): + shutil.rmtree(copy) + shutil.copytree(project, copy, ignore=shutil.ignore_patterns("work", "*.dylib")) + print(f"[{d['label']}] isolated copy {copy}") + call_patient("load_project", path=copy) + wait_settled() + applied = [] + for e in d["edits"]: + call(e["method"], **e["args"]) + applied.append(e) + time.sleep(0.5) + call("save_project", path=copy) + call("launch_scene", scene=a.scene) + media_rel = f"work/media/{cid}/excerpt.mp4" + media_abs = os.path.join(project, media_rel) + st = export_excerpt(media_abs, a.bars, a.fps) + rms = measure_rms_db(media_abs) + snap = call("work_snapshot_version", label=f"candidate {d['label']}: {d['purpose']}", source="candidate", + parent=base_v, brief_rev=1, into=project) + cand = {"schema": 1, "id": cid, "version": snap["id"], "baseline": base_v, "purpose": d["purpose"], "brief_rev": 1, + "created": now(), + "provenance": {"runner": "mcp/gate1/publish_review.py", "run": make_id("run", d["label"]), + "summary": f"applied {len(applied)} edit(s) on an isolated copy", "edits": applied, + "working_copy": copy}, + "evidence": {"media": [{"path": media_rel, "kind": "av", + "recipe": dict(recipe, **({"rms_db": rms} if rms is not None else {}))}], + "technical": [{"check": "export_av", "ok": True, "frames": st["frames"], + "dropped_frames": st.get("dropped_frames", 0), "peak": st["peak"], + "clipped": st["clipped"]}, + {"check": "protections", "ok": True, + "note": "no edit touched a protected track (visual-graph params only)"}], + "intent": [{"evaluator": "unavailable", "note": "no content/intent evaluator ran (ADR-0026)"}]}, + "status": "proposed"} + write_json(os.path.join(work, "candidates", cid + ".json"), cand) + append_jsonl(os.path.join(work, "events.jsonl"), {"at": now(), "type": "candidate_added", "data": {"candidate": cid, "version": snap["id"]}}) + cands.append(cid) + print(f"[{d['label']}] version {snap['id']} · {st['frames']} frames · rms {rms}") + if not a.keep_copies: + shutil.rmtree(copy, ignore_errors=True) + + # 5. Back to the original; publish the one question. + call_patient("load_project", path=project) + wait_settled() + rid = make_id("r", a.question) + review = {"schema": 1, "id": rid, "created": now(), "question": a.question, "baseline": base_v, + "baseline_media": base_media, "candidates": cands, + "passage": {"start_bar": 1, "end_bar": 1 + a.bars}, "status": "open"} + write_json(os.path.join(work, "reviews", rid + ".json"), review) + append_jsonl(os.path.join(work, "events.jsonl"), {"at": now(), "type": "review_opened", "data": {"review": rid, "question": a.question}}) + + print("\npublished:") + print(f" review {rid}") + print(f" baseline {base_v}") + for c in cands: + print(f" candidate {c}") + print(f" records {work}") + print("\nThe Review workspace shows the decision within ~5 s (badge on the Create | Review switch).") + print("Measurement protocol: docs/roadmap/gate1-measurement.md") + + +if __name__ == "__main__": + try: + main() + except ControlError as e: + sys.exit(f"error: {e}") diff --git a/mcp/vivid_mcp.py b/mcp/vivid_mcp.py index ce77a053..f0c2c809 100644 --- a/mcp/vivid_mcp.py +++ b/mcp/vivid_mcp.py @@ -272,6 +272,33 @@ def layout_graph() -> dict: return _post("layout_graph") +@mcp.tool +def work_status() -> dict: + """ADR-0061/0062 work records (gate 1): where the loaded project's records are and what state they + are in. Returns {project_dir, is_folder_project, has_records, preferred_version (the version the + document derives from, "" = none), dirty, brief_rev, open_reviews, reviews[{id,status,question, + candidates}], versions, candidates}. The records themselves are plain JSON under /work/ + (brief.json, versions//, candidates/.json, reviews/.json, feedback.jsonl, events.jsonl) + — read and write them directly; the Review workspace picks up new reviews within ~5 s.""" + return _post("work_status") + + +@mcp.tool +def work_snapshot_version(label: str, source: str = "manual", parent: str = "", brief_rev: int = 0, + into: str = "") -> dict: + """Snapshot the LOADED project folder as an immutable work-records version: a complete copy of + project.json + co-located assets (minus work/ and build outputs) with a SHA-256 per file and the + document's plugin identities. Reads project.json from DISK, so save_project first (an unsaved + document is refused with `conflict`). `into` = another project folder whose work/ receives the + version — the candidate flow: load_project a COPY, edit it, save, export media, then snapshot it + `into` the original so the foreground document is never touched (ADR-0062 §2). `source`: + manual | candidate | promotion. Returns {id, records_dir, dependencies, plugins, document_sha256}.""" + payload = {"label": label, "source": source, "parent": parent, "brief_rev": brief_rev} + if into: + payload["into"] = into + return _post("work_snapshot_version", payload) + + @mcp.tool def undo() -> dict: """Undo the last document edit (ADR-0017). One app-wide history covers the visual graph + the