reelson 0.7.0

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE.md +15 -0
  4. package/README.md +180 -0
  5. package/bin/reelson.js +649 -0
  6. package/bin/reelson.ts +676 -0
  7. package/bin/run.js +5 -0
  8. package/demo.config.example.json +51 -0
  9. package/docs/demo.webp +0 -0
  10. package/docs/prompting.md +72 -0
  11. package/docs/style-guide.md +117 -0
  12. package/examples/demo.config.json +18 -0
  13. package/examples/todo-add-item/scenario.ts +45 -0
  14. package/examples/todo-add-item/video.json +35 -0
  15. package/music/README.md +7 -0
  16. package/package.json +68 -0
  17. package/skills/reelson-compose/SKILL.md +226 -0
  18. package/skills/reelson-compose/schemas/video.schema.json +172 -0
  19. package/skills/reelson-compose/scripts/build.js +424 -0
  20. package/skills/reelson-compose/scripts/build.ts +532 -0
  21. package/skills/reelson-compose/scripts/captions.js +24 -0
  22. package/skills/reelson-compose/scripts/captions.ts +40 -0
  23. package/skills/reelson-compose/scripts/check.js +80 -0
  24. package/skills/reelson-compose/scripts/check.ts +93 -0
  25. package/skills/reelson-compose/scripts/composition.js +163 -0
  26. package/skills/reelson-compose/scripts/composition.ts +241 -0
  27. package/skills/reelson-compose/scripts/hyperframes.js +138 -0
  28. package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
  29. package/skills/reelson-compose/scripts/portrait.js +150 -0
  30. package/skills/reelson-compose/scripts/portrait.ts +218 -0
  31. package/skills/reelson-compose/scripts/project.js +238 -0
  32. package/skills/reelson-compose/scripts/project.ts +324 -0
  33. package/skills/reelson-compose/scripts/studio.js +330 -0
  34. package/skills/reelson-compose/scripts/studio.ts +394 -0
  35. package/skills/reelson-compose/scripts/timeline.js +271 -0
  36. package/skills/reelson-compose/scripts/timeline.ts +514 -0
  37. package/skills/reelson-compose/scripts/tts.js +303 -0
  38. package/skills/reelson-compose/scripts/tts.ts +371 -0
  39. package/skills/reelson-compose/scripts/verify.js +190 -0
  40. package/skills/reelson-compose/scripts/verify.ts +209 -0
  41. package/skills/reelson-compose/scripts/voice.js +178 -0
  42. package/skills/reelson-compose/scripts/voice.ts +224 -0
  43. package/skills/reelson-compose/scripts/zooms.js +259 -0
  44. package/skills/reelson-compose/scripts/zooms.ts +333 -0
  45. package/skills/reelson-compose/sections/README.md +5 -0
  46. package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
  47. package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
  48. package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
  49. package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
  50. package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
  51. package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
  52. package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
  53. package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
  54. package/skills/reelson-compose/sections/intro/split/section.css +30 -0
  55. package/skills/reelson-compose/sections/intro/split/section.html +12 -0
  56. package/skills/reelson-compose/sections/intro/split/section.js +19 -0
  57. package/skills/reelson-compose/sections/intro/split/section.json +9 -0
  58. package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
  59. package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
  60. package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
  61. package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
  62. package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
  63. package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
  64. package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
  65. package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
  66. package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
  67. package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
  68. package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
  69. package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
  70. package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
  71. package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
  72. package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
  73. package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
  74. package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
  75. package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
  76. package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
  77. package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
  78. package/skills/reelson-compose/studio/index.html +81 -0
  79. package/skills/reelson-compose/studio/studio.css +142 -0
  80. package/skills/reelson-compose/studio/studio.js +1031 -0
  81. package/skills/reelson-compose/templates/README.md +193 -0
  82. package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
  83. package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
  84. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
  85. package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
  86. package/skills/reelson-compose/templates/classic/stage.html +447 -0
  87. package/skills/reelson-compose/templates/classic/template.json +5 -0
  88. package/skills/reelson-record/SKILL.md +220 -0
  89. package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
  90. package/skills/reelson-record/scripts/capture.js +142 -0
  91. package/skills/reelson-record/scripts/capture.ts +190 -0
  92. package/skills/reelson-record/scripts/config.js +148 -0
  93. package/skills/reelson-record/scripts/config.ts +264 -0
  94. package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
  95. package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
  96. package/skills/reelson-record/scripts/doctor.js +128 -0
  97. package/skills/reelson-record/scripts/doctor.ts +147 -0
  98. package/skills/reelson-record/scripts/languages.js +29 -0
  99. package/skills/reelson-record/scripts/languages.ts +41 -0
  100. package/skills/reelson-record/scripts/record.js +365 -0
  101. package/skills/reelson-record/scripts/record.ts +451 -0
  102. package/skills/reelson-record/scripts/scenario.js +356 -0
  103. package/skills/reelson-record/scripts/scenario.ts +570 -0
  104. package/skills/reelson-record/scripts/validate.js +143 -0
  105. package/skills/reelson-record/scripts/validate.ts +179 -0
@@ -0,0 +1,447 @@
1
+ <!doctype html>
2
+ <html lang="{{LANG}}">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width={{STAGE_W}}, height={{STAGE_H}}" />
6
+ <!-- Vendored (assets/vendor/, copied by reelson build): renders work offline and never drift. -->
7
+ <script src="assets/vendor/gsap.min.js"></script>
8
+ <style>
9
+ /* Inter variable (SIL OFL, assets/vendor/Inter-OFL.txt): latin + latin-ext (ș ț ă ő ł …) */
10
+ @font-face { font-family: 'Inter Variable'; font-style: normal; font-weight: 100 900; font-display: block;
11
+ src: url(assets/vendor/inter-latin-ext-wght-normal.woff2) format('woff2-variations');
12
+ unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF; }
13
+ @font-face { font-family: 'Inter Variable'; font-style: normal; font-weight: 100 900; font-display: block;
14
+ src: url(assets/vendor/inter-latin-wght-normal.woff2) format('woff2-variations');
15
+ unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD; }
16
+ * { margin: 0; padding: 0; box-sizing: border-box; }
17
+ html, body { width: {{STAGE_W}}px; height: {{STAGE_H}}px; overflow: hidden; background: #0b1220; }
18
+ /* Design tokens: the ONLY things sections may assume about the stage (templates/README.md). */
19
+ :root {
20
+ --brand: {{BRAND_COLOR}}; /* demo.config.json brand.color */
21
+ --brand-soft: {{BRAND_COLOR_SOFT}};
22
+ --ink: #f8fafc;
23
+ --muted: #94a3b8;
24
+ --line: rgba(255, 255, 255, 0.10);
25
+ --surface: rgba(255, 255, 255, 0.04);
26
+ --bg: #0b1220;
27
+ --font: 'Inter Variable', Inter, ui-sans-serif, system-ui, sans-serif;
28
+ --stage-h: {{STAGE_H}}px;
29
+ --band-zoom: {{BAND_ZOOM}}; /* portrait, square: 16:9 cards zoomed to the stage width */
30
+ }
31
+ #root { position: relative; width: {{STAGE_W}}px; height: {{STAGE_H}}px; overflow: hidden; font-family: var(--font); color: var(--ink);
32
+ background: linear-gradient(160deg, #0b1220 0%, #101a2e 50%, #0c1424 100%); }
33
+ .clip { position: absolute; inset: 0; }
34
+
35
+ /* Background: four soft glows that drift and breathe (GSAP, yoyo) — slow but perceptible —
36
+ plus a faint dot grid. #glows is a wrapper so the intro can settle the whole layer at once. */
37
+ #bg { pointer-events: none; }
38
+ #glows { position: absolute; inset: 0; will-change: transform; }
39
+ .glow { position: absolute; border-radius: 50%; filter: blur(100px); will-change: transform; }
40
+ #glow-a { width: 1200px; height: 1200px; left: -320px; top: -560px; background: radial-gradient(circle, color-mix(in srgb, var(--brand) 58%, transparent), transparent 64%); }
41
+ #glow-b { width: 1100px; height: 1100px; right: -420px; bottom: -600px; background: radial-gradient(circle, rgba(56, 120, 246, 0.50), rgba(56, 120, 246, 0) 64%); }
42
+ #glow-c { width: 900px; height: 900px; right: 120px; top: -520px; background: radial-gradient(circle, rgba(139, 92, 246, 0.38), rgba(139, 92, 246, 0) 64%); }
43
+ #glow-d { width: 800px; height: 800px; left: 260px; bottom: -560px; background: radial-gradient(circle, rgba(20, 184, 166, 0.30), rgba(20, 184, 166, 0) 64%); }
44
+ #grid { position: absolute; inset: 0; opacity: 0.35;
45
+ background-image: radial-gradient(rgba(255, 255, 255, 0.16) 1.2px, transparent 1.4px);
46
+ background-size: 36px 36px; background-position: 18px 18px;
47
+ -webkit-mask-image: radial-gradient(ellipse at center, #000 35%, transparent 80%);
48
+ mask-image: radial-gradient(ellipse at center, #000 35%, transparent 80%); }
49
+
50
+ /* Brand pieces any section can use. .wordmark[data-text] is split into letters (.l) by the
51
+ stage script — or, when brand.logo is set, holds the logo as a single .l.logo image sized
52
+ to the wordmark's font-size, so letter animations work on both. */
53
+ .wordmark { display: flex; align-items: center; gap: 0.02em; font-size: 168px; font-weight: 900; letter-spacing: -0.02em; line-height: 1; }
54
+ .wordmark .l { display: inline-block; will-change: transform, opacity, filter; }
55
+ .wordmark .l.space { width: 0.32em; }
56
+ .wordmark .l.logo { display: block; height: 1em; width: auto; max-width: 9em; object-fit: contain; }
57
+ .brand-sub:empty { display: none; }
58
+ .brand-sub { font-size: 44px; font-weight: 600; letter-spacing: 0.42em; color: var(--muted); padding-left: 0.42em; will-change: transform, opacity; }
59
+ .brand-line { width: 220px; height: 6px; border-radius: 3px; background: linear-gradient(90deg, var(--brand), var(--brand-soft)); transform-origin: center; will-change: transform, opacity; }
60
+ .eyebrow { display: inline-flex; align-items: center; gap: 14px; font-size: 24px; font-weight: 600;
61
+ letter-spacing: 0.2em; text-transform: uppercase; color: var(--muted); }
62
+ .eyebrow::before { content: ""; width: 14px; height: 14px; border-radius: 4px; background: var(--brand);
63
+ box-shadow: 0 0 24px color-mix(in srgb, var(--brand) 70%, transparent); }
64
+ .accent { width: 140px; height: 6px; border-radius: 3px; background: linear-gradient(90deg, var(--brand), var(--brand-soft)); transform-origin: left center; }
65
+ /* "4 steps · 27 seconds" chip (DEMO.chip) */
66
+ .meta { display: inline-flex; align-items: center; gap: 12px; padding: 12px 22px; border-radius: 999px;
67
+ border: 1px solid var(--line); background: var(--surface); color: var(--muted); font-size: 24px; font-weight: 500; width: max-content; }
68
+ .meta b { color: var(--ink); font-weight: 600; }
69
+ /* Numbered badge (callouts, recap steps) */
70
+ .n { display: grid; place-items: center; width: 44px; height: 44px; border-radius: 50%; background: var(--brand);
71
+ font-size: 24px; font-weight: 700; flex: none; }
72
+
73
+ /* Framed recording */
74
+ #screen { display: grid; place-items: center; }
75
+ #frame { position: relative; width: {{FRAME_W}}px; height: {{FRAME_H}}px; border-radius: 20px; overflow: hidden;
76
+ /* No will-change here: it makes Chrome rasterize the frame once at 1x and stretch that
77
+ bitmap during zooms, which blurs the 2x capture. */
78
+ background: #fff; border: 1px solid rgba(255, 255, 255, 0.14);
79
+ box-shadow: 0 50px 140px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.35); }
80
+ /* The footage: fills the frame in landscape and square; in portrait it can be bigger than
81
+ the frame and moves with the camera (DEMO.layout.camera). Videos and the cursor layer ride inside it. */
82
+ #footage { position: absolute; left: 0; top: 0; width: 100%; height: 100%; }
83
+ #root.portrait #footage { width: {{FOOTAGE_W}}px; height: {{FOOTAGE_H}}px; }
84
+ #frame video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
85
+
86
+ /* Portrait (1080x1920): sections lay themselves out for the tall stage (their
87
+ `#root.portrait …` rules); one without (class "band") keeps its 16:9 layout, zoomed to
88
+ the stage width and centred. The callouts sit below the tall recording frame. */
89
+ #root.portrait > .band, #root.square > .band {
90
+ inset: auto; left: 0; top: calc((var(--stage-h) - 1080px * var(--band-zoom)) / 2 / var(--band-zoom));
91
+ width: 1920px; height: 1080px; zoom: var(--band-zoom); }
92
+ #root.portrait .transition-card .stack { gap: 56px; padding: 0 80px; }
93
+ #root.portrait .transition-card .title { font-size: 96px; max-width: 920px; }
94
+ #root.portrait .transition-card .subtitle { font-size: 40px; max-width: 900px; }
95
+ #root.portrait .role { font-size: 38px; padding: 18px 36px; }
96
+ #root.portrait .roles .arrow { width: 90px; }
97
+ #root.portrait #screen { place-items: center; padding-bottom: 210px; } /* centred above the callouts */
98
+ /* A phone take: the screen as tall as it fits, the callout just below it */
99
+ #root.portrait.phone #screen { place-items: start center; padding: 40px 0 0; }
100
+ #root.portrait.phone .callout { bottom: 56px; }
101
+ #root.portrait .callout { bottom: 70px; font-size: 40px; max-width: 980px; white-space: normal; text-align: left; }
102
+
103
+ /* Square (1080x1080): the square take fills the stage edge to edge — no bands, no margin.
104
+ The callouts ride over the bottom of the footage, as in landscape. */
105
+ #root.square #frame { border: 0; border-radius: 0; box-shadow: none; }
106
+ #root.square #footage { width: {{FOOTAGE_W}}px; height: {{FOOTAGE_H}}px; left: 50%; top: 50%; translate: -50% -50%; }
107
+ #root.square .callout { bottom: 40px; font-size: 34px; max-width: 1000px; white-space: normal; text-align: left; }
108
+ #root.square .transition-card .stack { gap: 48px; padding: 0 70px; }
109
+ #root.square .transition-card .title { font-size: 84px; max-width: 940px; }
110
+ #root.square .transition-card .subtitle { font-size: 36px; max-width: 900px; }
111
+ #root.square .role { font-size: 32px; padding: 16px 30px; }
112
+ #root.square .roles .arrow { width: 90px; }
113
+
114
+ /* Cursor layer (DEMO.cursor): the recorder logs the cursor instead of filming it, and
115
+ the stage draws it — inside #frame, so it rides with the footage. */
116
+ .cursor-layer { position: absolute; inset: 0; pointer-events: none; }
117
+ .cursor { position: absolute; left: 0; top: 0;
118
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.35)) drop-shadow(0 8px 16px rgba(0, 0, 0, 0.25)); }
119
+ .cursor svg { display: block; }
120
+ .cursor-ripple { position: absolute; border-radius: 50%; opacity: 0; }
121
+ .cursor-ripple.a { border: 4px solid var(--brand); background: color-mix(in srgb, var(--brand) 22%, transparent); }
122
+ .cursor-ripple.b { border: 2px solid color-mix(in srgb, var(--brand) 90%, transparent); }
123
+
124
+ /* Transition card: the hand-off between two actors (demo.transition() in the scenario).
125
+ The recording leaves through the top, this card rides in on the same belt, then the
126
+ recording comes back from below with the next actor. */
127
+ .transition-card { display: grid; place-items: center; }
128
+ .transition-card .stack { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 40px; will-change: transform, opacity; }
129
+ .roles { display: inline-flex; align-items: center; gap: 28px; }
130
+ .role { padding: 16px 34px; border-radius: 999px; border: 1px solid var(--line); background: rgba(255, 255, 255, 0.05);
131
+ font-size: 34px; font-weight: 600; color: var(--muted); will-change: transform, opacity; }
132
+ .role.to { position: relative; color: var(--ink); overflow: hidden; }
133
+ .role.to .fill { position: absolute; inset: 0; border-radius: inherit; background: var(--brand); transform-origin: left center; will-change: transform; }
134
+ .role.to span { position: relative; }
135
+ .roles .arrow { width: 120px; height: 4px; border-radius: 2px; background: linear-gradient(90deg, rgba(255, 255, 255, 0.25), var(--brand-soft));
136
+ transform-origin: left center; position: relative; will-change: transform; }
137
+ .roles .arrow::after { content: ""; position: absolute; right: -2px; top: -9px; border: 11px solid transparent; border-left: 16px solid var(--brand-soft); border-right: 0; }
138
+ .transition-card .title { font-size: 88px; font-weight: 800; letter-spacing: -0.04em; line-height: 1.05; max-width: 1400px; text-align: center; }
139
+ .transition-card .subtitle { font-size: 34px; font-weight: 400; color: var(--muted); max-width: 1300px; text-align: center; }
140
+
141
+ /* Callouts: numbered pills along the bottom of the stage (outside #frame so zooms don't scale them) */
142
+ .callout { position: absolute; left: 0; right: 0; bottom: 34px; margin: 0 auto; width: max-content; display: inline-flex; align-items: center;
143
+ gap: 18px; padding: 18px 34px 18px 22px; border-radius: 999px; background: rgba(11, 18, 32, 0.94); color: var(--ink);
144
+ font-size: 34px; font-weight: 500; letter-spacing: -0.01em; white-space: nowrap;
145
+ border: 1px solid var(--line); box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5); opacity: 0; }
146
+ /* Over what the demo works on at the bottom (DEMO.callouts[].top): at the top instead */
147
+ .callout.top { top: 34px; bottom: auto; }
148
+ #root.square .callout.top { top: 40px; bottom: auto; }
149
+ /* Who is acting (two-actor videos): a small role tag before the step text */
150
+ .callout .who { font-size: 20px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: var(--brand-soft);
151
+ padding-right: 18px; border-right: 1px solid var(--line); }
152
+
153
+ /* ---- Sections (intro, recap, outro), each scoped under its #slot id ---- */
154
+ {{SECTION_STYLES}}
155
+ </style>
156
+ </head>
157
+ <body>
158
+ <div id="root" class="{{FORMAT}}" data-composition-id="main" data-start="0" data-duration="{{TOTAL}}" data-width="{{STAGE_W}}" data-height="{{STAGE_H}}">
159
+
160
+ <!-- Background (untimed, always on) -->
161
+ <div id="bg" class="clip">
162
+ <div id="glows">
163
+ <div id="glow-a" class="glow"></div>
164
+ <div id="glow-b" class="glow"></div>
165
+ <div id="glow-c" class="glow"></div>
166
+ <div id="glow-d" class="glow"></div>
167
+ </div>
168
+ <div id="grid"></div>
169
+ </div>
170
+
171
+ <!-- 1. Intro section: 0 → DEMO.sections.intro.duration. Frame 0 is the poster. -->
172
+ {{INTRO}}
173
+
174
+ <!-- 2. Screen recording, framed and centred. The wrapper is NOT timed (lint:
175
+ a timed <video> must not sit inside a timed element); the <video>
176
+ carries the timing and #frame's transform/opacity are animated to match. -->
177
+ <section id="screen" class="clip">
178
+ <div id="frame">
179
+ <div id="footage">
180
+ {{VIDEOS}}
181
+ </div>
182
+ </div>
183
+ <div id="callouts"></div>
184
+ </section>
185
+ {{TRANSITIONS}}
186
+ {{AUDIO}}
187
+ {{MUSIC}}
188
+
189
+ <!-- 3. Recap section (absent when video.json says "recap": "none") -->
190
+ {{RECAP}}
191
+
192
+ <!-- 4. Outro section: ends on the brand, never on an empty frame -->
193
+ {{OUTRO}}
194
+ </div>
195
+
196
+ <script>
197
+ /* ------------------------------------------------------------------
198
+ * Generated by `reelson build` from video.json — edit video.json and
199
+ * rebuild; changes made here are overwritten. Times are composition
200
+ * seconds (intro included):
201
+ * sections: { intro: { start, duration, exit }, recap: { start, duration, maxSteps } | null,
202
+ * outro: { start, duration } }
203
+ * callouts: [{ at, duration, text, group?, top? }] numbered step pills (top: shown at the
204
+ * top — at the bottom it would cover what the demo works on)
205
+ * zooms: [{ at, duration, x, y, scale, in, out, path? }] x/y = 0..1 focus; path = [[t, x, y]] follow
206
+ * cursor: { size, ripple, idle, scale, path: [[t, x, y]], presses: [[t, x, y]] } | null
207
+ * the logged cursor (x/y recording px, × scale = frame px); null = filmed in
208
+ * transitions: [{ at, gap }] hand-off cards (fully in at `at`)
209
+ * layout: { format, stage: { width, height }, bandZoom, camera: [[t, k, x, y, h]] }
210
+ * format: landscape | portrait | square; camera: the portrait camera (else [])
211
+ * chip: { steps, seconds } "4 steps · 27 seconds", already pluralised
212
+ * ------------------------------------------------------------------ */
213
+ const DEMO = {{DEMO}};
214
+
215
+ /* ---- Stage builder: normally nothing below needs editing. ---- */
216
+ window.__timelines = window.__timelines || {};
217
+ const tl = gsap.timeline({ paused: true });
218
+ const FADE = 0.5;
219
+ // GSAP fromTo() renders its from-state at construction (immediateRender). For
220
+ // every 'out' tween that must stay off, or it overwrites the entry state at t=0.
221
+ const later = { immediateRender: false };
222
+
223
+ // Text from video.json is set with textContent, never innerHTML: "<", "&" stay literal.
224
+ const node = (tag, className, text) => {
225
+ const el = document.createElement(tag);
226
+ if (className) el.className = className;
227
+ if (text !== undefined) el.textContent = text;
228
+ return el;
229
+ };
230
+
231
+ // Background: glows drift and breathe on sine curves for the whole video — slow but
232
+ // perceptible, never busy — and the whole layer settles from a zoomed, slightly
233
+ // rotated pose over the intro so the opening feels alive.
234
+ // The drifts live on their own timeline, played exactly to the end, so the
235
+ // composition's timeline is never longer than the video.
236
+ const drift = gsap.timeline({ paused: true });
237
+ const breathe = (sel, vars, period) =>
238
+ drift.fromTo(sel, { x: 0, y: 0, scale: 1 }, { ...vars, duration: period, ease: 'sine.inOut', yoyo: true, repeat: Math.ceil(DEMO.total / period) }, 0);
239
+ breathe('#glow-a', { x: 560, y: 340, scale: 1.28 }, 4.8);
240
+ breathe('#glow-b', { x: -520, y: -320, scale: 1.22 }, 5.6);
241
+ breathe('#glow-c', { x: -420, y: 300, scale: 1.35 }, 4.2);
242
+ breathe('#glow-d', { x: 460, y: -260, scale: 1.3 }, 6.4);
243
+ tl.add(drift.tweenFromTo(0, DEMO.total, { duration: DEMO.total, ease: 'none' }), 0);
244
+ tl.fromTo('#glows', { scale: 1.4, rotation: -9 }, { scale: 1, rotation: 0, duration: DEMO.sections.intro.duration - 0.2, ease: 'power2.out' }, 0);
245
+
246
+ // Wordmarks: split into letters once so sections can animate them — or, with a brand
247
+ // logo, one .l.logo image (the same letter tweens then move the whole logo).
248
+ document.querySelectorAll('.wordmark').forEach((el) => {
249
+ if (el.dataset.logo) {
250
+ const img = node('img', 'l logo');
251
+ img.src = el.dataset.logo;
252
+ img.alt = el.dataset.text;
253
+ el.replaceChildren(img);
254
+ return;
255
+ }
256
+ el.replaceChildren(...[...el.dataset.text].map((ch) =>
257
+ ch === ' ' ? node('span', 'l space') : node('span', 'l', ch)));
258
+ });
259
+ // Every "4 steps · 27 seconds" chip.
260
+ document.querySelectorAll('.meta[data-chip]').forEach((el) => {
261
+ el.replaceChildren(node('b', '', DEMO.chip.steps), node('span', '', '·'), node('span', '', DEMO.chip.seconds));
262
+ });
263
+
264
+ // What sections may do to the framed recording. The intro calls stage.enter() once, at
265
+ // its hand-over (usually section.exit); the stage owns #frame because zooms move it too.
266
+ const BELT = 0.9;
267
+ // How far the recording and a hand-off card travel to leave the stage.
268
+ const BELT_Y = DEMO.layout.stage.height + 70;
269
+ const CARD_BELT_Y = BELT_Y;
270
+ const stage = {
271
+ entered: false,
272
+ enter(at, style = 'fade') {
273
+ stage.entered = true;
274
+ if (style === 'belt') { // rides in from below (pair with an intro leaving through the top)
275
+ tl.fromTo('#frame', { opacity: 1, y: BELT_Y }, { opacity: 1, y: 0, duration: BELT, ease: 'power3.inOut', ...later }, at);
276
+ } else if (style === 'zoom') { // grows out of the middle
277
+ tl.fromTo('#frame', { opacity: 0, scale: 0.84, y: 30 }, { opacity: 1, scale: 1, y: 0, duration: 0.9, ease: 'power3.out', ...later }, at);
278
+ } else { // 'fade'
279
+ tl.fromTo('#frame', { opacity: 0 }, { opacity: 1, duration: 0.6, ease: 'power2.out', ...later }, at);
280
+ }
281
+ },
282
+ };
283
+
284
+ // 2. Screen: fades out into the recap / outro.
285
+ tl.fromTo('#screen', { opacity: 1 }, { opacity: 0, duration: FADE, ease: 'power2.inOut', ...later }, DEMO.clipStart + DEMO.clipDuration - FADE);
286
+
287
+ // Hand-off cards: recording out through the top, card in on the same belt, hold,
288
+ // card out through the top, recording back in from below. The "to" role fills red.
289
+ DEMO.transitions.forEach((t, i) => {
290
+ const card = '#transition-' + i;
291
+ tl.fromTo('#frame', { y: 0 }, { y: -BELT_Y, duration: BELT, ease: 'power3.inOut', ...later }, t.at - BELT);
292
+ tl.fromTo(card + ' .stack', { y: CARD_BELT_Y }, { y: 0, duration: BELT, ease: 'power3.inOut', ...later }, t.at - BELT);
293
+ tl.fromTo(card + ' .role.from', { opacity: 0, x: -30 }, { opacity: 1, x: 0, duration: 0.5, ease: 'power3.out', ...later }, t.at - 0.3);
294
+ tl.fromTo(card + ' .arrow', { scaleX: 0 }, { scaleX: 1, duration: 0.6, ease: 'power3.inOut', ...later }, t.at);
295
+ tl.fromTo(card + ' .role.to', { opacity: 0, x: 30 }, { opacity: 1, x: 0, duration: 0.5, ease: 'power3.out', ...later }, t.at + 0.35);
296
+ tl.fromTo(card + ' .role.to .fill', { scaleX: 0 }, { scaleX: 1, duration: 0.6, ease: 'power3.inOut', ...later }, t.at + 0.7);
297
+ tl.fromTo(card + ' .subtitle', { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.5, ease: 'power3.out', ...later }, t.at + 0.2);
298
+ tl.fromTo(card + ' .stack', { y: 0 }, { y: -CARD_BELT_Y, duration: BELT, ease: 'power3.inOut', ...later }, t.at + t.gap);
299
+ tl.fromTo('#frame', { y: BELT_Y }, { y: 0, duration: BELT, ease: 'power3.inOut', ...later }, t.at + t.gap);
300
+ gsap.set(card + ' .stack', { y: CARD_BELT_Y });
301
+ gsap.set([card + ' .role', card + ' .subtitle'], { opacity: 0 });
302
+ gsap.set([card + ' .arrow', card + ' .role.to .fill'], { scaleX: 0 });
303
+ });
304
+
305
+ // Callouts.
306
+ const host = document.getElementById('callouts');
307
+ DEMO.callouts.forEach((c, i) => {
308
+ const el = document.createElement('div');
309
+ el.className = c.top ? 'callout top' : 'callout';
310
+ el.id = 'callout-' + i;
311
+ el.append(node('span', 'n', String(i + 1)));
312
+ if (c.group) el.append(node('span', 'who', c.group));
313
+ el.append(node('span', '', c.text));
314
+ host.appendChild(el);
315
+ tl.fromTo(el, { opacity: 0, y: c.top ? -24 : 24 }, { opacity: 1, y: 0, duration: 0.4, ease: 'power3.out', ...later }, c.at);
316
+ tl.fromTo(el, { opacity: 1 }, { opacity: 0, duration: 0.3, ease: 'power2.in', ...later }, c.at + c.duration - 0.3);
317
+ });
318
+
319
+ // Zooms on the framed recording (transform-origin at the focus point). A zoom with a
320
+ // `path` follows the cursor: its origin glides along the path instead of staying put.
321
+ const originOf = (x, y) => (x * 100) + '% ' + (y * 100) + '%';
322
+ DEMO.zooms.forEach((z) => {
323
+ const origin = originOf(z.x, z.y);
324
+ const zoomIn = z.in ?? 0.8;
325
+ const zoomOut = z.out ?? 0.8;
326
+ const fixed = z.path ? {} : { transformOrigin: origin };
327
+ tl.fromTo('#frame', { scale: 1 }, { scale: z.scale, ...fixed, duration: zoomIn, ease: 'power2.inOut', ...later }, z.at);
328
+ tl.fromTo('#frame', { scale: z.scale }, { scale: 1, ...fixed, duration: zoomOut, ease: 'power2.inOut', ...later }, z.at + z.duration - zoomOut);
329
+ if (z.path) {
330
+ for (let i = 1; i < z.path.length; i++) {
331
+ const a = z.path[i - 1], b = z.path[i];
332
+ tl.fromTo('#frame', { transformOrigin: originOf(a[1], a[2]) }, { transformOrigin: originOf(b[1], b[2]), duration: b[0] - a[0], ease: 'none', ...later }, a[0]);
333
+ }
334
+ }
335
+ });
336
+
337
+ // Portrait camera (DEMO.layout.camera, [t, k, x, y, h]): the footage scales and moves
338
+ // inside the frame to frame what the demo works on; the frame's height follows it.
339
+ const cam = DEMO.layout.camera;
340
+ const cameraAt = (t) => {
341
+ if (!cam.length) return 1;
342
+ const i = cam.findIndex((c) => c[0] > t);
343
+ if (i <= 0) return cam[i === -1 ? cam.length - 1 : 0][1];
344
+ const a = cam[i - 1], b = cam[i];
345
+ return a[1] + (b[1] - a[1]) * ((t - a[0]) / (b[0] - a[0]));
346
+ };
347
+ if (cam.length) {
348
+ gsap.set('#footage', { transformOrigin: '0 0', scale: cam[0][1], x: cam[0][2], y: cam[0][3] });
349
+ gsap.set('#frame', { height: cam[0][4] });
350
+ for (let i = 1; i < cam.length; i++) {
351
+ const a = cam[i - 1], b = cam[i], duration = b[0] - a[0];
352
+ tl.fromTo('#footage', { scale: a[1], x: a[2], y: a[3] }, { scale: b[1], x: b[2], y: b[3], duration, ease: 'none', ...later }, a[0]);
353
+ if (a[4] !== b[4]) tl.fromTo('#frame', { height: a[4] }, { height: b[4], duration, ease: 'none', ...later }, a[0]);
354
+ }
355
+ }
356
+
357
+ // Cursor layer: every logged move is a tween to the next point (a glide's moves are
358
+ // 20–50 ms apart; a longer gap is a jump: a cut, a new page). During a zoom the arrow
359
+ // is counter-scaled, so it keeps its size while the footage grows.
360
+ if (DEMO.cursor) {
361
+ const C = DEMO.cursor;
362
+ const size = C.size * C.scale;
363
+ const tip = { x: (5.5 / 24) * size, y: (2.5 / 24) * size }; // the arrow's point in its 24-unit box
364
+ const layer = node('div', 'cursor-layer');
365
+ const cursor = node('div', 'cursor');
366
+ cursor.innerHTML = '<svg viewBox="0 0 24 24" width="' + size + '" height="' + size + '">'
367
+ + '<path d="M5.5 2.5v17.4l4.6-4.4 2.9 6.5 3-1.3-2.9-6.4h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>';
368
+ const arrow = cursor.firstChild;
369
+ const E = gsap.parseEase('power2.inOut'); // the zooms' ease
370
+ const zoomAt = (t) => {
371
+ for (const z of DEMO.zooms) {
372
+ const zin = z.in ?? 0.8, zout = z.out ?? 0.8, end = z.at + z.duration;
373
+ if (t >= z.at && t < z.at + zin) return 1 + (z.scale - 1) * E((t - z.at) / zin);
374
+ if (t >= z.at + zin && t < end - zout) return z.scale;
375
+ if (t >= end - zout && t < end) return 1 + (z.scale - 1) * (1 - E((t - (end - zout)) / zout));
376
+ }
377
+ return 1;
378
+ };
379
+ const place = (p) => ({ x: p[1] * C.scale - tip.x, y: p[2] * C.scale - tip.y });
380
+
381
+ if (C.ripple) {
382
+ C.presses.forEach((p) => {
383
+ const counter = 1 / (zoomAt(p[0]) * cameraAt(p[0]));
384
+ [['a', 0.55, 0], ['b', 0.8, 0.08]].forEach(([kind, duration, delay]) => {
385
+ const ring = node('div', 'cursor-ripple ' + kind);
386
+ const d = 84 * C.scale;
387
+ Object.assign(ring.style, { width: d + 'px', height: d + 'px', left: p[1] * C.scale - d / 2 + 'px', top: p[2] * C.scale - d / 2 + 'px' });
388
+ layer.append(ring);
389
+ tl.fromTo(ring, { scale: 0.2 * counter, opacity: 1 }, { scale: 1.25 * counter, opacity: 0, duration, ease: 'power3.out', ...later }, p[0] + delay);
390
+ });
391
+ });
392
+ }
393
+ layer.append(cursor);
394
+ document.getElementById('footage').append(layer);
395
+ if (C.touch) cursor.style.visibility = 'hidden'; // a phone take: the taps only
396
+
397
+ gsap.set(cursor, { ...place(C.path[0]), transformOrigin: tip.x + 'px ' + tip.y + 'px' });
398
+ gsap.set(arrow, { transformOrigin: tip.x + 'px ' + tip.y + 'px' });
399
+ for (let i = 1; i < C.path.length; i++) {
400
+ const a = C.path[i - 1], b = C.path[i], gap = b[0] - a[0];
401
+ const glide = gap <= 0.1;
402
+ tl.fromTo(cursor, place(a), { ...place(b), duration: glide ? Math.max(gap, 0.001) : 0.001, ease: 'none', ...later }, glide ? a[0] : b[0] - 0.001);
403
+ }
404
+ // Counter-scale: a custom ease makes the arrow's scale exactly 1 / the frame's scale.
405
+ DEMO.zooms.forEach((z) => {
406
+ const zin = z.in ?? 0.8, zout = z.out ?? 0.8, k = 1 / z.scale;
407
+ const inverse = (u) => 1 / (1 + (z.scale - 1) * u);
408
+ tl.fromTo(cursor, { scale: 1 }, { scale: k, duration: zin, ease: (p) => (inverse(E(p)) - 1) / (k - 1), ...later }, z.at);
409
+ tl.fromTo(cursor, { scale: k }, { scale: 1, duration: zout, ease: (p) => (inverse(1 - E(p)) - k) / (1 - k), ...later }, z.at + z.duration - zout);
410
+ });
411
+ // The portrait camera scales the footage: keep the arrow its size.
412
+ for (let i = 1; i < cam.length; i++) {
413
+ const a = cam[i - 1], b = cam[i];
414
+ if (a[1] !== b[1]) tl.fromTo(cursor, { scale: 1 / a[1] }, { scale: 1 / b[1], duration: b[0] - a[0], ease: 'none', ...later }, a[0]);
415
+ }
416
+ if (cam.length) gsap.set(cursor, { scale: 1 / cam[0][1] });
417
+ // Idle: after C.idle s without a move or a press, fade out; back in as it moves again.
418
+ if (C.idle > 0) {
419
+ const active = [...C.path.map((p) => p[0]), ...C.presses.map((p) => p[0])].sort((a, b) => a - b);
420
+ for (let i = 0; i < active.length; i++) {
421
+ const quiet = active[i] + C.idle;
422
+ const next = active[i + 1] ?? Infinity;
423
+ if (next - quiet < 0.6) continue; // too short a rest to be worth hiding
424
+ tl.fromTo(cursor, { opacity: 1 }, { opacity: 0, duration: 0.3, ease: 'power1.in', ...later }, quiet);
425
+ if (next !== Infinity) tl.fromTo(cursor, { opacity: 0 }, { opacity: 1, duration: 0.2, ease: 'power1.out', ...later }, next - 0.25);
426
+ }
427
+ }
428
+ // The press squash.
429
+ C.presses.forEach((p) => {
430
+ tl.fromTo(arrow, { scale: 1 }, { scale: 0.82, duration: 0.09, ease: 'power1.out', ...later }, p[0]);
431
+ tl.fromTo(arrow, { scale: 0.82 }, { scale: 1, duration: 0.13, ease: 'power1.out', ...later }, p[0] + 0.09);
432
+ });
433
+ }
434
+
435
+ /* ---- Sections: each runs with `section` = its DEMO.sections entry, and may use
436
+ * tl, DEMO, FADE, later, node, stage. ---- */
437
+ {{SECTION_SCRIPTS}}
438
+
439
+ // An intro that never handed over still gets the recording on screen.
440
+ if (!stage.entered) stage.enter(DEMO.clipStart, 'fade');
441
+ gsap.set('#frame', { opacity: 0 }); // the recording card must not sit over the intro
442
+
443
+ window.__timelines.main = tl;
444
+ tl.seek(0);
445
+ </script>
446
+ </body>
447
+ </html>
@@ -0,0 +1,5 @@
1
+ {
2
+ "description": "Deep navy gradient with four drifting glows and a faint dot grid; the recording in a rounded frame with numbered callout pills and hand-off cards.",
3
+ "sections": { "intro": "poster", "recap": "steps", "outro": "wordmark" },
4
+ "timing": {}
5
+ }