muvid.footage.look¶
Compile a looks artifact into the fragment the assembler splices.
This is muvid’s half of the looks seam — looks’ build-order item 10, and
the first real consumer of that package. The division of labour is the whole
point and it is worth stating before any code:
``looks`` decides what a pixel becomes. muvid decides which pixels exist, when they appear, and how the process is run.
So looks emits a -vf chain fragment and nothing else. It owns no encode
setting: -c:v, -crf, -preset, -pix_fmt, the frame counts, the
concat, the mux and the per-invocation timeout all stay in
muvid.footage.assemble, where the bounded-memory invariant lives. The
fragment rides in muvid.footage.edl.EdlEntry.look and is spliced by
muvid.footage.assemble._part_filter().
Why a -vf fragment is the cheapest possible seam, which is why it was
chosen: it adds no -i. muvid’s assembler was rewritten (muvid#21/#24) because
one -filter_complex over all cuts held a decoder per cut and was OOM-killed at
30 cuts on a 3.7 GB box; the guarantee it bought is O(1) decoders per invocation.
A filter fragment cannot touch that.
**A look cannot reach a second source at all, and the earlier claim that it could
via movie= was wrong twice over.** movie= is a zero-input source filter,
so at the solo site — a simple filtergraph, one input and one output — it leaves
the preceding chain unconsumed and ffmpeg refuses the whole graph before decoding
a frame (“had 1 input(s) and 2 output(s)”, measured on ffmpeg 9.0.1; every
composited form, movie=X,overlay=10:10 included, fails identically, and the
forms that DO render all need [/]/;). At the transition site it does
run, and what it does there is call avformat_open_input on a path from inside
the fragment — a second container decoder that no invocation accounted for, which
is the muvid#21/#24 invariant going out the back door. So
muvid.footage.edl._validate_look() refuses it, along with every other filter
outside muvid.footage.edl.LOOK_FILTERS. Compositing needs a second splice
site the assembler does not have; that is a change to the assembler, not a look.
Three functions, in the order you are likely to want them:
punch_in()— the in-shot zoom the design partner asked for (muvid#66).motion()— an arbitrary camera path over the cut;punch_inis a named case of it.stylize()— a wholelooks.Look(grade, LUT, posterise …) compiled against the binary muvid will actually run.
chain() puts two of them on one cut. Each returns a LookFragment —
a str that also answers time_varying — except that
chain() returns None when nothing survives. Read the answer back with
is_time_varying(), which treats a plain str as static.
Which binary. The fleet has two ffmpegs in routine use and neither is a
superset of the other (looks’ research note 11 §3.4 measured 484 vs 481
filters, with zscale in one and drawtext in the other). looks refuses
to resolve one — the environment is an argument — and muvid is the party entitled
to answer, because muvid owns the invocation. muvid runs the bare name ffmpeg
from PATH (muvid.visualize.ffmpeg.run_ffmpeg()), so that is what
stylize() probes. Pass env= to compile against a different one.
**One limitation, measured rather than guessed at, and not fixable here — but no
longer silent.** A time-varying look on a cut that borders a
Transition restarts its ramp on the blended part.
The assembler renders a transitioned boundary as a separate two-input invocation
whose inputs are input-side-seeked to the blend window, so the filter clock there
begins at 0 again instead of continuing the cut’s.
Measured on a 3.0 s cut at 25 fps with a 0.4 s fade and a 1.12x punch: the solo part’s last frame is drawn at zoom 1.109 (mean |diff| 28.1/255 against the same frame rendered without the look), while the blend part’s first frame is drawn at zoom 1.000 (mean |diff| 0.7/255 against its own unlooked twin — i.e. indistinguishable from no punch at all). The move snaps back for the length of the blend and then the next cut begins.
A static look — a grade, a LUT, a posterise — is unaffected, because it never
reads the clock, and it is what the seam is mostly for. muvid still cannot rebase
the fragment: that means rewriting an arbitrary ffmpeg expression, which is
exactly what looks refuses to do for itself (its rule 27), and a wrong rebase
is worse than a documented one — it would exit 0 having moved the effect to a
different second of the clip.
What muvid can do, and now does (muvid#73), is say which kind a look is so
the assembler can warn instead of rendering the hitch quietly. Every function
here returns a LookFragment — a plain str that also answers
.time_varying — and punch_in_cuts() copies that answer onto
look_time_varying, which is what the
assembler reads. A caller assembling entries by hand should do the same; the flag
defaults to False, so an undeclared moving look is still silent, and that
limit is stated on the field rather than left to be discovered.
Module Attributes
the design partner described what he wanted as a "slight zoom/punch-in jump" (muvid#66), and a punch-in is a punctuation mark, not a transition. |
|
Where |
|
|
Functions
|
Join look fragments into one, dropping the empty ones. |
|
Whether |
|
A camera path over the cut, as a filter fragment. |
|
An in-shot punch-in: hold, then push in, WITHOUT leaving the shot (muvid#66). |
|
Put a punch-in on every |
|
A |
Classes
|
A compiled filter chain that remembers whether it READS THE CLOCK. |
Exceptions
A look could not be compiled. |
- muvid.footage.look.DEFAULT_PUNCH_ANCHOR = (0.5, 0.5)¶
Where
punch_in()zooms toward, as a fraction of the canvas. Centre.
- muvid.footage.look.DEFAULT_PUNCH_ZOOM = 1.12¶
the design partner described what he wanted as a “slight zoom/punch-in jump” (muvid#66), and a punch-in is a punctuation mark, not a transition. Larger values magnify the source, so a value that looks fine on a 4K clip is soft on a 720p one — which is why it is a named constant a caller overrides rather than a hidden literal.
- Type:
How far
punch_in()zooms by default. A slight move
- exception muvid.footage.look.LookError[source]¶
Bases:
ValueErrorA look could not be compiled. Carries what to do about it.
- class muvid.footage.look.LookFragment(fragment, *, time_varying)[source]¶
Bases:
strA compiled filter chain that remembers whether it READS THE CLOCK.
A plain
strin every way that matters — it splices, compares, serialises and JSON-encodes identically, so nothing downstream needs to know it exists — that additionally answerstime_varying. That is what letspunch_in_cuts()setmuvid.footage.edl.EdlEntry.look_time_varyingfrom the fragment instead of hardcoding a value beside the call that produced it, and what letschain()combine two fragments without either caller re-deriving the answer.A subclass rather than a
(str, bool)pair because the fragment is already a wire value: the EDL field is a string, the MCP reply is a string, and thelooksAPI returns a string. Changing that shape would push a rename table into every consumer to carry one bit that only muvid’s own compilers can know.The bit does not survive a round trip, and is not meant to.
stroperations return plainstr, and JSON has no place to put it — the durable home is the EDL entry’s own field, which is exactly why muvid#73 put it there.is_time_varying()reads a fragment of either kind, treating a plain string as static, which is the same default the field has.>>> frag = LookFragment("zoompan=d=1:s=64x48:fps=25", time_varying=True) >>> frag.time_varying, frag == "zoompan=d=1:s=64x48:fps=25" (True, True) >>> is_time_varying("hue=s=0") FalseCopyable and picklable, which needs saying because it is not free:
strsubclasses are reconstructed through__new__, and without__getnewargs_ex__bothcopy.deepcopyandpickleraiseTypeError: __new__() missing 1 required keyword-only argument— measured. AnEdlEntrycarrying one is an ordinary dataclass a caller may well copy.>>> import copy, pickle >>> copy.deepcopy(frag).time_varying, pickle.loads(pickle.dumps(frag)) == frag (True, True)
- muvid.footage.look.TIME_VARYING_EFFECTS = frozenset({'motion'})¶
lookseffects whose compiled fragment READS THE FILTER CLOCK, so a cut carrying one is affected by muvid#73’s restart on a blended boundary.**Pinned here rather than derived from
looks, for the same reasonmuvid.footage.edl.LOOK_FILTERSis** — a newlookseffect must be a decision someone records, not a silent change to what muvid warns about. The drift test intests/test_look_time_varying.pyfails whenlooks.effects()grows, which is what forces the classification.ImplRef.timelineis NOT this property and must not be mistaken for it: it says the implementation supports ffmpeg’senable=option.motion— the one genuinely moving effect — declarestimeline=False, and every static grade declaresTrue, so reading it would get every row backwards.
- muvid.footage.look.chain(*fragments)[source]¶
Join look fragments into one, dropping the empty ones.
A cut carries at most one look, so two effects on one cut are one chain. Returns
Nonewhen nothing survives, which is the value the EDL field wants for “no look” — an empty string is refused byvalidate_edldeliberately, so this does not produce one.The result is time-varying if ANY component is — the OR, not the AND and not the last one’s answer. A chain runs every link on every frame, so one moving link makes the whole fragment move, and muvid#73’s restart hits it. Getting this wrong in the safe-looking direction (AND) would silence the warning on exactly the chains most likely to have one: a punch composed with a grade.
A plain
strcomponent contributesFalse, matchingis_time_varying()and the EDL field’s own default — so hand-writing one half of a chain quietly downgrades only that half’s claim, never the other’s.- Return type:
>>> chain("hue=s=0", None, "", "unsharp=5:5:1") 'hue=s=0,unsharp=5:5:1' >>> chain(None, "") is None True >>> chain("hue=s=0", LookFragment("zoompan=d=1", time_varying=True)).time_varying True >>> chain("hue=s=0", "unsharp=5:5:1").time_varying False
- muvid.footage.look.is_time_varying(fragment)[source]¶
Whether
fragmentdeclares that it reads the clock. Plain strings: no.The one place that default lives, so a caller reading a fragment and the EDL field’s own default cannot drift apart.
- Return type:
>>> is_time_varying(LookFragment("zoompan=d=1", time_varying=True)) True >>> is_time_varying("hue=s=0"), is_time_varying(None) (False, False)
- muvid.footage.look.motion(keyframes, *, canvas, fps)[source]¶
A camera path over the cut, as a filter fragment.
lookspicks the filter.- Parameters:
keyframes (
Sequence) –(t_seconds, window)pairs, orlooks.Keyframe`s. The window is anything with ``x`/y/w/has fractions —muvid.footage.edl.CropWindowsatisfies that structurally, with no adapter, because both packages useburns.Rect’s convention on purpose.canvas –
(width, height)— the assembler’s delivery canvas.fps (
float) – the assembler’s delivery frame rate.
- Return type:
- Returns:
One linear ffmpeg filter chain, ready for the
lookfield, declaring itself time-varying — a camera path is a ramp in the filter’s own clock (in_timeunderzoompan,tundercrop), whichever filterlookspicks, so it is muvid#73’s affected kind. A path whose keyframes happen to hold still is still declared moving: the fragment reads the clock either way, and that is the property the warning is about.
The windows are fractions of the CANVAS, not of the source. That follows from where the fragment is spliced: after
scale/pad, so the frame it sees is the canvas with the source letterboxed into it. It is also what makes the move well-defined across a mixed-device edit — the same punch reads the same on a portrait phone clip and a landscape one, where a source-relative window would not. Usecrop/crop_endon the EDL entry for the source-relative framing decision; the two compose, crop first.Which ffmpeg filter this becomes is
looks’ decision and not a matter of taste: a constant-size window iscrop, a resizing one iszoompan, andcropcannot resize at all (itsw/hare evaluated once, at configure time, whentis NAN — it either refuses to configure or, worse, exits 0 having rendered every frame at one wrong size).- Raises:
LookError – If the path needs something that was not supplied.
- Return type:
- muvid.footage.look.punch_in(*, canvas, fps, duration_s, zoom=1.12, anchor=(0.5, 0.5), start_s=0.0, end_s=None)[source]¶
An in-shot punch-in: hold, then push in, WITHOUT leaving the shot (muvid#66).
The design partner asked for “roughly 2N” of these and was explicit that it is not a transition between two clips — it stays on the same shot. Before this there was no punch-in, zoom or Ken Burns move anywhere in the footage path; the only two mentions of
zoomunderfootage/were comments explaining whyzoompanwas unusable, and that reading has since been measured wrong:tis undefined insidezoompan, butin_timeworks andd=1is exactly 1:1 rather than the frame-duplicating default. Both corrections live inlooks.compile_motion, which is why this function is six lines of geometry and no ffmpeg.- Parameters:
canvas –
(width, height)— the assembler’s delivery canvas.fps (
float) – the assembler’s delivery frame rate.duration_s (
float) – the cut’s length in seconds. The move ends here by default.zoom (
float) – final magnification.1.12shows ~89% of the frame.anchor (
tuple[float,float]) – what stays put, as a fraction of the canvas.(0.5, 0.5)centres it;(0.5, 0.35)pushes toward a face in the upper third.start_s (
float) – hold the full frame until here, then move.end_s (
Optional[float]) – reach the final framing here and hold. Defaults toduration_s.
- Return type:
- Returns:
One linear ffmpeg filter chain, ready for the
lookfield, declaring itself time-varying (it goes throughmotion()). Put that on the entry aslook_time_varying—punch_in_cuts()does — or the assembler cannot warn you when the cut borders a transition and the move restarts (muvid#73).
A move is a ramp between two windows, and the windows are fractions of the canvas (see
motion()). The end window keeps the canvas’s aspect ratio, which is what letszoompandeliver at the canvas size with no stretch and no reframing crop.>>> frag = punch_in(canvas=(640, 360), fps=25, duration_s=3.0) >>> frag.startswith("zoompan=d=1:s=640x360:fps=25:") True >>> frag.time_varying TrueA pull-out is this move backwards, so it goes through
motion()with the windows in the order you want them, rather than a boolean here.- Raises:
LookError – If
zoom, the timings or the anchor are out of range.- Return type:
- muvid.footage.look.punch_in_cuts(entries, *, canvas, fps, every=2, zoom=1.12, anchor=(0.5, 0.5), offset=0)[source]¶
Put a punch-in on every
every-th footage entry — muvid#66’s other half.The request was for roughly 2N punch-ins “evenly redistributed so they occur about twice as often”, explicitly not two extra tacked on the end. Redistribution is therefore the whole job, and it is a stride over the cuts rather than a wall-clock interval: cuts are already beat-snapped by the selector, so a stride lands the moves on musical time for free, where a seconds-based interval would drift off it.
- Parameters:
canvas –
(width, height)— the delivery canvas.fps (
float) – the delivery frame rate.every (
int) – stride.2punches every other footage cut;1punches all.zoom (
float) – passed topunch_in().anchor (
tuple[float,float]) – passed topunch_in().offset (
int) – which footage cut in each stride gets the move.
- Return type:
- Returns:
A NEW list of entries. Gaps are skipped (they have no footage to punch into, and
validate_edlrefuses a look on one), and an entry that already carries a look keeps it — this composes with hand-authoring rather than overwriting it, because silently replacing an authored direction is the failure this package guards against elsewhere.Each punched entry also carries
look_time_varying, taken FROM the fragment viais_time_varying()rather than written as a literalTruebeside thepunch_incall. The two would agree today and the literal is the one that would stop agreeing — this is the same reason_edl_jsonis a table rather than fourifs. Without it the assembler cannot warn that a punch bordering a transition restarts its ramp (muvid#73).
- muvid.footage.look.stylize(look, *, canvas, fps, duration_s=None, ffmpeg='ffmpeg', env=None, policy=None)[source]¶
A
looks.Lookcompiled against the binary muvid will run.- Parameters:
look – a
looks.Look— an ordered stack of named effects.canvas –
(width, height)— the assembler’s delivery canvas.fps (
float) – the assembler’s delivery frame rate.duration_s (
Optional[float]) – the cut’s length, when a step needs to know it.ffmpeg (
str) – which binary to probe. Defaults to the bare name muvid runs.env – a
looks.FfmpegEnvto compile against, instead of probing.policy – a
looks.Policy— the licence ceiling.looks’ default applies when omitted.
- Return type:
- Returns:
One linear ffmpeg filter chain, ready for the
lookfield, declaring whether it is time-varying from the compiled plan rather than by assumption. A grade, a LUT, a posterise is static; two shapes are not, and both are reachable from here:a step naming a
TIME_VARYING_EFFECTSmember —motionis one oflooks’ registered effects, sostylizecan emit exactly thezoompanramppunch_in()does (verified by compiling it);a step with an
atlooks.Span, which compiles toenable='between(t,…)'— measured:Effect("blur", at=Span(0.5, 1.5))becomesgblur=sigma=2:enable='between(t,0.5,1.5)'. Reading the clock to decide whether to apply is the same restart, and the effect is not on any list of moving ones.
A blanket
Falsehere — the obvious reading, sincestylizeis the grade-shaped door — would have been wrong for both.
The clip is declared, not measured.
lookscompiles against a declared clip, and at this splice point muvid knows the geometry and the rate exactly — they are the canvas and the delivery rate the assembler is about to impose, not properties of whichever source is underneath. That is the payoff of splicing afterscale/pad/fpsrather than before it.origin_s=0.0is declared for the same kind of reason and is true for a stated cause rather than by default:lookstreats a span as being in the host’s decoder time, and input-side-ssrebases the filter timeline to 0 where output-side-ssdoes not.muvid.footage.assemble._render_part()seeks input-side. Move that seek and this declaration becomes false.A refusal is the feature.
looksresolves each step’s licence tier against the probed binary, so a look reaching a GPL-only filter on an LGPL build is either substituted or refused by name — rather than working on a laptop and quietly raising the licence tier of a shipped product.- Raises:
LookError – If the look cannot be compiled for this binary, this ceiling or this clip. The message is
looks’, which names the remedy.- Return type: