Skip to content

feat(jianying): add the jianying Plugin (10 MCP verbs + 7 Skills, one command to a finished film) - #72

Open
antianqi wants to merge 38 commits into
MiniMax-AI:mainfrom
antianqi:feature/jianying-plugin
Open

antianqi wants to merge 38 commits into
MiniMax-AI:mainfrom
antianqi:feature/jianying-plugin

Conversation

@antianqi

@antianqi antianqi commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

What changes

Adds plugins/antianqi/jianying — a Windows-only MiniMax Code Plugin that grades video with a
pixel-for-pixel re-implementation of Jianying's own grading math, edits Jianying drafts through the
running application's local named pipe, and renders a finished film from a draft in one command.

Layer Content
MCP verbs (9) draft_open · draft_read · draft_patch (27 official behaviour operations) · draft_commit · render_frames · grade_apply · audio_mix · verify_diff · make_film (one command from a draft to a finished film) — mcp/server.mjs, stdio, 0 npm dependencies, one hand-written input schema per verb under mcp/tools/
Skills (7) jianying-cut · jianying-draft-edit · jianying-review · jianying-talk-cut · jianying-pipeline (cross-chain orchestration) + two library Skills (jianying-verify, jianying-grade-chain) that only activate when referenced
Knowledge references/ — chain API semantics, verification discipline, pitfalls, the official operation set, a machine-readable intent map (utterance → parameters) and a machine-readable pipeline profile set (four cross-chain routes)
Guardrails scripts/ — the plugin's own MCP client, an environment self-check, five self-tests, a session-containment security probe, and the talk-cut planner/applier
Effect probes draft_patch returns the changes it made and whether it believes the change took effect, and takes an optional expect that turns an acceptance into a verdict — because an official acceptance is not a change (see below)

Both runtime layouts ship in parallel: plugin.json + mcp.json (portable Agent Plugins 1.0, read by
mcode 0.3.x) and .claude-plugin/plugin.json (mcode 0.4.0+ manifest, with the seven Skill paths and the
stdio MCP server).

The grading math is re-implemented rather than delegated: the official exporter is blocked by -100007
(session mode) and -100015 (unauthorized host process), and its command tree is five days older than
the application build. The Plugin re-implements the 13 grading features to ≤ 1 LSB and states the
block in the README instead of working around it.

Security — the session-state path traversal (review finding, fixed)

The review on this PR found a path traversal in the session state, and it was real. The session id was
pasted into the state file name, session-${sid}.json, while the schema only required it to be a string.
A value such as x/../../../../tmp/canary was normalised by path.join into a path outside
PLUGIN_DATA
: draft_read answered with the contents of that file, and draft_patch wrote a snapshot
there.

Three layers now stop it, in mcp/server.mjs:

  1. Shape — a session id must be ve-session-<digits>, the form the server itself issues, so it can
    carry no separator and no ...
  2. Containment — the joined path must lie strictly inside the state directory; a sibling whose name
    merely shares the prefix does not count as inside.
  3. Symlink — the state directory is resolved before use, and a state file that is itself a symlink is
    refused, for reading and for writing.

One related exposure was found while fixing it: draft_read used to fall back to a read with no path,
which returned some default draft — so an invalid session id still produced a draft, which was both
an exposure and a false success. The fallback now uses the path the session itself recorded, and with
neither a path nor a session it reports that there is nothing to read. The regression that came with
that change was caught by the suite before it shipped: reading a session with no snapshot had gone
through the same fallback.

The proof, and the negative injection that shows the check bites

scripts/security-probe.mjs isolates PLUGIN_DATA, places a canary beside the state directory and
beside a sibling directory, then sends nine forms of session id (POSIX relative traversal, Windows
backslash traversal, POSIX absolute, Windows absolute, sibling-prefix, URL-encoded, NUL byte, bare ..,
dot-segment) to three verbs (draft_read, draft_patch, draft_commit). It asserts that each is
refused, that the canary marker never appears in a response, that no file appears outside the state
directory, and — the part that matters — that a well-formed id whose file does sit inside the state
directory is still read. Without that last case a probe that refused everything would look green.

Result
Probe on the fixed head 30 PASS / 0 FAIL
Probe with the old implementation restored (three defences removed) 22 PASS / 8 FAIL, including the canary being read back (泄漏canary=true) and draft_commit reporting committed: true outside the directory
Probe after the file is restored 30 PASS / 0 FAIL

The probe is wired into test/jianying.test.mjs, so the windows CI job proves it on every push, and
into the Plugin self-test.

User value

A MiniMax Code user can grade a clip and export a 10-bit master without opening Jianying, can edit a
Jianying draft — add a clip, trim, split, change a segment's volume — while the application stays the only
execution platform and the only renderer, and can hand a draft to the Plugin and get a finished film back
with one command.

Example prompt

给这个视频调色出片,风格用暖一点的,输出到 out\cut\demo.mp4

Expected result

  1. The environment self-check runs first and names any missing dependency.
  2. grade_apply exports graded10.mp4 (direct point-wise path, recommended) and graded_lut.mp4
    (65³ LUT path), and returns a per-node effectiveness probe: every node in the spec must produce a
    non-zero MAD, and a zero node fails the verb instead of silently dropping that node.
  3. The reply carries the whole-chain change amount and the output paths.
  4. A follow-up "review this version" asks for a multi-dimension comparison (visibility / picture
    quality / loudness / timeline / draft integrity) and reports the smallest repair range, not a vague
    suggestion.

Two more example prompts, for the parts that are not grading:

把这个草稿里的第一段音量调到 25%,改完落盘并确认生效
审一下这版,告诉我哪里要返修、返修后用什么证据确认

And the cross-chain one, which jianying-pipeline exists for:

把这个剪映草稿调暖一点,然后出成片

⇒ draft_open → make_film on the same copy → built-in acceptance (both streams present, duration
within one frame of the official timeline, loudness within 1 LUFS of the target). The routes are also
machine-readable in references/pipeline-profiles.json (4 routes, 18 steps), and the audit checks every
verb, argument and variable of it against the schemas.

Plugin submission checklist

  • Plugin lives at plugins/<github-owner>/<plugin-name> — plugins/antianqi/jianying.
  • plugin.json name matches the Plugin directory — jianying in both manifests.
  • README.md includes a real example prompt and expected result.
  • LICENSE and plugin.json declare an open-source license — Apache-2.0 in all three places.
  • Required executables, accounts, paid services, and supported platforms are disclosed —
    Windows 10/11; ffmpeg 6.0, Python 3.9+, Node ≥ 22, Jianying 11.6 (running for the draft verbs);
    JYAI_HOME pointing at the toolchain; no account, no paid service, no API key.
  • Network destinations and data handled by the plugin are disclosed — no network access, no
    credentials, no telemetry, no third-party services; the only socket is the local Windows named
    pipe of a running Jianying instance; drafts are read-only and every write happens on a copy with
    a per-file sha assertion that the real draft did not change.
  • Session state cannot be used to read or write outside PLUGIN_DATA — see the security section:
    three independent layers plus a 30-case probe that is negative-injected.
  • No credentials, private endpoints, hidden telemetry, installers, symlinks, or native binaries are
    included — 42 text files, no symlinks, no binaries, no scaffold placeholders.
  • Every scaffold TODO has been replaced.
  • npm run check passes.

Evidence

node scripts/validate.mjs

OK   plugin antianqi/jianying
Validated 30 hosted Plugins and all examples.

node --test test/jianying.test.mjs — 11 pass / 0 fail. The suite pins the things that fail
silently: every skills[] path in the v0.4 manifest resolves and its frontmatter name equals its
directory name (a mismatch is dropped by the runtime with no diagnostic), the stdio transport and its
portable working directory, all nine verb schemas, ${PLUGIN_ROOT} anchoring of script invocations, the
existence of every referenced file, the absence of a network client / credential literal / telemetry, the
absence of contributor machine paths, the README disclosures, and the session containment probe.

The Skill list is read from the directory rather than hard-coded, so adding a Skill cannot silently
desynchronise the manifest and the test.

Negative-injection audit of that test — eight defects injected one at a time, suite re-run, file
restored from a backup afterwards; each injection failed for the expected reason and the suite returned
to 11/11 after every restore:

Injected defect Expected failure
frontmatter name != directory name frontmatter name must equal its directory name
description over 1024 characters description is 1285 chars (limit 1024)
hardcoded drive-letter path in a script must not hardcode a drive letter path
fetch() in a script must not make HTTP requests
README loses "expected result" README must state the expected result
README loses the "no telemetry" disclosure README must disclose telemetry behaviour
skills[] entry points at a missing file deep-equal mismatch on the Skill path list
a Skill directory removed from skills[] deep-equal mismatch on the Skill path list

Plugin self-tests (Windows, manual — the end-to-end ones need a live Jianying 11.6 and a real draft;
the paths are supplied through JYAI_DRAFT_LIB, and the suite prints an explicit SKIP when it is not set,
so it can never report a false green):

Self-test Result
selftest-mcp.mjs 48 / 48
selftest-params.mjs 8 / 8
selftest-draft-edit.mjs 16 / 16
selftest-talk-cut.mjs 10 / 10
selftest-subtitle.mjs 4 / 4
selftest-audio-ai.mjs 8 / 8
security-probe.mjs 30 / 30
check-env.mjs 17 OK / 0 MISS

These carry their own negative injections: a synthetic noise frame set must be judged "not visible", a
synthetic large change must be judged "visible", a white background pushed to 255 must be reported as
newly clipped, an expect that says a field must not change must fail, a misspelled grading parameter
must be refused instead of baking an identity LUT
, and a near-black source must be reported as
carrying no measurable information
rather than as a node that did not take effect.

Mechanical audit of the whole Plugin — a sweep over the manifests, the schema to handler parameter
mapping, hard-coded paths, document references, Skill frontmatter, document to implementation
consistency, the intent map and the pipeline profiles: 0 issues (HIGH 0 / MED 0 / LOW 0). The audit
tool carries 28 negative injections of its own, so "0 issues" is known to be reachable only by a
correct Plugin, and the tool is known to report a false negative when it is wrong.

Two findings came out of that audit and are worth naming here, because both were invisible before:

  • A misspelled parameter name in the grading spec was silently ignored and baked an identity LUT —
    the cube came out byte for byte identical to the cube of an empty spec, so the render succeeded and the
    acceptance passed while no grading happened at all. make_film now validates its grade against the
    grade_apply spec schema, and the baking step itself refuses unknown parameter names, so bypassing
    the schema is also covered.
  • The intent map (utterance → parameters) was the only place a wrong operation could hide, because it
    lived in prose. It is now machine-readable, and the audit checks every parameter name against the
    schema and every operation against the official set. That check found an operation the draft-edit Skill
    offered, invokeBatch, which does not exist among the 199 official operations.

Parameter coverage — a sweep over the schemas listed twelve parameters that no test had
touched. Seven of the nine are now covered by selftest-params.mjs, which makes its own media (one
grey clip, two frame directories, two clips with a tone at different levels) so it depends on no user
asset, and whose criteria are deterministic: a file is present or not, a threshold moved or not, a
frame count within a bound or not. Two of them turned out to have conditions the name did not suggest:
noiseK only takes part in the criterion line when a noise directory is supplied, and maxFrames
shortens the film below the official timeline so the built-in acceptance refuses it — a truncated film
must not pass.

An official acceptance is not a change — draft_patch used to repeat what the official side said,
so an operation the official side accepted without touching the draft looked like a success. It now returns
the segment level differences it computed itself, says whether it believes the change took effect, and accepts
an optional expect that refuses the call when the expected field did not move. The measurements behind this:
Jianying writes eleven keys per segment to disk while the official edit returns fifty two, and comparing
those two puts dozens of shape fields into the difference — which is why the comparison now looks only at
fields present on both sides and reports the shape difference separately, and why the semantic criterion runs
two patches in one session so that both sides have the same shape.

Windows CI — .github/workflows/jianying-windows.yml runs only node --test test/jianying.test.mjs
on windows-latest, filtered to this Plugin's paths, so the session containment probe is proven on every
push. The repository's validate job stays on ubuntu-latest; this is the scoped Windows counterpart
that .github/workflows/ci.yml asks a non-tool-map Windows Plugin to add, rather than re-introducing a
repository-wide Windows job (that job was dropped in round-9 because it failed on files a PR does not own).

