Repository navigation
feat(jianying): add the jianying Plugin (10 MCP verbs + 7 Skills, one command to a finished film) - #72
feat(jianying): add the jianying Plugin (10 MCP verbs + 7 Skills, one command to a finished film)#72antianqi wants to merge 38 commits into
Conversation
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
left a comment
There was a problem hiding this comment.
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:
-
Unvalidated
sessionIdescapesPLUGIN_DATA.plugins/antianqi/jianying/mcp/server.mjs:122-123,300-307buildssnapshot-${sid}.jsonwithpath.join()and accepts any schema-valid string. A reproducible isolated probe setPLUGIN_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 reachessnapshotFile()duringdraft_patch(:346-356), so a successful edit response can also write a snapshot outsidePLUGIN_DATA.Validate session IDs against the server-issued format and ownership (for example, the exact
ve-session-<digits>value recorded bydraft_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.
|
@hetaoBackend Thanks for the catch — the traversal was real, and it applied to the write path as well as the read path. Fixed in Three layers in
The check also runs at the verb entry points, and a missing id is reported differently from a malformed one. Proof — It isolates
The probe is wired into One more exposure, found while fixing it
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.
… 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
left a comment
There was a problem hiding this comment.
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.
|
@hetaoBackend Thanks — approved and all checks green on |
What changes
Adds
plugins/antianqi/jianying— a Windows-only MiniMax Code Plugin that grades video with apixel-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.
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 undermcp/tools/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 referencedreferences/— 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)scripts/— the plugin's own MCP client, an environment self-check, five self-tests, a session-containment security probe, and the talk-cut planner/applierdraft_patchreturns the changes it made and whether it believes the change took effect, and takes an optionalexpectthat 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 bymcode 0.3.x) and
.claude-plugin/plugin.json(mcode 0.4.0+ manifest, with the seven Skill paths and thestdio 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 thanthe 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/canarywas normalised bypath.joininto a path outsidePLUGIN_DATA:draft_readanswered with the contents of that file, anddraft_patchwrote a snapshotthere.
Three layers now stop it, in
mcp/server.mjs:ve-session-<digits>, the form the server itself issues, so it cancarry no separator and no
...merely shares the prefix does not count as inside.
refused, for reading and for writing.
One related exposure was found while fixing it:
draft_readused 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.mjsisolatesPLUGIN_DATA, places a canary beside the state directory andbeside 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 isrefused, 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.
泄漏canary=true) anddraft_commitreportingcommitted: trueoutside the directoryThe probe is wired into
test/jianying.test.mjs, so the windows CI job proves it on every push, andinto 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
Expected result
grade_applyexportsgraded10.mp4(direct point-wise path, recommended) andgraded_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.
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:
And the cross-chain one, which
jianying-pipelineexists for:⇒
draft_open→make_filmon the same copy → built-in acceptance (both streams present, durationwithin 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 everyverb, argument and variable of it against the schemas.
Plugin submission checklist
plugins/<github-owner>/<plugin-name>—plugins/antianqi/jianying.plugin.jsonname matches the Plugin directory —jianyingin both manifests.README.mdincludes a real example prompt and expected result.LICENSEandplugin.jsondeclare an open-source license — Apache-2.0 in all three places.Windows 10/11;
ffmpeg6.0, Python 3.9+, Node ≥ 22, Jianying 11.6 (running for the draft verbs);JYAI_HOMEpointing at the toolchain; no account, no paid service, no API key.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.
PLUGIN_DATA— see the security section:three independent layers plus a 30-case probe that is negative-injected.
included — 42 text files, no symlinks, no binaries, no scaffold placeholders.
TODOhas been replaced.npm run checkpasses.Evidence
node scripts/validate.mjsnode --test test/jianying.test.mjs— 11 pass / 0 fail. The suite pins the things that failsilently: every
skills[]path in the v0.4 manifest resolves and its frontmatternameequals itsdirectory 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, theexistence 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:
name!= directory namefrontmatter name must equal its directory namedescriptionover 1024 charactersdescription is 1285 chars (limit 1024)must not hardcode a drive letter pathfetch()in a scriptmust not make HTTP requestsREADME must state the expected resultREADME must disclose telemetry behaviourskills[]entry points at a missing fileskills[]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):
selftest-mcp.mjsselftest-params.mjsselftest-draft-edit.mjsselftest-talk-cut.mjsselftest-subtitle.mjsselftest-audio-ai.mjssecurity-probe.mjscheck-env.mjsThese 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
expectthat says a field must not change must fail, a misspelled grading parametermust 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:
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_filmnow validates its grade against thegrade_applyspec schema, and the baking step itself refuses unknown parameter names, so bypassingthe schema is also covered.
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 (onegrey 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:
noiseKonly takes part in the criterion line when a noise directory is supplied, andmaxFramesshortens 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_patchused 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
expectthat 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.ymlruns onlynode --test test/jianying.test.mjson
windows-latest, filtered to this Plugin's paths, so the session containment probe is proven on everypush. The repository's
validatejob stays onubuntu-latest; this is the scoped Windows counterpartthat
.github/workflows/ci.ymlasks a non-tool-map Windows Plugin to add, rather than re-introducing arepository-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)
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.
primaryWheeland the user pick key ofhslare not wired into the chain API form: they used tobake an identity LUT silently, and they now fail loudly.
clear(8-level pyramid) is not wired into the chain.talk-cutdoes not do ASR: the official ASR lives in the agent runtime and is not reachable from aplugin, so it takes a user-supplied SRT and only decides which lines are filler, pauses or repeats.
audio.addanswers withacceptedand 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 ofmake_film(beautify,targetLufs) remain without an end-to-end check.make_film(beautify,targetLufs) are the only ones still without an end-to-end check. Everydraft 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:
That repository carries the same Plugin files, an
EVIDENCE.mdwith the measured numbers, and the sametest/jianying.test.mjs— the focused suite is layout-agnostic (it detects the Plugin root instead ofassuming 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
scripts/. The keys ofargsare the command-line flags ("--srt": "$srt"), so the script receives what the profile says.truebecomes a bare flag,false/nullis not passed."confirm": truestops 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.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:
verdict=AWAIT_CONFIRM,gate.step=3,gate.pendingSteps=3confirmverdict=REFUSED; the self-test asserts thattalk-cut-apply.mjswas not among the executed steps找不到流水线状态confirmverdict=PASS, and the cut step proves 2/2 ranges removedTwo bugs fell out of the work, and both made every verdict unreliable
so
draft_readreturned a different draft — once one left over from two days earlier. Thesymptom was a split that was
acceptedyet changed nothing.draft_opennow drops the stalesnapshot for a recycled id, and the session records the draft id so a mismatch is named.
"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 ofform, 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 µstook the track from 3 000 000 to 2 600 000 µs, and200000..900000 µsfrom 2 600 000 to 1 900 000 µs — 2/2 verified, with the real draft's shaunchanged.
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). ThePlugin 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.mjsfails for the rightreason (
不该变的 "volume" 字段却变了) instead of for a vague one.Numbers
scripts/selftest-mcp.mjsscripts/selftest-draft-edit.mjsscripts/selftest-talk-cut.mjsscripts/check-env.mjsThe two self-test failures that predate this round are worth flagging:
selftest-draft-edit.mjscalled
draft_readwith no target at all (it lost its argument when thescopeparameter wasremoved), and
check-env.mjsasserted a hard-coded number of schemas. A red self-test that nobodyfixes 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_mixandrender_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
subtitle-then-renderdraft_open → draft_read → add-subtitles.mjs → draft_commit → make_filmrender-and-mixdraft_open → make_film → audio_mixmixed.mp4measures -14.28 LUFSsubtitle-then-renderis the second script step, so the script-step mechanism is not aone-off patch for talk-cut. The subtitle script now follows the same rule as
talk-cut-apply:--sessionIdreuses the orchestrator's session and leaves the commit to theorchestrator — scripts only change; opening and committing belong to the orchestrator.
Products are handed from one step to the next
audio_mixneeds the picture thatmake_filmjust produced, so a step can now declare what itproduces:
{ "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 wasright — and calling the new route was rejected:
The
profileenum inrun-pipeline.schema.jsonis hand-written and was never reconciled withthe 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:
subtitle-then-renderjianying-draft-edit(matched 加字幕, 字幕)render-and-mixjianying-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-mixfails and says where it stopped(
nothing to mix) — which is correct, so the self-test asserts that: with an audio draft itasserts success, without one it asserts the honest failure. "No audio track" is not a reason
to fail; pretending to succeed is.
Numbers
scripts/selftest-mcp.mjsReviewing 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_diffburied the dimensions it could not judge insidechecks, so aPASSread as "everything was checked".What a single-version review can honestly judge
visibilityskipped) — but it reports the noise floorpictureaudiotimelinedraft_integrityThat 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) andverdictNote:The self-test caught a second, sneakier case: a video-only draft has no audio stream, so
ebur128measures nothing andintegratedLufscame backnull— neither skipped norproblematic, so it read as "loudness is fine". Now a dimension that was checked but could not be
measured also reports
undeterminedReasonand lands inundetermined.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-onlyIt is read-only: reviewing does not authorise editing, so the route opens a copy, measures and
never commits. Measured on the fixture:
clipRatio = 0.00724· verdict = "参考(未判定)"single: trueundeterminedA report-level defect found on the way
The orchestrator's step summary keeps a whitelist of fields — and
verify_diff's verdict lives inchecks, so it was dropped: the route ran successfully and produced no review at all.verify_diffnow has its own compact summary (key numbers per dimension, per-frame detaildropped so the report stays readable).
Numbers
scripts/selftest-mcp.mjsComparing 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 draftsOpening two drafts in one route meant the second
draft_openoverwrote$sessionIdand$dir, so the first draft's copy was lost. Two mechanisms, with different timings:produces$outDir) — so the step's own arguments can use themsetVars$sessionId/$dir/$draftJson) under another nameValues 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 oneroute no longer overwrites them.
The audit now guards both: every
setVars/producestarget and every variable they reference mustbe declared, and a
producespath may not contain..or be absolute.The eighth route:
review-compareOne 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):
clipDelta = 0★ 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": -14next to "loudness is not normalised to−14 LUFS". The check was
Math.abs(c.integratedLufs) > 0.5— comparing the loudness valueitself, 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-comparewithout 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
scripts/selftest-mcp.mjssetVars/producesguards)The last uncovered verb: curve-class grading
A tally showed that of the ten verbs,
grade_applywas the only one no route used — and itis the only path that can do curves, HSL and colour wheels (
make_film --gradecovers theintensity 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:
Measured with
adjustColor+logWheel:adjustColor = 0.0136·logWheel = 0.0124— every node changes the picture on its owngradeChangeMad = 0.0260graded/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 forwardproduces$outDir)capturesetVarscapturerefuses politely: if the response has no such field, it says so instead of quietlystoring
undefined.Two more report-layer gaps closed
grade_apply's verdict lives inprobeandgradeChangeMad,verify_diff's inchecks—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
varsfield: 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 — madethe audit crash with a stack trace, so it reported nothing else. Second time (the first was an
intent-routes.jsonfield). Two fixes: write the note as one string, and make the audit's JSONparsing go through
readJson()(17 places) so a broken file becomes one finding and the auditkeeps 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.mjshas always said "never guess: when nothing matches, say so" — and theconfident: falsebranch 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 clearintent ("帮我看看这个", "你看着办", "老规矩", "整理一下", "能不能用"…), each with a
why— andthe 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>" --decidedecision.route/decision.skillA 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)
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 (theevidence 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
scripts/selftest-mcp.mjsThe 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.jsongained a structuredinputsper 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_pipelinepre-checks:The verdict carries it
jianying-cutspec = {"adjustColor":{"temperature":0.15}}talk-cut-then-render$draft,$srtgrade-chain-then-render$draft,$grade(a curve shape cannot be guessed)review-only$draftAudit 7p guards it: every route must declare inputs, every input must be a declared variable,
frommust 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 wasrunning (nine processes).
The real culprit was the WMI provider host (
WmiPrvSE.exe): it had burned 704178 secondsof CPU (≈8 days) since September, so every
Get-CimInstance/tasklistcall timed out andthe bridge could no longer resolve the VE runtime's pipe. The message blamed the app instead of
the query.
WmiPrvSE.exe(the service respawns it) turned a 20 s timeout into a 550 ms answerA 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_openwas handedve-session-2while a state file from an earlier session 2 was still on disk (exactly thehazard fixed earlier). Measured:
draft_readreturned the correct draft (duration 3000000 ·2 segments), not the previous session's leftovers.
Numbers
scripts/selftest-mcp.mjs