A lens is a face lens: it draws over a tracked face. Screen effects have their own manifest,
effect-manifest-v1.md, with its own enums; nothing here applies to
them. (Before the split, this manifest also had a kind 2 for screen effects, with durationMs
and screen layer types 6–8; they left it inside format-v1.0.0, before anything shipped.)
| Enum |
Values |
layer type |
1 colour look (LUT) · 2 face sticker · 3 frame overlay · 4 animated sprite · 5 face particles |
anchor |
1 forehead · 2 left eye · 3 right eye · 4 nose · 5 left cheek · 6 right cheek · 7 chin · 8 left ear · 9 right ear · 10 full face |
placement mode |
1 on a face anchor · 2 full frame |
sheet.endBehavior (type 4) |
1 hold last frame · 2 hide (layer's visible becomes false; a later show/play sprite shows it again from frame 0) |
event source |
1 lifecycle · 2 face signal · 3 host (any name) |
action type |
1 show · 2 hide · 3 play sprite (→ type 4) · 4 emit particles (→ type 5, needs count) · 6 play animation (names a clip: animation) — 5 is unused |
animation track property |
1 offset x · 2 offset y · 3 scale · 4 rotation · 5 opacity · 7 intensity (6, zoom, is a screen-effect property) |
key easing |
1 linear · 2 ease in · 3 ease out · 4 ease in-out · 5 hold · 6 back · 7 bounce |
clip endBehavior |
1 hold the last values · 2 return to the resting values |
Lifecycle names: lens.started. Face-signal
names: mouth.open, brows.raised, blink, head.tilt, smile (with optional threshold
0–1; threshold is allowed only on face-signal events). Host names: any
^[a-z][a-z0-9_.-]{0,63}$ — the platform defines the mechanism, the customer the vocabulary.
A colour-look asset is a 512 × 512 PNG holding a 64³ LUT as an 8 × 8 grid of 64 × 64 tiles
(red across each tile, green down each tile, blue across tiles left→right, top→bottom).
Offsets and particle speeds are in face widths.
Full face (anchor 10). The centre of the whole face, brows to chin: face-frame
position (0, +0.15) from the eye midpoint, in face widths (rendering-v1 2.2). With scale 1 a
sticker is exactly one face width wide (height from its image's aspect), so a mask drawn at the
face's proportions covers the face; it turns with head roll like any other anchor. It is valid
on every field that takes an anchor — transform.anchor (types 2 and 4 on a face) and face
particles' anchor (type 5, which then spawn at the face centre). It is engine level 1
(ENGINE_LEVEL_BY_ANCHOR): it is part of format-v1.0.0 (added before any lens was published or
any SDK shipped), so the SDKs' 1.0.0 render it and a lens using it keeps minEngineVersion 1.
Example: fixtures/lens/valid/face-mask.json.
A sprite sheet (sheet, type 4) requires endBehavior: it applies when
loop is false (1 hold last frame, 2 hide); when loop is true it is still required but
has no effect. Face particles (type 5) require offset — the spawn offset from the anchor
point, same shape, range and meaning as transform.offset. See
rendering-v1.md for the normative rendering rules (playback, spawn
position, draw order, timing, and everything else a renderer needs pinned).
#Animations
An optional top-level animations list holds keyframe clips; a lens without it is unchanged. A
clip animates one layer over its own time, on lens time; the rendering rules are
rendering-v1.md §11, which uses the easing formulas and the clip clock of
effect-rendering-v1.md E10.1–E10.3.
json
{
"id": "hat-pop", "layer": "hat",
"durationMs": 1000, "loop": false, "autoplay": true, "startMs": 200, "endBehavior": 1,
"tracks": [
{ "property": 3, "keys": [{ "atMs": 0, "value": 0.3, "easing": 7 }, { "atMs": 1000, "value": 0.6, "easing": 1 }] }
]
}
id — same pattern as a layer id, unique among clips. layer — the layer it animates.
durationMs 50–10 000; loop; autoplay (the clip starts itself when lens time reaches
startMs); startMs optional, 0–10 000 (absent = 0); endBehavior required (no effect when
loop is true).
tracks — 0–6, at most one per property. keys — 1–32 per track, atMs 0–durationMs,
strictly increasing; a key's easing shapes the segment to the next key (the last key's easing
is unused but required).
- Values are absolute: a playing clip's track replaces the property's resting value. One clip
plays per layer at a time; action 6 (
{ "type": 6, "animation": "<clip id>" }) (re)starts a clip
and implies show of its layer.
Properties each layer may animate, and each property's key range (ids 1–5 mean what they mean for
screen effects, in the layer's own units):
| Property |
Sticker 2 |
Sprite 4, on a face (mode 1) |
Sprite 4, full frame (mode 2) |
Overlay 3 |
Particles 5 |
Colour look 1 |
Key range |
| 1 offset x |
transform.offset.x |
placement.transform.offset.x |
— |
— |
offset.x |
— |
−2…2 face widths |
| 2 offset y |
transform.offset.y |
placement.transform.offset.y |
— |
— |
offset.y |
— |
−2…2 face widths |
| 3 scale |
transform.scale |
placement.transform.scale |
— |
— |
— |
— |
> 0, ≤ 4 face widths |
| 4 rotation |
transform.rotationDeg |
placement.transform.rotationDeg |
— |
— |
— |
— |
−720…720 (a key may spin past the resting ±180) |
| 5 opacity |
opacity |
opacity (resting value 1) |
opacity (resting value 1) |
opacity |
— |
— |
0–1 |
| 7 intensity |
— |
— |
— |
— |
— |
intensity |
0–1 |
| 8 anchor (engine 2) |
transform.anchor |
placement.transform.anchor |
— |
— |
anchor |
— |
a whole anchor id 1–10 |
| 9 roll follow (engine 2) |
transform.followRoll as 0 / 1 |
placement.transform.followRoll as 0 / 1 |
— |
— |
— |
— |
0–1 |
Tables in code: lensAnimatableProperties(layer),
LENS_ANIMATION_VALUE_RANGE, MAX_ANIMATIONS (16), MAX_TRACKS_PER_ANIMATION (6),
MAX_KEYS_PER_TRACK (32); the easings are AnimationEasing, shared with screen effects. Every
animation feature above except properties 8 and 9 is engine level 1. Example:
fixtures/lens/valid/animated.json animates every engine-1 property on every layer kind that may,
with every engine-1 easing, and starts two clips from a mouth.open trigger.
Added after iOS SDK 0.2.0 shipped. All optional; a lens that uses any of them needs
minEngineVersion 2, so an engine-1 SDK refuses it as "engine too old" (see Versioning). Rendering:
rendering-v1.md 11.8–11.12.
json
{
"id": "hat-spin", "layer": "hat", "lane": 2, "durationMs": 2000, "loop": true, "autoplay": true, "endBehavior": 1,
"tracks": [{ "property": 4, "keys": [
{ "atMs": 0, "value": -10, "easing": 8, "curve": { "x1": 0.68, "y1": -0.6, "x2": 0.32, "y2": 1.6 } },
{ "atMs": 2000, "value": 10, "easing": 1 } ] }]
},
{
"id": "frame-mouth", "layer": "frame", "durationMs": 1000, "loop": false, "autoplay": true, "endBehavior": 1,
"drive": { "signal": "mouth.open" },
"tracks": [{ "property": 5, "keys": [{ "atMs": 0, "value": 0, "easing": 2 }, { "atMs": 1000, "value": 1, "easing": 1 }] }]
}
- Anchor track — property 8, on stickers, sprites on a face and face particles. A key's value is
an anchor id (a whole number 1–10); between two keys the anchor point glides from one anchor to the
next along the first key's easing (Hold jumps at the next key).
- Roll follow track — property 9, on stickers and sprites on a face: the share 0–1 of the face's
roll the layer follows (its resting value is
followRoll as 1 or 0).
- Custom curve — easing
8 with the key's curve: { x1, y1, x2, y2 }, a CSS cubic-bézier;
x1, x2 in [0, 1], y1, y2 in [−1, 2]. curve is required with easing 8 and refused with any
other easing.
- Lanes — a clip's optional
lane 1–4 (absent = 1). Starting a clip replaces only the clip
playing on the same layer and lane; clips on other lanes keep playing, and where two lanes animate
the same property the higher lane wins. One autoplay clip per layer and lane.
- Signal drive — a clip's optional
drive: { "signal": "<face signal>" } (one of the five face
signals). Once started (autoplay or action 6), its clip time follows the signal: 0 → the clip's
start, 1 → its end (durationMs); loop and endBehavior have no effect.
Tables in code: ENGINE_LEVEL_BY_ANIMATION_PROPERTY, ENGINE_LEVEL_BY_EASING,
ENGINE_LEVEL_BY_FIELD (lane, drive, curve — any presence counts, even "lane": 1),
LENS_CUSTOM_CURVE_EASING (8), MAX_ANIMATION_LANES (4). Example:
fixtures/lens/valid/animated-v2.json uses each of them.
#Files
- JSON Schema (language-neutral, generated):
packages/format/schema/lens.v1.schema.json
- Fixtures:
packages/format/fixtures/lens/valid/ (must pass) and fixtures/lens/invalid/ (a patch on a
valid fixture + the exact outcome a validator must report). SDKs pin these by git tag.
json
{
"description": "...",
"base": "face-crown",
"patch": [{ "op": "replace", "path": "/triggers/0/event/name", "value": "lens.exploded" }],
"expectedIssues": [{ "path": "/triggers/0/event/name", "code": "trigger.unknown_event" }]
}
base — a file name in fixtures/lens/valid/, without .json.
patch — an RFC 6902 JSON Patch applied in order to base. Only add, remove, replace
are supported (exact RFC 6902 semantics): add to an array index inserts, and - appends;
add to an object key creates or replaces; replace and remove require the target to
already exist; removing an array element splices it out. An unsupported op or a missing
target throws.
- Exactly one of:
expectedIssues — the semantic issues checkRules must report, compared as a set of
path + code (order does not matter).
rejectedBySchema: true — the manifest must fail schema validation. Only that it is
rejected is normative; the schema.* issue codes mirror the reference implementation's
validation library (zod) and are not normative for other implementations.
#Validation = schema + rules
A JSON Schema check is necessary but not sufficient. Semantic rules run only on a
schema-valid manifest — a schema failure is reported as schema issues alone, and the rules
below never run against it. For schema failures, conformance means "rejected"; only the
path + code of a semantic issue are normative.
| Code |
Path reported |
Condition (exact operator) |
Notes |
layer.duplicate_id |
/layers/i/id |
A layer id repeats an earlier layer's id |
|
asset.missing |
/layers/i/asset |
A layer's asset is not a key of assets |
|
asset.unused |
/assets/<name> |
An assets key is not referenced by any layer |
|
package.too_large |
/assets |
Σ assets[*].bytes > 2 MiB (LENS_PACKAGE_BYTE_CAP) |
|
particles.too_many |
/layers |
Σ maxParticles over the face-particle layers (type 5) > 300 |
Per-layer maxParticles ≤ 300 is a schema cap; this is the cross-layer total |
trigger.unknown_event |
/triggers/i/event/name |
source 1: name is not lens.started (the effect names effect.started / effect.ended are not lens events); source 2: name is not in the face-signal vocabulary |
Host events (source 3) have no fixed vocabulary — any name matching the pattern is valid |
trigger.threshold_not_applicable |
/triggers/i/threshold |
threshold is present and source ≠ 2 |
|
trigger.unknown_layer |
/triggers/i/actions/j/layer |
An action's layer matches no layer id |
Suppresses trigger.action_mismatch for that same action — an action on a nonexistent layer is never also reported as targeting the wrong type |
trigger.action_mismatch |
/triggers/i/actions/j |
The action's layer exists but its type is not one ACTION_TARGET_TYPES[action.type] allows |
Neither this rule nor trigger.unknown_layer applies to action 6 |
trigger.unknown_animation |
/triggers/i/actions/j/animation |
Action 6 names no clip id |
|
animation.duplicate_id |
/animations/i/id |
A clip id repeats an earlier clip's id |
|
animation.unknown_layer |
/animations/i/layer |
A clip's layer matches no layer id |
Suppresses animation.property_not_animatable for that clip |
animation.property_not_animatable |
/animations/i/tracks/j/property |
The layer may not animate the property (lensAnimatableProperties: by layer type, and a sprite by placement.mode) |
|
animation.duplicate_property |
/animations/i/tracks/j/property |
A property repeats within one clip |
|
animation.keys_out_of_order |
/animations/i/tracks/j/keys/k/atMs |
atMs ≤ the previous key's |
|
animation.key_outside_clip |
/animations/i/tracks/j/keys/k/atMs |
atMs > the clip's durationMs |
|
animation.value_out_of_range |
/animations/i/tracks/j/keys/k/value |
Outside the property's key range (LENS_ANIMATION_VALUE_RANGE; scale's minimum 0 is itself out) |
|
animation.autoplay_conflict |
/animations/i/autoplay |
A second autoplay clip on the same layer and lane (absent lane = 1) |
Before format-v1.1.0 there were no lanes: one per layer |
animation.curve_missing |
/animations/i/tracks/j/keys/k/easing |
easing is 8 and the key has no curve |
Engine 2 |
animation.curve_not_applicable |
/animations/i/tracks/j/keys/k/curve |
The key has a curve and its easing is not 8 |
Engine 2 |
animation.anchor_not_whole |
/animations/i/tracks/j/keys/k/value |
A property-8 (anchor) key's value is not a whole number |
The 1–10 range is animation.value_out_of_range |
animation.unknown_signal |
/animations/i/drive/signal |
drive.signal is not a face signal |
Engine 2 |
engine.too_low |
/minEngineVersion |
minEngineVersion < the max engine level required by any layer type, anchor, event source, track property, key easing or engine-2 field used |
Levels: ENGINE_LEVEL_BY_LAYER_TYPE, ENGINE_LEVEL_BY_ANCHOR, ENGINE_LEVEL_BY_EVENT_SOURCE, ENGINE_LEVEL_BY_ANIMATION_PROPERTY, ENGINE_LEVEL_BY_EASING, ENGINE_LEVEL_BY_FIELD in constants.ts. Fixtures: engine-too-low-for-animation, engine-too-low-for-lane. |
Notes for implementers:
MAX_TEXTURE_DIMENSION (1024) bounds sheet.frameWidth / sheet.frameHeight in the
manifest — the sprite-sheet cell size — but the manifest carries no image dimensions, so it
cannot bound the sheet's overall pixel size. That is enforced separately, by decoding the
PNG at upload time.
asset.sheet_too_small — for a type-4 layer, if frameCount exceeds the number of
cells the asset's actual pixel dimensions provide (floor(width / frameWidth) × floor(height / frameHeight)), the manifest is rejected. This is a decode-time check (the
manifest has no image dimensions), not part of checkRules — the lenses API runs it at
validate and publish time against the asset's stored size; see
docs/api/lenses.md.
#Versioning
formatVersion changes only on a breaking change to this document. Adding a layer type, an
anchor or an event is not breaking: it raises that feature's engine level, and older engines
refuse lenses whose minEngineVersion they do not meet. A renderer must therefore compare
minEngineVersion with its own engine version before it validates the manifest strictly:
an engine-1 renderer that parsed a lens using a later anchor first would report "invalid
anchor" instead of "engine too old".
Tags. The SDKs pin this package by git tag. Until an SDK shipped, every v1 change was part
of format-v1.0.0 and the tag moved with it. iOS SDK 0.2.0 shipped on format-v1.0.0 (engine 1),
so that tag is now fixed for good: each later additive change gets a new tag (format-v1.1.0, …)
and raises the engine level of what it adds.
| Engine |
Tag |
Covers |
| 1 |
format-v1.0.0 |
Everything in format-v1.0.0, including anchor 10 full face and keyframe animations with action 6 — what iOS SDK 0.2.0 renders |
| 2 |
format-v1.1.0 |
Anchor (8) and roll follow (9) tracks, easing 8 with curve, clip lane and drive (Animations, "Engine 2 additions") |