Status: normative, part of the format-v1.0.0 contract. This document, together with effect-manifest-v1.md, is the normative rendering contract for the screen effect manifest v1. It is self-contained: a renderer for screen effects needs only these two documents. The format is tagged format-v1.0.0; until the first effect is published and the SDKs' 1.0.0 ship, every change to v1 is part of that tag (the tag is moved forward and the SDKs re-pin it).
format-v1.0.0
effect-manifest-v1.md
effect-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 is pinned once, here.
Each rule carries one marker:
2026-09-28-ingevora-mirage-design.md
Layer types: 1 particles · 2 full-screen sprite · 3 motion · 4 image · 5 sprite. Event sources: 1 lifecycle · 2 host. Keyframe animation: §E10.
E1.1 [S] §3.3, §4.2 — A screen effect draws in an overlay view the host places above its UI.
E1.2 [P] Overlay axes: origin top-left of the overlay's bounds, +x right, +y down, in the overlay's points/dp (not device pixels). origin.x and position.x are fractions of the overlay width, origin.y and position.y of its height (0,0 top-left; 1,1 bottom-right). Rationale: the author places emitters and images relative to the screen edges.
origin.x
position.x
origin.y
position.y
E1.3 [P] All other lengths — particle speed (per second), gravity (per second²), sizeScale, image and sprite scale, shake amplitude — are multiples of S = min(overlay width, overlay height). Rationale: one isotropic unit keeps directionDeg and spreadDeg true angles on any aspect ratio; the short side keeps sizes stable across phones.
speed
gravity
sizeScale
scale
S = min(overlay width, overlay height)
directionDeg
spreadDeg
E1.4 [P] Gravity is a constant acceleration along +y (down) in overlay axes, in S/s²; negative values accelerate upward. Rationale: gravity is a world direction; the confetti scenario (speed 0.8 up, gravity 1) then rises 0.32 S and falls back, which is its intent.
S/s²
E1.5 [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 confetti scenario's −90 fires upward). Rationale: consistent with the y-down overlay axes; matches CSS rotate() for the web preview.
rotationDeg
rotate()
E2.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)
E2.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 (every image actually decodes). Issue code asset.sheet_too_small (decode-time, not in the manifest rules, because the manifest has no image dimensions).
frameCount > columns × rows
asset.sheet_too_small
E2.3 [P] Frame index during playback: k = floor(tPlay × fps / 1000) where tPlay is ms since playback started. 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 makes it visible again, E2.7).
k = floor(tPlay × fps / 1000)
tPlay
loop: true
k mod frameCount
loop: false
frameCount − 1
sheet.endBehavior
1
2
visible
show
play sprite
E2.4 [P] A sprite layer that is visible but not playing (for example one shown again after its end-hide, E2.7) shows frame 0.
E2.5 [P] Sampling: bilinear, clamped to the frame's own cell (inset half a texel) so neighbouring frames never bleed in; no mipmaps.
E2.6 [P] A type 2 sprite keeps its explicit fit, relative to the overlay and centred with the frame's aspect preserved: contain (1) scales it to fit inside the overlay, cover (2) scales it to cover the overlay and the overflow is cropped by the overlay. Rationale: an animation must not distort.
fit
E2.7 [P] When a non-looping sprite with endBehavior: 2 passes its last frame — visible or not (a sprite hidden by an action keeps its clock, E6.2) — its playback stops and visible becomes false. A later show makes it visible showing frame 0 without playing (E6.1 — show sets visible only; E2.4); a later play sprite replays it from frame 0 (E6.3). The end-hide is evaluated when the sprite clock advances at the start of a frame (E5.4), so a show or play sprite on the frame the sprite ends wins.
endBehavior: 2
E3.1 [P] An image (type 4) and a sprite (type 5) each draw one textured quad:
C = (position.x × overlay width, position.y × overlay height)
w = scale × S
h = w × image height / image width
w × frameHeight / frameWidth
C
opacity
Worked example (scenario effect/placed-layers.json): on a 390 × 845 overlay (S = 390) an image at position (0.5, 0.4), scale 0.5, 200 × 100 px has corners (97.5, 289.25), (292.5, 289.25), (292.5, 386.75), (97.5, 386.75).
effect/placed-layers.json
S
position
E3.2 [P] A type 5 sprite plays exactly like a type 2 sprite (E2.3, E2.4, E2.7): it starts at startMs if visible at the start (E7.2), play sprite restarts it from frame 0 and implies show (E6.3), and endBehavior applies when loop is false.
startMs
endBehavior
loop
E3.3 [P] Shake and pulse (E8.1, E8.3) move and scale types 4 and 5 like every other non-flash layer. Nothing else moves them in v1. Scenario effect/shake-pulse-placed.json pins it; its description works every corner out by hand.
effect/shake-pulse-placed.json
E4.1 [P] layers[] is ordered. Array order is back to front: index 0 is drawn first (nearest the host app's UI, the back of the stack), the last layer on top.
layers[]
E4.2 [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).
E4.3 [P] opacity (types 4, 5) multiplies the premultiplied source. Particles (type 1) draw at 1; a full-screen sprite (type 2) draws at its animated opacity, which rests at 1 (E10.5); a flash (type 3) draws with its own alpha (E8.2).
E4.4 [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.
E4.5 [P] Within a particle layer, particles draw in emission order (oldest first), each as an upright quad centred on its position, width sizeScale × S, 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 × S
lifetimeMs
E4.6 [P] Several effect instances at once (overlapping plays) are independent: each has its own clock, particles and cap; the later-started instance draws on top. Overlap itself is [S] §2 ("Simultaneous presses: both play, overlapping") — generalised here as a platform rule.
E5.1 [P] Lifecycle: effect.started fires at effect time 0, on the first frame processed after the effect starts; effect.ended at effect time durationMs (E7.3).
effect.started
effect.ended
durationMs
E5.2 [P] Host events are delivered to every running effect instance; they are applied at the next rendered frame. Events arriving before an effect has started or after it has ended are dropped, never queued. Events with no matching trigger are ignored.
E5.3 [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
E5.4 [P] Order within one frame. At effect time t:
t
visible: true
t ≥ 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
burstCount
t_0 = t
k = 0
All actions of steps 2 and 3 apply to this frame's render, and later actions win across the whole sequence; E5.3 still orders the triggers matching one event. Particles emitted by an action (E6.4) or a burst are born at t; the burst of a layer that first becomes visible through show or emit happens when that action is applied, before the emit's own count (E6.7, E6.4).
count
Example: an effect.started action hides a startMs: 0 layer. Step 1a starts it at effect time 0; step 2 then hides it. A type 3 motion stops (E6.2) and has no effect unless run again (a later show alone does not restart it, E6.1). A type 2 or type 5 sprite keeps its clock running from 0 and, when shown later, appears mid-animation (E6.2).
startMs: 0
The durationMs cut (E7.3): the first frame whose time t ≥ durationMs runs none of the steps above. It evaluates the effect.ended triggers, then the instance is torn down: that frame and every later one draw nothing, and host events delivered with them are dropped (E5.2).
t ≥ durationMs
Rationale: one fixed order is the only way two renderers agree when a host event and a scheduled start 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.
E6.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.
E6.2 [P] hide on a particle layer clears its live particles and stops continuous emission; hide on a motion layer stops the motion; hide on a sprite keeps its playback clock running (visibility and playback are independent).
E6.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.
E6.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
maxParticles
E6.5 [P] run motion implies show and restarts from 0 if already running.
run motion
E6.10 [P] play animation (action 6) implies show of its clip's layer and starts the clip from 0 at that frame — restarting it if it is playing, replacing another clip playing on the same layer, and cancelling the clip's own pending autoplay start (E10.3).
play animation
E6.6 [P] Continuous emission (type 1) runs at ratePerSecond while the layer is visible. 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 E5.4 (1c) specifies. ratePerSecond: 0 never emits continuously.
ratePerSecond
t_k = k / ratePerSecond
ratePerSecond: 0
E6.7 [P] burstCount (type 1) = particles emitted at the instant the layer first becomes active: effect time 0 if visible: true, otherwise at the first show/emit. Only once per effect instance.
E6.8 [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 S units. Every length — speed, gravity, sizeScale — uses the S taken at emission, and the spawn point is the origin at the emission instant (animated, E10.5) × the overlay size at emission. A resized overlay or a moving origin does not move or resize live particles. Rationale: identical positions on any frame rate.
p(t) = p0 + v0·t + ½·g·t²
v0 = speed × (cos θ, sin θ)
g = (0, gravity)
E6.9 [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 "<effectId>:<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
"<effectId>:<layerId>"
E7.1 [P] Clock. Effect time = display time since the host's play call. Golden tests supply timestamps directly: effect time is a step's timeMs minus that of the first step.
timeMs
E7.2 [P] startMs (types 2, 3, 5) is measured from effect time 0, and applies only to layers with visible: true at start, which begin automatically at startMs (not drawn before). A layer started by an action (play sprite / run motion) starts at that action; its startMs is ignored. Rationale: the existing timing rules (timing.exceeds_duration) check startMs against the effect timeline, so that is what it means.
timing.exceeds_duration
E7.3 [P] Everything in an effect is cut at durationMs: an action-started motion or sprite that would overrun stops there, and live particles vanish. At durationMs the engine evaluates effect.ended triggers, then tears the instance down; nothing renders after, and the host's "effect finished" callback (§4.2) fires. Actions on effect.ended therefore have no visible result in v1 — the editor should warn about them. Rationale: hosts key cooldowns to durationMs (§3.2); an effect must never outlive it.
E7.4 [P] A host stopping an effect early ends it immediately without firing effect.ended.
E7.5 [S] §4.2 — The SDK does not transport effect events; the host moves them and calls play on each device.
Let τ = t / 1000 seconds since the motion started, D its durationMs, f = frequencyHz, I = intensity, e = 1 − t / D a linear decay envelope.
τ = t / 1000
D
f
frequencyHz
I
intensity
e = 1 − t / D
E8.1 [P] Shake (1): displaces all other layers of the same effect instance — types 1, 2, 4 and 5; a flash fill (E8.2) is excluded — by d = I · e · 0.03 S · (sin(2π f τ), sin(2π · 0.8 f · τ + π/3)). At I = 1 the peak is 3 % of the short side. The overlay cannot move the host's UI (§4.2 places it above the UI), so SDKs may also expose d(t) to the host, but golden tests cover the overlay only. A flash fill always covers the whole overlay and is never displaced or scaled.
d = I · e · 0.03 S · (sin(2π f τ), sin(2π · 0.8 f · τ + π/3))
I = 1
d(t)
E8.2 [P] Flash (2): draws, at its own array position, a full-overlay white fill with alpha I · (0.5 − 0.5 cos(2π f τ)) — f flashes per second, each starting and ending at 0. Rationale: frequencyHz then is the flash rate; the flashing-limit profile rule counts these cycles in any one-second window, all flash motions together (rule-profiles-v1 §5).
I · (0.5 − 0.5 cos(2π f τ))
E8.3 [P] Pulse (3): scales all other layers of the instance (types 1, 2, 4 and 5; not the flash fill) about the overlay centre by 1 + 0.10 · I · e · (0.5 − 0.5 cos(2π f τ)). A flash fill always covers the whole overlay and is never displaced or scaled.
1 + 0.10 · I · e · (0.5 − 0.5 cos(2π f τ))
E8.4 [P] Several running motions combine: shake displacements add; pulse scales multiply; the transform applies as scale then translate. A hidden motion has no effect.
translate
E9.1 [P] Initial state. At start every layer takes its manifest visible; motions, sprites, emitters and clips are idle except as E6.6, E6.7, E7.2 and E10.3 start them.
E9.2 [P] Particle cap is per layer (maxParticles) per instance; the cross-layer 300 total is a validation rule (particles.too_many), not a runtime pool.
particles.too_many
E9.3 [P] Forward path for fields. Because parsing is strict, a field added after the tag must come with a field-level engine level (an EFFECT_ENGINE_LEVEL_BY_FIELD table alongside EFFECT_ENGINE_LEVEL_BY_LAYER_TYPE and EFFECT_ENGINE_LEVEL_BY_EVENT_SOURCE, feeding the computation of minEngineVersion), so an older SDK reports "engine too old" instead of "invalid manifest". No manifest change — a rule for how the editor computes minEngineVersion.
EFFECT_ENGINE_LEVEL_BY_FIELD
EFFECT_ENGINE_LEVEL_BY_LAYER_TYPE
EFFECT_ENGINE_LEVEL_BY_EVENT_SOURCE
minEngineVersion
E9.4 [P] Rendering is per frame and stateless apart from sprite clocks, motion clocks, pending startMs starts, particle pools with their emission state, PRNG streams, and each layer's playing clip with its start time and the pending autoplay starts (E10.6) — the full list of per-instance state an SDK keeps.
A clip (animations[], effect-manifest-v1.md Animations) animates one layer. Its tracks hold numeric keys; a playing clip's values replace the layer's resting values. Face lenses use E10.1–E10.3 as they stand, on lens time (rendering-v1.md §11).
animations[]
rendering-v1.md
E10.1 [P] Value at a moment. For a track at clip time c (ms): before the first key, the first key's value; after the last key, the last key's value; between keys a and b, u = (c − a.atMs) / (b.atMs − a.atMs) and value = a.value + (b.value − a.value) · ease_a(u), where ease_a is key a's easing. A property with no track in the playing clip — or on a layer with no playing clip — takes its resting value: the layer's own field (position.x, position.y, scale, rotationDeg, opacity, or origin.x / origin.y for particles), and 1 for a full-screen sprite's opacity and zoom. Rotation interpolates the raw degrees (no wrap). Easings may overshoot (back): when a value is used, an opacity is clamped to [0, 1] and a scale or zoom to ≥ 0; x, y and rotation are used as they are.
c
a
b
u = (c − a.atMs) / (b.atMs − a.atMs)
value = a.value + (b.value − a.value) · ease_a(u)
ease_a
E10.2 [P] Easing, u ∈ [0, 1]:
u
ease(u)
u²
1 − (1 − u)²
2u²
u < 0.5
1 − (−2u + 2)² / 2
0
1 + 2.70158 (u − 1)³ + 1.70158 (u − 1)²
n = 7.5625
d = 2.75
n u²
u < 1/d
n (u − 1.5/d)² + 0.75
u < 2/d
n (u − 2.25/d)² + 0.9375
u < 2.5/d
n (u − 2.625/d)² + 0.984375
E10.3 [P] Clip clock.
autoplay
c = elapsed
elapsed mod durationMs
elapsed ≥ durationMs
c = durationMs
E10.4 [P] Frame order (E5.4). Autoplay starts are scheduled starts (step 1a); clip ends are clock advances (step 1b); play animation runs with the other actions (steps 2–3); drawing (step 5) evaluates every layer's values at t.
E10.5 [P] What the values change.
x
y
E10.6 [P] State. Per layer: the playing clip and its start time (the clip history back to the previous frame, for E10.5's catch-up), and the pending autoplay starts.
Golden scenarios live in packages/render/fixtures/scenarios/effect/*.json: confetti-shake (particles under a shake, E8.1), continuous-emission-cap (whole-microsecond emission at the cap, E5.4 (1c)), placed-layers (E3.1), shake-pulse-placed (E3.3), and for keyframes animation-easings (E10.2), animation-clips (E10.3), animation-origin (E10.5) and animation-under-shake (E10.5, E8.1). The "Scenario fixtures" section of packages/render/README.md describes the same file shape, which lens and screen effect scenarios share. Every renderer (the editor preview and the iOS and Android SDKs) replays them and must agree.
packages/render/fixtures/scenarios/effect/*.json
confetti-shake
continuous-emission-cap
placed-layers
shake-pulse-placed
animation-easings
animation-clips
animation-origin
animation-under-shake
packages/render/README.md
A scenario file is { description, manifest, assets, seed, steps: [{ input, expect }] }:
{ description, manifest, assets, seed, steps: [{ input, expect }] }
manifest
assets
{ "<asset name>": { "width": 256, "height": 128 } }
seed
fnv1a32("<effectId>:<layerId>")
steps
input
{ kind: 2, timeMs, overlaySize, hostEvents }
kind
packages/render
kind: 2
overlaySize
{ width, height }
hostEvents
expect
lut
{ op: 'quad', layerId, asset, frameIndex, src, corners, opacity }
src
corners
frameIndex
null
{ op: 'fill', layerId, color, alpha }
color
alpha
A renderer passes a step when every number in its draw list is within 0.002 of the expected value and everything else is exact: op, layerId, asset, frameIndex (including null), object keys and list lengths.
op
layerId
asset