Known limits (stated in the README as well)

  • Windows only. The draft verbs need Jianying 11.6 running; grading and export do not need the GUI.
  • Structured grading fields (curves, HSL, colour wheels) cannot be written back into a Jianying preview —
    the official preview renderer itself does not read them (it handles intensity-style grading and geometry
    only), so grading is local export only. The curve parameters come from the Plugin's own interface, so
    the user does not have to save a graded draft in Jianying first.
  • primaryWheel and the user pick key of hsl are not wired into the chain API form: they used to
    bake an identity LUT silently, and they now fail loudly.
  • The official exporter stays unreachable, and the Plugin does not attempt to bypass the block.
  • clear (8-level pyramid) is not wired into the chain.
  • talk-cut does not do ASR: the official ASR lives in the agent runtime and is not reachable from a
    plugin, so it takes a user-supplied SRT and only decides which lines are filler, pauses or repeats.
  • The official audio.add answers with accepted and a changed path, and the draft still holds no audio material at all — whether the file sits outside the draft or inside its own directory. The Plugin therefore reports the change as not verified instead of repeating the acceptance, and the audio parameters of make_film (beautify, targetLufs) remain without an end-to-end check.
  • The draft library holds no draft with an audio track, so the two audio parameters of
    make_film (beautify, targetLufs) are the only ones still without an end-to-end check. Every
    draft in it is either video only or encrypted. That is a gap in the fixture rather than in the
    code, and it is recorded as such.

Standalone copy

The same Plugin is also published as a standalone repository whose root is the Plugin root, so it can
be installed from the local marketplace before this PR merges:

git clone https://github.com/antianqi/jianying-plugin.git ~/.minimax/plugins/jianying
mcode plugin list          # the local marketplace is a directory scan: jianying@local enabled

That repository carries the same Plugin files, an EVIDENCE.md with the measured numbers, and the same
test/jianying.test.mjs — the focused suite is layout-agnostic (it detects the Plugin root instead of
assuming the hosted plugins/<owner>/<name>/ path), so one file guards either layout.


Update since the last review — the orchestrator learns to wait for a human

The tenth verb, run_pipeline, previously refused script steps outright
("not wired into the orchestrator yet"), so the one route that must ask a human before it
cuts — talk-cut-then-render — could not run at all. It can now.

What the pipeline gained

Capability How it behaves
Script steps A step may be a script under scripts/. The keys of args are the command-line flags ("--srt": "$srt"), so the script receives what the profile says. true becomes a bare flag, false/null is not passed.
A human confirmation gate A step marked "confirm": true stops the run after it completes and hands back the artifact, its sha, a preview and how many steps are still pending. The gate is declared explicitly ("artifact": "$planPath"); the orchestrator never guesses.
A two-phase resume Progress is persisted to PLUGIN_DATA/pipelines/<token>/state.json, so the second phase survives an interrupted call. resume{token, confirm:true} continues it.

The gate is not a promise, it is enforced — and the self-test checks the enforcement, not the feel:

Assertion Expected Measured
First phase stops at the gate, the remaining 3 steps never run verdict=AWAIT_CONFIRM, gate.step=3, gate.pendingSteps=3
Resume without confirm refused, nothing runs verdict=REFUSED; the self-test asserts that talk-cut-apply.mjs was not among the executed steps
A token that does not exist a named error (never a silent restart) 找不到流水线状态
Resume with confirm runs to the end verdict=PASS, and the cut step proves 2/2 ranges removed
The real draft untouched throughout identical sha before and after

Two bugs fell out of the work, and both made every verdict unreliable

  1. Jianying recycles session ids. A brand-new session inherited the previous one's snapshot,
    so draft_read returned a different draft — once one left over from two days earlier. The
    symptom was a split that was accepted yet changed nothing. draft_open now drops the stale
    snapshot for a recycled id, and the session records the draft id so a mismatch is named.
  2. The effect probe read its baseline from the encrypted draft file. It parsed nothing, decided
    "nothing changed" and reported changes=0 — while the segment really had been split in two.
    The probe now falls back to the official pipe.

Generalisation: when the baseline of a verdict cannot be read, find another way to read it — never
treat it as "there is no baseline".

The cut criterion was rewritten to be form-independent

The old criterion compared total durations of two different forms (the on-disk draft is compact,
the official answer is expanded) and was a false red. The runtime also shifts later segments left
when a segment is deleted, so "the range is no longer covered" was a false red as well. The new
criterion is: the duration total of that track decreased by exactly t1 - t0 µs — independent of
form, independent of shifting, grouped by track id (two video tracks share a type). In dry-run mode
the criterion cannot hold, which the script prints as its own negative control, and a failed verdict
now exits non-zero so the orchestrator reports a failed step instead of a successful one.

Measured on the fixture: 2200000..2600000 µs took the track from 3 000 000 to 2 600 000 µs, and
200000..900000 µs from 2 600 000 to 1 900 000 µs — 2/2 verified, with the real draft's sha
unchanged.

Also fixed: "cannot tell" must not look like "did not change"

The segment-level diff only carries the fields that changed, so "unchanged" and "not comparable"
were indistinguishable — expect:{segmentFieldUnchanged} was silently satisfied (a false green). The
Plugin now counts how many segments have the field on both sides and reports "cannot tell" as
a failure. After the fix the negative injection of selftest-draft-edit.mjs fails for the right
reason (不该变的 "volume" 字段却变了) instead of for a vague one.

Numbers

Suite Result
scripts/selftest-mcp.mjs 56 / 56 (was 52; four new gate assertions)
scripts/selftest-draft-edit.mjs 17 / 17 (was 13/3 — the three failures predate this round and are fixed)
scripts/selftest-talk-cut.mjs 7 / 7
scripts/check-env.mjs 17 OK / 0 MISS (the schema count was hard-coded to 8 and has been failing since the tenth verb landed)
mechanical audit 0 findings (HIGH 0 / MED 0 / LOW 0)
audit self-test 32 / 32 (two new negative injections)
parameter coverage 10 / 10
intent routing top-1 100% / top-3 100%
security probe 30 / 30
MCP semantics 14 / 14
focused suite / upstream validator 11 / 11 · 30 plugins green

The two self-test failures that predate this round are worth flagging: selftest-draft-edit.mjs
called draft_read with no target at all (it lost its argument when the scope parameter was
removed), and check-env.mjs asserted a hard-coded number of schemas. A red self-test that nobody
fixes is the same as no self-test.


Two more routes, and the enum drift that hid them

The orchestrator had exactly one script route (talk-cut-then-render), and two verbs —
audio_mix and render_frames — belonged to no route at all. Two more routes now exist,
and the schema turned out to be the reason nobody noticed.

The routes

Route Steps Measured
subtitle-then-render draft_open → draft_read → add-subtitles.mjs → draft_commit → make_film PASS · the subtitle script proves 2/2 cues land within ±1 frame (33333 µs) · film 3.000000 s
render-and-mix draft_open → make_film → audio_mix PASS · two streams, -14.00 LUFS (target -14) · mixed.mp4 measures -14.28 LUFS

subtitle-then-render is the second script step, so the script-step mechanism is not a
one-off patch for talk-cut. The subtitle script now follows the same rule as
talk-cut-apply: --sessionId reuses the orchestrator's session and leaves the commit to the
orchestrator — scripts only change; opening and committing belong to the orchestrator.

Products are handed from one step to the next

audio_mix needs the picture that make_film just produced, so a step can now declare what it
produces:

{ "verb": "make_film",
  "args": { "draft": "$dir", "outDir": "$outDir", "denoise": "auto" },
  "produces": { "$film": "film.mp4", "$mixed": "mixed.mp4" } },
{ "verb": "audio_mix", "args": { "draft": "$dir", "video": "$film", "out": "$mixed" } }

The blind spot: a hand-written enum nobody reconciled

Both new routes were added to pipeline-profiles.json. The audit stayed green, the file was
right — and calling the new route was rejected:

args.profile: "subtitle-then-render" 不在允许集合
[grade-and-render, edit-and-verify, edit-then-render, talk-cut-then-render]

The profile enum in run-pipeline.schema.json is hand-written and was never reconciled with
the route file — two sources of truth, each with its own opinion. The audit now reconciles them
in both directions, and a negative injection (㉜) proves it fires.

A weight function that contradicted its own comment

The two new test sentences were both mis-routed at first:

Sentence Expected Before
给这个视频加字幕再出片 subtitle-then-render jianying-draft-edit (matched 加字幕, 字幕)
出片的时候把混音按草稿重做一遍 render-and-mix jianying-cut (matched 出片)

The weight function said "longer keywords are more specific, so they weigh more" — and then
gave every keyword of three characters or more exactly the same weight. A sentence matching
"加字幕再出片" (6 characters) therefore lost to the generic skill entry that matched
"加字幕" (3) plus "字幕" (2). The weight now grows with length, and the classifier is back at
top-1 100% / top-3 100% over 33 sentences.

A comment says what the code means, not what the code does.

Honest failure when there is nothing to mix

On a draft without an audio track, render-and-mix fails and says where it stopped
(nothing to mix) — which is correct, so the self-test asserts that: with an audio draft it
asserts success, without one it asserts the honest failure. "No audio track" is not a reason
to fail; pretending to succeed is.

Numbers

Suite Result
scripts/selftest-mcp.mjs 58 / 58 (two new route cases)
mechanical audit 0 findings
audit self-test 33 / 33 (new negative injection for the enum drift)
intent routing 33 sentences · top-1 100% / top-3 100%
routes 6 routes / 26 steps / 3 script steps
the rest parameter coverage 10/10 · security probe 30/30 · MCP semantics 14/14 · draft-edit 17/17 · talk-cut 7/7 · env check 17 OK/0 MISS · focused suite 11/11 · 30 plugins green

Reviewing one version, and "PASS" not meaning "everything was checked"

The pipeline still had no route for reviewing a single version — "审一下这版" (let me review this
cut) had to be done by hand — and verify_diff buried the dimensions it could not judge inside
checks, so a PASS read as "everything was checked".

What a single-version review can honestly judge

Dimension Single version Why
visibility cannot be judged (reported skipped) — but it reports the noise floor with no "before" there is no change to compare
picture absolute values only, never a failure pure white/black that was already in the footage is not a defect
audio judged this version's LUFS / true peak (absolute target −14 LUFS)
timeline this version's structure only tracks / segments / duration
draft_integrity judged the real draft's sha

That table was not designed — it was derived from the evidence boundary: only conclude on the
dimensions the tool actually covers.

"PASS" must not mean "everything was checked"

The result now carries undetermined (which dimensions were not judged, and why) and
verdictNote:

"有 1 个维度【未判定】(visibility) ⇒ 这不是"完整通过";未测的维度要写成"待确认",不能写成"正常""

The self-test caught a second, sneakier case: a video-only draft has no audio stream, so
ebur128 measures nothing and integratedLufs came back null — neither skipped nor
problematic, so it read as "loudness is fine". Now a dimension that was checked but could not be
measured also reports undeterminedReason and lands in undetermined.

The self-test assertion is accordingly: loudness must either be on target
(|LUFS + 14| ≤ 0.5) or appear in undetermined — neither means failure.

The seventh route: review-only

draft_open(copy) → draft_read → render_frames(this version) → render_frames(★same state again = noise floor)
→ make_film (its own duration/loudness assertions) → verify_diff (one-sided)

It is read-only: reviewing does not authorise editing, so the route opens a copy, measures and
never commits. Measured on the fixture:

Item Measured
Noise floor MAD = 0.0000206 on one run, 0 on another ⇒ the noise floor is itself non-deterministic ⇒ measure it every time
Picture clipRatio = 0.00724 · verdict = "参考(未判定)"
Loudness integratedLufs = −14 ✓
Timeline 3 segments · single: true
Not judged visibility named in undetermined

A report-level defect found on the way

The orchestrator's step summary keeps a whitelist of fields — and verify_diff's verdict lives in
checks, so it was dropped: the route ran successfully and produced no review at all.
verify_diff now has its own compact summary (key numbers per dimension, per-frame detail
dropped so the report stays readable).

Numbers

Suite Result
scripts/selftest-mcp.mjs 59 / 59 (one new single-version review case)
mechanical audit 0 findings
audit self-test 33 / 33
intent routing 34 sentences · top-1 100% / top-3 100%
route table 39 entries (32 skill + 7 routes)
routes 7 routes / 32 steps / 3 script steps
the rest parameter coverage 10/10 · security probe 30/30 · MCP semantics 14/14 · draft-edit 17/17 · talk-cut 7/7 · env check 17 OK/0 MISS · focused suite 11/11

Comparing two versions, and a criterion that was written backwards

The orchestrator could review one version; the review skill's main workflow — before/after —
was still manual. The eighth route closes that.

setVars: one route, two drafts

Opening two drafts in one route meant the second draft_open overwrote $sessionId and
$dir, so the first draft's copy was lost. Two mechanisms, with different timings:

Mechanism When What it does
produces before the step the paths this step will produce (relative to $outDir) — so the step's own arguments can use them
setVars after the step captures what the step produced ($sessionId / $dir / $draftJson) under another name

Values may be a variable reference ("$dirBefore": "$dir") or a template ("$beforeOut": "$outDir/before").
The draft JSON files are now named per step (draft-<n>.json), so reading several drafts in one
route no longer overwrites them.

The audit now guards both: every setVars/produces target and every variable they reference must
be declared, and a produces path may not contain .. or be absolute.

The eighth route: review-compare

draft_open(before) → setVars($sidBefore/$dirBefore) → draft_read(before) → setVars($beforeDraftJson)
→ draft_open(after)  → setVars($sidAfter/$dirAfter)   → draft_read(after)  → setVars($afterDraftJson)
→ render_frames(before) → render_frames(after) → render_frames(★again = noise floor)
→ make_film(before) → make_film(after)
→ verify_diff(visibility + picture + audio + timeline + draft_integrity)

One comparison answers five questions: did the picture change (with the noise floor
calibrated), how much new clipping, what is each version's loudness, what the timeline
differs in, and whether the real draft was touched.

Measured (before = the video-only fixture, after = the fixture with an audio track):

Item Measured
Picture FAIL (the change is not visible) — the two versions differ only in audio ⇒ the criterion is not always-true
Noise floor MAD = 0.0000584 / 0.0000206 (different per run ⇒ the noise floor is itself non-deterministic)
Picture quality clipDelta = 0
Loudness before −14 LUFS (true peak −11.3 dBFS) · after −14 LUFS (true peak −13 dBFS)
Timeline segment count Δ = 1

★ That FAIL is correct — the picture really did not change. A criterion that always said
"visible" would be the one to worry about; this one saying "not visible" is the evidence that it
can be trusted.

A criterion written backwards

The report contradicted itself: "integratedLufs": -14 next to "loudness is not normalised to
−14 LUFS"
. The check was Math.abs(c.integratedLufs) > 0.5 — comparing the loudness value
itself
, so the absolute value of −14 (which is 14) tripped it and every properly normalised
track was reported as un-normalised
. It now compares the deviation: |lufs − (−14)| > 0.5.

★ Such a bug does not turn the self-test red — it only adds a note — so it can only be found
by a human reading the report. Review conclusions still have to be read line by line. The same
round also named the case where the before version cannot be measured (no audio): that is now
reported as "cannot compare", not as "loudness did not change".

The reconciliation guard earned its keep

Adding review-compare without updating the schema enum made the audit report — immediately —
pipeline profile「review-compare」没有出现在 run-pipeline.schema.json 的 profile 枚举里 ⇒ 这条路线存在却调不动.
The guard added one commit earlier caught a real drift on its very next use.

Numbers

Suite Result
scripts/selftest-mcp.mjs 60 / 60 (one new two-version comparison case)
mechanical audit 0 findings (incl. the new setVars/produces guards)
audit self-test 33 / 33
intent routing 35 sentences · top-1 100% / top-3 100%
route table 40 entries (32 skill + 8 routes)
routes 8 routes / 42 steps / 3 script steps
the rest parameter coverage 10/10 · security probe 30/30 · MCP semantics 14/14 · draft-edit 17/17 · talk-cut 11/11 · env check 17 OK/0 MISS · focused suite 11/11

The last uncovered verb: curve-class grading

A tally showed that of the ten verbs, grade_apply was the only one no route used — and it
is the only path that can do curves, HSL and colour wheels (make_film --grade covers the
intensity nodes the official preview renderer happens to carry). So the requests that most need
the grading chain could not be served by the orchestrator at all.

The ninth route closes that:

draft_open(copy) → make_film(★no grading: picture + mix only) → grade_apply(our per-pixel chain)

Measured with adjustColor + logWheel:

Item Measured
Per-node probe adjustColor = 0.0136 · logWheel = 0.0124 — every node changes the picture on its own
Grade change gradeChangeMad = 0.0260
Products graded/graded10.mp4 (10-bit) · graded/graded_lut.mp4 · graded/grade-side-by-side.png

⇒ All ten verbs are now covered by a route.

capture: the third way to hand something forward

Mechanism When What it does
produces before the step declares the paths the step will produce (relative to $outDir)
capture after the step takes a value out of the response — the graded film's exact file name is only known there
setVars after the step aliases an existing variable / derives a template

capture refuses politely: if the response has no such field, it says so instead of quietly
storing undefined.

Two more report-layer gaps closed

grade_apply's verdict lives in probe and gradeChangeMad, verify_diff's in checks —
neither is on the orchestrator's summary whitelist, so the route "succeeded" while the report
showed nothing about whether the grading had happened. Both now have their own compact summary,
and the report gained a vars field: what this route actually produced, visible at a glance.

The same mistake twice, and a tool that must not go blind

Writing "note": "first half " + "second half" in a JSON file — JSON does not support that — made
the audit crash with a stack trace, so it reported nothing else. Second time (the first was an
intent-routes.json field). Two fixes: write the note as one string, and make the audit's JSON
parsing go through readJson() (17 places) so a broken file becomes one finding and the audit
keeps going
.

A tool's failure mode is part of its design: a stack trace is blindness, a finding is still a
diagnosis.


From suggestions to verdicts: making "never guess" measurable

The classifier had always been honest about its role — "retrieval clues and candidate ranking;
the model still picks the chain"
— but that left the gap between the third and the fourth tier
open: could the machine actually decide?

It can, and building it exposed something worse than the missing feature.

A discipline written in a comment, guarded by nothing

classify-intent.mjs has always said "never guess: when nothing matches, say so" — and the
confident: false branch has always been there. But nothing measured it.

The consequence is concrete: anyone who adds "看看" or "弄一下" to the keyword table turns the
classifier into a guesser, and nothing turns red. A discipline nobody guards is only a comment.

So the plugin now ships references/intent-refusal-set.json — 18 sentences with no clear
intent
("帮我看看这个", "你看着办", "老规矩", "整理一下", "能不能用"…), each with a why — and
the self-test asserts that not one of them may be judged as a chain (hard guesses must be 0).

Measured: 18 sentences, 0 hard guesses. The behaviour was already correct — it simply was
not being protected.

Three possible outcomes

node scripts/classify-intent.mjs "<what the user said>" --decide
Outcome Condition What to do
Verdict top score ≥ 2 and it leads the nearest different target by ≥ 1 follow decision.route / decision.skill
Refusal · no signal top score < 2 judge by each Skill's description, or ask the user what they want done
Refusal · ambiguous two different targets are within 1 point show both candidates and let the user choose

A verdict is auditable: the matched words, the score and the margin are all in evidence.

One design point that had to be thought through

The first version measured the margin as top score − second score. "电影感" (jianying-cut,
7 points) and "导个片调个色" (also jianying-cut, 7 points) tie ⇒ margin 0 ⇒ refusal.

But those are two phrasings of the same answer, not two competitors. The margin is now
measured against the best candidate pointing at a different skill or route.

The threshold moved from 3 to 2

"撤销刚才那步" (matches "撤销", 2 points) and "红色太淡了,鲜亮点" (matches "鲜艳", 2 points) are
unambiguous, but one two-character keyword was not enough to clear the old threshold of 3.

It is now 2 — one specific two-character keyword opens the board. Measured: the refusal set still
has 0 hard guesses (none of those 18 sentences matches a specific keyword at all), and the
verdict rate went from 30/36 to 36/36.

One route also gained its real distinguishing feature ("混音按草稿", "按草稿重做") — that route
is the one that re-mixes according to the draft, so the words belong to it.

The refusal set is guarded too (audit 7o)

Check Catches
must exist and have ≥ 10 sentences a few sentences cannot guard a discipline
must not overlap the labelled test set the same sentence cannot have and lack a clear intent
no route/skill id inside a refusal sentence that is an intent signal ⇒ it belongs in the labelled set

Negative injection ㉝ (put a labelled sentence into the refusal set ⇒ must be reported) ⇒
audit self-test 34 / 34.

The calling convention changed with it

The pipeline skill now says: ask for a verdict first (--decide). Verdict ⇒ follow it (the
evidence is in the output). Refusal, no signal ⇒ judge by description or ask the user. Refusal,
ambiguous ⇒ show both candidates. And a verdict is only a starting point: if execution shows the
verdict does not match what the user said, stop and ask — do not run it anyway.

Numbers

Suite Result
intent self-test 36 sentences · top-1 100% / top-3 100% · refusal set 18 sentences, 0 hard guesses · verdicts 36/36 (all correct)
scripts/selftest-mcp.mjs 61 / 61
mechanical audit 0 findings (incl. the new 7o)
audit self-test 34 / 34
route table 41 entries (32 skill + 9 route)
the rest parameter coverage 10/10 · security probe 30/30 · MCP semantics 14/14 · draft-edit 17/17 · talk-cut 11/11 · env check 17 OK/0 MISS · focused suite 11/11

The verdict now says what to ask, and a failure that blamed the wrong thing

The verdict answered which chain to take. It did not answer how to fill the parameters or
what to ask the user for next — so the decision was still one step short of being actionable.

Every route declares its inputs

pipeline-profiles.json gained a structured inputs per route:

{ "name": "$srt", "kind": "path", "desc": "转写稿(SRT)", "required": true,
  "from": "user", "note": "★ 官方 ASR 拿不到 ⇒ 必须由用户提供,不许自己编" }

from ∈ {user, caller, derived, intent} — user (only the user can supply it), caller
(the caller supplies it), derived (the chain derives it), intent (it comes with the request).

Missing parameters are now named all at once, before anything runs

Before: the route failed at step 3 with one missing variable. Now run_pipeline pre-checks:

路线 "talk-cut-then-render" 还缺 2 个必需输入:
  · $srt(转写稿(SRT))—— ★ 必须由用户提供;★ 官方 ASR 拿不到 ⇒ 必须由用户提供,不许自己编
  · $outDir(成片输出目录)—— 由调用方给
⇒ 补齐后再跑;缺什么就问什么,不要拿默认值糊过去。

The verdict carries it

The user says Verdict Carried along
「调暖一点」 skill jianying-cut the parameter itself: spec = {"adjustColor":{"temperature":0.15}}
「把口播剪了再出成片」 route talk-cut-then-render ask the user for: $draft, $srt
「曲线调完再出片」 route grade-chain-then-render ask the user for: $draft, $grade (a curve shape cannot be guessed)
「给这版审一遍」 route review-only ask the user for: $draft

Audit 7p guards it: every route must declare inputs, every input must be a declared variable,
from must be one of those four, and a required input must actually be used by a step.

A failure that blamed the wrong thing

The whole end-to-end suite went red with
could not resolve the agent host parent pid (is JianyingPro running?) — while Jianying was
running
(nine processes).

The real culprit was the WMI provider host (WmiPrvSE.exe): it had burned 704178 seconds
of CPU
(≈8 days) since September, so every Get-CimInstance / tasklist call timed out and
the bridge could no longer resolve the VE runtime's pipe. The message blamed the app instead of
the query.

Fix
Timeout 20 s → 6 s (nobody should wait 20 s)
Diagnosis the two failures are now reported separately; when Jianying runs but the VE runtime is missing it says "this is not the Plugin's fault — opening a draft in Jianying brings it back"
Fallback enumerate the named pipes (the pipe name carries the pid) — but measured, the VE pipe is not enumerable, so it is only a fallback
Machine killing the runaway WmiPrvSE.exe (the service respawns it) turned a 20 s timeout into a 550 ms answer

A message must point at the layer that actually broke. "The dependency timed out" is not "the
dependency isn't running".

And a real-world replay of the recycled-session-id fix

Jianying restarted and its session counter began again at 2 — so draft_open was handed
ve-session-2 while a state file from an earlier session 2 was still on disk (exactly the
hazard fixed earlier). Measured: draft_read returned the correct draft (duration 3000000 ·
2 segments), not the previous session's leftovers.

Numbers

Suite Result
intent self-test top-1 100% / top-3 100% · refusal set 0 hard guesses · verdicts 36/36 · route verdicts 10/10 carry inputs + must-ask · 5/5 carry parameters
scripts/selftest-mcp.mjs 62 / 62 (new missing-parameter case)
mechanical audit 0 findings (incl. the new 7p)
audit self-test 34 / 34
the rest parameter coverage 10/10 · security probe 30/30 · MCP semantics 14/14 · draft-edit 17/17 · talk-cut 11/11 · env check 17 OK/0 MISS · focused suite 11/11

Adds `plugins/antianqi/jianying`: a Windows-only MiniMax Code Plugin that
grades video with a pixel-for-pixel re-implementation of Jianying's own
grading math, and edits Jianying drafts through the application's local
named pipe.

The Plugin ships two runtime layouts in parallel:

- `plugin.json` + `mcp.json` — the portable Agent Plugins 1.0 shape read by
  mcode 0.3.x.
- `.claude-plugin/plugin.json` — the mcode 0.4.0+ manifest, with the six
  Skill paths and the stdio MCP server.

MCP layer (8 verbs, `mcp/server.mjs`, zero npm dependencies): `draft_open`,
`draft_read`, `draft_patch`, `draft_commit`, `render_frames`, `grade_apply`,
`audio_mix`, `verify_diff`. Every verb has a hand-written input schema under
`mcp/tools/`; unknown keys and out-of-range values are rejected before the
call leaves the plugin.

Skill layer (6 Skills): `jianying-cut` (grade and export), `jianying-draft-edit`
(edit a draft through the 27 official behaviour operations), `jianying-review`
(multi-dimension review), `jianying-talk-cut` (cut a spoken take), plus two
library Skills — `jianying-verify` and `jianying-grade-chain` — that only
activate when another Skill references them.

Shared knowledge lives in `references/` (chain API semantics, verification
discipline, pitfalls, machine-readable capability dictionary) and the
deterministic guardrails in `scripts/`.

Why not use the official exporter: the official CLI is blocked by `-100007`
(session mode) and `-100015` (unauthorized host process), and its command
tree is five days older than the application build. The Plugin re-implements
the grading math instead (13/13 features reproduced to <= 1 LSB) and says so
in the README rather than working around the block.

Design compliance

- Both manifests declare `name: jianying`, matching the Plugin directory,
  and license `Apache-2.0` with an Apache-2.0 `LICENSE` file.
- The v0.4 manifest lists all six Skills as `./skills/<name>/SKILL.md`. Every
  path resolves, and every frontmatter `name` equals its directory name —
  a mismatch is silently dropped by the runtime with no diagnostic, so
  `test/jianying.test.mjs` pins both halves.
- The MCP server uses the `stdio` transport, a single `node` argument and a
  contained `./mcp` working directory. The Plugin does not set `PLUGIN_ROOT`
  or `PLUGIN_DATA` itself.
- Every runnable script invocation inside a Skill is anchored to
  `${PLUGIN_ROOT}`; every referenced `scripts/` and `references/` file exists.
- The Plugin ships no network client, no credential literal, no telemetry and
  no third-party service. The only socket it opens is the local Windows
  named pipe of a running Jianying instance.
- No contributor machine path is hardcoded: paths resolve from `JYAI_HOME`,
  `HOME`, `LOCALAPPDATA`, `ProgramFiles` and `PATH`.
- README discloses the platform, a copyable example prompt, the expected
  result, the required executables, the account/paid-service position and
  the four-part data behaviour (no credentials / no network / no telemetry /
  no third-party services).
- The six Skills stay well inside the runtime limits (64 Skill directories,
  8 MCP servers per Plugin).

Validation

- `node scripts/validate.mjs` -> `OK   plugin antianqi/jianying`;
  `Validated 30 hosted Plugins and all examples.`
- `npm run check` (validate + the repository test suite) is green on
  ubuntu-latest, which is the only job the repository runs.
- `node --test test/jianying.test.mjs` -> 10 pass / 0 fail.

Test evidence

- `test/jianying.test.mjs` (new, 10 cases): both manifest layouts, every
  Skill path in the v0.4 manifest resolving with a matching frontmatter name,
  the portable layout exposing the same six Skills, the stdio transport and
  its portable working directory, all eight verb schemas parsing, script
  invocations anchored to `${PLUGIN_ROOT}`, referenced files existing, the
  absence of a network client / credential literal / telemetry, the absence of
  contributor machine paths, and the README disclosures.
- Negative-injection audit of that file: seven defects injected one at a
  time, the suite re-run, and the file restored from a backup afterwards.
  Each injection failed for the expected reason — frontmatter name mismatch,
  description over 1024 characters, a hardcoded drive-letter path, a `fetch`
  call, a missing "expected result" section, a missing "no telemetry"
  disclosure, and a `skills[]` entry pointing at a missing file — and the
  suite returned to 10/10 after every restore.
- Plugin self-tests (Windows, manual, need a live Jianying 11.6):
  `selftest-mcp.mjs` 17/17, `selftest-draft-edit.mjs` 16/16,
  `selftest-talk-cut.mjs` 10/10, `check-env.mjs` 17 OK / 0 MISS. The
  verification cases carry negative injections of their own: a synthetic
  noise frame set must be judged "not visible", a synthetic large change must
  be judged "visible", a white background pushed to 255 must be reported as
  newly clipped, and an `expect` that says a field must not change must fail.
- `.github/workflows/jianying-windows.yml` (new) runs only
  `node --test test/jianying.test.mjs` on windows-latest, filtered to this
  Plugin's paths. The repository's own `validate` job stays on ubuntu-latest;
  this is the scoped Windows counterpart the CI file asks a non-tool-map
  Windows Plugin to add, rather than re-introducing a repository-wide
  windows job.

Known limits (also in the README)

- Windows only, and the draft verbs need Jianying 11.6 running; grading and
  export do not need the GUI.
- Structured grading fields (curves, HSL, colour wheels) cannot be written
  back into a Jianying preview yet: the draft-side field names are not
  confirmed, so grading is local export only.
- The official exporter stays unreachable; the Plugin does not work around
  the block.
- `clear` (8-level pyramid) is not wired into the chain.
- `talk-cut` does not do ASR: the official ASR lives in the agent runtime and
  is not reachable from the plugin, so it takes a user-supplied SRT and only
  decides which lines are filler, pauses or repeats.
The same test file has to guard two layouts: the hosted community layout
(plugins/<owner>/<name>/) and a standalone Plugin repository whose root is
the Plugin itself, which is what the local marketplace installs from.

Detect the Plugin root instead of assuming the hosted path, so the suite
can be copied into a standalone repository unchanged.
Two defects in references/capability-dict.json, the contract an agent reads
to decide what it can do:

- verbs.draft_patch.ops listed only 'video.add', so the contract claimed one
  op where the Plugin implements all 27 official behaviour operations.
- acceptance.verify_diff still stated the uncalibrated criterion
  ('max MAD > threshold'). The official renderer is not deterministic: the
  same draft rendered twice differs in 14-17 of 108 frames with a worst-case
  MAD of 48-123, two orders of magnitude above the threshold. An uncalibrated
  MAD cannot tell a change from noise, so the contract now requires the
  noise floor to be measured (noiseDir) and names the semantic criterion
  (expect over the written draft's per-segment field diff) as the one that
  does not depend on pixels.

The draft_patch guard now also states, in machine-readable form, that none of
the 27 official operations sets a grading or filter parameter: they cover
structure, volume, layout, text and undo/redo only. Grading parameters
therefore cannot be written into a draft yet (the draft-side field names are
not confirmed), and grading goes through grade_apply, which renders from a
media file and never touches the draft.
The pitch said 'we can export a graded film', which reads as a claim about
producing the deliverable. What the re-implementation actually buys is
verification: it is a faithful model of Jianying's grading maths (13/13
features, <= 1 LSB, byte-identical curve LUTs), so a grading result can be
confirmed from the chat instead of by opening Jianying and squinting at a
preview.

That framing also needs the reason it is worth doing, which was measured
here for the first time. The official render path is PNG frames -> h264
High / yuv420p / 8bit mp4, and that step is lossy: comparing the PNG
originals against frames pulled back out of that same mp4 gives MAD 2-8,
maxAbs 157, and 95% of pixels changed. Checking a grading against a preview
that carries its own 8-bit 4:2:0 projection cannot settle the question, and
the 1 LSB model settles it directly.

README now leads with the verification framing and the error table;
EVIDENCE.md records the measurement.
…w work

Three claims in the Plugin were wrong, and this corrects all of them.

1. "The official behaviour operations are 27" — wrong. The 27 came from a
   historical whitelist constant in the application's app-server.mjs, and we
   tested only that list. Sweeping the full set against the bridge shows
   **18 domains / 199 operations, every one of them admitted** (173 accepted
   with empty parameters, 26 rejected only for missing parameters, **0**
   unsupported). The capability dictionary now lists all 199, and
   references/official-ops.md records the sweep, the confirmed parameter
   contracts and the traps below.

2. "Grading parameters cannot be written into a draft" — wrong, the door is
   open. effect.add / effect.updateAdjustParams / adjustment.add /
   adjustment.setBasicColor / filter.add / keyframe.* / video.setBasicColor
   are all admitted, and the draft already carries the matching material
   collections (materials.hsl, hsl_curves, color_curves, log_color_wheels,
   primary_color_wheels). effect.add lands a materials.video_effects entry and
   adjustment.add lands a materials.placeholders entry with type "adjust".
   The parameter contracts are not mapped yet, so local export is still the
   main path, but the boundary is no longer "impossible".

3. "segment.setTimeRange takes startTimeUs/startUs plus durationUs" — a false
   positive. The application answers accepted:true with changedPaths:["/"],
   and the time range in the draft comes back {start:0, duration:0}: the
   operation does not know those key names and writes zero for the fields it
   did not recognise. Nine parameter shapes were tested in isolation.

New: subtitles. The bridge's own normalisation function shows that text.add
accepts startTime and duration directly, so a subtitle track is just a text
track with time ranges — no split-then-retime dance. Measured on a copy:

    text.add {content:"第一句字幕", startTime:1000000, duration:2500000}
    -> text track, 1 segment, target_timerange = {start:1000000, duration:2500000}

The application aligns to frame boundaries, so the tolerance is one frame
(33333us at 30fps). scripts/add-subtitles.mjs turns an SRT or a talk-cut plan
into that track, verifies the time ranges by reading the draft back, and
commits; scripts/selftest-subtitle.mjs covers it, including a negative case
that proves the probe can tell "has subtitles" from "has none".

Also recorded in references/official-ops.md: the application ships a
dedicated text.addSubtitles operation with per-word timing
(material.words[]), but its parameter shape is not mapped yet, and
needInsertTargetTrack=true inserts into whichever track trackIndex points at
— even a video track — so several subtitles share one text track only with
needInsertTargetTrack=false plus that track's index.

Validation

- selftest-mcp 17/17, selftest-draft-edit 16/16, selftest-talk-cut 10/10,
  selftest-subtitle 4/4, check-env 17 OK / 0 MISS.
- node --test test/jianying.test.mjs 10/10.
- node scripts/validate.mjs -> OK plugin antianqi/jianying; 30 hosted Plugins
  validated.
…o AI effects

Two things land here.

The operation set is now the whole thing. The v0.3 schema carried a
27-entry enum, which came from a historical whitelist constant in the
application rather than from the bridge's actual authorisation. Sweeping the
full set against the bridge shows 18 domains / 199 operations and **not one
of them is rejected** (173 accepted with empty parameters, 26 rejected only
for missing parameters, 0 unsupported). The enum now carries all 199, the
capability dictionary lists them, and references/official-ops.md records the
sweep together with the parameter contracts that were confirmed against the
draft rather than against "the application said accepted".

The audio AI effects work, and they differ from each other in an important
way:

- Denoise is `video.denoiseOn`, not `audio.denoiseOn`. Parameters
  {segmentId, isDenoise?, denoiseMode?, denoiseRate?} land in
  materials.realtime_denoises as {is_denoise, denoise_mode, denoise_rate}
  alongside sami_name "denoise_v2" and sami_version "1.0" — a local SAMI
  realtime engine, so the denoise is computed on the machine.
- Vocal beautify is `audio.setVocalBeautify` {segmentId, enable:true},
  landing in materials.vocal_beautifys as {enable:true, type:"vocal_beautify",
  ambient_sound_level:75, voice_change_mode:null, production_path:""}. An
  empty production_path means realtime rather than a cloud product, so this
  one is computed locally too.
- Voice change is `audio.setVoiceEffect` {segmentId}, landing in
  materials.audio_effects.
- Vocal separation is different: `audio.setVocalSeparation` builds
  materials.vocal_separations with {type:"vocal_separation", choice,
  removed_sounds, time_range, final_algorithm:"", production_path:""}. The
  empty production_path is the point — the separated audio is a task result,
  so the reference can be written but the artefact is not ours to produce.
  Six parameter shapes were tried and none changed the draft, so the
  contract is still unmapped.

scripts/selftest-audio-ai.mjs drives all of this through the Plugin's own
draft_patch verb rather than the raw pipe, and checks the draft afterwards:
8/8, including a negative case that requires the three material collections
to be empty before the change, so the probe cannot pass vacuously.

Also confirmed here: the key names come in both styles (isDenoise and
is_denoise, denoiseMode and denoise_mode are all accepted), while `enable`
is only `enable` — `isEnable` is ignored.

Validation

- selftest-mcp 17/17, selftest-draft-edit 16/16, selftest-talk-cut 10/10,
  selftest-subtitle 4/4, selftest-audio-ai 8/8, check-env 17 OK / 0 MISS.
- node --test test/jianying.test.mjs 10/10.
- node scripts/validate.mjs -> OK plugin antianqi/jianying; 30 hosted Plugins
  validated.
…ot the same

as present in the output

The membership check fires at export, not while editing: the official FAQ
says advanced features marked with a VIP crown (voice cloning, smart denoise)
only prompt for a subscription before the video is exported. That matches
what we measured — without a membership, video.denoiseOn, audio.setVocalBeautify
and audio.setVoiceEffect all wrote into the draft without complaint.

It also means the earlier phrasing needs a correction. The export pipeline is
the official renderer for picture (player domain) plus our own ffmpeg mix for
sound, and that mix handles volume, fades, speed and loudness only: it does
not read realtime_denoises or vocal_beautifys. The official renderer emits a
video stream with no audio track at all, the official media domain only has
file-level tools, and the official exporter is blocked by -100007 and
-100015. So the effects land in the draft, and Jianying will honour them
when it exports, but the files we produce do not carry them.

