greenlight-motion 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,7 @@
29
29
  | You are making one version of a project for Variants (your copy, the scope, `ready`) | [video-variants.md](video-variants.md) |
30
30
  | AI Viral Edits (kinetic words around a layer's subject) and sequences with an Essential panel (`clip.essential`) | the `viral-edits` workflow on the `greenlight` MCP server ([SKILL.md](../SKILL.md#ai-workflows-the-greenlight-mcp-server)); `essential` below |
31
31
 
32
- In this file: Editing and verification · Video Projects (list, get, create, update, collaborative boards, refresh revision, activate, delete, build from clips) · Editor `data` shape · Sequence clips · Video presets (and project export / import bundles, GreenLight Motion pages) · Interface scenes.
32
+ In this file: Editing and verification · Text animation pace · Video Projects (list, get, card, create, update, collaborative boards, refresh revision, activate, delete, build from clips) · Editor `data` shape · Sequence clips · Video presets (and project export / import bundles, GreenLight Motion pages) · Interface scenes.
33
33
 
34
34
  Use `GET /api/video-editor/capabilities` for the installed build's effect and transition ids, fields, ranges and render support. Read the relevant subtree instead of copying a catalog into the prompt. For custom MotionFast authoring, use [the authoring skill](../../motionfast-templates/SKILL.md).
35
35
 
@@ -41,6 +41,17 @@ Use `GET /api/video-editor/capabilities` for the installed build's effect and tr
41
41
  4. GET again to verify the saved structure. For visible changes, use a composited still or a short full render from [video-rendering.md](video-rendering.md) to check the result: a successful save alone does not prove an effect or track renders.
42
42
  5. Keep all media used by the project, including nested sequences and effect assets, even when it has no canvas card. Follow [media cleanup](media.md#clean-up-helper--intermediate-media-do-this-at-the-end-of-a-task).
43
43
 
44
+ ## Text animation pace
45
+
46
+ By default, text that animates in, animates out or gets a one-time pass (a sweep, a highlight, a typed line) takes **at least 0.8–1 s**, counted from the moment its first unit starts to the moment its last unit settles. Faster than that, the words flash past before anyone can read them. The rule holds however the motion is built:
47
+
48
+ - **Advanced Animations on Time** (`textAnimators[].range.drive: "time"`): the span is `(units − 1) × stagger + duration`. For example, 4 words with `stagger` 0.06 and `duration` 0.8 take 0.98 s. For moves (rise, slide, scale, rotate, blur), reach the span with `duration`, because a long `stagger` makes a long line crawl. A typewriter (`duration` 0) gets its pace from `stagger`.
49
+ - **Keyframes** (`txa:` range tracks such as Start or Offset, or a text layer's own position, scale and opacity tracks): the first and last key of one move sit at least 0.8 s apart. Key positions are % of the layer, so a move from `p1` to `p2` lasts `(p2 − p1) / 100 × layer seconds`.
50
+ - **Layer entrances and exits** on a text layer: `transitionInDuration` / `transitionOutDuration` of 0.8 or more.
51
+ - **Recipes** from [video-text-kinetic.md](video-text-kinetic.md) and [video-text-presets.md](video-text-presets.md) keep their tuned timing. On very short text (one or two words), where the span works out under 0.8 s, raise `duration` until it reaches 0.8 s.
52
+
53
+ Go faster only when the edit really needs it: the user asks for snappy or fast text, a word hits on a beat or a fast cut, a glitch or scramble flicks, the layer is too short to fit a 0.8 s move, or the animator uses `drive: "voice"` (the speech sets the pace there).
54
+
44
55
  ## Video Projects (per board)
45
56
 
46
57
  A "video project" is a saved video-editor session inside a board.
@@ -48,11 +59,13 @@ A "video project" is a saved video-editor session inside a board.
48
59
  ### List video projects
49
60
 
50
61
  ```
51
- GET /api/moodboards/{board_id}/video-projects[?data=active]
62
+ GET /api/moodboards/{board_id}/video-projects[?data=active|none]
52
63
  → { "projects": [{ "id", "name", "data", "updated_at", "summary", "preview", ... }], "active_project_id?" }
64
+ GET /api/video-projects/counts
65
+ → { "counts": { "<board id>": <number of projects>, … } } (boards without projects are left out)
53
66
  ```
54
67
 
55
- `?data=active` leaves out every payload except the open project's (`data: null`, `has_data: false`), so it is the cheap way to survey a board: each project still carries `summary`, which answers "how long is it, how big, how busy" without downloading timelines. The single-project GET / POST / PUT answers carry both fields too. Both are derived and read-only — never send them back.
68
+ `?data=active` leaves out every payload except the open project's (`data: null`, `has_data: false`) and `?data=none` leaves out all of them, so they are the cheap ways to survey a board: each project still carries `summary`, which answers "how long is it, how big, how busy" without downloading timelines. The single-project GET / POST / PUT answers carry both fields too. Both are derived and read-only — never send them back.
56
69
 
57
70
  - `summary`: `{duration, width, height, layers, clips, markers, background, media}` — timeline length in seconds (speed curves approximated), canvas size, channels that hold items, item count, marker count, the solid background colour (or null), and the first picture on the timeline.
58
71
  - `preview`: null, or any of `thumbnail` + `thumbnailVersion` (a ~480 px frame of the project as it was last left after changes, and the project version it shows) and `frames` + `framesDuration` + `framesAt` (one JPEG strip of five frames side by side, cut from the last full-project render at `framesAt[i] × framesDuration` seconds). The URLs live under the board's `.project-previews/`.
@@ -63,13 +76,24 @@ GET /api/moodboards/{board_id}/video-projects[?data=active]
63
76
  GET /api/moodboards/{board_id}/video-projects/{project_id}
64
77
  ```
65
78
 
79
+ ### Project card (what a canvas card shows)
80
+
81
+ ```
82
+ GET /api/moodboards/{board_id}/video-projects/{project_id}/card
83
+ → { "id", "board_id", "board_name", "name", "version", "updated_at", "summary", "preview" } 404 once removed
84
+ ```
85
+
86
+ The project without its payload, plus its board's name: what a `video-project` canvas card shows ([elements.md](elements.md#video-editor-project-cards-a-video-project-on-any-board)). Use it to check that a board/project pair exists before making a card.
87
+
66
88
  ### Create video project
67
89
 
68
90
  ```
69
91
  POST /api/moodboards/{board_id}/video-projects
70
- { "name": "Cut 01", "data": { ... } }
92
+ { "name": "Cut 01", "data": { ... }, "activate?": true }
71
93
  ```
72
94
 
95
+ A new project becomes the board's open one (the Video Editor opens it next). Send `"activate": false` when the project is for later, e.g. behind a canvas card, so the user's editor keeps the project it has.
96
+
73
97
  ### Update video project
74
98
 
75
99
  ```
@@ -210,6 +234,10 @@ If you need to build the editor state by hand instead of using `/from-clips`, th
210
234
 
211
235
  **Layer titles.** Optional `"customName"` on any timeline item — the display title drawn on the clip in the timeline (and in the inspector's selected-layer header) in place of the clip/media name. Trimmed string, max 120 chars; empty or missing falls back to the default (the clip's `name`, or the text content for text layers). Set it whenever you build or edit a timeline so layers are self-describing — `"Intro logo"`, `"CTA caption"`, `"BG loop"` — instead of raw filenames; users can also rename in-app via the pencil beside the selected-layer title in the inspector panel. Display-only: no render path reads it.
212
236
 
237
+ **Videos and photos draw at their real size.** A `video` clip (like a bitmap `image`) keeps its own aspect ratio and never gets bars: larger than the composition, it is shrunk to fit; smaller than the composition on both axes, it keeps its pixel size. A 1280×720 clip in a 1920×1080 project covers 1280×720 at `zoom: 1` (set `zoom: 1.5` to fill the frame), and a 1080×1920 clip in a 16:9 project is a centred portrait picture. Whatever the picture does not cover shows the layers below, or the project background.
238
+
239
+ **Fit screen.** To make a video or image layer's whole picture fit the frame, call `POST /api/moodboards/{board_id}/video-projects/{project_id}/fit-screen` with `{"itemId": "<item id>"}` (the item can be inside a sequence) — the editor's **Fit screen** command (⌘K). It reads the media's real size on the server and saves the project: `zoom` becomes the fitting Scale (`max(1, min(compW/mediaW, compH/mediaH))` for a video or photo, `min(compW/w, compH/h)` of the declared size for an SVG), `scaleX`/`scaleY` become 1 and `panX`/`panY` 0. Rotation and keyframes are kept; `scale` keyframes multiply the new `zoom`. Answer: `{itemId, zoom, mediaWidth, mediaHeight, frame, version}`. 400 for a layer that is not a video or image, 422 when the media size cannot be read.
240
+
213
241
  **SVG clips are vector layers.** An `mediaType: "image"` clip whose `url` ends in `.svg` (or is an `image/svg+xml` data url) is **not** letterbox-fitted to the frame like a bitmap: it is laid out at the size the document itself declares (`width`/`height`, else the `viewBox`, else 300×150 by CSS default sizing) and re-rasterized at whatever resolution it ends up covering, so it stays sharp under any `zoom` or camera push-in. Consequences when authoring: a 200×80 logo occupies 200×80 composition px at `zoom: 1` — set `zoom` (or scale keyframes) to size it, never assume it fills the frame; artwork larger than the composition overflows it. Everything else is an ordinary image clip (filters, transitions, effects, keyframes). An SVG loaded this way cannot reach external resources — fonts must be outlined and images embedded, the same restriction any `<img>`\-loaded SVG has.
214
242
 
215
243
  **Animated image clips (GIF, animated WebP, APNG).** An `mediaType: "image"` clip whose `url` is an animated GIF, animated WebP or APNG plays as a moving picture instead of freezing on its first frame. Which frame shows is a pure function of the item's clip-local time, so preview and every render agree, and it is resolved from the file's own per-frame delays rather than an assumed frame rate — a file built around one long hold frame plays at the rate it was authored at. Two optional fields on the **item** control playback: `"gifLoopMode"` — `"loop"` (default, restart at the end), `"hold"` (freeze on the last frame) or `"pingpong"` (forwards then backwards) — and `"gifSpeed"`, a playback multiplier from 0.1 to 10, default 1, which changes only how fast the animation runs inside the clip and never the clip's length on the timeline. Both are dropped from the persisted JSON at their defaults, and a value outside those ranges is clamped on load. Set the clip `duration` (and the item's `trimEnd`) to the file's loop length for exactly one play-through — a longer span loops, a shorter one cuts — and `trimStart` offsets where the animation starts. Everything else is an ordinary image clip: plate sizing, filters, transitions, masks, effects and keyframes are unchanged, and still images are unaffected. **`/render-onserver` and `/render-still` bake the animation; the fast `/render` cannot ingest an animated file at all** (its ffmpeg image input assumes a still and errors on one), so send any timeline holding one to `/render-onserver`.
@@ -337,10 +365,16 @@ POST /api/moodboards/{board_id}/video-projects/{project_id}/export-light
337
365
  "modelFiles?": { "<preset id>": "<the URL the app serves its .glb at>" }, "pack?": false }
338
366
  → { folder, index, packed: { path, bytes } | null, files, bytes, missing, leftOut, fields }
339
367
  POST /api/moodboards/{board_id}/video-projects/import-light
340
- { "path": "/abs/folder | …/index.html | a one-file page.html", "activate?": true }
368
+ { "path": "/abs/folder | …/index.html | a one-file page.html | a .glvideo file", "activate?": true }
341
369
  → the new project (like POST video-projects) + { copied }
370
+ GET /api/moodboards/{board_id}/video-projects/{project_id}/glvideo
371
+ → <name>.glvideo (answers once the whole file is written)
342
372
  ```
343
373
 
374
+ - A `.glvideo` is the same project folder in one stored zip: `glvideo.json` (`{format: "glvideo", version: 1, title}`),
375
+ `project.json`, `assets/media/<rel>` and `assets/hdr/`. It is the file to hand a project to another GreenLight Dash:
376
+ `import-light` with its path opens it there as a new project.
377
+
344
378
  - `template.fields[]` = `{ key, label, type: text|color|image|number|select|toggle, targets: [{ itemId | clipId | (neither = project), path }] }`
345
379
  — e.g. `{ "key": "headline", "label": "Headline", "type": "text", "targets": [{ "itemId": "<item id>", "path": "textContent" }] }`,
346
380
  a shape colour `path: "shapeFillColor"`, the background `{ "path": "background.solidColor" }`. Omit `template` to keep
@@ -34,7 +34,9 @@ works. Append the second recipe with fresh ids (next section).
34
34
  Preferred presets: once the table has given the category, and the user has not named a preset, choose from these
35
35
  first:
36
36
 
37
- - Sweep Reveal: [Glide Soft](#glide-soft), [Ember Type](#ember-type).
37
+ - Sweep Reveal: [Glide Text](#glide-text) (the default text animation), [Ember Type](#ember-type).
38
+ [Glide Continuous](#glide-continuous) and [Glide Soft Stop](#glide-soft-stop) keep the word drifting for the whole
39
+ layer and never let it rest: use them only when the user asks for that.
38
40
  - Emphasis: [Marker](#marker), [Outline Pass](#outline-pass).
39
41
  - Loop: [Neon Flow](#neon-flow).
40
42
  - Characters: [Glitch](#glitch).
@@ -77,7 +79,7 @@ Type on Shape has no preferred preset: pick by the shape the text needs.
77
79
 
78
80
  <!-- PRESETS:BEGIN (generated by tests/text-animators/skill_presets.mjs from presets.js — edit there) -->
79
81
 
80
- 47 recipes in 6 categories. **Lengths** to multiply by `textFontSize / 100`: `blur` `amount`; `box` `padX`, `padY`, `radius`; `glow` `size`; `position` `x`, `y`; `rotate3d` `perspective`; `stroke` `width`.
82
+ 48 recipes in 6 categories. **Lengths** to multiply by `textFontSize / 100`: `blur` `amount`; `box` `padX`, `padY`, `radius`; `glow` `size`; `position` `x`, `y`; `rotate3d` `perspective`; `stroke` `width`.
81
83
 
82
84
  **A fade-out exit for an `in` recipe**, ending with the layer: append this animator with unused ids. Set its `basedOn` to the recipe's first animator's, and `stagger` to 0.015 for letters, 0.04 for words or 0.08 for lines.
83
85
 
@@ -89,8 +91,9 @@ Type on Shape has no preferred preset: pick by the shape the text needs.
89
91
 
90
92
  | Recipe | Category | What it does | Unit | Plays |
91
93
  |---|---|---|---|---|
92
- | [Glide Soft](#glide-soft) | Sweep Reveal | Letters type in under a chasing blue band as the word glides through, slow in the middle, then slides off to the left | letters | in, the first 1.13 s (Glide in across the whole layer), then out at the end |
93
- | [Glide Soft Stop](#glide-soft-stop) | Sweep Reveal | The same typing and band, but the word all but stops in the middle of its glide, then slides off to the left | letters | in, the first 1.13 s (Glide in across the whole layer), then out at the end |
94
+ | [Glide Text](#glide-text) | Sweep Reveal | Letters type in as the word glides left under a chasing blue band and settles, then it slides off to the left | letters | in, the first 1.3 s, then out at the end |
95
+ | [Glide Continuous](#glide-continuous) | Sweep Reveal | Letters type in under a chasing blue band as the word keeps gliding through the whole layer, slow in the middle, then slides off to the left | letters | in, the first 1.13 s (Glide in across the whole layer), then out at the end |
96
+ | [Glide Soft Stop](#glide-soft-stop) | Sweep Reveal | The same typing and band, but the word glides through the whole layer and all but stops in the middle, then slides off to the left | letters | in, the first 1.13 s (Glide in across the whole layer), then out at the end |
94
97
  | [Ember Type](#ember-type) | Sweep Reveal | Letters type in falling softly; a warm band trails behind the typing | letters | in, the first 3 s |
95
98
  | [Molten Edge](#molten-edge) | Sweep Reveal | Letters land glowing hot at the typing edge and cool down behind it | letters | in, the first 2 s |
96
99
  | [Focus Type](#focus-type) | Sweep Reveal | A long soft wave of letters falls out of a blur under a cool gradient | letters | in, the first 2.9 s |
@@ -139,9 +142,46 @@ Type on Shape has no preferred preset: pick by the shape the text needs.
139
142
 
140
143
  ## Sweep Reveal
141
144
 
142
- ### Glide Soft
145
+ ### Glide Text
143
146
 
144
- Letters type in under a chasing blue band as the word glides through, slow in the middle, then slides off to the left. Plays: in, the first 1.13 s (Glide in across the whole layer), then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
147
+ Letters type in as the word glides left under a chasing blue band and settles, then it slides off to the left. Plays: in, the first 1.3 s, then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
148
+
149
+ ```jsonc
150
+ {
151
+ "textAnimators": [
152
+ { "aid": "a1", "name": "Type",
153
+ "range": {"basedOn": "charsNoSpace", "smooth": 0},
154
+ "effects": [{"pid": "p1", "type": "opacity", "opacity": 0}] },
155
+ { "aid": "a2", "name": "Blue band",
156
+ "range": {"basedOn": "charsNoSpace", "start": 0, "end": 100, "shape": "smooth", "easeHigh": 40},
157
+ "effects": [{"pid": "p2", "type": "fill", "color": "#3e59ad"}] },
158
+ { "aid": "a3", "name": "Glide in",
159
+ "range": {"basedOn": "charsNoSpace", "around": "all"},
160
+ "effects": [{"pid": "p3", "type": "position", "x": 250, "y": 0}] },
161
+ { "aid": "a4", "name": "Glide out",
162
+ "range": {"basedOn": "charsNoSpace", "drive": "time", "animate": "out", "around": "all", "stagger": 0, "duration": 0.5, "curve": "Power2 In"},
163
+ "effects": [{"pid": "p4", "type": "position", "x": -150, "y": 0}, {"pid": "p5", "type": "opacity", "opacity": 0}] }
164
+ ],
165
+ "propertyKeyframes": {
166
+ "txa:a1:start": [
167
+ { "position": 0, "value": "0", "easing": "AE In" }, // 0 s
168
+ { "position": 20, "value": "100", "easing": "Smooth" } // 1 s
169
+ ],
170
+ "txa:a2:offset": [
171
+ { "position": 6, "value": "-58", "easing": "Power1 Out" }, // 0.3 s
172
+ { "position": 26, "value": "101", "easing": "Smooth" } // 1.3 s
173
+ ],
174
+ "txa:a3:amount": [
175
+ { "position": 0, "value": "100", "easing": "AE In" }, // 0 s
176
+ { "position": 20, "value": "0", "easing": "Smooth" } // 1 s
177
+ ]
178
+ }
179
+ }
180
+ ```
181
+
182
+ ### Glide Continuous
183
+
184
+ Letters type in under a chasing blue band as the word keeps gliding through the whole layer, slow in the middle, then slides off to the left. Plays: in, the first 1.13 s (Glide in across the whole layer), then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
145
185
 
146
186
  ```jsonc
147
187
  {
@@ -178,7 +218,7 @@ Letters type in under a chasing blue band as the word glides through, slow in th
178
218
 
179
219
  ### Glide Soft Stop
180
220
 
181
- The same typing and band, but the word all but stops in the middle of its glide, then slides off to the left. Plays: in, the first 1.13 s (Glide in across the whole layer), then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
221
+ The same typing and band, but the word glides through the whole layer and all but stops in the middle, then slides off to the left. Plays: in, the first 1.13 s (Glide in across the whole layer), then out at the end. Designed for a `#05070a` background, left-aligned. Written for text like “External Storage”.
182
222
 
183
223
  ```jsonc
184
224
  {
@@ -131,6 +131,8 @@ Orders {{{COUNTER:0-2,480; style=roll; duration=2.5; easing=expo_out}}} in {{{TI
131
131
  - Energetic text (a title, hook, promo or social line, anything punchy, bouncy or kinetic): [video-text-kinetic.md](video-text-kinetic.md) — 21 in-and-out animations (Swing Pop and its family).
132
132
  - Everything else: [video-text-presets.md](video-text-presets.md) — 46 recipes. Sweep Reveal (typing under a colour band), Type on Shape (text typed onto a pill, card or bar that grows with it), Emphasis (one highlight pass), Loop (neon, rainbow, float, flicker), Characters (decode, binary, glitch) and Voice (caption karaoke on `textWordTimings`).
133
133
 
134
+ **Pace:** an entrance, an exit or a one-time pass takes at least 0.8–1 s by default, from the first unit starting to the last one settling. On Time that span is `(units − 1) × stagger + duration`. Go faster only when the edit needs it. The rule and its exceptions are in [video-projects.md](video-projects.md#text-animation-pace).
135
+
134
136
  The After Effects text-animator model on a text layer (stored as `textAnimators`). `item.textAnimators` is a list of animators; each one **selects** units of the text with a range (plus an optional wiggle) and applies its **effects** to them. Every effect value is what a unit gets at 100 % selection; the unit's selection amount (−1…1) scales it. What moves the selection is the range's `drive`: `range` (default) = Start / End / Offset, animated with `txa:` keyframe tracks or with `flow` for an endless keyframe-free travel; `time` = every unit takes its turn in seconds (a stagger, with In / Out and a highlight clock); `voice` = every unit takes its turn when it is spoken (captions). Renders identically in preview, `/render-onserver` and stills.
135
137
 
136
138
  `textAnimators` is the only text animation. Never write `textSplit*`, `splitIn*`, `splitOut*`, `textHighlight*` or `textCaret*` fields, or `textRevealProgress` / `textHighlightColor` tracks: the app ignores them. A stored project that still carries them is converted into `textAnimators` when the app loads it, so a `GET` of a project nobody has opened since may still show them.
@@ -258,9 +260,9 @@ Worked examples (spread onto a text item; durations assume a 4 s layer):
258
260
 
259
261
  // Fade Up (Time): words rise in 0.06 s apart, and leave upward at the end.
260
262
  "textAnimators": [
261
- { "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.6, "curve": "Power2 Smooth" },
263
+ { "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.8, "curve": "Power2 Smooth" },
262
264
  "effects": [ { "pid": "p1", "type": "position", "x": 0, "y": 50 }, { "pid": "p2", "type": "opacity", "opacity": 0 } ] },
263
- { "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.5, "curve": "Power2 Smooth" },
265
+ { "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.7, "curve": "Power2 Smooth" },
264
266
  "effects": [ { "pid": "p3", "type": "position", "x": 0, "y": -50 }, { "pid": "p4", "type": "opacity", "opacity": 0 } ] }]
265
267
 
266
268
  // Energetic in and out (Swing Pop and 20 more, each two Time animators): video-text-kinetic.md.
@@ -320,9 +322,9 @@ PUT /api/moodboards/{board_id}/video-projects/{project_id}
320
322
  "textFontWeight": 800, "textAlign": "center",
321
323
  "panX": 0, "panY": 35,
322
324
  "textAnimators": [
323
- { "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.6, "curve": "Power2 Smooth" },
325
+ { "aid": "a1", "name": "In", "range": { "drive": "time", "animate": "in", "basedOn": "words", "stagger": 0.06, "duration": 0.8, "curve": "Power2 Smooth" },
324
326
  "effects": [ { "pid": "p1", "type": "position", "x": 0, "y": 50 }, { "pid": "p2", "type": "opacity", "opacity": 0 } ] },
325
- { "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.5, "curve": "Power2 Smooth" },
327
+ { "aid": "a2", "name": "Out", "range": { "drive": "time", "animate": "out", "basedOn": "words", "stagger": 0.06, "duration": 0.7, "curve": "Power2 Smooth" },
326
328
  "effects": [ { "pid": "p3", "type": "position", "x": 0, "y": -50 }, { "pid": "p4", "type": "opacity", "opacity": 0 } ] }
327
329
  ]
328
330
  }