Status: Approved (2026-09-28), part of the v1 contract. This document, together with manifest-v1.md, is the normative rendering contract for the face lens manifest v1. The format is tagged format-v1.0.0; a field-level change here needs the process in §10.9. Screen effects have their own rendering contract, effect-rendering-v1.md. Before the split this document also covered them, as lens kind 2. Those items moved there; each keeps its number here as a one-line pointer, and no number is reused, so a citation of an unchanged item still points at the same text. One version until the SDKs ship. No lens has been published and no SDK has shipped, so every change to v1 so far is part of format-v1.0.0 itself: the tag is moved forward to include it, and there is no v1.0.1 or v1.1.0. Every renderer (the editor preview's reference renderer, the iOS and Android SDKs) follows the text as tagged. The edits made after the first draft of this document are listed below so an SDK that pinned an earlier commit knows what moved. Clarifications — they pin what the first draft left open: 2.5 (no-face wording now points at 6.10), 3.7 (a sprite hidden by endBehavior: 2, then shown or played), 4.3 (a LUT grades un-premultiplied colour), 6.9 (order of events within one frame — clocks, particle emission and expiry, the start event, host events, face triggers and emission start; emission instants in whole microseconds), 7.7 (emission instants evaluated in whole microseconds, as 6.9 (1c) specifies) and 10.12 (the per-instance state list). Behaviour decisions made after the first draft — not gap-fills: each changes what the first draft literally required for some valid manifests. No SDK renderer existed when they were made (both SDK repos were at scaffold plus the format pin), so no renderer had to change. (a) Triggers start armed (6.10; 2.5 and 6.3 point at it). Under the first draft's edge wording (6.3) and 2.5's "face-signal values read as 0" without a face, a threshold: 0 trigger never fired and a signal already high on the first frame was left undefined. Now a threshold: 0 trigger fires on the frame a face is acquired or re-acquired, and a signal at or above threshold on the first frame with a face fires. (b) Moved: the flash's exclusion from shake and pulse is a screen-effect rule (effect-rendering-v1.md E8.1, E8.3). (c) Face-particle lengths are fixed at emission (7.9; 1.6 amended). The first draft's 1.6 listed particle speed, gravity and sizeScale among the lengths re-evaluated every frame. Now a particle takes W once, at emission, for every length. Added before anything shipped: keyframe animation — the optional animations list and action 6, play animation (§11, 7.11; 5.4, 6.9 steps 1a–1b, 10.6 and 10.12 amended). Like the full-face anchor (10.11) it is engine level 1: part of format-v1.0.0.
Status: Approved (2026-09-28), part of the v1 contract. This document, together with manifest-v1.md, is the normative rendering contract for the face lens manifest v1. The format is tagged format-v1.0.0; a field-level change here needs the process in §10.9.
manifest-v1.md
format-v1.0.0
Screen effects have their own rendering contract, effect-rendering-v1.md. Before the split this document also covered them, as lens kind 2. Those items moved there; each keeps its number here as a one-line pointer, and no number is reused, so a citation of an unchanged item still points at the same text.
effect-rendering-v1.md
One version until the SDKs ship. No lens has been published and no SDK has shipped, so every change to v1 so far is part of format-v1.0.0 itself: the tag is moved forward to include it, and there is no v1.0.1 or v1.1.0. Every renderer (the editor preview's reference renderer, the iOS and Android SDKs) follows the text as tagged. The edits made after the first draft of this document are listed below so an SDK that pinned an earlier commit knows what moved.
Clarifications — they pin what the first draft left open: 2.5 (no-face wording now points at 6.10), 3.7 (a sprite hidden by endBehavior: 2, then shown or played), 4.3 (a LUT grades un-premultiplied colour), 6.9 (order of events within one frame — clocks, particle emission and expiry, the start event, host events, face triggers and emission start; emission instants in whole microseconds), 7.7 (emission instants evaluated in whole microseconds, as 6.9 (1c) specifies) and 10.12 (the per-instance state list).
endBehavior: 2
Behaviour decisions made after the first draft — not gap-fills: each changes what the first draft literally required for some valid manifests. No SDK renderer existed when they were made (both SDK repos were at scaffold plus the format pin), so no renderer had to change.
threshold: 0
threshold
speed
gravity
sizeScale
W
Added before anything shipped: keyframe animation — the optional animations list and action 6, play animation (§11, 7.11; 5.4, 6.9 steps 1a–1b, 10.6 and 10.12 amended). Like the full-face anchor (10.11) it is engine level 1: part of format-v1.0.0.
animations
manifest-v1.md defines the manifest's shape and validation. It does not say how a renderer interprets the values. The iOS SDK, the Android SDK and the editor's web preview are three independent renderers that must agree in golden tests, so every interpretation below has to be pinned once, here.
Each item carries one marker:
2026-09-28-ingevora-mirage-design.md
Both decisions below were approved as recommended, adding required fields to v1 before it is tagged (no formatVersion bump).
formatVersion
sheet.loop: false
sheet.endBehavior
spriteSheet
1
2
visible
show
play sprite
loop
offset: { x, y }
offset
transform.offset
Everything else in this document is [S] or [P]. Note that the schema is strictObject everywhere and engine levels exist only per layer type, anchor and event source, so any field added after the tag — even an optional one — makes older SDKs reject the lens with no minEngineVersion to explain why (see 10.9).
strictObject
minEngineVersion
1.1 [S] §2, §4.2, §7.2 — A face lens is drawn into the outgoing frame; the processed frame is what remote viewers receive, and the self-view shows those same processed frames.
1.2 [P] The renderer's input and output are the un-mirrored camera image, upright (the host rotates the buffer, or passes its orientation, before rendering). Mirroring for a self-view is a display transform the preview view applies after rendering; the sent frame is never mirrored. Rationale: one image space for all renderers; text on a sticker reads correctly to the people who receive the video.
1.3 [P] Image axes: origin at the top-left pixel corner, +x right, +y down, pixel units of the output frame. Pixel centres are at (i + 0.5, j + 0.5). Rationale: matches every platform's 2D image convention (Core Graphics flipped, Android Canvas, HTML canvas).
(i + 0.5, j + 0.5)
1.4 [P] "Left" and "right" in anchor names (2/3, 5/6, 8/9) are the subject's anatomical left and right. In the un-mirrored image the subject's left eye appears on the image's right. Each SDK maps its tracker's naming to this; the landmark fixtures (10.4) pin it. Rationale: trackers disagree on naming; anatomical is the only unambiguous choice.
1.5 [P] Face frame. Let EL, ER be the subject's left and right eye centres. M = (EL + ER) / 2 is the face origin. The face-frame x axis u = normalize(EL − ER) (points to image-right for an upright face); the y axis v is u rotated +90° (points down, toward the chin). Roll = atan2(u.y, u.x) in degrees: 0 for an upright face, positive when the head tilts clockwise on screen. Rationale: eye centres are the landmarks every tracker (Vision, ML Kit, MediaPipe) returns most stably.
EL
ER
M = (EL + ER) / 2
u = normalize(EL − ER)
v
u
atan2(u.y, u.x)
1.6 [P] Face width W = 2.2 × |EL − ER| in pixels (2.2 ≈ bizygomatic width ÷ inter-pupillary distance for an adult). All lengths — scale, offset, particle speed, gravity, sizeScale — are multiples of W, re-evaluated every frame for non-particle layers; particles take W once, at emission (7.9). (Amended after the first draft: decision (c) in the header.) Rationale: a face-contour width depends on each tracker's contour model and on yaw; the eye-distance proxy is identical across trackers. Foreshortening under yaw shrinks W, which is the intended 2D approximation of "follows head pose" (§5.3: v1 is 2D affine).
W = 2.2 × |EL − ER|
scale
1.7 [S] §5.3 — v1 layers are 2D: anchors plus affine transforms. No yaw/pitch perspective, no occlusion by the head.
1.8 [P] Sticker placement (type 2, and type 4 with placement.mode 1). The asset (or one sprite frame) is drawn with its centre at P = A + offset, width scale × W, height from the asset's aspect ratio (frame aspect for sprites).
placement.mode
P = A + offset
scale × W
followRoll: true
P = A + W·(offset.x·u + offset.y·v)
roll + rotationDeg
followRoll: false
P = A + W·(offset.x, offset.y)
rotationDeg
1.9 [P] Rotation: degrees, positive = clockwise on screen (y-down), 0 = the asset upright as authored. Applies to rotationDeg and directionDeg. directionDeg 0 = +x (right), 90 = down, −90 = up (the face-sparks fixture's −90 fires upward). Rationale: consistent with the y-down image axes; matches CSS rotate() for the web preview.
directionDeg
face-sparks
rotate()
1.10–1.12 — Moved: the overlay's space and its unit S are the screen effect contract's (effect-rendering-v1.md E1.1–E1.3).
S
1.13 [P] Gravity is a constant acceleration along +y (down) in image axes, in W/s²; negative values accelerate upward. It is never rotated by head roll. Rationale: gravity is a world direction; a particle fired straight up with speed 0.8 and gravity 1 rises 0.32 W and falls back.
W/s²
2.1 [P] Measured anchors (from landmarks, per frame):
3
4
7
2.2 [P] Derived anchors (fixed offsets in the face frame from M, in W):
M
(+x toward the subject's left, per 1.5.) Rationale: trackers have no forehead point and no ears, and their cheek points differ; fixed offsets are identical everywhere. The constants are proposals for the phase-0 spike to tune before the tag — after the tag they are contract.
2.2a [P] Full face (anchor 10) is the centre of the whole face, brows to chin, at face-frame (0, +0.15) — halfway between a hairline at about −0.55 W and the menton at about +0.86 W above/below M for an adult face. It is placed exactly like every other anchor (1.8): with scale 1 the layer is one face width W wide, height from its image's aspect, rotated by roll + rotationDeg when followRoll is on. Face particles on it spawn at that point (2.6). It is a derived anchor, not the menton-based midpoint, so it does not move when the mouth opens. The scenario packages/render/fixtures/scenarios/lens/full-face-mask.json pins it. Rationale: a mask needs the face's centre and the face's size; both are already in the face frame, so no tracker needs anything new. It is engine level 1 and part of format-v1.0.0 (added before any lens was published or any SDK shipped), so every engine-1 renderer (the SDKs' 1.0.0) implements it.
followRoll
packages/render/fixtures/scenarios/lens/full-face-mask.json
2.3 [P] Anchor positions are not smoothed by the renderer. Any landmark smoothing belongs to the SDK's tracking stage, upstream of the renderer, and is off when face data is supplied externally (§4.2 "external face data") — which is how golden tests feed faces (10.4). Rationale: smoothing filters are the first place two SDKs would diverge.
2.4 [P] One face. If several faces are detected, the lens tracks the one with the largest W; ties go to the one whose M is closest to the frame centre. The choice is re-evaluated every frame.
2.5 [P] No face. While no face is tracked: face-anchored layers (types 2, 4-mode-1, 5 emission) are not drawn / do not emit, without changing their visible state; full-frame layers (1, 3, 4-mode-2) keep drawing; already-emitted particles keep simulating; face-signal triggers are not evaluated and re-arm (6.10; behaviour decision (a) in the header).
2.6 [D2] Face particles (type 5) spawn at anchor point A + offset, rotated with the face frame exactly as a sticker offset is (1.8).
A
3.1 [P] Frames are laid out row-major from the top-left: columns = floor(sheetWidth / frameWidth), rows = floor(sheetHeight / frameHeight); frame k is the cell at column k mod columns, row floor(k / columns). Leftover pixels on the right/bottom edges are ignored.
columns = floor(sheetWidth / frameWidth)
rows = floor(sheetHeight / frameHeight)
k
k mod columns
floor(k / columns)
3.2 [P] frameCount > columns × rows is invalid, rejected where images are decoded: at upload/publish by the API, and by the SDK's validate-before-cache (§4.3 "every image actually decodes"). Issue code asset.sheet_too_small (decode-time, not in checkRules, because the manifest has no image dimensions).
frameCount > columns × rows
asset.sheet_too_small
checkRules
3.3 [D1] Frame index during playback: k = floor(tPlay × fps / 1000) where tPlay is ms since playback started (5.x). loop: true → k mod frameCount; loop: false → after frameCount − 1, the layer follows sheet.endBehavior: 1 holds on the last frame · 2 hides (visible becomes false; a later show or play sprite shows it again from frame 0, per 7.3 and 7.6).
k = floor(tPlay × fps / 1000)
tPlay
loop: true
k mod frameCount
loop: false
frameCount − 1
3.4 [P] A sprite layer that is visible but not playing (never played, e.g. autoplay: false) shows frame 0.
autoplay: false
3.5 [P] Sampling: bilinear, clamped to the frame's own cell (inset half a texel) so neighbouring frames never bleed in; no mipmaps.
3.6 [P] Type 4 with placement.mode 2 and type 3 frame overlays have no fit: type 3 is stretched to the full output frame (borders stay on every edge); type 4 mode 2 uses cover (centred, aspect preserved, overflow cropped). Rationale: a border must touch all four edges on every aspect ratio; an animation must not distort.
fit
3.7 [P] (clarification, D1 addendum) When a non-looping sprite with endBehavior: 2 passes its last frame — visible or not (a sprite hidden by an action keeps its clock, 7.2) — its playback stops and visible becomes false. A later show makes it visible showing frame 0 without playing (7.1 — show sets visible only; 3.4); a later play sprite replays it from frame 0 (7.3). The end-hide is evaluated when the sprite clock advances at the start of a frame (6.9), so a show or play sprite on the frame the sprite ends wins.
4.1 [P] Layout as already documented (64³, 8 × 8 tiles). The LUT maps sRGB-encoded input to sRGB-encoded output; no linearisation. Rationale: the authoring tools that export LUTs work in encoded values; three renderers agree trivially.
4.2 [P] Sampling is trilinear: r, g, b ∈ [0,1] map to lattice coordinates c × 63; red and green interpolate bilinearly within a tile (texel centres), blue interpolates linearly between the two neighbouring tiles.
r, g, b ∈ [0,1]
c × 63
4.3 [P] intensity blends in encoded space: out = mix(in, lut(in), intensity); alpha is untouched; the LUT asset's alpha channel is ignored. A LUT grades un-premultiplied colour: in is the pixel's straight colour, so its grade never depends on its alpha. A premultiplied renderer divides by alpha before the lookup and multiplies the result by the unchanged alpha; a pixel with alpha 0 stays as it is. (Clarification after the first draft.)
intensity
out = mix(in, lut(in), intensity)
in
4.4 [P] A LUT grades everything below it in layer order (the camera image plus lower layers) — not layers above it. Put it at index 0 to grade only the camera.
5.1 [S] §3.2 — layers[] is ordered.
layers[]
5.2 [P] Array order is back to front: index 0 is drawn first (nearest the camera image / host UI), the last layer on top.
5.3 [P] Compositing is source-over with premultiplied alpha, in sRGB-encoded space (no linear blending). PNGs are decoded as straight alpha and premultiplied once at load; every asset is treated as sRGB and embedded colour profiles / gamma chunks are ignored. Rationale: matches HTML canvas (the editor preview) and is cheap on the oldest devices (§4.4).
5.4 [P] opacity (types 2, 3) multiplies the premultiplied source. Layers without opacity (4, 5) draw at 1. An animated opacity (§11.4) multiplies the same way — for type 4 too, resting at 1; face particles keep 1.
opacity
5.5 [P] Within a particle layer, particles draw in emission order (oldest first), each as an upright quad centred on its position, width sizeScale × W, height by the asset's aspect ratio, opacity 1 for its whole life, removed when its age reaches lifetimeMs. No per-particle rotation or fade in v1.
sizeScale × W
lifetimeMs
5.6 — Moved: overlapping screen-effect instances (effect-rendering-v1.md E4.6).
6.1 [P] Face signals are scalars in [0, 1] computed per frame from the tracked face:
s
mouth.open
0.35 W
brows.raised
blink
head.tilt
smile
Rationale: each is a ratio of landmark distances, so it is scale-invariant and computable from the same injected face data in every renderer. Constants are spike-tuned before the tag and pinned by signal fixtures (10.4).
6.2 [P] Default threshold when omitted: 0.5 for every face signal.
6.3 [P] Face-signal triggers are edge-triggered: fire on the frame where s goes from < threshold to ≥ threshold. The arm state in 6.10 makes this exact, including the first frame and threshold: 0 (decision (a) in the header).
< threshold
≥ threshold
6.4 [P] Re-arm with hysteresis: after firing, the trigger fires again only after s has dropped below max(0, threshold − 0.1). No time-based cooldown. A lost face (2.5) re-arms. Rationale: stops one mouth-opening from firing on every jittery frame, without a hidden timer.
max(0, threshold − 0.1)
6.5 [P] A trigger fires with s measured on the frame; its actions apply to that same frame's render.
6.6 [P] Lifecycle: lens.started fires once, on the first frame processed after the lens is attached.
lens.started
6.7 [P] Host events are delivered to the attached face lens (running screen effects receive them too, effect-rendering-v1.md E5.2); they are applied at the next rendered frame. Events arriving before the lens has started or after it is detached are dropped, never queued. Events with no matching trigger are ignored.
6.8 [P] When one event matches several triggers, triggers run in array order and actions in array order within each, all before the frame renders; later actions win (show then hide in the same frame = hidden).
hide
6.9 [P] (clarification) Order within one frame. At frame time t:
t
autoplay
startMs
t_k ≤ t
t_k
Math.round
.rounded()
T = round(t × 1000)
T_0
T
T_k = T_0 + round((k × 1 000 000) / ratePerSecond)
B
T_k
emit
T_k ≤ T
X
X − B ≥ lifetimeMs × 1000
(T − B) / 1 000 000
t_k < t
t_0 = t
k = 0
All actions of steps 2–4 apply to this frame's render (6.5), and later actions win across the whole sequence; 6.8 still orders the triggers matching one event. Particles emitted by an action (7.4) are born at t.
Example: on one frame a host event hides a sprite and a mouth.open trigger shows it. The host event runs first (step 3) and the face trigger second (step 4), so the sprite is drawn.
Rationale: one fixed order is the only way two renderers agree when a host event and a face trigger touch the same layer on the same frame; pinning emission and expiry to event instants (t_k), not to frames, keeps particle pools identical at any frame rate.
6.10 [P] (behaviour decision (a) after the first draft, see the header; 2.5 / 6.3 / 6.4 addendum) Each face-signal trigger is armed or disarmed, and starts armed. On a frame with a tracked face, an armed trigger whose signal s ≥ threshold fires and disarms; a disarmed trigger re-arms when s < max(0, threshold − 0.1) (6.4). On a frame with no tracked face, face-signal triggers are not evaluated and all re-arm. This state machine is authoritative where it differs from the edge wording of 6.3 — notably a trigger with threshold: 0 fires on the frame a face is acquired or re-acquired, and not while the face is absent.
s ≥ threshold
s < max(0, threshold − 0.1)
7.1 [P] show / hide are idempotent: they set visible and nothing else. show on a visible layer and hide on a hidden layer do nothing.
7.2 [P] hide on a particle layer clears its live particles and stops continuous emission; hide on a sprite keeps its playback clock running (visibility and playback are independent).
7.3 [P] play sprite implies show, and restarts from frame 0 if already playing. Rationale: playing an invisible sprite is never the intent; restart is what "play" means on every media API. (The face-crown fixture's show + play stays valid, just redundant.)
face-crown
play
7.4 [P] emit particles implies show, then emits count particles at once. If fewer than count slots are free under maxParticles, the excess is dropped — live particles are never evicted. Rationale: deterministic and allocation-free.
emit particles
count
maxParticles
7.5 — Moved: run motion is a screen-effect action (effect-rendering-v1.md E6.5).
run motion
7.6 [P] autoplay: true (type 4) = an implicit play sprite at lens.started, without changing visible. A hidden autoplaying sprite runs its clock and appears mid-animation when shown.
autoplay: true
7.7 [P] Continuous emission runs at ratePerSecond while the layer is visible and autoEmit is true and a face is tracked. Emission times are t_k = k / ratePerSecond for k = 0, 1, 2, … measured from when emission became active (re-started at 0 on each show), evaluated in whole microseconds as 6.9 (1c) specifies. ratePerSecond: 0 never emits continuously.
ratePerSecond
autoEmit
t_k = k / ratePerSecond
ratePerSecond: 0
7.8 — Moved: burstCount is a screen-particle field (effect-rendering-v1.md E6.7).
burstCount
7.9 [P] Particle motion is evaluated in closed form, never integrated: p(t) = p0 + v0·t + ½·g·t² with t the particle's age in seconds, v0 = speed × (cos θ, sin θ) and g = (0, gravity), in W units. W is taken at emission: particles live in image space and do not follow the head afterwards. Every particle length — speed, gravity, sizeScale — uses W taken at emission; 1.6's per-frame re-evaluation applies to non-particle layers only. (Behaviour decision (c) after the first draft, see the header.) Rationale: identical positions on any frame rate.
p(t) = p0 + v0·t + ½·g·t²
v0 = speed × (cos θ, sin θ)
g = (0, gravity)
7.10 [P] Spread and randomness. θ = directionDeg − spreadDeg/2 + spreadDeg × r, r ∈ [0, 1). r comes from a mulberry32 stream per layer per instance, seeded with the 32-bit FNV-1a hash of the UTF-8 string "<lensId>:<layerId>", one draw per emitted particle in emission order (dropped particles draw nothing). Angle is the only random quantity in v1. Rationale: without a pinned PRNG, particle layers can never pass a golden test.
directionDeg − spreadDeg/2 + spreadDeg × r
r ∈ [0, 1)
r
"<lensId>:<layerId>"
7.11 [P] play animation (action 6, §11.2) implies show of its clip's layer and (re)starts the clip from 0, replacing any clip playing on that layer; it cancels that clip's pending autoplay start. It names a clip (animation), not a layer.
play animation
animation
8.1 [S] §3.2 — A face lens has no durationMs and runs until stopped (a screen effect declares one, effect-rendering-v1.md E7.3).
durationMs
8.2 [P] Clock. Lens time = the input frame's presentation timestamp minus that of the first processed frame (never wall clock). Golden tests supply timestamps directly.
8.3 — Moved: startMs is a screen-effect field (effect-rendering-v1.md E7.2).
8.4 — Moved: the durationMs cut and effect.ended (effect-rendering-v1.md E7.3).
effect.ended
8.5 — Moved: a host stopping an effect early (effect-rendering-v1.md E7.4).
8.6 [P] A face lens's lifecycle ends when the host detaches it; there is no end event. Its triggers stop, and state is discarded (re-attaching starts fresh and fires lens.started again).
8.7 [S] §4.4 — On sustained overload the engine drops the face lens (raw frames pass through) and reports it.
8.8 — Moved: moving effect events between devices (effect-rendering-v1.md E7.5).
Screen motion is a screen-effect layer: effect-rendering-v1.md §E8 (shake, flash, pulse, and how they combine). Items 9.1–9.4 are not reused.
10.1 [P] Output size: a face lens outputs exactly the input frame's size and pixel format; it never crops or letterboxes.
10.2 [P] Asset colour/format: every asset is an 8-bit sRGB PNG (RGBA or RGB); 16-bit or palette PNGs are expanded to 8-bit RGBA at decode. Texture dimensions ≤ 1024 are already enforced at upload (manifest-v1.md notes).
10.3 [P] Sticker aspect: width is scale × W; height follows the asset's (or frame's) own aspect ratio — never the face's.
10.4 [P] Golden-test inputs. Add (outside the manifest) fixtures/render/: each case = a manifest + a sample frame + an explicit face-data file (the landmark points this document uses: eye centres, nose tip, menton, inner-lip centres, mouth corners, brow centres, eyelid points, eye corners) + timestamps (and event injections) → expected PNG. Plus fixtures/signals/: face data → expected signal values (6.1). Face data is injected, so no tracker runs in a golden test.
fixtures/render/
fixtures/signals/
10.5 [S] §5.3 — Renderers agree "within a tolerance". [P] Proposed tolerance: per channel |Δ| ≤ 2/255 on ≥ 99.5 % of pixels and ≤ 8/255 on all, after compositing over an opaque test background. (Counted under its leading [S]; the tolerance value itself is a proposal.)
|Δ| ≤ 2/255
≤ 8/255
10.6 [P] Initial state. At start every layer takes its manifest visible; sprites and emitters are idle except as 7.6 and 7.7 start them; no clip plays until its autoplay start or an action (§11.7).
10.7 [P] Particle cap is per layer (maxParticles) per instance; the cross-layer 300 total is a validation rule, not a runtime pool.
10.8 — Withdrawn: a lens has no screen layers, so face triggers and screen layers cannot mix.
10.9 [P] Forward path for fields. Because parsing is strict, a field added after the tag must come with a field-level engine level (a ENGINE_LEVEL_BY_FIELD table feeding computeMinEngineVersion), so an older SDK reports "engine too old" instead of "invalid manifest". No manifest change — a rule for how the editor computes minEngineVersion.
ENGINE_LEVEL_BY_FIELD
computeMinEngineVersion
10.10 [P] Host face data (§4.2) must be supplied in the same un-mirrored image space as the frame (1.2); otherwise left/right anchors swap.
10.11 [P] Observation, no change proposed: there is no mouth anchor and head.tilt is unsigned, so "tilt left" vs "tilt right" and "from the mouth" effects are not expressible in v1. Adding an anchor value or event name later is non-breaking (a new engine level). The full-face anchor (10, 2.2a) was added before anything shipped, so it is engine level 1.
10.12 [P] Rendering is per frame and stateless apart from trigger arm state, sprite clocks, particle pools with their emission state, PRNG streams, and each layer's playing clip with its start time and the pending autoplay clip starts (§11.7) — the full list of per-instance state an SDK keeps.
A face lens's optional animations (manifest-v1 "Animations") each animate one layer over their own time. The value, easing and clip-clock rules are the screen effect's, pinned once.
11.1 [P] Value, easing, clip clock. Exactly effect-rendering-v1.md E10.1 (the value at a moment), E10.2 (the seven easings) and E10.3 (starts, visibility, looping, end, one clip per layer), with lens time (8.2) in place of effect time.
11.2 [P] Starts. An autoplay clip starts when lens time reaches its startMs (absent = 0), its clock at startMs — a scheduled start (6.9 step 1a). A play animation action (7.11) starts or restarts its clip on the frame of the event that fired it (lens.started on the first frame, a face-signal crossing, a host event), its clock at 0 at t, and cancels that clip's pending autoplay start. Starting a clip on a layer replaces the one playing there; properties the new clip does not animate return to rest at once.
11.3 [P] Frame order (6.9). Step 1a starts the scheduled autoplay clips; step 1b ends non-looping clips with the sprite clocks; play animation runs in steps 2–4 like every action (later actions win); step 6 draws every layer at its values at t, so a clip started on this frame shows its clip-time-0 values on this frame.
11.4 [P] What the values change.
11.5 [P] No face. Clip clocks run on lens time whether or not a face is tracked. Face-anchored layers are not drawn without a face (2.5) and reappear mid-clip when it returns.
11.6 [P] Visibility. hide does not stop a clip and show does not restart one — visibility and playback are independent, as for sprites (7.2).
11.7 [P] State. Each layer's playing clip (id, start time) and the pending autoplay starts, per instance (10.12). Empty at attach — no clip plays until its autoplay start or an action — and discarded on detach (8.6).
Golden scenarios (packages/render/fixtures/scenarios/lens/, hand-checked): animation-sticker.json (a sticker keyed on a still face: offset, scale, rotation, opacity, back easing; followRoll on a tilted face), animation-mouth-open.json (a mouth.open crossing starts a clip), animation-particles.json (spawn offsets sampled at t_k), animation-colour-look.json (an intensity fade).
packages/render/fixtures/scenarios/lens/
animation-sticker.json
animation-mouth-open.json
animation-particles.json
animation-colour-look.json