muvid.visualize.canvas¶
Lay a cover image out on a 16:9 canvas, and derive a thumbnail from it.
Cover art is usually square (or, worse, portrait) while video platforms are
16:9. Letting the platform pillarbox the art leaves black bars; instead we fill
the frame with a blurred, darkened copy of the cover and place the sharp cover
centred on top. It reads as intentional, and it is the same treatment whether
the result becomes a still video, the ground truth for a Ken Burns pan, or the
upload thumbnail — one CoverLayout, one filtergraph, three uses.
Every filter chain here is built as a string rather than executed, so the
same chains compose into the bigger filtergraph that muvid.visualize.video
assembles for audio-reactive visuals.
Module Attributes
1080p is YouTube's sweet spot for a static music video. |
|
YouTube rejects thumbnails over 2 MiB, and wants at least 1280x720. |
Functions
|
Filter chain turning cover stream |
|
|
|
Render the composed canvas (background + centred cover + title) as a PNG. |
|
The whole cover-on-canvas filtergraph: background, centred cover, title. |
|
The bounding box the sharp cover is fitted into, for |
|
Filter chain scaling cover stream |
Path to a usable TrueType font, or |
|
|
|
|
Escape |
|
A |
|
Filter chain centring the |
|
Render |
|
Filter chain burning |
Classes
|
How a cover image is placed on the canvas. |
|
How a burnt-in title is drawn (ffmpeg |
- class muvid.visualize.canvas.CoverLayout(background='blur', blur_sigma=30.0, dim=0.65, saturation=0.8, cover_fraction=0.92, cover_alpha=1.0, background_color='black')[source]¶
Bases:
objectHow a cover image is placed on the canvas.
- background¶
"blur"(a blurred, darkened copy of the cover fills the frame) or"color"(a flatbackground_color).
- blur_sigma¶
Gaussian blur strength for the
"blur"background.
- dim¶
How much to darken the background, 0 (unchanged) to 1 (black). Multiplicative — the background’s luma is scaled by
1 - dimabout black, so a shadow gets darker rather than being deleted. Seedim_saturation_lut(); the constant is not comparable with the additive offset that preceded it (muvid#70).
- saturation¶
Background saturation (< 1 desaturates, so the sharp cover stays the focal point).
- cover_fraction¶
How much of the frame the sharp cover fills. The cover is scaled up, keeping its aspect ratio, until it reaches this fraction of either the frame width or the frame height — whichever it hits first (so a wide cover is width-bound, a tall one height-bound).
1.0touches the edges; below 1 leaves padding.
- cover_alpha¶
Opacity of the sharp cover, 0 (invisible) to 1 (opaque). Below 1 lets whatever is behind the cover — a reactive visualizer, the blurred background — show through it.
- background_color¶
Fill colour when
background="color".
- dim: float = 0.65¶
the multiplicative dim that lands the plate’s mean DISPLAY luma where the additive
0.25left it, pooled over four photographs (muvid#70). The two forms are not comparable at the same nominal value — seedim_saturation_lut().- Type:
Measured, not chosen
- muvid.visualize.canvas.DEFAULT_SIZE = (1920, 1080)¶
1080p is YouTube’s sweet spot for a static music video.
- Type:
Default 16
- Type:
9 canvas
- muvid.visualize.canvas.THUMBNAIL_SIZE = (1280, 720)¶
YouTube rejects thumbnails over 2 MiB, and wants at least 1280x720.
- class muvid.visualize.canvas.TitleStyle(size_fraction=0.045, color='white', font=None, margin_fraction=0.06, box=True, box_color='black@0.45')[source]¶
Bases:
objectHow a burnt-in title is drawn (ffmpeg
drawtext).- size_fraction¶
Font size as a fraction of canvas height.
- color¶
Text colour.
- font¶
Font file path, or
Noneto auto-detect one.
- margin_fraction¶
Distance from the bottom edge, as a fraction of height.
- box¶
Draw a translucent plate behind the text (keeps it legible over busy artwork).
- box_color¶
Colour (with alpha) of that plate.
- muvid.visualize.canvas.background_chain(size, layout, *, src, out)[source]¶
Filter chain turning cover stream
srcinto a full-frame background.The darken/desaturate step is
lutyuv, noteq:eqis GPL-only (muvid#69). Its luma half is no longereq’s arithmetic either — a dim has to scale, not subtract, or the plate’s shadows are deleted rather than darkened. Seedim_saturation_lut().- Return type:
- muvid.visualize.canvas.brightness_saturation_lut(*, brightness=0.0, saturation=1.0)[source]¶
lutyuvy/u/v expressions reproducingeq’s brightness + saturation.eqis compiled into ffmpeg only under--enable-gpl, so every chain that reached for it made muvid require a GPL build for what is arithmetic on three planes.lutyuvis LGPL and expresses the same two knobs exactly asvf_eqdefines them: brightness is an ADDITIVE offset ofbrightness * 255on luma, saturation a scaling of chroma about_CHROMA_PIVOT_SCALE.Returns a
{component: expression}mapping rather than a filter string because the two callers need different shapes — one composes a filtergraph, the other emits onesendcmdcommand per component — and a second copy of this arithmetic is exactly how the two would drift apart.- Parameters:
- Return type:
Examples
>>> lut = brightness_saturation_lut(brightness=-0.25, saturation=0.8) >>> lut["y"] 'clip(val-63.75,0,if(eq(minval,0),maxval,maxval*255/235))' >>> lut["u"] == lut["v"] True >>> brightness_saturation_lut()["y"], brightness_saturation_lut()["u"] ('clip(val+0,0,if(eq(minval,0),maxval,maxval*255/235))', 'clip(val*1+(if(eq(minval,0),maxval,maxval*255/240))*0,0,if(eq(minval,0),maxval,maxval*255/240))')
- muvid.visualize.canvas.canvas_image(image, *, saveas=None, size=(1920, 1080), layout=None, title=None, title_style=None)[source]¶
Render the composed canvas (background + centred cover + title) as a PNG.
Composing once into an image — rather than re-running a 1080p blur on every frame — is what makes a still-image music video cheap to render, and it gives the thumbnail and the video’s first frame a single source of truth.
- Parameters:
saveas (
str|Path|None) – Output PNG path (default:<image-stem>.canvas.png).layout (
CoverLayout|None) – Placement/treatment of the cover (a default one when omitted).title (
str|None) – Burn this title into the canvas (omit for no title).title_style (
TitleStyle|None) – How to draw that title.
- Return type:
- Returns:
Path to the rendered PNG.
- muvid.visualize.canvas.compose_chain(size, layout, *, src, out, title=None, title_style=None)[source]¶
The whole cover-on-canvas filtergraph: background, centred cover, title.
srcis a single cover-image stream; it is split so the same image feeds both the blurred background and the sharp foreground.- Return type:
- muvid.visualize.canvas.cover_box(size, layout)[source]¶
The bounding box the sharp cover is fitted into, for
size/layout.Scales with both frame dimensions, so a cover fitted into it with
force_original_aspect_ratio=decreasegrows until it meets whichever edge comes first — filling the frame up tocover_fraction, minus padding.
- muvid.visualize.canvas.cover_chain(size, layout, *, src, out)[source]¶
Filter chain scaling cover stream
srcto the centred sharp cover.When
layout.cover_alpha < 1the scaled cover is made semi-transparent (colorchannelmixer=aa=…, over anrgbacopy so an opaque source gains an alpha channel), so a followingoverlay_chain()lets the background show through it.- Return type:
- muvid.visualize.canvas.default_font()[source]¶
Path to a usable TrueType font, or
Noneif none was found.
- muvid.visualize.canvas.dim_saturation_lut(*, dim=0.0, saturation=1.0)[source]¶
lutyuvy/u/v expressions that DARKEN luma and desaturate chroma.The sibling of
brightness_saturation_lut(), and deliberately not the same arithmetic.eq’s brightness — which this replaces at the one site that wanted to darken rather than to shift — is an additive offset, and subtracting a constant does not dim a picture: it slides the histogram down and clamps everything below the offset to the floor. The plate’s shadows did not get darker, they were deleted, and at the reactive constants that took 57–99% of the plate to display black (muvid#70).So luma is scaled by
1 - dimabout_DIM_PIVOT, which preserves the order of every pair of pixels — a shadow stays darker than what is next to it instead of joining it at black. Chroma is untouched by the change and still goes through_chroma_expr(), so the desaturation half remainseq’s.dimtherefore means something different from the additive offset it replaces, and the constants that ship were re-measured rather than converted: seeCoverLayoutandmuvid.visualize.visuals.- Parameters:
- Return type:
Examples
>>> lut = dim_saturation_lut(dim=0.65, saturation=0.8) >>> lut["y"] 'clip((val-minval)*0.35+minval,0,if(eq(minval,0),maxval,maxval*255/235))' >>> lut["u"] == lut["v"] == brightness_saturation_lut(saturation=0.8)["u"] True >>> dim_saturation_lut()["y"] # 0 is a no-op, and reads as one 'clip((val-minval)*1+minval,0,if(eq(minval,0),maxval,maxval*255/235))'
- muvid.visualize.canvas.escape_filter_value(value)[source]¶
Escape
valuefor use as a filter option inside an ffmpeg filtergraph.ffmpeg unescapes such a value twice, so escaping it once is not enough:
The filtergraph parser reads the whole graph first, splitting it on
_GRAPH_LEVEL_SPECIALSand consuming one layer of quoting.Each surviving per-filter argument string is only then split into options on
_OPTION_LEVEL_SPECIALS, consuming a second layer.
Escaping therefore runs in the mirror order — option level first, then graph level over that result — so that each ffmpeg pass peels off exactly one layer. A literal
'comes out as\\\'and a literal:as\\:.Escaping only once is why a title like
"Song: Part 1"used to become a filtergraph syntax error, and why a workdir whose name contained'or:broke everysendcmd=f=<path>render.Note that
%is deliberately not escaped: neither parser treats it as special, so\%was simply unescaped back to%and escaping it never did anything. drawtext’s%{...}text expansion is a third level that applies to that one filter’stextoption only, and is out of scope for a general-purpose filtergraph escaper.- Return type:
- muvid.visualize.canvas.lut_filter(exprs, *, label='')[source]¶
A
lutyuvfilter from either LUT builder’s expressions.The expressions contain
,, which the filtergraph parser reads as “next filter”, so every one goes throughescape_filter_value()— the same escaper, and the same reason, as asendcmdscript path.- Parameters:
- Return type:
Examples
>>> lut_filter({"y": "clip(val+0,0,255)"}, label="flash") 'lutyuv@flash=y=clip(val+0\\,0\\,255)'
- muvid.visualize.canvas.overlay_chain(*, background, cover, out, shortest=False)[source]¶
Filter chain centring the
coverstream over thebackgroundstream.- Parameters:
background (
str) – Label of the background video stream.cover (
str) – Label of the (already scaled) cover stream.out (
str) – Label to emit.shortest (
bool) – End the overlay when the shortest input ends — required when a finite, audio-driven background is overlaid with an endlessly looping still cover, or the render would never terminate.
- Return type:
- muvid.visualize.canvas.thumbnail_image(image, *, saveas=None, size=(1280, 720), layout=None, title=None, title_style=None, max_bytes=2097152)[source]¶
Render
imageas a 16:9 JPEG thumbnail that YouTube will accept.Same composition as the video canvas, so the thumbnail matches what the viewer sees when they press play. JPEG quality is stepped down until the file fits
max_bytes(YouTube’s hard limit).- Parameters:
saveas (
str|Path|None) – Output JPEG path (default:<image-stem>.thumb.jpg).size (
tuple[int,int]) – Thumbnail size (YouTube wants >= 1280x720, 16:9).layout (
CoverLayout|None) – Placement/treatment of the cover.title (
str|None) – Burn this title into the thumbnail (omit for none).title_style (
TitleStyle|None) – How to draw that title.max_bytes (
int) – Hard size ceiling.
- Return type:
- Returns:
Path to the rendered JPEG.
- muvid.visualize.canvas.title_chain(title, size, style=None, *, src, out)[source]¶
Filter chain burning
titleinto the bottom of streamsrc.- Raises:
FfmpegError – This ffmpeg has no
drawtext, or no font was found.- Return type: