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.
- package/CHANGELOG.md +99 -0
- package/LICENSE +21 -0
- package/NOTICE.md +15 -0
- package/README.md +180 -0
- package/bin/reelson.js +649 -0
- package/bin/reelson.ts +676 -0
- package/bin/run.js +5 -0
- package/demo.config.example.json +51 -0
- package/docs/demo.webp +0 -0
- package/docs/prompting.md +72 -0
- package/docs/style-guide.md +117 -0
- package/examples/demo.config.json +18 -0
- package/examples/todo-add-item/scenario.ts +45 -0
- package/examples/todo-add-item/video.json +35 -0
- package/music/README.md +7 -0
- package/package.json +68 -0
- package/skills/reelson-compose/SKILL.md +226 -0
- package/skills/reelson-compose/schemas/video.schema.json +172 -0
- package/skills/reelson-compose/scripts/build.js +424 -0
- package/skills/reelson-compose/scripts/build.ts +532 -0
- package/skills/reelson-compose/scripts/captions.js +24 -0
- package/skills/reelson-compose/scripts/captions.ts +40 -0
- package/skills/reelson-compose/scripts/check.js +80 -0
- package/skills/reelson-compose/scripts/check.ts +93 -0
- package/skills/reelson-compose/scripts/composition.js +163 -0
- package/skills/reelson-compose/scripts/composition.ts +241 -0
- package/skills/reelson-compose/scripts/hyperframes.js +138 -0
- package/skills/reelson-compose/scripts/hyperframes.ts +157 -0
- package/skills/reelson-compose/scripts/portrait.js +150 -0
- package/skills/reelson-compose/scripts/portrait.ts +218 -0
- package/skills/reelson-compose/scripts/project.js +238 -0
- package/skills/reelson-compose/scripts/project.ts +324 -0
- package/skills/reelson-compose/scripts/studio.js +330 -0
- package/skills/reelson-compose/scripts/studio.ts +394 -0
- package/skills/reelson-compose/scripts/timeline.js +271 -0
- package/skills/reelson-compose/scripts/timeline.ts +514 -0
- package/skills/reelson-compose/scripts/tts.js +303 -0
- package/skills/reelson-compose/scripts/tts.ts +371 -0
- package/skills/reelson-compose/scripts/verify.js +190 -0
- package/skills/reelson-compose/scripts/verify.ts +209 -0
- package/skills/reelson-compose/scripts/voice.js +178 -0
- package/skills/reelson-compose/scripts/voice.ts +224 -0
- package/skills/reelson-compose/scripts/zooms.js +259 -0
- package/skills/reelson-compose/scripts/zooms.ts +333 -0
- package/skills/reelson-compose/sections/README.md +5 -0
- package/skills/reelson-compose/sections/intro/minimal/section.css +27 -0
- package/skills/reelson-compose/sections/intro/minimal/section.html +12 -0
- package/skills/reelson-compose/sections/intro/minimal/section.js +16 -0
- package/skills/reelson-compose/sections/intro/minimal/section.json +9 -0
- package/skills/reelson-compose/sections/intro/poster/section.css +27 -0
- package/skills/reelson-compose/sections/intro/poster/section.html +14 -0
- package/skills/reelson-compose/sections/intro/poster/section.js +28 -0
- package/skills/reelson-compose/sections/intro/poster/section.json +9 -0
- package/skills/reelson-compose/sections/intro/split/section.css +30 -0
- package/skills/reelson-compose/sections/intro/split/section.html +12 -0
- package/skills/reelson-compose/sections/intro/split/section.js +19 -0
- package/skills/reelson-compose/sections/intro/split/section.json +9 -0
- package/skills/reelson-compose/sections/outro/compact/section.css +23 -0
- package/skills/reelson-compose/sections/outro/compact/section.html +10 -0
- package/skills/reelson-compose/sections/outro/compact/section.js +11 -0
- package/skills/reelson-compose/sections/outro/compact/section.json +8 -0
- package/skills/reelson-compose/sections/outro/endcard/section.css +25 -0
- package/skills/reelson-compose/sections/outro/endcard/section.html +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.js +13 -0
- package/skills/reelson-compose/sections/outro/endcard/section.json +8 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.css +11 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.html +5 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.js +12 -0
- package/skills/reelson-compose/sections/outro/wordmark/section.json +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.css +26 -0
- package/skills/reelson-compose/sections/recap/compact/section.html +8 -0
- package/skills/reelson-compose/sections/recap/compact/section.js +23 -0
- package/skills/reelson-compose/sections/recap/compact/section.json +11 -0
- package/skills/reelson-compose/sections/recap/steps/section.css +36 -0
- package/skills/reelson-compose/sections/recap/steps/section.html +8 -0
- package/skills/reelson-compose/sections/recap/steps/section.js +27 -0
- package/skills/reelson-compose/sections/recap/steps/section.json +11 -0
- package/skills/reelson-compose/studio/index.html +81 -0
- package/skills/reelson-compose/studio/studio.css +142 -0
- package/skills/reelson-compose/studio/studio.js +1031 -0
- package/skills/reelson-compose/templates/README.md +193 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/Inter-OFL.txt +93 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/gsap.min.js +11 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-ext-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/assets/vendor/inter-latin-wght-normal.woff2 +0 -0
- package/skills/reelson-compose/templates/classic/stage.html +447 -0
- package/skills/reelson-compose/templates/classic/template.json +5 -0
- package/skills/reelson-record/SKILL.md +220 -0
- package/skills/reelson-record/schemas/demo.config.schema.json +269 -0
- package/skills/reelson-record/scripts/capture.js +142 -0
- package/skills/reelson-record/scripts/capture.ts +190 -0
- package/skills/reelson-record/scripts/config.js +148 -0
- package/skills/reelson-record/scripts/config.ts +264 -0
- package/skills/reelson-record/scripts/cursor-overlay.js +111 -0
- package/skills/reelson-record/scripts/cursor-overlay.ts +122 -0
- package/skills/reelson-record/scripts/doctor.js +128 -0
- package/skills/reelson-record/scripts/doctor.ts +147 -0
- package/skills/reelson-record/scripts/languages.js +29 -0
- package/skills/reelson-record/scripts/languages.ts +41 -0
- package/skills/reelson-record/scripts/record.js +365 -0
- package/skills/reelson-record/scripts/record.ts +451 -0
- package/skills/reelson-record/scripts/scenario.js +356 -0
- package/skills/reelson-record/scripts/scenario.ts +570 -0
- package/skills/reelson-record/scripts/validate.js +143 -0
- 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>
|