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