Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ From building the first real look (Que Calor V2), and independently reproduced h

## The types, in one paragraph

`Effect` is what the caller **asks for** and carries **no tier** — if it could, the refusal would be theatre. `Look` is an ordered stack plus a `Policy`; `a + b` takes the **stricter** of the two, per field. `Ref` is a parameter that must be measured, and one with no default that the probe does not answer **raises**. `ImplRef` declares `Terms`, never a tier. A tier appears for the first time in a compiled `Step`, *read* off the selected implementation. Three identity levels: `look_hash` (intent — policy-independent), `plan_hash` (pixels — includes the clip's colour state and the env fingerprint), `output_key` (content-addressed — takes a digest of **bytes** and refuses a path).
`Effect` is what the caller **asks for** and carries **no tier** — if it could, the refusal would be theatre. `Look` is an ordered stack plus a `Policy`; `a + b` takes the **stricter** of the two, per field. `Ref` is a parameter that must be measured, and one with no default that the probe does not answer **raises**. `ImplRef` declares `Terms`, never a tier, and also `timeline` (can it be gated to an `Effect.at`?) and `time_varying` (does its own fragment read the clock regardless of any span?) — close to inverses in practice (issue #17), and `time_varying` is **tri-state**: `None` means undeclared, never "does not read the clock". A tier appears for the first time in a compiled `Step`, *read* off the selected implementation; `Step.time_varying` and `LookPlan.time_varying` are tri-state too, ORing/aggregating `ImplRef.time_varying` with whether an `Effect.at` Span actually bounds something. Three identity levels: `look_hash` (intent — policy-independent), `plan_hash` (pixels — includes the clip's colour state and the env fingerprint), `output_key` (content-addressed — takes a digest of **bytes** and refuses a path).

`_admits` **delegates** to `licence.check` rather than comparing tiers itself, and a test asserts its body has no comparison operator. The one place a tier meets a ceiling must stay one place.

Expand Down
3 changes: 3 additions & 0 deletions docs/decisions_and_rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ class ImplRef:
requires_filters: tuple[str, ...] = () # ffmpeg filters this impl emits
impl_version: str = "1" # behaviour lock; enters plan_hash UNCONDITIONALLY
timeline: bool = True # can this be gated to an Effect.at?
time_varying: bool | None = None # does its OWN fragment read the clock? None = undeclared
preference: int = 0 # explicit tiebreak within one tier; lower wins
lossy_substitute_for: tuple[str, ...] = () # see rule 12
```
Expand All @@ -189,6 +190,8 @@ class ImplRef:

**`timeline` is a candidate FILTER, not a post-check.** Verified on ffmpeg 8.1: `lut3d`, `lutrgb`, `curves`, `unsharp`, `gblur`, `colorchannelmixer`, `colorlevels`, `geq` carry the `T` flag; `scale`, `crop`, `elbg`, `palettegen`, `zoompan` do not, and `xfade` does not. If `Effect.at` is set, ungateable candidates are dropped in step 1 alongside the `impl`/`backend` pins — checking it *after* tier selection discards a perfectly good gateable candidate and then refuses. [R3][2]

**`time_varying` answers a different question from `timeline`, and the two are close to inverses in practice (issue #17).** `timeline` means "can be gated to an `Effect.at`"; `time_varying` means "does the fragment read the clock regardless of any span". `motion.ffmpeg.crop` compiles to `zoompan`/`crop` expressions that reference `in_time` — the one implementation registered here that reads the clock — and it has `timeline=False`, while every static grade has `timeline=True` and never reads the clock. **Tri-state, and `None` is not `False`**, for the same reason `Step.cpu_seconds` and `DependencyReport.can_flicker` are tri-state: an implementation nobody declared this for is *unknown*, not "does not read the clock" — a silent `False` would let a consumer like muvid#73 ship an unwarned ramp restart across a transitioned boundary. `register_defaults` therefore requires every built-in implementation to declare it explicitly, with no default. `Step.time_varying` ORs it with "this step carries an `at` Span that actually bounds something" (`Span.is_whole`, shared with `looks.ffmpeg.gated`'s own emission check, so the two decisions cannot drift apart) — a certain span-gate is `True` even over an undeclared impl. `LookPlan.time_varying` aggregates: one certain `True` step wins outright; otherwise any unknown step poisons the answer to `None`, mirroring `has_unknown_costs`/`total_cpu_seconds`. Measured: `frame_dependency.classify`'s own TIME_VARYING probe is a spatially uniform still, so it cannot see a moving crop window — `motion.ffmpeg.crop` reads `time_delta=0.0` against it and is classified `CONTENT_ADAPTIVE` instead, a blind spot in that probe's still rather than a wrong declaration (confirmed independently by compiling the effect and reading `LookPlan.time_varying`).

### 4.6 The compiled form: `Step` and `LookPlan`

```python
Expand Down
64 changes: 56 additions & 8 deletions looks/ffmpeg.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,10 +136,12 @@ def gated(fragment: str, at: Optional[Span]) -> str:
>>> gated("scale=2:2,crop=1:1", Span(0.0, 1.0))
"scale=2:2:enable='between(t,0,1)',crop=1:1:enable='between(t,0,1)'"
"""
if at is None or (at.start is None and at.end is None):
# A Span open at BOTH ends bounds nothing, so it is not a gate. Emitting
# `enable=` for it would be a filter option that always evaluates true —
# noise in the string and a lie in a diff.
if at is None or at.is_whole:
# A Span open at BOTH ends (`Span.is_whole`) bounds nothing, so it is
# not a gate. Emitting `enable=` for it would be a filter option that
# always evaluates true — noise in the string and a lie in a diff.
# `looks.spec.Step.time_varying` asks this exact question through the
# same predicate, on purpose: one decision, shared rather than copied.
return fragment
if at.start is None:
window = f"enable='lte(t,{_num(at.end)})'"
Expand Down Expand Up @@ -388,7 +390,21 @@ def register_defaults(registry: Optional[EffectRegistry] = None) -> EffectRegist
return reg
terms = _ffmpeg_terms()

def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
def add(
key,
filters,
compiler,
*,
time_varying,
preference=0,
timeline=True,
tags=(),
):
# `time_varying` has no default, deliberately: `ImplRef.time_varying`
# is unknown (`None`) unless declared, and unknown is not the same
# claim as "does not read the clock". Forcing every built-in
# registration to say which is how this module avoids shipping a
# silent `False` for something nobody checked.
effect, backend, _variant = key.split(".")
reg.register(
ImplRef(
Expand All @@ -399,6 +415,7 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
requires_filters=filters,
preference=preference,
timeline=timeline,
time_varying=time_varying,
),
compiler,
tags=tags,
Expand All @@ -409,6 +426,7 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
"gradient_map.ffmpeg.lut3d",
("lut3d",),
_gradient_map_compiler,
time_varying=False,
)
add(
"lut3d.ffmpeg.default",
Expand All @@ -417,32 +435,42 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
"lut3d",
lambda p: {"file": p["cube"], "interp": p.get("interp", "tetrahedral")},
),
time_varying=False,
)
add(
"saturation.ffmpeg.colorchannelmixer",
("colorchannelmixer",),
_simple("colorchannelmixer", _saturation_matrix),
time_varying=False,
)
add(
"saturation.ffmpeg.eq",
("eq",),
_simple("eq", lambda p: {"saturation": p.get("amount", 1.0)}),
preference=1,
tags=("gpl-gated",),
time_varying=False,
)
add(
"contrast.ffmpeg.lutyuv",
("lutyuv",),
_simple("lutyuv", _contrast_luma),
time_varying=False,
)
add(
"contrast.ffmpeg.eq",
("eq",),
_simple("eq", lambda p: {"contrast": p.get("amount", 1.0)}),
preference=1,
tags=("gpl-gated",),
time_varying=False,
)
add(
"gamma.ffmpeg.lutrgb",
("lutrgb",),
_simple("lutrgb", _gamma_expression),
time_varying=False,
)
add("gamma.ffmpeg.lutrgb", ("lutrgb",), _simple("lutrgb", _gamma_expression))
add(
"levels.ffmpeg.colorlevels",
("colorlevels",),
Expand All @@ -457,9 +485,13 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
"bimax": p.get("white", 1),
},
),
time_varying=False,
)
add(
"posterize.ffmpeg.lutrgb", ("lutrgb",), _simple("lutrgb", _posterize_expression)
"posterize.ffmpeg.lutrgb",
("lutrgb",),
_simple("lutrgb", _posterize_expression),
time_varying=False,
)
add(
"flatten.ffmpeg.bilateral",
Expand All @@ -475,20 +507,23 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
"planes": p.get("planes", 7),
},
),
time_varying=False,
)

# --- spatial ------------------------------------------------------------
add(
"blur.ffmpeg.gblur",
("gblur",),
_simple("gblur", lambda p: {"sigma": p.get("sigma", 2)}),
time_varying=False,
)
add(
"blur.ffmpeg.boxblur",
("boxblur",),
_simple("boxblur", lambda p: {"luma_radius": p.get("radius", 2)}),
preference=1,
tags=("gpl-gated",),
time_varying=False,
)
add(
"sharpen.ffmpeg.unsharp",
Expand All @@ -500,6 +535,7 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
# the registry sweep, which is what that sweep is for.
lambda p: {"luma_amount": p.get("amount", 1.0)},
),
time_varying=False,
)

# --- geometry, wrapping looks.geometry ----------------------------------
Expand All @@ -510,14 +546,26 @@ def add(key, filters, compiler, *, preference=0, timeline=True, tags=()):
# its own registry data — the refusal existed and never fired, and the
# binary rejected the command instead. A gated geometry step is now
# refused at SELECTION, which is where a caller can act on it.
add(f"{mode}.ffmpeg.scale", ("scale",), _geometry(mode), timeline=False)
add(
f"{mode}.ffmpeg.scale",
("scale",),
_geometry(mode),
timeline=False,
time_varying=False,
)

# --- motion, wrapping looks.motion --------------------------------------
add(
"motion.ffmpeg.crop",
("crop", "setpts"),
_motion_compiler,
timeline=False,
# `zoompan`/`crop` compile to expressions that reference `in_time` —
# the one implementation registered here that reads the clock
# regardless of any Effect.at span. See issue #17: `timeline=False`
# (above) answers "can it be gated to a span", which is a different
# and close-to-inverse question.
time_varying=True,
)
return reg

Expand Down
14 changes: 14 additions & 0 deletions looks/frame_dependency.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,20 @@
discontinuity at every cut. Do not read that as making the gap harmless —
it means the gap is not currently load-bearing, not that it is closed.

**A purely positional clock-reader can be misclassified `CONTENT_ADAPTIVE`.**
The TIME_VARYING probe's still (`_still_source`) is spatially UNIFORM
(`color=c=gray`), so an effect whose output depends on the clock only through
*where* it samples — a moving crop window, say — produces zero luma
difference against it: there is nothing for a diff to see when every pixel is
the same colour regardless of position. Measured on
``looks.ffmpeg``'s ``motion.ffmpeg.crop`` (a `crop` whose x/y expressions
read `t`): `time_delta == 0.0` against this probe. The asymmetric content
probe incidentally reacts to the moving window (`content_delta` nonzero,
because the window drifts into the varying half), so the effect is reported
`CONTENT_ADAPTIVE` rather than `TIME_VARYING` — the wrong class for the right
reason. See `looks` issue #20 and `looks/tests/test_time_varying.py`'s
``TestTheStillProbeCannotSeeAMovingCropWindow`` for the full measurement.

**And an `INDEPENDENT` verdict is evidence, not proof.** It says: none of three
probes could make this effect change a pixel it should not have touched. An
effect adapting to a statistic none of them moves would pass, and so would
Expand Down
95 changes: 94 additions & 1 deletion looks/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -724,7 +724,30 @@ class ImplRef:
impl_version: A behaviour lock, not a receipt — "same interface,
changed behaviour" bumps it, and it enters :func:`plan_hash`
**unconditionally**.
timeline: Whether it can be gated to an :attr:`Effect.at`.
timeline: Whether it can be gated to an :attr:`Effect.at`. Answers a
different question from :attr:`time_varying` and the two are close
to inverses in practice: ``motion.ffmpeg.crop`` compiles to
``zoompan``/``crop`` expressions that read ``in_time`` and has
**no** timeline support (``timeline=False``), while every static
grade has timeline support (``timeline=True``) and never reads the
clock. See issue #17.
time_varying: Whether this implementation's own fragment reads the
clock **regardless of any span** — true only for a camera move
whose expressions reference ``in_time``. Distinct from
:attr:`Step.time_varying`, which also accounts for an
:attr:`Effect.at` Span gating an otherwise-static filter with
``enable='between(t,...)'``.

**Tri-state, and ``None`` is not ``False``.** The same rule as
:attr:`Step.cpu_seconds` and
:attr:`~looks.frame_dependency.DependencyReport.can_flicker`:
"we were not told" and "it does not" are different claims, and only
one of them is a guarantee a caller may act on. An implementation
registered without declaring this reads as **unknown**, never as
"does not read the clock" — a silent ``False`` here is exactly the
false permission that would let muvid#73's ramp restart ship
unwarned. :func:`looks.ffmpeg.register_defaults` requires every
built-in implementation to declare it explicitly for this reason.
preference: Explicit tiebreak within one tier; lower wins.

Examples:
Expand All @@ -736,6 +759,8 @@ class ImplRef:
'1'
>>> impl.timeline
True
>>> impl.time_varying is None # undeclared is unknown, not False
True
"""

effect: str
Expand All @@ -745,6 +770,7 @@ class ImplRef:
requires_filters: tuple[str, ...] = ()
impl_version: str = "1"
timeline: bool = True
time_varying: Optional[bool] = None
preference: int = 0

def __post_init__(self) -> None:
Expand Down Expand Up @@ -895,6 +921,22 @@ class Step:
'lut3d=look.cube'
>>> s.cpu_seconds is None # unknown, not free
True

An impl that declares it does not read the clock stays that way — gate
it to a span and it does, regardless (issue #17, the question
``ImplRef.timeline`` looks like but is not):

>>> from dataclasses import replace
>>> static = replace(s, impl=replace(impl, time_varying=False))
>>> static.time_varying
False
>>> replace(static, at=Span(1.0, 2.0)).time_varying
True

An impl that never declared it reads as **unknown**, not ``False``:

>>> s.time_varying is None
True
"""

effect: str
Expand Down Expand Up @@ -922,6 +964,30 @@ def __post_init__(self) -> None:
object.__setattr__(self, "payload", _freeze(self.payload))
object.__setattr__(self, "metadata", _freeze(self.metadata))

@property
def time_varying(self) -> Optional[bool]:
"""Whether this compiled fragment reads the clock (issue #17).

Not ``not self.impl.timeline`` — that field means "can be gated to a
span", which is close to the *inverse* of this question: the one
genuinely clock-reading implementation registered today
(``motion.ffmpeg.crop``, whose ``zoompan``/``crop`` expressions
reference ``in_time``) has **no** timeline support, while every static
grade has it and never reads the clock.

**Tri-state.** A span that actually bounds something is a *certain*
``True`` regardless of what the implementation declares — it compiles
with ``enable='between(t,...)'`` no matter what — using the same
predicate :func:`looks.ffmpeg.gated` uses to decide whether to emit
one (:attr:`Span.is_whole`, so the two never drift apart). Absent
that, the answer is exactly :attr:`ImplRef.time_varying`, unknown
included: an implementation that never declared it does not become
"does not read the clock" just because no span was given either.
"""
if self.at is not None and not self.at.is_whole:
return True
return self.impl.time_varying


@dataclass(frozen=True, slots=True, kw_only=True)
class LookPlan:
Expand Down Expand Up @@ -1002,6 +1068,33 @@ def has_unknown_costs(self) -> bool:
"""Whether anything is unpriced. A gate reads this, not the sum."""
return self.unknown_step_count > 0

@property
def time_varying(self) -> Optional[bool]:
"""Whether any compiled step reads the clock — see :attr:`Step.time_varying`.

The question issue #17 was filed for: a consumer that renders a blend
as a separate input-seeked invocation (input-side ``-ss`` rebases the
filter clock to 0) needs to know, per compiled fragment, whether that
rebasing can affect it. ``not impl.timeline`` answers a different
question and gets it wrong on three of the four filters that matter —
this property is the one to ask instead.

**Tri-state, and a known ``True`` wins over an unknown step.** One
step that certainly reads the clock makes the plan ``True`` even if
another step's implementation never declared itself — a plan is not
"unknown" about the one thing it is sure of. Absent any certain
``True``, an unknown step poisons the answer to ``None``: "every step
we could classify does not read the clock" is not "nothing here
reads the clock", the same asymmetry :attr:`has_unknown_costs` keeps
separate from :attr:`total_cpu_seconds`.
"""
values = [s.time_varying for s in self.steps]
if any(v is True for v in values):
return True
if any(v is None for v in values):
return None
return False

@property
def realtime_factor(self) -> Optional[float]:
"""CPU-seconds per second of output, or ``None`` when anything is unknown.
Expand Down
Loading
Loading