Automated release: version and notes generated from pull requests merged since 3.6.1.
Security
- Every GitHub Action is pinned to a commit. A tag can be moved; the release job's
third-party step ran on one after the release token was in the checkout. Eachuses:now names
the commit of the tag it replaced (Dependabot keeps them current), and a contract test refuses
a tag reference. - Write scopes are granted per job. No workflow grants a write scope at the top level any
more; CodeQL, Pages, Dependabot auto-merge and the release each grant theirs to the one job
that needs it, and checkouts that never push no longer keep the token. - The landing page loads its icon stylesheet with Subresource Integrity. The unpkg script that
added six stylesheets at run time is replaced by the one stylesheet the page uses, pinned by
hash. - The input parsers are fuzzed.
fuzz/holds atheris targets for the time grammar, the SRT
reader and the hardened timeline XML reader; ClusterFuzzLite fuzzes them on each pull request
that changesscripts/**.py,fuzz/or the fuzzing setup, and for half an hour each week, and the contract suite runs every target on
its seed inputs so a target that stops importing fails CI. SECURITY.mdlinks the private advisory form by its full address, which also resolves outside
GitHub's file view.- MCP annotations now match what a call can write.
probe,checkandcropdetectwere
annotatedreadOnlyHint: true, yetplan+overwritelet them replace any existing file, and
every tool saiddestructiveHint: false. Over MCP the server now runs the three read-only tools
withFFMPEG_SKILL_READ_ONLY=1, which refuses--planstructured or in any rawargv
spelling, and their schemas say so (kind: input,error.reason: read_only_plan_refused). Every tool
that writes is nowdestructiveHint: true. The CLI is unchanged. - A structured value is never read as an option. MCP arguments and render.py's stage commands
pass a value that starts with-as one--flag=valuetoken, so overlay text-50%or a title
--helpworks instead of failing with "expected one argument". Positionals that start with-
follow--, so an input named--plan=xcan no longer set--plan.
Fixed
batch.pyproject recipes read the project's side files beside the project. Each item's
copy of the project was written into its work directory with its relative paths unchanged, and
render.pyreads those against the project file's directory, so a project naming
"music": "bed.wav", a brand, captions or a chapters file failed every item after its earlier
stages had encoded (the dry run said it would complete). The copy now carries them absolute.
The same list of path keys is whatrender.pyapplies to its own project, so
audiogram.imageandoverlays[].font_fileare read against the project file too, not the
working directory, andmulticam.py --write-projectwrites its clip paths absolute, so the
project renders from wherever it is written.batch.py --watchno longer processes a source that is still being written. A file copied
into the watched folder was processed as the fraction already there and reportedok(and
verifiedfor MKV and TS); once the copy finished, every later pass refused that output with
output_exists, so the truncated file stayed. A source is processed once its size and mtime
are unchanged across one poll (first seen: last modified at least one interval ago); until then
its row isskipped: "settling". A file an earlier pass of the same watch delivered (the
output and any side file a step wrote, such as an.srt), and nobody changed since, is
replaced when its source changes. Each file is checked on its own, by the output check of the
step that writes it (batch passes no--overwrite) or under the output lock when batch moves
the output into place, so an edited sidecar (also one edited while the same item was still
encoding: each file is recorded as it was when written), or an output another process put there
meanwhile, is still refused without--overwrite.- Text symbols are no longer taken for emoji.
♪ ✓ ★(not emoji in Unicode's emoji-data)
and♥ ✔ ☺(emoji only when VS16 follows) were counted as emoji because the whole Misc
Symbols, Dingbats and Misc Symbols and Arrows blocks were. Amode: nonerun (auto on macOS, or
on Linux with no colour emoji face) then cut SDH music notes and check marks out of the burned
captions and said no face could draw them. In those blocks only Emoji_Presentation characters
start an emoji on their own now, and a text-default one does with VS16 or a skin tone; the
playing cards and mahjong tiles other than 🀄 🃏 are text too. The same rule reaches
graphics.pyandoverlay.py, where such a title no longer switched to libass or reported
emoji: mono. - A wrap pass that moves a break keeps the space that stood there. The orphan, weak-line and
rebalance passes guessed whether a space stood at a break from the script on each side, so a
moved break in Korean, in Japanese or Chinese written with spaces, next to an emoji, or between
Latin and CJK ran two words together (늦지마세요,したよろしく) while the result said
text_unchanged: true. The greedy fill now records each break's space and every pass carries
it. Korean is wrapped as the spaced script it is: at the spaces between words, never inside one
unless the word alone is wider than the line (broken_inside_word). Shared bycaption.py,
graphics.pyandoverlay.py --text. caption.pystyles SRT markup on the routes where it writes its own ASS.--animate,
--karaoke, an emoji-forced ASS and the tiktok/reels/shorts templates drew<i>,<b>,
<font color>and{\an8}as text, and counted them as width in the wrap and the size fit.
They become the overrides ffmpeg's own SRT route gives them (<u>and<s>too); the wrap,
the fit, the emoji placement and the blank-cue count measure the words only, and a cue split
across--max-lineskeeps its italics and position on every part. Any other tag or{...}
block stays escaped text, as before; under--karaokea<font color>is dropped for the
highlight's colours, and--karaoke-style wordno longer resets the cue's style ({\r}) after
the active word. A tag pair around whitespace alone (A<font color=red> </font>B)
stays around it through the wrap, rather than styling the next word, and a{\an8}that a
--max-linessplit puts in a later part places the earlier parts too.speedramp.py: a sped-up segment no longer leaves the rest of the picture late.setpts
kept each sped-up frame's unscaled duration, and the concat started the next segment after it:
a 2x segment followed by a 1x one showed every later frame two frames (67 ms) after its sound
and wrote 92 frames for 90. Each segment's frames now get the source rate's durations back
(fpsaftersetpts, asreverseandfreezedo);0-4:2writes 60 frames,0-4:0.5240.sync.py --trim-secondno longer stream-copies a video source file. The copy started at
the keyframe before the trim point and showed the frames from it, so the "synced" file started
early by up to a GOP (with a 5 s GOP nothing was trimmed at all) and still reportedverified: true; even a keyframe on the trim point moved the picture by its B-frame delay. A video source
is now re-encoded from the frame at the trim point and re-measures at 0; a sound-only source is
still copied.cut.py: an upper-case.MOV/.MP4output is an MPEG-4 output. The join's container
checks compared the suffix case-sensitively, so a--segmentsre-cut of more than 32 ranges
(and everysilence.pyrender keeping that many) intoIMG_0001_tight.MOVwrote HEVC tagged
hev1, which Apple players refuse, with the index at the end, and a copy join into.MP4lost
+faststart. A.TSpart list is also recognised as MPEG-TS for the copy join's check.- The start timecode moves with the start of the picture.
cut.py(copy and re-encode, one
range),fit.py --method trimandsync.py --trim-secondwrote the source's start timecode
unchanged on a file that starts later (a cut from 1 s stamped 01:00:00:00 on frame 30), and so
did arender.pyclip with anin, disagreeing with the render's own--export-timeline. They
now write the label of the frame the output starts on (drop-frame where the source is; a padded
head counts back). A join of several ranges writes no start timecode, and a stream copy out of
MXF writes none, both listed undersource_carry.dropped.source_carrycompares the
timecode's value, not only its presence. The label is counted at the rate the output is
delivered at (fit.py --fps 50on a 25 fps source starting 01:00:00:12 writes 01:00:00:24, and
arender.pyclip reachingframe.fpsthe same), a cut that starts before a picture which
starts after the file's origin is stamped with that picture's first frame, andsync.py --trim-secondandfit.py --method trimseek a second early and drop the margin on the output
side, ascut.pydoes, so a B-frame HEVC camera does not lose the frames before a keyframe.
The source label and the offset to the first frame are rounded once, at the delivered rate,
and a delivered rate below 1 fps (where no frame count names a frame) writes no label instead
of failing. audio.py --downmixno longer clips, and uses the weights the docs name. pan's=summed
2.7× per side (3.2× on 7.1) and mixed the LFE in at 0.5, so a 5.1 with every channel at
-6 dBFS came out hard-clipped (flat factor 20+) while the run said verified, and the weights
were not the ITU onesreferences/scripts.mdclaimed. The fold-down is now ITU-R BS.775
(centre and surrounds at -3 dB, LFE dropped), scaled with pan's<so each side's gains sum to
1: in-phase channels cannot clip, and a front channel alone is 7.7 dB lower (9.9 dB on 7.1).color.py --to-sdrtakes the source peak to SDR white;look.py's HDR frames match it.
The linearising zscale usednpl=--peak, whiletonemapreads linear 1.0 as 100 nits, so no
HDR pixel reached white: PQ at 1000 nits and HLG at 100 % came out at Y 151 of 235, HLG reference
white at 91, and raising--peakto a brighter master's darkened the picture further. It now
linearises atnpl=100and passespeak=--peak/100(HLG: always 1000 nits, the peak zimg
decodes it to), so a PQ pixel at--peaknits and HLG at 100 % land on Y 235.look.pyused
the samenpl=1000chain for HDR sheets and tone-mapped a BT.2020 SDR source too; it now uses
--to-sdr's chains at their defaults.look.py --compareconverts each picture by its own
tags: the first input's tone map ran on both, so an SDR clip beside a PQ one was read as PQ
(and an HDR clip given second was shown untone-mapped).overlay.py --video --opacitybelow 1 no longer shifts the colours of the whole frame. The
alpha was scaled withcolorchannelmixer, which takes RGB only, so FFmpeg composited the main
picture in RGB and it came back BT.601 under its BT.709 tag (the defect 2.10.2 removed from
--image). The alpha is now scaled in YUV (lut). This coversrender.pyvideo layers with an
opacitytoo. An untagged PiP over a BT.709-tagged main (or the other way round) is labelled BT.709 too:
FFmpeg 8.0's overlay converted the tagged picture to the untagged one's "unknown" (BT.601)
matrix, so the main picture shifted away from the PiP at any opacity.- Overlays onto a 10-bit HDR or ProRes source keep its 10 bits. overlay's default format is
8-bit,format=autopicks 8-bit beside an 8-bit layer anddrawboxis 8-bit only on 6.1, so
broll.py,redact.py(blur, pixelate),graphics.py(bars, boxes, emoji),caption.py's
emoji andoverlay.py --videotook the whole picture to 8 bits before the Main10 encoder, and
overlay.py --imagetook it through 8-bit RGB, shifting its colours (Y/Cb/Cr 402/602/422 came
out 399/608/418). These overlays now run at 10 bits in the source's chroma subsampling
(overlay_format()), graphics.py draws its boxes as layers there, and a 10-bit PiP stays
10-bit. - The sentence flags from 3.6.0 are named wherever transcription is.
silence.py --transcribewith--sentence-min-silenceor--sentence-margin-tailruns the speech engine
(under--dry-runtoo, and it may download a model on first use), but the contract's
capabilities, refusal reasons and model-download statement,PRIVACY.md, the plugin README and
README.mdnamed only--filler --transcribe. README'ssilence.pyrow and SKILL.md's routing
table now carry the 3.5.0 and 3.6.0 flags. silence.py's sentence lookup is a binary search. Placing the word before each silence
scanned the whole transcript once per silence: about 1.2 s for an hour of speech and 11 s for
three hours. It is now proportional to the number of silences times the log of the words.- fix: the audit's high findings (captions, timing, audio, colour, batch), MCP argument safety, and Scorecard hardening (#469)
What's Changed
- fix: the audit's high findings (captions, timing, audio, colour, batch), MCP argument safety, and Scorecard hardening by @kajisho5 in #469
Full Changelog: v3.6.1...v3.6.2