Two consequences recorded in references/official-ops.md and the capability
dictionary: the denoise and beautify entries now say explicitly that landing
in the draft does not mean landing in our output, and the acceptance line
separates "written into the draft" from "present in the delivered file".

Also worth recording from the local 11.6 install: the paywall surfaces in
the agent panel (vip_use_popup) and answers with gateId, action "retry" and
expectedRevision, which means a membership block is distinguishable from a
parameter mistake — a distinction the verification discipline should carry.

Making the effect audible in our own output means implementing it in the
mix chain instead (ffmpeg afftdn for denoise, EQ and compression as an
approximation for beautify, a Demucs-class model for separation), and saying
plainly that those are not Jianying's own DSP.
…inst a global noise floor

The user pointed out that Jianying also has a live preview while editing, which
challenged how we had attributed the "official renderer noise floor". Measuring
it properly, in one session, for the same target timestamp:

- capture the frame twice without seeking: 4 of 6 timestamps differ
- seek away and back, capture again: 4 of 6 differ
- the VEF1 timestamp delta against the requested time: 0 every time

So the non-determinism is not a mis-aimed frame, not a time-alignment problem
and not a globally unstable renderer — a simpler one-line draft is fully
deterministic (0/6) while this draft is not, and the same draft rendered twice
differs in 69 of 108 frames. It happens per capture, on particular frames:
frames 001-004 come back wholly different (MAD around 123, maxAbs 255) while
frames 000 and 005-009 differ by under 1.7. A preview engine like this is also
what the GUI runs, so nobody notices it during playback, but a static
per-pixel comparison magnifies it.

That makes a single global noise floor too coarse, and actively wrong: with
one frame sitting at noise 128 the global bar becomes 192, which then buries a
real change of 64 on a perfectly stable frame and reports a false FAIL. The
criterion is now per frame, bar_i = max(threshold, noise_i * noiseK), so a
frame whose own noise already exceeds the threshold counts as undecidable
while a real change on any stable frame is still detected.

verify_diff now reports unstableFrames, visibleMaxMad, visibleFrame and a
per-frame frameDetail, and the self-test covers it: three synthetic frame sets
where one frame is unstable and another carries a real change, asserting that
the unstable frame is flagged and the stable change still reads as visible.

Worth stating plainly: this does not touch the grading fidelity result. The
13/13 at 1 LSB is measured against an offline GL run of the official shader
fragments, not against frames pulled from the preview renderer, so it was
never exposed to this non-determinism.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Request changes for exact current head d726180bce3dedf083ab67693f7ab4a0038f2ab4.

The package/manifest checks and repository-owned exact-head Checks are positive, but the MCP server has a merge-blocking local file disclosure/path traversal:

  1. Unvalidated sessionId escapes PLUGIN_DATA. plugins/antianqi/jianying/mcp/server.mjs:122-123,300-307 builds snapshot-${sid}.json with path.join() and accepts any schema-valid string. A reproducible isolated probe set PLUGIN_DATA=/tmp/pr72-state, created /tmp/pr72-canary.json, then called:

    draft_read({"sessionId":"x/../../../../tmp/pr72-canary"})

    The server returned the canary JSON with source: "session", because the normalized path became /tmp/pr72-canary.json. This is an arbitrary local JSON read outside the plugin state directory. The same unsanitized value reaches snapshotFile() during draft_patch (:346-356), so a successful edit response can also write a snapshot outside PLUGIN_DATA.

    Validate session IDs against the server-issued format and ownership (for example, the exact ve-session-<digits> value recorded by draft_open), reject separators/.., and enforce canonical containment for every state/snapshot read and write. Add negative tests for POSIX and Windows traversal, absolute paths, sibling-prefix paths, and symlinked state directories.

Do not merge until the boundary is fixed and the exact-head regression suite proves that session state cannot read or write outside PLUGIN_DATA. [code]smith = SKIPPED, if present, is not evidence.

…t written into the draft

The previous text said our own output chain does not apply the draft audio
effects at all, so "written into the draft" was never "present in the output".
That statement was accurate when it was written, but the mixer now reads
materials.realtime_denoises out of the draft, matches it per segment through
extra_material_refs, and applies ffmpeg afftdn. This commit replaces the
stale claim with the measured result and its boundaries.

Validation
- End to end, official video.add + video.denoiseOn -> mixer -> muxed file:
  in-file SNR 5.646 dB (no denoise) -> 12.128 dB (denoise), +6.482 dB.
- Negative injection: the same denoised draft mixed with the effect forced
  off reproduces the baseline exactly, A - C = 0.0000 dB. The difference
  therefore comes from the denoise application and from nothing else.
- Criterion is the in-file SNR (speech band RMS - noise band RMS), so the
  loudness normalisation in the mix chain cannot bias the number.

Test evidence
- plugin selftests, all green: selftest-mcp 19/19, selftest-draft-edit 16/16,
  selftest-talk-cut 10/10, selftest-subtitle 4/4, selftest-audio-ai 8/8.
- node scripts/validate.mjs: 30 hosted plugins validated.
- capability-dict.json re-parsed after the edit. The hosted validator does
  not parse this file, so its green run says nothing about it.
- Parameter calibration on 20 s of real speech plus a noise floor calibrated
  to a known level: nr = 6 + 15*rate, nf = -26 / -23 / -20 for mode 1/2/3.

Design compliance
- The approximation is labelled as such. It is ffmpeg afftdn, not the
  Jianying SAMI denoise_v2 model, and the doc now says so next to the number
  instead of hiding it behind a stale "not applied" claim.
- The boundaries travel with the claim: the speech band sits 3.1 to 5.4 dB
  below the clean reference, only stationary pink noise was calibrated, and
  the zero-gap check rules out whole-phrase dropouts but not fine grained
  musical noise.
- The strength ladder is explicitly our own. The denoise_mode semantics of
  Jianying are not recoverable from the app bundle, so the doc says the
  ladder is ours and not a mirror of the product.
- Known upstream trap recorded for anyone reproducing the calibration: the
  ffmpeg afftdn nf parameter is range [-80, -20] in current builds, so -20
  is the strongest usable value and -15 is rejected outright.
… finished film

The plugin could render frames (render_frames) and mix audio (audio_mix), but
calling them meant wiring two verbs plus an intermediate file by hand, and
neither of them carried an acceptance criterion. make_film is the missing
verb: it chains the official renderer, the optional 3D LUT, our own mix
chain and ffmpeg, then verifies the result with assertions instead of
printing it.

Validation
- make_film drives jyai-make-film.mjs: official renderer frames -> optional
  lut3d -> our mix chain (volume / fade / denoise / loudnorm) -> ffmpeg -> a
  file with both a video and an audio stream.
- The acceptance step asserts, and fails the run on: no video stream, a
  duration that does not match the official timeline within one frame, and a
  loudness more than 1 LUFS away from the requested target.
- A draft with no usable audio is an input state, not a broken chain: the
  run produces a picture-only film, reports how many materials were
  missing, and does not fail.
- The official export tier is deliberately not used. ExportService, its mojo
  interface and the official encoder are not reachable from any non-GUI
  surface, so the film is built from the official renderer plus our own
  grading and mixing.

Test evidence
- selftest-mcp.mjs, 28 assertions, all green. New cases: the verb is
  exposed with its schema, a missing required argument is rejected, a
  non-existent draft fails loudly, a real run produces a two-stream file
  whose duration and loudness pass the built-in acceptance, and a draft
  whose audio materials are absent from disk still produces a film.
- The two end-to-end cases are skipped, not silently passed, when
  JYAI_DRAFT_LIB is not set, so a missing environment can never look green.
- node --test test/jianying.test.mjs: 10 pass, 0 fail.
- node scripts/validate.mjs: 30 hosted plugins validated.
- Three real defects were found by these tests and fixed before this commit:
  the renderer accepted a non-existent draft path and rendered an empty
  film instead of failing, the film builder picked the previous run's
  output as its own input when the same output directory was reused, and
  the self-test read the ffprobe stream list from stderr instead of stdout
  (which made a two-stream film look like it had no streams).

Design compliance
- The picture is the official renderer's output, not something we compute.
  The grading and the mix are ours, and the schema says so: denoise is
  labelled as an ffmpeg approximation rather than Jianying's own model.
- Every acceptance criterion is an assertion. A measurement that cannot
  fail is not a criterion, so the loudness check rejects a target it
  cannot read and the duration check compares against the official
  timeline reported by the renderer.
- The schema carries the same boundary note as the skills: the film is the
  official picture plus our grading and mix, not a Jianying export-tier
  film.
- Plugin version moved to 0.2.0 because a verb was added, and the
  manifest description now says 9 verbs instead of 8.
…ilure now fails loudly

Two things came out of measuring the grading chain against the app.

First, the curve parameter contract was wrong in the docs and in the schema.
The contract is the official A,L,R layout, three vec2 per control point, six
numbers each, and the R, G, B and Y curves all have to be given together.
The plugin described plain xy pairs, so a user writing curves into the
schema had it accepted and then dropped: the baker returned an identity
curve and the run reported success. The baker now also accepts the
documented chain API names, so both spellings produce the same LUT, and a
curve set that is missing a channel raises instead of quietly baking an
identity.

Second, make_film now takes a grade spec directly. It bakes the spec into
a 3D LUT and hands that to the renderer, so a film with curves on it is
one call, and no step depends on a field the app does not expose.

Validation
- make_film with a four channel curve spec bakes a cube whose deviation
  from identity is 0.375, so the curve is genuinely in the LUT.
- The same spec spelled with RPoints and the spec spelled with
  red, green, blue and luma bake to the same deviation, so the two
  spellings are equivalent.
- A spec with only one channel raises with the names of the missing
  channels and the accepted key names, instead of baking an identity LUT.
- The film path asserts both streams when the draft still has its audio
  material on disk, and reports the missing material count when it does
  not, so an absent asset is not reported as a broken chain.

Test evidence
- selftest-mcp.mjs, 32 assertions green, including the three new ones
  above. The two stream assertion is conditional on the audio material
  being present and says so when it is skipped.
- node --test test/jianying.test.mjs: 10 pass, 0 fail.
- node scripts/validate.mjs: 30 hosted plugins validated.

Design compliance
- The contract is written the way the app defines it, and the chain API
  spelling is documented as an equivalent alias rather than a second
  format that might drift.
- The document states that the luminance curve applies to all three
  channels on purpose, because that is the official behaviour and a user
  would otherwise read the cross channel change as a bug.
- The grading path does not go through the draft. The app preview
  renderer does not apply curves at all, so the parameters come from our
  own interface and nothing waits on an unexposed field.
- Failures stay loud. A spec that cannot be applied raises, and a film
  whose audio assets are missing is reported as a missing asset rather
  than as a failed mix.
The 3D LUT path can only express a per point function. The three
rendering time operators, sharpen, vignetting and particle, depend on a
neighbourhood or on uv, so they cannot be expressed that way, and they
were being dropped without a word. The same held for a node that is not
on the chain at all, smartColorAdjustment, which is in neither list.

The baker now refuses both cases and names the way out, which is the
per point path, the grade_apply verb. The check sits in the baker
itself, so no route can bypass it. The render time operators stay in the
schema on purpose, so that a user who writes one gets the pointer to
the right verb instead of an unknown key.

The direct path already runs these operators and already probes each
node against zero, so nothing there changes. Running them inside the
film pipeline as well is a capability question, not a bug.

Validation
- A curve spec bakes to a LUT whose deviation from identity is 0.375.
- A spec with sharpen or vignetting raises, and the message names the
  per point path.
- allow_render_nodes lets the direct path through, and the direct path
  is the one that actually runs these operators.
- The four grading regressions are unchanged: curves against the
  official shader at a MAD of 0.0012, hsl 0.0008, logWheel 0.0010, and
  hsl_curves at its recorded values, with the identity and the non
  identity checks still passing.

Test evidence
- selftest-mcp.mjs, 32 assertions green, including the new case that a
  render time operator in the grade spec is refused with a pointer to
  grade_apply.
- node --test test/jianying.test.mjs: 10 pass, 0 fail.
- node scripts/validate.mjs: 30 hosted plugins validated.

Design compliance
- The two chains are now documented side by side, with the split
  between what a LUT can express and what only the direct path can, so
  a caller picks the right verb instead of guessing.
- Nothing fails silently. An operator that would have been dropped is
  refused, and the refusal says which verb to use.
- The operator list stays in the schema. Removing it would turn a
  helpful pointer into a bare unknown key, which is less useful to the
  caller and hides the reason.
The draft library was cleaned, and the two drafts the end to end cases
used were ours, so they went with the rest. The plain video probe stayed,
so it is the film draft now, and it happens to have no usable audio,
which is exactly the case the second case wanted to cover. The two
cases therefore run against one draft and one run instead of two.

The audio probes that pointed at the removed audio draft now take the
draft as an argument and, when it is not there, say so and list what is
there, instead of failing on a path that no longer resolves.

The acceptance step in the film path also stopped treating a small file
as a broken product. A three second black frame encodes to about three
kilobytes, and that is a valid film when the draft's media is missing
from disk. Size is now only a hint, the video stream and the duration
are the criteria, and a black first frame is reported as the missing
media that it is.

Validation
- The film end to end runs against the plain video probe and passes: the
  film lands, the acceptance step passes, and the no audio case is
  reported as a warning rather than a failure.
- The grading cases are unchanged and still green, including the two
  that require an error rather than a film.
- selftest-mcp.mjs, 32 assertions green.

Design compliance
- A missing asset is an input problem. It is reported as such, and it
  does not turn into a false failure that hides a healthy pipeline.
- The criteria are the stream and the duration, which say something
  about the film, rather than a byte count, which says nothing.
…and fix the audit tools

Audit pass of 2026-10-22: mechanical sweep over the whole Plugin, a
dynamic sweep of every MCP verb, and a manual review of the nine verbs.
Eight defects were real; three of the reported defects were the audit
tools lying about the code.

Plugin
- draft_read: the `scope` parameter was declared in the schema with an
  enum, but the handler never forwarded it and the backend cannot do a
  segmented read. A legal value was therefore accepted and silently
  ignored, and the Skill even taught users to pass it. The parameter is
  removed from the schema, the Skill example is corrected, and a
  regression assertion now requires the call to be rejected.
- server: `grade_apply` reported "node did not take effect" whenever the
  per-node probe came back as 0. On a near-black source that is the
  expected outcome (a brightness lift is clipped back to 0), so a working
  chain was reported as broken. The probe now samples every frame and
  takes the maximum, and a near-black source is reported as "the source
  carries no measurable information" with the source luma attached.
- server: `verify_diff` expressed an acceptance failure only through
  `isError`, which is indistinguishable from a tool fault. It now also
  sets `failureKind: "acceptance"`; `isError` is kept so the existing
  Skill semantics stay intact.
- server: the interpreter lookup hardcoded Python 3.14. It now probes
  3.9-3.14, the per-user install, the launcher and `python3`.
- server: candidate paths are built from ProgramFiles / LOCALAPPDATA
  only, so no drive letter is baked into the source any more.
- server: removed the unreachable `a.draftDir` branch in draft_commit
  (the schema rejects unknown keys, so it could never be set).
- server: the reduce accumulator in verify_diff was named `a`, the same
  as the handler argument, which made the parameter scan read `a.mad` as
  an input parameter.
- selftest: draft_read, draft_patch, draft_commit, render_frames and
  audio_mix had never been exercised through the MCP layer. They now
  have a smoke test, and the test media is generated on the fly so the
  suite no longer depends on user assets.

References
- verify-discipline.md: added disciplines 8-10 (a positive control has to
  use the same path and the same object, an expectation has to be proven
  on a known-good implementation first, and the noise floor has to be
  measured per frame rather than as an average).
- pitfalls.md: added pitfalls 18-23 and fixed a dead script reference.

Audit tooling (JYAI/out/probe, outside the Plugin)
- audit-plugin.mjs v2: strips comments before scanning for parameters,
  masks `reduce` calls instead of excluding parameters by name, resolves
  documentation references against the repository root as well, and
  extracts the called verbs from the real `tools/call` sites only.
  v1 reported a.draftDir from a comment and a.mad from a reduce
  accumulator, so three of its findings were false positives.
- audit-plugin.mjs gains a --selftest mode that injects known defects
  into a copy of the Plugin and requires every check to fire.
- audit-mcp-verbs.mjs v2: judged the verbs by exit code, but execFile
  never exposes exitcode, so every verb was reported as failing. Each
  case now declares the expected behaviour, including the cases where a
  rejection is the correct outcome.

Validation
- Plugin self-test: 40 PASS / 0 FAIL
- repository focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK
- mechanical audit v2: 0 issues (HIGH 0 / MED 0 / LOW 0)
- MCP semantic audit: 14/14

Test evidence
- audit-plugin.mjs --selftest: 12 PASS / 0 FAIL. Negative injection
  proves each check fires: a real a.scope is reported while a commented
  one is not; a reduce accumulator is not reported; a drive letter is
  reported while a regex such as "I:" followed by a backslash is not; a
  mismatched Skill name, a dead documentation reference, a missing
  additionalProperties and a mismatched manifest version are all
  reported.
- audit-mcp-verbs.mjs: draft_read rejects `scope` with "unknown key";
  audio_mix on a draft without audio reports "nothing to mix";
  grade_apply succeeds on a bright source and reports the near-black
  source separately; verify_diff returns verdict FAIL with
  failureKind=acceptance.

Design compliance
- Skill-only Plugin: no network client, no credential handling, no
  telemetry, no third-party service.
- Every input schema keeps additionalProperties:false, so a parameter
  that the backend cannot honour is rejected instead of ignored.
- No absolute path of any contributor machine is baked into the Plugin.
The heading still said six while rules 8-10 had been added in the audit pass.
… that way

The audit of 2026-10-22 covered the verbs. It did not cover the walk a
user actually takes: read the README, pick a Skill, copy the example,
get a file. Walking the six flows end to end found four breaks, two of
them guaranteed failures.

Breaks
- README said eight verbs three times and its table listed no make_film,
  the newest and strongest one, so the capability was invisible from the
  front door. It also said seventeen self-test assertions instead of
  forty, pointed at sections 54-76 instead of 54-100, and claimed forty
  six capabilities while the dictionary holds twenty one.
- The draft_commit example omitted sessionId, which the schema marks
  required, so copying it failed on the first call.
- The verify_diff example in the review Skill carried a line comment
  inside the JSON. JSON has no comments, so copying it failed with
  "arguments are not valid JSON". This one is deterministic.
- The capability dictionary still advertised draft_read.ops as
  full/timeline/segments/materials, the exact scope values the audit
  removed a day earlier.

Alignments
- README and four Skills pinned Python 3.14 while the interpreter lookup
  has been generalised to 3.9-3.14, so the documentation was the thing
  rejecting users.
- jianying-verify still said seven rules while the reference holds ten.
- jianying-grade-chain still said that writing curves back to Jianying
  needs the user to save a draft with curves in the GUI. That conclusion
  was overturned: the curve parameters come from our own interface and
  the official preview renderer does not read curve nodes anyway.
- jianying-cut had no trigger phrase for "render this draft to a file",
  so the request could fail to route to any Skill.

Guard rail
The mechanical audit grows a section seven that compares the
documentation against the implementation: the verb count, the capability
count, leftover scope values, and whether every documented run-verb
example parses and satisfies the required parameters of its verb. An
example whose JSON does not parse is reported as a defect rather than
skipped, because "not checked" and "checked and fine" must not look the
same.

Validation
- Plugin self-test: 40 PASS / 0 FAIL
- mechanical audit: 0 issues, 16 of 16 documented examples satisfy their schema
- audit tool self-test: 17 PASS / 0 FAIL (five new negative injections)
- focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The five new negative injections are: a README claiming eight verbs,
  a capability dictionary carrying scope values, a document claiming
  forty six capabilities, an example missing a required sessionId, and
  an example with a comment inside its JSON. Each one must be reported.
- Before the fix, the documented examples resolved to fifteen that
  satisfied their schema and one that did not parse.

Design compliance
- No behaviour, schema or dependency changed; this commit only repairs
  the documentation and adds a check that keeps it honest.
- The Plugin remains Skill-only with no network, credentials or
  telemetry.
…lent identity LUT

Following the usage-flow review, the question of what else to check
produced a real defect rather than a list. make_film takes a grade spec
that is the same chain API as the spec of grade_apply, but the make_film
schema declared every node of that spec as a bare object, so nothing
inside a node was ever validated. On top of that the baking step had no
branch for adjustColor, so the spec was handed to the node function
unchanged and any key the node did not recognise was dropped without a
word.

Measured: make_film with a grade of adjustColor.brightnessX 0.1 produced
a cube whose md5 was identical to the cube of an empty spec, 3abf7454.
The grading was simply absent while the render succeeded and the
acceptance passed. A misspelled parameter is the most ordinary mistake
in the world and it failed silently.

Plugin
- server: make_film now validates its grade against the spec schema of
  grade_apply, so the two entry points share one contract instead of two
  strengths. A misspelled key and an out of range value are both
  rejected, and the message says the grade follows the same contract as
  the spec of grade_apply.
- selftest: three assertions cover it. A misspelled key is rejected, an
  out of range value is rejected, and the baking step rejects a
  misspelled key even when the schema is bypassed, which is the case the
  implementation level guard exists for.

References
- chain-api, the grade-chain Skill and the capability dictionary now
  state that primaryWheel and the user pick key of hsl are not wired into
  the chain API form. Both were measured to bake an identity LUT, and
  both now fail loudly instead.
- pitfalls gains the twenty-fourth entry: a misspelled parameter name
  bakes an identity LUT, the schema was too weak and the implementation
  did not notice, and the hard criterion is that the cube must differ
  byte for byte from the cube of an empty spec.

Validation
- Plugin self-test: 43 PASS / 0 FAIL
- mechanical audit: 0 issues
- audit tool self-test: 19 PASS / 0 FAIL
- MCP semantic audit: 14/14
- focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK
- baking guard probe: 0 failures, ten negative cases and eight positive
  ones
- equivalence against the pre-change baking step: 13 of 13 cases match
  byte for byte, the three exceptions being the inputs that used to
  fail silently and now raise

Test evidence
- The equivalence check imports the pre-change baking step from a
  snapshot and compares the two implementations on the same specs. Every
  case that used to work still produces the identical cube, which
  matters because the baking file had to be reconstructed by hand after
  a damaged edit.
- The positive cases confirm that a legal spec still bakes, including
  the old flat form of hsl, the alias red, and the user pick key shape
  that must now raise.

Design compliance
- The behaviour change is limited to inputs that previously produced no
  grading at all. Nothing that worked before is affected, and the
  equivalence run proves it.
- The Plugin remains Skill-only with no network, credentials or
  telemetry.
…s, and wait for a cold Jianying

The coverage audit produced a list of parameters that no test had ever
touched. The most important of them were expect, beforeDraft and
afterDraft, which together form the semantic criterion, the one the
draft-edit Skill teaches as step five and the one the discipline calls
the first choice for the draft chain. That path was never exercised
through the MCP layer, so the workflow the documentation teaches had
never been run.

What the new test found
- The two drafts handed to verify_diff must be bare drafts whose top
  level carries tracks. The response of draft_read wraps the draft in
  one more layer, so saving the response as is yields tracks: 0 and the
  criterion silently compares nothing. The schema and two Skills now say
  so.
- The criterion does pass once the drafts are bare: a patched volume is
  reported as changed, and a negative control with two identical drafts
  is reported as FAIL, so the criterion has discrimination.

Also fixed
- The self test ran once against a Jianying that had just started, when
  the VE host is not up yet, and reported a batch of failures that were
  not defects: 37 PASS / 3 FAIL on the first run, 43 / 0 on the second.
  It now waits up to thirty seconds for the VE pipe and skips the end to
  end section explicitly when it never comes up, without counting it as a
  pass.

Validation
- Plugin self-test: 45 PASS / 0 FAIL, up from 43
- mechanical audit: 0 issues
- audit tool self-test: 19 PASS / 0 FAIL
- per node non-identity probe: 0 failures across eleven nodes
- focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The uncovered parameter list dropped from twelve entries to nine once
  the semantic criterion moved under test.
- The per node probe proves the criterion has discrimination: a node
  with empty parameters is judged identical, while six real nodes are
  judged non identical against the cube of an empty spec.
- The negative control passes two identical drafts and requires FAIL, so
  a criterion that always passed could not satisfy the assertion.

Design compliance
- The only behaviour change is the new wait for the VE pipe, which turns
  a cold start into an explicit skip instead of a batch of failures.
- The Plugin remains Skill-only with no network, credentials or
  telemetry.
…he intent map machine readable

Five of the six Skills said the same thing in their description: that
something had "taken effect". The user asks "did the change take
effect" and the router has four candidates, the cut chain, the draft
edit chain, the review chain and the verify library, all of which claim
that sentence. The measured overlap was eight pairs, the worst sharing
five two character groups. The words were not the problem, the
assignment was: each chain now names the artefact it judges, the picture
for the cut chain, the draft field for the edit chain, the verdict for
the review chain, the time line for the talk cut chain and the criterion
for the verify library. Overlap is now zero, both literally and
semantically, with the general question forms removed from the measure
so that only real collisions count.

The intent map becomes machine readable
- references/intent-map.json holds the twenty one utterances and the
  parameters they map to, extracted from the three Skills that carry
  such a table by a script rather than by hand, so the two forms cannot
  drift apart silently.
- The audit now checks every mapped parameter name against the schema of
  grade_apply and every mapped operation against the list of one hundred
  ninety nine official operations, and compares the map with the tables
  in both directions.
- That check immediately found a false operation. The draft edit Skill
  offered invokeBatch for "do several steps at once", and that
  operation does not exist; the official batch operations are
  batchStart, batchEnd and freezeVideo. The line now says that there is
  no batch operation and that several draft_patch calls in a row are
  the way, which is what the workflow above it already showed.

Validation
- Plugin self-test: 45 PASS / 0 FAIL
- mechanical audit: 0 issues, twenty one mappings checked
- audit tool self-test: 22 PASS / 0 FAIL, three new negative injections
- routing probe: zero literal and zero semantic collisions
- per node non-identity probe: 0 failures
- focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The three new negative injections cover a misspelled parameter in the
  map, a map entry that the Skill table does not contain, and an
  operation that is not among the official ones.
- The routing probe was rebuilt twice during this work. Its first
  version reported a table it had not read, because it reset its state
  on the blank line between a heading and the table, and its second
  version reported two false collisions caused by the shared tail of a
  question. Both were fixed before the numbers were used.

Design compliance
- No behaviour changed, only how the Skills announce themselves and one
  false operation in a table.
- The Plugin remains Skill-only with no network, credentials or
  telemetry.
…s has a written route

The Plugin had six Skills and each one covered a single chain. A user
who wants a whole thing done, grade the draft and render it, or cut the
talk track and then render it, had no written route: the router had to
pick a chain, and every chain started by opening its own copy of the
draft, so the state between the steps never lined up.

The new Skill is the orchestration, not an implementation. Every step
lands on a verb that already exists, the parameters come from the intent
map, and the judgements come from the discipline document. It covers
four routes, and it states what each one needs before it starts, because
a missing prerequisite is what produces a black or silent film: Jianying
has to be running, the media has to be on disk, the talk cut route needs
a transcript from the user, and nobody invents one.

The shared session is the part that matters. One draft_open gives a
session for the draft verbs and a directory for the render verbs, and
the route hands both down, so the whole job runs on one copy instead of
one copy per step. The document says plainly that the render verbs take
a directory and not a session, because that is the seam between the two
halves of the Plugin.

The audit learned two things from this
- New checks for the manifest: every listed Skill must exist, every
  directory must be registered, and the number of Skills a document
  claims must match the number on disk. A Skill that is not registered
  simply does not load, silently.
- The repository test listed the six Skill names in a constant, so
  adding one turned two cases red. The list is now read from the
  directory, which removes the second place a new Skill has to be
  remembered.

Validation
- Plugin self-test: 45 PASS / 0 FAIL
- mechanical audit: 0 issues, 29 documented examples checked
- audit tool self-test: 25 PASS / 0 FAIL, three new negative injections
- routing probe: zero literal and zero semantic collisions, the new
  Skill included
- focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The new negative injections require the manifest check to report
  exactly one problem, not merely to report something. The first
  version of that check reported every Skill twice because it joined the
  path wrongly, and the weaker assertion had passed it. Asserting the
  count is what caught it.
- The routing probe puts the new Skill against the other six: no
  literal overlap and no semantic overlap after the general question
  forms are removed.

Design compliance
- The Skill only orchestrates existing verbs, adds no dependency and no
  network, no credential and no telemetry.
- The Plugin remains a Skill-only Plugin with zero npm dependencies.
The pipeline Skill wrote four routes in prose, which means a wrong verb
or a wrong parameter in them would have stayed invisible until somebody
tried it. The routes are now a data file, references/pipeline-profiles,
with four profiles and eighteen steps, and every step names the verb it
calls, the arguments it passes and the variables it produces.

The variables carry the part that used to be folklore. draft_open
produces a session for the draft verbs and a directory for the render
verbs, and the render verbs take a directory rather than a session, so
the copy directory is the seam between the two halves of the Plugin. The
profiles say so in the place where a step consumes it, and the Skill
says the same thing in prose, and the audit checks both.

The audit checks the profiles against the schemas: a verb that does not
exist, an argument that is not a parameter of that verb, a variable that
was never declared and a required parameter that was never given are all
reported. Three new negative injections cover them.

Validation
- Plugin self-test: 45 PASS / 0 FAIL
- mechanical audit: 0 issues, four profiles and eighteen steps checked
- audit tool self-test: 28 PASS / 0 FAIL
- focus tests: 10 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The new negative injections require a made up verb, an argument that
  does not belong to the verb and an undeclared variable to be reported
  separately, so a check that reported everything would not pass them.
- The four profiles are the ones the Skill describes in prose, and the
  audit compares the two, so the document and the data cannot drift.

Design compliance
- The file adds no dependency and no behaviour. It is a description of
  routes that already exist, in a form that can be checked.
- The Plugin remains a Skill-only Plugin with zero npm dependencies, no
  network, no credential and no telemetry.
The review on pull request 72 found a path traversal in the session
state, and it was real. The session id was pasted into the state file
name, session-${sid}.json, and the schema only required it to be a
string, so a value such as x/../../../../tmp/canary was normalised by
path.join into a path outside PLUGIN_DATA. draft_read answered with the
contents of that file and draft_patch wrote a snapshot there.

Three layers now stop it
- The shape: a session id must be ve-session-<digits>, the form the
  server itself issues, so it can hold no separator and no dot dot.
- The containment: the joined path must lie strictly inside the state
  directory, and a sibling whose name merely shares the prefix does not
  count as inside.
- The symlink: the state directory is resolved before use and a state
  file that is itself a symlink is refused, for reading and for writing.

Two things changed while fixing it
- draft_read used to fall back to a read with no path, which returned
  some default draft. An invalid session id therefore still produced a
  draft, which was both an exposure and a false success. The fallback
  now uses the path the session itself recorded, and with neither a path
  nor a session it reports that there is nothing to read.
- The regression that came with that change was caught by the suite
  before it shipped: reading a session with no snapshot had gone through
  the same fallback. It now reads the draft the session recorded.

The proof
- scripts/security-probe.mjs isolates PLUGIN_DATA, puts a canary beside
  the state directory and beside a sibling directory, then sends nine
  forms of session id to three verbs. It checks that each is refused,
  that the canary never appears in a response, that no file appears
  outside the state directory, and that a well formed id whose file does
  sit inside the state directory is still read. That last case matters:
  without it a probe that refused everything would look green.
- The negative injection puts the old implementation back, drops the
  three defences and runs the probe again. It fails with eight cases,
  including the canary being read, and it comes back to green when the
  file is restored. The check is therefore known to bite.
- The probe is wired into the repository test, so the windows job
  proves it on every push, and into the Plugin self test.

Validation
- Plugin self-test: 46 PASS / 0 FAIL
- session containment probe: 30 PASS / 0 FAIL
- mechanical audit: 0 issues
- audit tool self-test: 28 PASS / 0 FAIL
- MCP semantic audit: 14/14
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Design compliance
- No dependency, no network, no credential and no telemetry was added.
- The Plugin remains a Skill-only Plugin with zero npm dependencies.
@antianqi antianqi changed the title feat(jianying): add the jianying Plugin (8 MCP verbs + 6 Skills) feat(jianying): add the jianying Plugin (9 MCP verbs + 7 Skills, one command to a finished film) Oct 23, 2026
@antianqi

Copy link
Copy Markdown
Contributor Author

@hetaoBackend Thanks for the catch — the traversal was real, and it applied to the write path as well as the read path.

Fixed in b7d3059 (current head of feature/jianying-plugin).

Three layers in mcp/server.mjs

  1. Shape — a session id must be ve-session-<digits>, the form draft_open itself issues, so it can carry no separator and no ...
  2. Containment — the joined path must lie strictly inside the state directory; a sibling that merely shares the prefix is not inside.
  3. Symlink — the state directory is resolved before use, and a state file that is itself a symlink is refused, for reads and for writes.

The check also runs at the verb entry points, and a missing id is reported differently from a malformed one.

Proof — plugins/antianqi/jianying/scripts/security-probe.mjs

It isolates PLUGIN_DATA, puts a canary beside the state directory and beside a sibling directory, and sends nine forms of session id (POSIX relative traversal, Windows backslash traversal, POSIX absolute, Windows absolute, sibling-prefix, URL-encoded, NUL byte, bare .., dot-segment) to three verbs. It asserts that each is refused, that the canary marker never appears in a response, that no file appears outside the state directory, and — the case that keeps the probe honest — that a well-formed id whose file does sit inside the state directory is still read. Without that last case a probe that refused everything would look green.

result
probe on the fixed head 30 PASS / 0 FAIL
probe with the old implementation restored 22 PASS / 8 FAIL — including the canary being read back, and draft_commit reporting committed: true outside the directory
probe after the file is restored 30 PASS / 0 FAIL

The probe is wired into test/jianying.test.mjs, so the windows-latest job proves it on every push, and into the Plugin self-test (selftest-mcp.mjs).

One more exposure, found while fixing it

draft_read used to fall back to a read with no path, which returned some default draft — so an invalid session id still produced a draft. That was an exposure of its own and a false success. The fallback now uses the path the session itself recorded, and with neither a path nor a session it reports that there is nothing to read. The regression that came with that change was caught by the self-test before it shipped: reading a session with no snapshot had gone through the same fallback.

PR description updated — new title, a security section with the reproduction and the numbers above, and the current counts (9 verbs, 7 Skills, 41 files, self-test 46/46, focus suite 11/11, validator 30 green).

Happy to re-run anything, or to reshape the three layers if you would rather see a different shape.

…the session

The commit verb compared the checksum of the real draft before and after
its own write, which catches the case where the write itself reached the
real draft by mistake. It could not catch the other case: somebody else
changed the real draft while the session was open, for instance the user
edited it in the running application, or a second window saved over it.
Then the Plugin would write its own older copy on top and the change
would be lost without a word.

The session now records the checksum of the source draft when it opens,
and the commit verb compares the current checksum against that one
before it writes anything. A mismatch refuses the commit and says what
happened. The write never happens, so nothing is overwritten.

The negative injection proves it. It opens a copy of the draft from the
library, patches it, then deliberately touches the source draft to
simulate somebody else changing it, and commits. The commit has to be
refused, and the message has to name the reason. The test uses a
temporary copy inside the draft library and removes it afterwards, so
the user's own drafts are never touched.

Validation
- Plugin self-test: 47 PASS / 0 FAIL, up from 46
- mechanical audit: 0 issues
- audit tool self-test: 28 PASS / 0 FAIL
- session containment probe: 30 PASS / 0 FAIL
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The new assertion is the one that reports
  "the real draft was changed during this session, refusing to write, so
  your change is not overwritten", and the temporary copy is confirmed
  gone from the draft library afterwards.
- The existing case still passes, which matters because it is the
  positive side of the same guard: a draft that nobody touched is written
  as before.

Design compliance
- The guard compares checksums that the Plugin already computed; no new
  dependency, no network, no credential and no telemetry.
- The Plugin remains a Skill-only Plugin with zero npm dependencies.
…ified

A sweep over the schemas listed twelve parameters that no test had ever touched. The audit had
already proved that the handlers read all of them, so this is about whether they work, not about
whether they exist. Seven of the nine are now covered by a new self test, and the two that remain
are covered as far as the environment allows.

The new self test makes its own media, so it does not depend on user assets: one grey clip for the
grading path, two frame directories for the pixel path, and two clips with a tone at different
levels for the audio path. Its criteria are deterministic. A file is present or not, a threshold
moved or not, a frame count is within a bound or not.

Two of the tests had to be written against what the code actually does rather than what the name
suggests
- noiseK only takes part in the criterion line when a noise directory is supplied, because the
  line is the maximum of the threshold and the noise floor times noiseK. Without a noise
  directory the threshold stands alone and the parameter has no effect. The test now says so, and
  it asserts the positive precondition first, that the noise floor was measured, so a line that
  does not move cannot be blamed on the floor.
- maxFrames shortens the film below the official timeline, so the built in acceptance rejects it
  with a duration mismatch. That is the correct outcome, a truncated film is not a passing film,
  so the test expects the refusal rather than a successful render.

The audio parameters beautify and targetLufs are still not covered, because both need a draft that
actually carries an audio track and the draft library has none. Every draft in it is either
video only or encrypted. This is a gap in the fixture, not in the code, and it is recorded as
such.

Validation
- Plugin self-test: 48 PASS / 0 FAIL, up from 47
- parameter coverage self test: 8 PASS / 0 FAIL
- mechanical audit: 0 issues
- audit tool self-test: 28 PASS / 0 FAIL
- session containment probe: 30 PASS / 0 FAIL
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The new cases assert, among others, that noLut yields the point-wise file and no LUT file, that
  the criterion line moves with noiseK once a noise floor is present, that an explicit threshold is
  adopted, that an external cube is accepted, that a truncated film is refused by the acceptance,
  and that the audio dimension reports two different loudness values and says skipped when the
  videos are missing.
- The schema descriptions now state the two conditions the parameters actually have: maxFrames
  makes the acceptance fail on duration, and noiseK does nothing without a noise directory.

Design compliance
- The self test adds no dependency: it uses the ffmpeg and Python already required by the Plugin.
- The Plugin remains a Skill-only Plugin with zero npm dependencies, no network, no credential and
  no telemetry.
…ow proves one

Building a fixture with an audio track turned up something larger. The
official audio.add answers with accepted and a changed path, and the
draft still holds no audio material at all, whether the file sits
outside the draft or inside its own directory. The official diff also
carries only the value after the change and never the value before, so
a baseline has to be kept on this side.

The drafts come in two shapes. Jianying writes eleven keys per segment to
disk, and the official edit returns fifty two, the extra ones including
the volume field. Comparing those two puts dozens of shape fields into
the diff, and that is what made the semantic criterion pass for the
wrong reason: the field that appeared was read as a field that changed.
That test has been rewritten to patch twice in one session, so both
sides are the same shape and the comparison is exact.

The comparison now only looks at fields present on both sides and
reports the shape difference separately, and both verify_diff and
draft_patch tell apart three outcomes: the field changed, the field did
not change, and the field cannot be judged because the baseline is the
short shape. The third used to be reported as the second.

draft_patch carries the evidence now. It returns the changes it made,
whether it believes the change took effect, the shape of the baseline it
compared against, and an optional expect that turns an official
acceptance into a verdict. The probe that made this necessary is the
interesting part: audio.add is refused with segmentsDelta one not equal
one, and without the expect the reply still says that the draft did not
change although the official call was accepted.

Validation
- Plugin self-test: 48 PASS / 0 FAIL
- mechanical audit: 0 issues
- audit tool self-test: 28 PASS / 0 FAIL
- session containment probe: 30 PASS / 0 FAIL
- MCP semantic audit: 14/14
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The negative case is the official one: a call that the official side
  accepts and that changes nothing is now refused by the expect, and
  without the expect the reply carries the note that the draft did not
  change.
- The semantic criterion now runs two patches in one session, so the
  baseline is the runtime shape rather than the short one, and its
  negative control still has to fail.

Design compliance
- No dependency, no network, no credential and no telemetry was added.
- The Plugin remains a Skill-only Plugin with zero npm dependencies.
Every real draft of the user is sealed on disk, and the read path only
touched the disk, so the plain request to change my draft could not be
served at all. The official draft domain is the way through: readFile
takes the draft json and the root of the draft library and hands back the
plaintext even when the file is sealed, and writeFile takes the same pair
plus the content and seals it again.

The read now falls back to the official pipe when the disk copy cannot
be parsed, and it found the draft of October the twentieth with eleven
tracks, four of them audio. That also removes a hazard from the semantic
criterion: the official read returns the stored shape while the patch
snapshot is the runtime shape, so taking both sides from the read keeps
them the same shape.

A sealed fixture is now part of the suite, and the contract is written
into the authoritative operations document with the errors that lead to
it, so nobody has to guess it again.

This came out of a dead end that is recorded as well. The official
track.add and audio.add still refuse every parameter shape, and
audio.add accepts the call without touching the draft whatever the audio
format, so adding sound through the official operations is not available
here. The same dump produced the structure of an audio segment, which is
most of what a fixture with a sound track would need.

Validation
- Plugin self-test: 49 PASS / 0 FAIL
- mechanical audit: 0 issues
- audit tool self-test: 28 PASS / 0 FAIL
- session containment probe: 30 PASS / 0 FAIL
- MCP semantic audit: 14/14
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The new assertion reads a sealed draft through the MCP layer and
  requires the tracks to come back, and it skips explicitly when the
  fixture is absent instead of counting a pass.
- The official contract is recorded with the error each wrong shape
  produces, so the probe that finds it can be repeated.

Design compliance
- The fallback reads through the local named pipe of a running Jianying,
  the same channel the Plugin already uses, and sends nothing anywhere.
- The Plugin remains a Skill-only Plugin with zero npm dependencies, no
  network, no credential and no telemetry.
The coverage sweep had two parameters that could not be checked because no draft in the library carried
a sound track, so the loudness assertion had never actually judged anything and every film so far came
out as a picture with no audible track.

With a fixture that carries a sound, make_film produces its first film with both streams and a loudness
of minus fourteen LUFS against a target of minus fourteen. The two parameters are now covered, the
beautify one as a parameter path with a note that the fixture is a sine wave so the hit count is zero,
and the loudness target one with the target really applied.

The suite also learned two things it now says out loud. The renderer works on whichever draft the
running application currently holds, so the fixture is opened first, and opening a draft copies what
the application holds, including changes that were never saved, so the suite says to restart Jianying
before a run if it reports something strange.
…e route

The routes existed as data in references/pipeline-profiles.json and as prose in the pipeline Skill, but
running one still meant calling four verbs in the right order and carrying the session and the copy
directory between them. The tenth verb closes that.

It reads the route definition instead of restating it, so a route changes in one place. It carries the
values between the steps: the session for the draft verbs, the copy directory for the renderer, the
segment it read, and the two bare drafts the semantic criterion compares. When a variable has no value
the run stops and names it rather than guessing. When a step fails the report says which step it was,
and the run is marked failed instead of quietly ending.

Two things it refuses to do. A route that needs a script is not implemented yet, and the verb says so
instead of skipping the step, because the parameter shape of those scripts is not known. And a value
that is a whole variable keeps its type, which matters because the grade spec has to reach the baking
step as an object and not as a string of JSON.

The self test now asks for ten verbs, walks the grade and render route end to end and requires the
film to exist, and asks for a route that does not exist and requires a refusal rather than a different
route being run quietly.

The repository test counted the verb schemas and was pinned to nine, so it now expects ten and the
comment lists them.

Validation
- Plugin self-test: 51 PASS / 0 FAIL, up from 49
- mechanical audit: 0 issues
- audit tool self-test: 28 PASS / 0 FAIL
- parameter coverage self test: 10 PASS / 0 FAIL
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The route walk reports both steps and a verdict, and the film is on disk.
- The unknown route is refused, either by the schema or by the verb, and the assertion accepts either
  refusal because both are correct.
- The audit tool case that pinned the old count now injects the wrong number structurally, so it keeps
  working when the number of verbs changes.

Design compliance
- No dependency, no network, no credential and no telemetry was added.
- The Plugin remains a Skill-only Plugin with zero npm dependencies.
…easurable

The routes between a sentence and a chain existed only as trigger words in prose, which is how the
collisions of the audit were found in the first place. They are now a table, thirty six entries over
the seven Skills and the four routes, and a classifier reads it.

The classifier gives candidates with the words it matched and a score, and it says so when it has
nothing to go on instead of guessing. A labelled set of thirty one real sentences measures it, and the
self test fails the Plugin when the accuracy drops below eighty percent on the first choice or ninety
percent on the first three.

Two things came out of measuring it. The route entries must carry only the combination that belongs to
that route, because a general word like out a film made every route match every sentence. And the
first version replaced a whole variable with a string of JSON, so the grading spec arrived at the
baking step as text.

The audit grew a check that every Skill has an entry, that every route points at a route that exists,
and that the labelled set covers every Skill and is not too small, with a negative injection for each.

Widening the verb count check to match a count written without the measure word found three places
where the documents still said nine verbs, which the old pattern could not see.

Validation
- Plugin self-test: 52 PASS / 0 FAIL, up from 51
- intent route self test: top-1 100 percent, top-3 100 percent, thresholds eighty and ninety
- mechanical audit: 0 issues
- audit tool self-test: 29 PASS / 0 FAIL
- focus tests: 11 pass / 0 fail
- repository validator: 30 hosted Plugins OK

Test evidence
- The labelled set is the evidence: thirty one sentences, and the two that were wrong in the first
  round are named in the report so the words they lacked can be added.
- The audit injection that removes a Skill from the route table is reported.

Design compliance
- No dependency, no network, no credential and no telemetry was added.
- The Plugin remains a Skill-only Plugin with zero npm dependencies.
… the script broke every time

draft_read no longer accepts scope (the backend does not support segmented reads); talk-cut-apply.mjs (5 occurrences) and selftest-draft-edit.mjs (1 occurrence) still passed scope: 'segments' => they were rejected by the schema every time.
…real bugs fall out

The pipeline gains three things: script steps (scripts/*.mjs driven by real CLI flags), a human confirmation gate (a step marked confirm:true stops the run and hands back the artifact + sha), and a two-phase resume (progress is persisted under PLUGIN_DATA/pipelines/<token>, so confirm:true continues it; without confirm it returns REFUSED and runs nothing).

Two bugs that made every verdict unreliable also fall out: 1) Jianying recycles session ids, so a new session inherited the previous one's snapshot and draft_read returned a different draft (once one from two days earlier); draft_open now drops the stale snapshot and the session records the draft id so a mismatch is named. 2) The effect probe read its baseline from the encrypted draft file, parsed nothing, and reported changes=0 while the segment really was split; it now falls back to the official pipe.

The cut criterion is rewritten to be form-independent (the runtime shifts later segments left, so 'the range is no longer covered' was a false red) and to fail loudly (exit code 4) instead of printing a verdict and exiting 0.
@antianqi antianqi changed the title feat(jianying): add the jianying Plugin (9 MCP verbs + 7 Skills, one command to a finished film) feat(jianying): add the jianying Plugin (10 MCP verbs + 7 Skills, one command to a finished film) Oct 24, 2026
… them

The pipeline gains subtitle-then-render (a second script step, so the mechanism is not a one-off patch for talk-cut) and render-and-mix (audio_mix had no route at all). A new produces mechanism hands one step output to the next. The schema profile enum was hand-written and never reconciled with pipeline-profiles.json, so a new route existed but could not be called; the audit now reconciles both directions, with a negative injection. The intent classifier weight grew with keyword length, matching what its own comment always claimed.
…ything was checked"

A seventh route, review-only, reviews a single cut: it opens a copy, renders it twice to measure the noise floor, renders the film, and runs verify_diff one-sided. The route is read-only. verify_diff now supports one-sided input and names the dimensions it could not judge in undetermined plus verdictNote, so a PASS is not read as a full pass. A second, sneakier case the self-test caught: a video-only draft measures no loudness, and a null used to read as "loudness is fine" - it is now named too.
…itten backwards

The eighth route, review-compare, compares a before and an after draft: both copies, both frame renders plus a same-state re-render for the noise floor, both films, then one verify_diff across every dimension. setVars captures a step output under another name so one route can open two drafts without the second overwriting the first; produces paths are audited for declared variables and for .. or absolute paths. The loudness check compared the loudness value itself instead of its deviation from the target, so every properly normalised track was reported as un-normalised.
… no longer goes blind on broken JSON

grade_apply was the only verb no route used, and it is the only path that can do curves, HSL and colour wheels - so the requests that most need the grading chain could not be served by the orchestrator at all. The ninth route now renders without grading first and then grades with our per-pixel chain, whose per-node probe proves every node changed the picture. capture takes a value out of a step response (the graded film name is only known there), joining produces and setVars. The audit now parses JSON through readJson, so a broken file becomes one finding instead of a stack trace.
…s" finally has a guard

The classifier can decide: --decide returns either a verdict (with the matched words, the score and the margin as evidence) or an explicit refusal - no signal, or two candidates too close to separate. Building it exposed that the "never guess" rule had never been measured: anyone adding a generic keyword to the table would have made it guess with nothing turning red. The plugin now ships a refusal set of 18 sentences with no clear intent, and the self-test asserts not one of them may be judged as a chain. The margin is measured against the nearest candidate pointing at a different target, since two phrasings of the same skill are one answer, not two competitors.
… the parameters

Every route now declares its inputs (name/kind/required/from), so run_pipeline pre-checks before it starts and names all missing inputs at once, marking the ones only the user can supply - instead of failing at step 3 with one missing variable. The verdict carries the same picture: routeInputs, mustAsk and params (when the utterance maps to a known one, the parameter value comes along). The audit gained 7p: every route must declare inputs, every input must be a declared variable, from must be one of user/caller/derived/intent, and a required input must actually be used by a step.
The inputs were added by rewriting the whole file with JSON.stringify, which expanded every step object and buried the 41 lines that actually changed under 600 lines of reformatting. This restores the original compact layout, so the diff against the previous version is exactly the inputs blocks and nothing else.

@hetaoBackend hetaoBackend left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed current exact head de15275. The sessionId path-traversal blocker is fixed: IDs are constrained to server-issued ve-session-, state/snapshot paths are contained under the real PLUGIN_DATA directory, and existing symlink files are rejected. The current package test passes 11/11, the traversal negative test is rejected, and all repository-owned exact-head checks are green.

@antianqi

antianqi commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

@hetaoBackend Thanks — approved and all checks green on de15275. Requesting a squash merge.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants