Transformations without text: clips can now be cropped, explicitly resized,
speed-adjusted, and dithered, and a new preview command extracts a single
full-colour PNG still so framing can be confirmed before any GIF is encoded.
Every transformation parameter is an integer, a bounded decimal, or a member of a
fixed enumeration, so no user-supplied text ever reaches an FFmpeg filter graph.
Captions and subtitle burn-in are deliberately excluded and deferred to 0.4.0
(spec §3.3, §25.4).
Added
- Cropping (spec FR-025):
--crop <x>:<y>:<w>:<h>in orientation-normalized
source pixels, applied before scaling, so aspect-ratio preservation, the
profile maximum width, and the no-upscale rule all evaluate against the cropped
rectangle. The requested rectangle is applied exactly — never rounded, clamped,
re-centered, or expanded; a rectangle the decoded pixel format cannot express
triggers a conversion to a non-subsampled format instead of an adjustment. - Explicit resizing (spec FR-026):
--widthand the new--height, both
integers in the closed range 2–8192. They are maximum bounds with the aspect
ratio preserved; supplying both fits the frame inside that box. An explicit
bound overrides the effective quality profile's maximum width. Upscaling
remains gated by--allow-upscale, and an oversized request is clamped back to
the effective source size. - Playback-speed adjustment (spec FR-027):
--speed, a decimal multiplier
from 0.25 to 4.0 with at most three fractional digits, implemented by retiming
presentation timestamps. The selected source range is unchanged; the output GIF
duration becomesround(durationMs / speed). No frames are interpolated, and
retiming is applied before frame-rate conversion so the requested fps describes
the finished GIF. - Dithering control (spec FR-028):
--ditherwith the fixed enumeration
none,bayer,floyd_steinberg,sierra2,sierra2_4a, plus
--bayer-scale(0–5) forbayer. Documented per-profile defaults (§15.5):
bayerwith scale 5 forsmall,sierra2_4aforbalanced,high, and
custom— these reproduce 0.1.0/0.2.0 output, so a job that specifies no
dither is functionally equivalent to earlier releases. previewsubcommand (spec FR-029, §12.9): extracts a single full-colour
PNG still instead of producing a GIF.preview --input <source> --at <timestamp>for one frame, orpreview --manifest <manifest>for one still
per clip at that clip's start timestamp with that clip's effective
transformations. Preview output is never palette-quantized. Crop, resize, and
orientation normalization apply exactly as they would for the corresponding
GIF;speed,fps,loop,colors,dither, andbayerScaleare accepted,
change nothing, and produce oneTRANSFORMATION_NOT_APPLICABLEwarning.
--dry-run, collision policies, project-boundary rules, and remote sources all
apply unchanged. Generated names are<video-stem>_<at>.png, or
<clip-name>_<start>.pngin the manifest form.- Configuration: a
transformationsobject in.video-to-gif.jsonwith
width,height,speed,dither, andbayerScale(spec §9.6).cropis
not permitted there — a rectangle is only meaningful against one specific
source — andvalidate-configrejects it with the field path
transformations.crop. - Manifest fields (spec §10.4, §11.2):
crop,width,height,speed,
dither, andbayerScaleat the top level, at the clip level, or both, in
both JSON and CSV manifests. JSON accepts either the object form
{ "x", "y", "width", "height" }or the string form"x:y:width:height"; CSV
uses the string form in acropcolumn, where an empty cell means "not
specified for this row". - Transformation precedence (spec FR-024, refining §9.3): clip-level manifest
field > command-line flag > top-level manifest field > project configuration >
built-in default. A per-clip value is more specific than a batch-wide flag and
wins; every non-transformation setting follows §9.3 unchanged. - Result reporting (spec FR-030): every
createdentry gains a
transformationsobject (crop,sourceWidth,sourceHeight,
effectiveSourceWidth,effectiveSourceHeight,speed,dither,
bayerScale,upscaled) andoutputDurationMs; apreviewsarray carries
path,atMs,width,height,sizeBytes, and the sametransformations
object;summarygains apreviewscount. Preview entries never appear in
createdand are never counted bysummary.created. Preview extraction emits
progress under stagepreview. - Warnings with stable leading tokens (spec §13.4):
UPSCALE_NOT_ALLOWED
when an explicitly supplied bound was clamped to the effective source size, and
TRANSFORMATION_NOT_APPLICABLEwhen settings a still frame cannot express were
supplied topreview. - Documentation: new
references/transformations.md(spec NFR-007) covering
the crop coordinate model, the width/height bounds and their interaction with
profiles and upscaling, the speed multiplier and its duration math, the dither
enumeration with size/quality guidance, preview frames, per-clip manifest
transformations, and the filter-chain order.
Changed
- Output dimensions are unchanged. Every 0.1.0 and 0.2.0 invocation produces
the same GIF it did: with no transformation specified, output is
byte-comparable to 0.2.0 for the same source, range, profile, and
configuration.--widthkeeps its 0.1.0 meaning as a maximum output width, and
its derived height is unchanged. This was verified by executing the released
0.2.0 engine and 0.3.0 side by side over profile-only,--width, and manifest
widthinvocations across several source geometries and comparing both the
reported dimensions and the produced GIF bytes. Configuration, manifest, and
structured result schema versions all remain1, and no new exit code is
introduced — invalid transformations reuse exit 6 (INVALID_CROP,
INVALID_DIMENSIONS,INVALID_SPEED,INVALID_DITHER), apreview --output-namewith a non-.pngextension reuses exit 2 (INVALID_USAGE), and
a preview collision reuses exit 7. - Dimension parity, stated exactly (spec FR-026). An explicitly supplied
width/heightis honored exactly, odd values included: GIF is a palette-based
format without chroma subsampling, so it imposes no even-dimension constraint
and rounding an explicit bound would silently contradict the request. A
derived dimension is rounded to the nearest integer and may itself be odd —
the same rule 0.1.0 used, on every path (profile-only, width-only,
height-only, and both-bounds). No even-rounding is applied anywhere. - Manifest
widthis now range-checked (rejected-input change). FR-026
requires both dimension bounds to be integers in the closed range 2–8192, and
that check applies to the manifestwidthfield, which existed in 0.1.0. A
manifest carryingwidth: 1orwidth: 10000was accepted by 0.2.0 and is now
rejected withINVALID_DIMENSIONSand exit 6. No previously accepted output
changes — a value in 2–8192 behaves exactly as before — but a manifest relying
on an out-of-range width will need that value corrected. Whitespace-padded
values such as" 480"are unaffected and still accepted: a CSV cell is
trimmed before validation in every column, and a JSON manifestwidthstring
is trimmed exactly as 0.2.0 trimmed it. Fields introduced in 0.3.0 (height,
crop,speed,dither,bayerScale) have no such legacy, so in JSON they
take the strict grammar with no padding allowed. - Dithering is now a public option. The 0.1.0 allowance to change dithering
internally is superseded: an explicitly requested mode andbayerScaleare
honored exactly and will not change across patch releases, while a profile's
default may change only in a minor release with a changelog entry (spec §15.5).
Security
- SEC-018 — Transformation parameter validation. Transformation values become
arguments inside an FFmpeg filter graph and are treated as an injection
surface. Only integers, bounded decimals, and members of the fixed
enumerations are accepted; free text, filter strings, filter-graph fragments,
filter scripts, FFmpeg expressions, and option key-value pairs are rejected
from the command line, manifests, and configuration alike. Every parameter is
parsed and range-checked before any filter graph is constructed, and the
graph is built exclusively from values the engine re-serializes from its own
validated numeric and enum types — user-supplied text is never concatenated
into a filter graph. Any value containing a character outside its grammar is
rejected — this covers at least inner whitespace, inner newlines, and the
characters, ; ' " \ [ ] = % ( ) $ ` *. The colon is permitted only as the
field separator inside--crop, which must contain exactly three colons and
four unsigned integer fields.
Two deliberate exceptions concern surrounding whitespace only, and neither
reaches the filter graph.--ditheris compared after its surrounding
whitespace is trimmed (FR-028), so"sierra2_4a\n"is accepted — what the
engine then emits is the matched enum member, never the supplied text, so a
padded value and a clean one produce byte-identical arguments. A CSV cell is
likewise trimmed before its grammar runs, consistently across every column and
as 0.2.0 did forwidth; the strict grammar still runs on the trimmed text,
so inner whitespace, embedded newlines, and every metacharacter above remain
rejected, and padding a hostile value does not launder it. The
identical validated filter chain is applied to the palette-generation pass and
the encoding pass, so palette generation cannot be driven by a different or
unvalidated parameter set. No user-supplied filter script file or inline filter
definition is accepted through any flag, field, or key. - Numeric bounds are part of the security contract, not only usability: an
unbounded dimension, crop offset, or speed value is a resource-exhaustion
vector under SEC-011. Crop components are capped at 65535, dimensions at 8192,
and speed at 4.0. - SEC-010 unchanged for the new paths. The local-only protocol whitelist is
enforced on every FFmpeg and ffprobe invocation including preview extraction,
so no filter may reference a remote resource. SEC-001 remains in force:
subprocess arguments are always passed as arrays andshell=Trueis never
used.