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,333 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zooms on the framed recording, planned from the cursor and checked against it.
|
|
3
|
+
*
|
|
4
|
+
* Rule (style guide #13): a zoom rides along with the cursor and is done before the click.
|
|
5
|
+
* 1. zoom in while the cursor glides to the first click it frames: start at most
|
|
6
|
+
* EARLY s before the glide starts, and finish SETTLE s before the click;
|
|
7
|
+
* 2. zoom out while the cursor glides to the next target: start at most EARLY s
|
|
8
|
+
* before that glide, finish SETTLE s before that click. A later click still in
|
|
9
|
+
* view that follows within ABSORB s joins the hold instead (no out-and-back-in);
|
|
10
|
+
* a longer pause ends the zoom — widen `clicks` to hold through it. When the next
|
|
11
|
+
* glide is more than MAX_IDLE s away, the zoom lingers, then leaves on its own;
|
|
12
|
+
* 3. never ease during a click or while typing, and every click in the hold is
|
|
13
|
+
* inside the zoomed view.
|
|
14
|
+
*
|
|
15
|
+
* `planZoom` turns `{ clicks: [a, b], scale }` into a zoom that satisfies this;
|
|
16
|
+
* `checkZoom` verifies any zoom (planned or manual) and suggests a fix. Pure.
|
|
17
|
+
*/
|
|
18
|
+
import type { Markers, Timeline, ZoomSpec } from './timeline.ts'
|
|
19
|
+
import { round } from './timeline.ts'
|
|
20
|
+
|
|
21
|
+
export const ZOOM_RULES = {
|
|
22
|
+
defaultEase: 0.8, // the template's default zoom in/out duration
|
|
23
|
+
minEase: 0.4, // shorter reads as a jump
|
|
24
|
+
settle: 0.1, // the zoom is done this long before the click
|
|
25
|
+
early: 0.35, // may start this long before the glide (not more: no waiting)
|
|
26
|
+
linger: 1.2, // with no next target, hold this long after the last action
|
|
27
|
+
absorb: 1.0, // a visible later click whose glide starts within this joins the hold
|
|
28
|
+
maxIdle: 3.0, // a longer wait for the next glide: linger and zoom out, don't sit zoomed
|
|
29
|
+
eps: 0.005, // float slack (13.03 + 4.36 must not fail against 17.39)
|
|
30
|
+
margin: 0.03, // keep clicks this far (fraction of the frame) inside the view
|
|
31
|
+
}
|
|
32
|
+
const R = ZOOM_RULES
|
|
33
|
+
|
|
34
|
+
/** A zoom on the composition timeline, as the template consumes it. */
|
|
35
|
+
export interface Zoom {
|
|
36
|
+
at: number
|
|
37
|
+
duration: number
|
|
38
|
+
x: number
|
|
39
|
+
y: number
|
|
40
|
+
scale: number
|
|
41
|
+
in: number
|
|
42
|
+
out: number
|
|
43
|
+
/**
|
|
44
|
+
* `follow`: the focus over time, [t, x, y] (transform-origin fractions, composition s),
|
|
45
|
+
* so the view pans with the cursor. The stage tweens between the points.
|
|
46
|
+
*/
|
|
47
|
+
path?: [number, number, number][]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* How a following zoom pans: sampled every `step` s, easing towards the cursor with `lag` s
|
|
52
|
+
* of smoothing, aimed `lead` s ahead — where the cursor is going, not where it was (which
|
|
53
|
+
* also cancels the smoothing's delay, and keeps the zoom-in on the click it glides to).
|
|
54
|
+
*/
|
|
55
|
+
export const FOLLOW = { step: 1 / 15, lag: 0.45, lead: 0.45 }
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The focus path of a zoom that follows the cursor: at each sample, the transform-origin
|
|
59
|
+
* that puts the cursor in the middle of the zoomed view (clamped so the view never leaves
|
|
60
|
+
* the frame), smoothed so the camera glides instead of jittering. Starts from the planned
|
|
61
|
+
* focus. Pure; `cursor` is the timeline's cursor layer (composition s, recording px).
|
|
62
|
+
*/
|
|
63
|
+
export function followPath(z: Zoom, cursor: NonNullable<Timeline['cursor']>, viewport: { width: number; height: number }): [number, number, number][] {
|
|
64
|
+
const at = (t: number): [number, number] => {
|
|
65
|
+
const path = cursor.path
|
|
66
|
+
let i = path.findIndex((p) => p[0] > t)
|
|
67
|
+
if (i === -1) i = path.length
|
|
68
|
+
const a = path[Math.max(0, i - 1)]
|
|
69
|
+
const b = path[i]
|
|
70
|
+
if (!b || i === 0 || b[0] - a[0] > 0.1) {
|
|
71
|
+
return [a[1], a[2]]
|
|
72
|
+
}
|
|
73
|
+
const u = (t - a[0]) / (b[0] - a[0])
|
|
74
|
+
return [a[1] + (b[1] - a[1]) * u, a[2] + (b[2] - a[2]) * u]
|
|
75
|
+
}
|
|
76
|
+
const originFor = (p: number): number => Math.min(1, Math.max(0, (p - 0.5 / z.scale) / (1 - 1 / z.scale)))
|
|
77
|
+
const alpha = 1 - Math.exp(-FOLLOW.step / FOLLOW.lag)
|
|
78
|
+
const end = z.at + z.duration
|
|
79
|
+
let ox = z.x
|
|
80
|
+
let oy = z.y
|
|
81
|
+
const out: [number, number, number][] = [[round(z.at), z.x, z.y]]
|
|
82
|
+
for (let t = z.at + FOLLOW.step; t < end + 1e-9; t += FOLLOW.step) {
|
|
83
|
+
const [cx, cy] = at(Math.min(t + FOLLOW.lead, end))
|
|
84
|
+
ox += (originFor(cx / viewport.width) - ox) * alpha
|
|
85
|
+
oy += (originFor(cy / viewport.height) - oy) * alpha
|
|
86
|
+
out.push([Math.round(t * 1000) / 1000, Math.round(ox * 10000) / 10000, Math.round(oy * 10000) / 10000])
|
|
87
|
+
}
|
|
88
|
+
return out
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A recorded click placed on the composition timeline. */
|
|
92
|
+
export interface CompClick {
|
|
93
|
+
/** 1-based position in markers.json `clicks` (what video.json zooms refer to). */
|
|
94
|
+
index: number
|
|
95
|
+
kind: 'click' | 'type'
|
|
96
|
+
/** The click. */
|
|
97
|
+
comp: number
|
|
98
|
+
/** The glide towards it starts. */
|
|
99
|
+
glide: number
|
|
100
|
+
/** Typing ends (kind 'type'); equals comp for clicks. */
|
|
101
|
+
until: number
|
|
102
|
+
/** Position as a fraction of the frame. */
|
|
103
|
+
fx: number
|
|
104
|
+
fy: number
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class ZoomError extends Error {}
|
|
108
|
+
|
|
109
|
+
export function compositionClicks(markers: Markers, timeline: Timeline): CompClick[] {
|
|
110
|
+
if (!markers.clicks || markers.clicks.some((c) => c.move === undefined)) {
|
|
111
|
+
return []
|
|
112
|
+
}
|
|
113
|
+
return markers.clicks
|
|
114
|
+
.map((c, i) => ({ c, index: i + 1 }))
|
|
115
|
+
.filter(({ c }) => c.at >= timeline.mediaStart && c.at <= timeline.mediaEnd)
|
|
116
|
+
.map(({ c, index }) => ({
|
|
117
|
+
index,
|
|
118
|
+
kind: c.kind,
|
|
119
|
+
comp: timeline.toComposition(c.at),
|
|
120
|
+
glide: timeline.toComposition(c.move ?? c.at),
|
|
121
|
+
until: timeline.toComposition(c.until ?? c.at),
|
|
122
|
+
fx: c.x / timeline.viewport.width,
|
|
123
|
+
fy: c.y / timeline.viewport.height,
|
|
124
|
+
}))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Resolves a video.json zoom (click-anchored or manual) to composition time. */
|
|
128
|
+
export function planZoom(spec: ZoomSpec, clicks: CompClick[], timeline: Timeline): Zoom {
|
|
129
|
+
const scale = spec.scale
|
|
130
|
+
if (!spec.clicks) {
|
|
131
|
+
if (spec.at === undefined || spec.duration === undefined || spec.x === undefined || spec.y === undefined) {
|
|
132
|
+
throw new ZoomError('a zoom needs `clicks`, or all of `at`, `duration`, `x`, `y` (manual)')
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
at: timeline.toComposition(spec.at),
|
|
136
|
+
duration: spec.duration,
|
|
137
|
+
x: spec.x,
|
|
138
|
+
y: spec.y,
|
|
139
|
+
scale,
|
|
140
|
+
in: spec.in ?? R.defaultEase,
|
|
141
|
+
out: spec.out ?? R.defaultEase,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (spec.at !== undefined || spec.duration !== undefined) {
|
|
145
|
+
throw new ZoomError('a zoom with `clicks` is timed automatically; drop `at`/`duration` (or drop `clicks` for a manual zoom)')
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const [a, b = a] = spec.clicks
|
|
149
|
+
if (b < a) {
|
|
150
|
+
throw new ZoomError(`clicks [${a}, ${b}]: the first number must not be after the second`)
|
|
151
|
+
}
|
|
152
|
+
const byIndex = (n: number): CompClick => {
|
|
153
|
+
const found = clicks.find((c) => c.index === n)
|
|
154
|
+
if (!found) {
|
|
155
|
+
const available = clicks.map((c) => c.index)
|
|
156
|
+
throw new ZoomError(
|
|
157
|
+
`click ${n} is not in the trimmed recording (available: ${available.length ? `${available[0]}–${available.at(-1)}` : 'none — re-record to log clicks'})`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
return found
|
|
161
|
+
}
|
|
162
|
+
const first = byIndex(a)
|
|
163
|
+
byIndex(b)
|
|
164
|
+
const selected = clicks.filter((c) => c.index >= a && c.index <= b)
|
|
165
|
+
|
|
166
|
+
// A following zoom pans to each click, so they need not fit in one view: it starts with
|
|
167
|
+
// the first click in the middle of the view.
|
|
168
|
+
const centred = (p: number): number => Math.round(Math.min(1, Math.max(0, (p - 0.5 / scale) / (1 - 1 / scale))) * 10000) / 10000
|
|
169
|
+
const x = spec.x ?? (spec.follow ? centred(first.fx) : fitFocus(selected.map((c) => c.fx), scale, `clicks ${a}–${b}`, 'wide'))
|
|
170
|
+
const y = spec.y ?? (spec.follow ? centred(first.fy) : fitFocus(selected.map((c) => c.fy), scale, `clicks ${a}–${b}`, 'tall'))
|
|
171
|
+
const visible = spec.follow ? () => true : isVisibleIn(viewOf(x, y, scale))
|
|
172
|
+
|
|
173
|
+
const zoomIn = spec.in ?? R.defaultEase
|
|
174
|
+
const at =
|
|
175
|
+
spec.in !== undefined
|
|
176
|
+
? round(first.comp - R.settle - zoomIn)
|
|
177
|
+
: round(Math.max(first.glide - R.early, first.comp - R.settle - R.defaultEase))
|
|
178
|
+
const inEase = spec.in ?? round(Math.max(R.minEase, Math.min(R.defaultEase, first.comp - R.settle - at)))
|
|
179
|
+
|
|
180
|
+
// The zoom holds its clicks, plus later ones it still shows that follow right away
|
|
181
|
+
// (zooming out and straight back in would jump); the first other click ends it.
|
|
182
|
+
let holdEnd = Math.max(...selected.map((c) => c.until))
|
|
183
|
+
let next: CompClick | undefined
|
|
184
|
+
for (const c of clicks.filter((c) => c.index > b)) {
|
|
185
|
+
if (visible(c) && c.glide - holdEnd <= R.absorb) {
|
|
186
|
+
holdEnd = Math.max(holdEnd, c.until)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
next = c
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The recording belts out before a hand-off card and fades out at the end.
|
|
194
|
+
const nextCard = timeline.transitions.find((t) => t.at > first.comp)
|
|
195
|
+
const limit = Math.min(timeline.clipEnd - 0.1, nextCard ? nextCard.at - timeline.belt : Infinity)
|
|
196
|
+
|
|
197
|
+
let outEnd: number
|
|
198
|
+
let outEase: number
|
|
199
|
+
if (next && next.comp - R.settle <= limit && next.glide - holdEnd <= R.maxIdle) {
|
|
200
|
+
outEnd = next.comp - R.settle
|
|
201
|
+
const outStart = Math.max(next.glide - R.early, outEnd - R.defaultEase, holdEnd + 0.2)
|
|
202
|
+
outEase = spec.out ?? Math.max(R.minEase, outEnd - outStart)
|
|
203
|
+
} else {
|
|
204
|
+
outEase = spec.out ?? R.defaultEase
|
|
205
|
+
outEnd = Math.min(holdEnd + R.linger + outEase, limit)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
at,
|
|
210
|
+
duration: round(outEnd - at),
|
|
211
|
+
x,
|
|
212
|
+
y,
|
|
213
|
+
scale,
|
|
214
|
+
in: round(inEase),
|
|
215
|
+
out: round(outEase),
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export interface ZoomCheck {
|
|
220
|
+
problems: string[]
|
|
221
|
+
framed: CompClick[]
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Zooms on screen at the same time (they would fight over the frame): one message per
|
|
226
|
+
* overlapping pair, keyed by the 0-based index of the later zoom in video.json order.
|
|
227
|
+
*/
|
|
228
|
+
export function zoomOverlaps(zooms: Zoom[]): { index: number; message: string }[] {
|
|
229
|
+
const found: { index: number; message: string }[] = []
|
|
230
|
+
zooms.forEach((z, j) => {
|
|
231
|
+
zooms.forEach((other, i) => {
|
|
232
|
+
if (i < j && z.at < round(other.at + other.duration) && other.at < round(z.at + z.duration)) {
|
|
233
|
+
found.push({
|
|
234
|
+
index: j,
|
|
235
|
+
message: `overlaps zoom ${i + 1} (${other.at}–${round(other.at + other.duration)}s) — one zoom over both click ranges, or drop one`,
|
|
236
|
+
})
|
|
237
|
+
}
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
return found
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Verifies a zoom against the clicks; problems carry a copy-paste fix. */
|
|
244
|
+
export function checkZoom(z: Zoom, clicks: CompClick[], timeline: Timeline): ZoomCheck {
|
|
245
|
+
const end = round(z.at + z.duration)
|
|
246
|
+
const visible = isVisibleIn(viewOf(z.x, z.y, z.scale))
|
|
247
|
+
const describe = (c: CompClick): string =>
|
|
248
|
+
`${c.kind} #${c.index} at ${c.comp}s (glide from ${c.glide}s, ${Math.round(c.fx * 100)}%, ${Math.round(c.fy * 100)}%)`
|
|
249
|
+
|
|
250
|
+
const problems: string[] = []
|
|
251
|
+
const inside = clicks.filter((c) => c.comp >= z.at && c.comp <= end)
|
|
252
|
+
// A following zoom keeps the cursor in view: every click in it is framed.
|
|
253
|
+
const framed = z.path ? inside : inside.filter(visible)
|
|
254
|
+
const first = framed[0]
|
|
255
|
+
const last = framed.at(-1)
|
|
256
|
+
const next = clicks.find((c) => c.comp > (last?.comp ?? z.at) && !(c.comp <= end && (z.path || visible(c))))
|
|
257
|
+
|
|
258
|
+
if (!first) {
|
|
259
|
+
problems.push('frames no click — anchor it with `clicks`, or drop the zoom')
|
|
260
|
+
} else {
|
|
261
|
+
const fix = ` → use { "clicks": [${first.index}, ${(last ?? first).index}], "scale": ${z.scale} }`
|
|
262
|
+
if (z.at + z.in > first.comp - R.settle + R.eps) {
|
|
263
|
+
problems.push(`zoom-in not done before the ${describe(first)}${fix}`)
|
|
264
|
+
} else if (z.at < first.glide - R.early - R.eps) {
|
|
265
|
+
problems.push(`zoom-in starts before the cursor moves (the viewer waits)${fix}`)
|
|
266
|
+
}
|
|
267
|
+
for (const c of inside) {
|
|
268
|
+
if (!visible(c) && !z.path) {
|
|
269
|
+
problems.push(`${describe(c)} is outside the zoomed view${fix}`)
|
|
270
|
+
} else if (c.comp > end - z.out + R.eps) {
|
|
271
|
+
problems.push(`${describe(c)} happens while the zoom eases out${fix}`)
|
|
272
|
+
} else if (c.until > end - z.out + R.eps) {
|
|
273
|
+
problems.push(`typing (${describe(c)}) is still going when the zoom eases out${fix}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (next && end > next.comp - R.settle + R.eps && !inside.includes(next)) {
|
|
277
|
+
problems.push(`zoom-out not done before the ${describe(next)}${fix}`)
|
|
278
|
+
} else if (next && end - z.out < next.glide - R.early - R.eps && end > next.glide - R.early + R.eps) {
|
|
279
|
+
// Easing out across the start of the glide without riding it (a zoom that is fully
|
|
280
|
+
// out before the cursor even moves is fine: it lingered, then left).
|
|
281
|
+
problems.push(`zoom-out starts before the cursor heads for the next target${fix}`)
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (z.in < R.minEase || z.out < R.minEase) {
|
|
285
|
+
problems.push(`ease shorter than ${R.minEase}s reads as a jump — add a beat in the scenario instead`)
|
|
286
|
+
}
|
|
287
|
+
if (end > timeline.clipEnd + R.eps) {
|
|
288
|
+
problems.push(`runs past the end of the recording (${timeline.clipEnd}s)`)
|
|
289
|
+
}
|
|
290
|
+
for (const t of timeline.transitions) {
|
|
291
|
+
if (z.at < t.at && end > t.at - timeline.belt + R.eps) {
|
|
292
|
+
problems.push(`still zoomed when the recording leaves for the hand-off card at ${t.at}s`)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return { problems, framed }
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** The part of the frame visible at full zoom (transform-origin = focus point). */
|
|
300
|
+
function viewOf(x: number, y: number, scale: number) {
|
|
301
|
+
return {
|
|
302
|
+
left: x - x / scale,
|
|
303
|
+
right: x + (1 - x) / scale,
|
|
304
|
+
top: y - y / scale,
|
|
305
|
+
bottom: y + (1 - y) / scale,
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function isVisibleIn(view: ReturnType<typeof viewOf>): (c: CompClick) => boolean {
|
|
310
|
+
return (c) =>
|
|
311
|
+
c.fx >= view.left + R.margin - R.eps &&
|
|
312
|
+
c.fx <= view.right - R.margin + R.eps &&
|
|
313
|
+
c.fy >= view.top + R.margin - R.eps &&
|
|
314
|
+
c.fy <= view.bottom - R.margin + R.eps
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* A focus coordinate (0..1) that keeps every point visible at `scale`, as close
|
|
319
|
+
* to their centre as possible. With origin o, point p shows iff
|
|
320
|
+
* o - o/s ≤ p ≤ o + (1 - o)/s.
|
|
321
|
+
*/
|
|
322
|
+
function fitFocus(points: number[], scale: number, what: string, axis: string): number {
|
|
323
|
+
const p1 = Math.min(...points) - R.margin
|
|
324
|
+
const p2 = Math.max(...points) + R.margin
|
|
325
|
+
const lo = Math.max(0, (p2 * scale - 1) / (scale - 1))
|
|
326
|
+
const hi = Math.min(1, (p1 * scale) / (scale - 1))
|
|
327
|
+
if (lo > hi + 1e-9) {
|
|
328
|
+
throw new ZoomError(`${what} are too far apart (${axis}) to show at ${scale}x — lower the scale or split the zoom`)
|
|
329
|
+
}
|
|
330
|
+
const mid = (Math.min(...points) + Math.max(...points)) / 2
|
|
331
|
+
|
|
332
|
+
return Math.round(Math.min(hi, Math.max(lo, mid)) * 1000) / 1000
|
|
333
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Sections
|
|
2
|
+
|
|
3
|
+
Intro, recap and outro designs, one folder per `<slot>/<name>/`, mixed and matched on any
|
|
4
|
+
template with `"sections"` in demo.config.json or video.json. The contract (files, tokens,
|
|
5
|
+
script API, timing, placeholders) and how to make one: [../templates/README.md](../templates/README.md).
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#intro { display: flex; align-items: center; padding: 0 220px; }
|
|
2
|
+
#intro .block { display: flex; flex-direction: column; align-items: flex-start; gap: 30px; max-width: 1480px; will-change: transform, opacity, filter; }
|
|
3
|
+
#intro .brand { display: flex; align-items: center; gap: 22px; margin-bottom: 18px; }
|
|
4
|
+
#intro .wordmark { font-size: 46px; }
|
|
5
|
+
#intro .brand-sub { font-size: 20px; letter-spacing: 0.36em; padding-left: 22px; border-left: 2px solid var(--line); }
|
|
6
|
+
#intro .title { font-size: 128px; font-weight: 800; letter-spacing: -0.045em; line-height: 1.0; will-change: transform, opacity; }
|
|
7
|
+
#intro .accent { width: 180px; }
|
|
8
|
+
#intro .subtitle { font-size: 40px; font-weight: 400; color: var(--muted); line-height: 1.35; max-width: 1300px; }
|
|
9
|
+
#intro .meta { margin-top: 8px; }
|
|
10
|
+
|
|
11
|
+
/* Portrait: the same left-aligned block, narrower and bigger */
|
|
12
|
+
#root.portrait #intro { padding: 0 90px; }
|
|
13
|
+
#root.portrait #intro .block { gap: 36px; max-width: 900px; }
|
|
14
|
+
#root.portrait #intro .wordmark { font-size: 56px; }
|
|
15
|
+
#root.portrait #intro .brand-sub { font-size: 24px; }
|
|
16
|
+
#root.portrait #intro .title { font-size: 116px; }
|
|
17
|
+
#root.portrait #intro .subtitle { font-size: 44px; max-width: 900px; }
|
|
18
|
+
#root.portrait #intro .meta { font-size: 30px; }
|
|
19
|
+
|
|
20
|
+
/* Square: the same left-aligned block, narrower */
|
|
21
|
+
#root.square #intro { padding: 0 80px; }
|
|
22
|
+
#root.square #intro .block { gap: 28px; max-width: 920px; }
|
|
23
|
+
#root.square #intro .wordmark { font-size: 48px; }
|
|
24
|
+
#root.square #intro .brand-sub { font-size: 21px; }
|
|
25
|
+
#root.square #intro .title { font-size: 96px; }
|
|
26
|
+
#root.square #intro .subtitle { font-size: 38px; max-width: 920px; }
|
|
27
|
+
#root.square #intro .meta { font-size: 26px; }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<section id="intro" class="clip" data-start="{{START}}" data-duration="{{DURATION}}" data-track-index="{{TRACK}}">
|
|
2
|
+
<div class="block">
|
|
3
|
+
<div class="brand">
|
|
4
|
+
<div class="wordmark" data-text="{{BRAND}}" data-logo="{{BRAND_LOGO}}"></div>
|
|
5
|
+
<div class="brand-sub">{{BRAND_SUB}}</div>
|
|
6
|
+
</div>
|
|
7
|
+
<h1 class="title">{{TITLE}}</h1>
|
|
8
|
+
<div class="accent"></div>
|
|
9
|
+
<p class="subtitle">{{SUBTITLE}}</p>
|
|
10
|
+
<div class="meta" data-chip></div>
|
|
11
|
+
</div>
|
|
12
|
+
</section>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Frame 0 = the poster: brand row and title already legible (70%), a little low. They
|
|
2
|
+
// settle up into place, the accent line grows, subtitle and chip rise in, the block holds,
|
|
3
|
+
// then it softly lifts and fades while the recording fades in behind it.
|
|
4
|
+
// The from-states below ARE the poster — keep them sharp (no blur, no `later` at t=0).
|
|
5
|
+
const m0 = section.start + 0.08;
|
|
6
|
+
tl.fromTo('#intro .brand .l', { opacity: 0.6, y: 10 }, { opacity: 1, y: 0, duration: 0.9, ease: 'sine.inOut', stagger: 0.04 }, m0);
|
|
7
|
+
tl.fromTo('#intro .brand-sub', { opacity: 0.6 }, { opacity: 1, duration: 0.9, ease: 'sine.inOut' }, m0 + 0.2);
|
|
8
|
+
tl.fromTo('#intro .title', { opacity: 0.7, y: 34 }, { opacity: 1, y: 0, duration: 1.2, ease: 'sine.inOut' }, m0);
|
|
9
|
+
tl.fromTo('#intro .accent', { scaleX: 0.3 }, { scaleX: 1, duration: 1.0, ease: 'power3.inOut' }, m0 + 0.2);
|
|
10
|
+
tl.fromTo('#intro .subtitle', { opacity: 0, y: 18 }, { opacity: 1, y: 0, duration: 0.6, ease: 'power3.out', ...later }, section.start + 0.6);
|
|
11
|
+
tl.fromTo('#intro .meta', { opacity: 0, y: 14 }, { opacity: 1, y: 0, duration: 0.5, ease: 'power3.out', ...later }, section.start + 0.8);
|
|
12
|
+
tl.fromTo('#intro .block', { opacity: 1, y: 0, filter: 'blur(0px)' },
|
|
13
|
+
{ opacity: 0, y: -26, filter: 'blur(8px)', duration: 0.5, ease: 'power2.in', ...later }, section.exit - 0.5);
|
|
14
|
+
stage.enter(section.exit, 'fade');
|
|
15
|
+
|
|
16
|
+
gsap.set(['#intro .subtitle', '#intro .meta'], { opacity: 0 });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Left-aligned and quiet: a small wordmark row, a big title with an accent line, subtitle and steps chip. Legible at frame 0 (the poster), settles in place, then fades into the recording. Shorter than poster.",
|
|
3
|
+
"timing": {
|
|
4
|
+
"duration": 2.7,
|
|
5
|
+
"exit": 2.6
|
|
6
|
+
},
|
|
7
|
+
"portrait": true,
|
|
8
|
+
"square": true
|
|
9
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#intro { display: grid; place-items: center; }
|
|
2
|
+
/* gap: room between the tagline and the title, which only rises a little into place */
|
|
3
|
+
#intro .stack { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 115px; will-change: transform, opacity; }
|
|
4
|
+
#intro .top, #intro .bottom { display: flex; flex-direction: column; align-items: center; will-change: transform, opacity; }
|
|
5
|
+
#intro .top { gap: 22px; }
|
|
6
|
+
#intro .bottom { gap: 24px; }
|
|
7
|
+
#intro .wordmark { font-size: 99px; }
|
|
8
|
+
#intro .brand-sub { font-size: 30px; }
|
|
9
|
+
#intro .brand-line { width: 180px; }
|
|
10
|
+
#intro .title { font-size: 92px; font-weight: 800; letter-spacing: -0.04em; line-height: 1.05; max-width: 1380px; will-change: transform, opacity; }
|
|
11
|
+
#intro .subtitle { font-size: 36px; font-weight: 400; color: var(--muted); max-width: 1300px; line-height: 1.35; }
|
|
12
|
+
|
|
13
|
+
/* Portrait: one centred column, sized for a phone screen */
|
|
14
|
+
#root.portrait #intro .stack { gap: 170px; padding: 0 70px; }
|
|
15
|
+
#root.portrait #intro .wordmark { font-size: 150px; }
|
|
16
|
+
#root.portrait #intro .brand-sub { font-size: 40px; }
|
|
17
|
+
#root.portrait #intro .title { font-size: 124px; max-width: 940px; }
|
|
18
|
+
#root.portrait #intro .subtitle { font-size: 50px; max-width: 900px; }
|
|
19
|
+
#root.portrait #intro .meta { font-size: 34px; padding: 16px 30px; }
|
|
20
|
+
|
|
21
|
+
/* Square: one centred column that fits the height */
|
|
22
|
+
#root.square #intro .stack { gap: 90px; padding: 0 70px; }
|
|
23
|
+
#root.square #intro .wordmark { font-size: 112px; }
|
|
24
|
+
#root.square #intro .brand-sub { font-size: 32px; }
|
|
25
|
+
#root.square #intro .title { font-size: 88px; max-width: 940px; }
|
|
26
|
+
#root.square #intro .subtitle { font-size: 38px; max-width: 900px; }
|
|
27
|
+
#root.square #intro .meta { font-size: 26px; }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<section id="intro" class="clip" data-start="{{START}}" data-duration="{{DURATION}}" data-track-index="{{TRACK}}">
|
|
2
|
+
<div class="stack">
|
|
3
|
+
<div class="top">
|
|
4
|
+
<div class="wordmark" data-text="{{BRAND}}" data-logo="{{BRAND_LOGO}}"></div>
|
|
5
|
+
<div class="brand-line"></div>
|
|
6
|
+
<div class="brand-sub">{{BRAND_SUB}}</div>
|
|
7
|
+
</div>
|
|
8
|
+
<div class="bottom">
|
|
9
|
+
<h1 class="title">{{TITLE}}</h1>
|
|
10
|
+
<p class="subtitle">{{SUBTITLE}}</p>
|
|
11
|
+
<div class="meta" data-chip></div>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
</section>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Frame 0 = the poster: letters, line, sub and title are big (130%) and half-transparent,
|
|
2
|
+
// the letters spread a little. Then everything "breathes" down into place (sine.inOut,
|
|
3
|
+
// letters 60 ms apart from the centre, no snap anywhere), the subtitle and chip rise in,
|
|
4
|
+
// the card holds, and at section.exit the whole stack leaves through the top while the
|
|
5
|
+
// recording arrives from below on the same belt.
|
|
6
|
+
// The from-states below ARE the poster — keep them sharp (no blur, no `later` at t=0).
|
|
7
|
+
const c0 = section.start + 0.08;
|
|
8
|
+
// Portrait and square have no room to spare at the sides: a gentler poster that still fits the width.
|
|
9
|
+
const narrow = DEMO.layout.format !== 'landscape';
|
|
10
|
+
const big = narrow ? 1.08 : 1.3;
|
|
11
|
+
const spread = narrow ? 8 : 26;
|
|
12
|
+
tl.fromTo('#intro .l', { scale: big, opacity: 0.6, x: (i, el, all) => (i - (all.length - 1) / 2) * spread },
|
|
13
|
+
{ scale: 1, opacity: 1, x: 0, duration: 1.4, ease: 'sine.inOut', stagger: { each: 0.06, from: 'center' } }, c0);
|
|
14
|
+
tl.fromTo('#intro .brand-line', { scaleX: big, opacity: 0.6 }, { scaleX: 1, opacity: 1, duration: 1.3, ease: 'sine.inOut' }, c0 + 0.25);
|
|
15
|
+
tl.fromTo('#intro .brand-sub', { scale: big, opacity: 0.6 }, { scale: 1, opacity: 1, duration: 1.3, ease: 'sine.inOut' }, c0 + 0.35);
|
|
16
|
+
tl.fromTo('#intro .title', { scale: big, opacity: 0.65 }, { scale: 1, opacity: 1, duration: 1.4, ease: 'sine.inOut' }, c0 + 0.2);
|
|
17
|
+
tl.fromTo('#intro .stack', { y: 0 }, { y: -30, duration: 1.2, ease: 'power2.out' }, c0);
|
|
18
|
+
tl.fromTo('#intro .top', { y: -24 }, { y: 0, duration: 1.3, ease: 'power2.out' }, c0);
|
|
19
|
+
tl.fromTo('#intro .bottom', { y: 33 }, { y: 0, duration: 1.3, ease: 'power2.out' }, c0 + 0.15);
|
|
20
|
+
tl.fromTo('#intro .subtitle', { opacity: 0, y: 20 }, { opacity: 1, y: 0, duration: 0.6, ease: 'power3.out', ...later }, section.start + 0.9);
|
|
21
|
+
tl.fromTo('#intro .meta', { opacity: 0, y: 16 }, { opacity: 1, y: 0, duration: 0.5, ease: 'power3.out', ...later }, section.start + 1.05);
|
|
22
|
+
// Off through the top of the stage (a zoomed 16:9 card, class "band", moves in its own px),
|
|
23
|
+
// fading as it goes: its text never lingers over the recording riding in.
|
|
24
|
+
const away = (DEMO.layout.stage.height + 70) / (document.getElementById('intro').classList.contains('band') ? DEMO.layout.bandZoom : 1);
|
|
25
|
+
tl.fromTo('#intro .stack', { y: -30, opacity: 1 }, { y: -away, opacity: 0, duration: 0.9, ease: 'power3.inOut', ...later }, section.exit);
|
|
26
|
+
stage.enter(section.exit, 'belt');
|
|
27
|
+
|
|
28
|
+
gsap.set(['#intro .subtitle', '#intro .meta'], { opacity: 0 });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "Wordmark, accent line and tagline over the title, subtitle and steps chip, centred. Frame 0 is the poster (everything big and 60% transparent); it breathes into place, holds, and lifts out through the top as the recording rides in on the same belt.",
|
|
3
|
+
"timing": {
|
|
4
|
+
"duration": 4.0,
|
|
5
|
+
"exit": 3.0
|
|
6
|
+
},
|
|
7
|
+
"portrait": true,
|
|
8
|
+
"square": true
|
|
9
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#intro { display: grid; grid-template-columns: 760px 1fr; align-items: center; }
|
|
2
|
+
#intro .panel { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 24px; padding: 0 60px;
|
|
3
|
+
background: linear-gradient(160deg, color-mix(in srgb, var(--brand) 82%, #000) 0%, color-mix(in srgb, var(--brand) 45%, var(--bg)) 100%);
|
|
4
|
+
box-shadow: 30px 0 120px rgba(0, 0, 0, 0.45), inset -1px 0 0 rgba(255, 255, 255, 0.12); will-change: transform; }
|
|
5
|
+
#intro .wordmark { font-size: 96px; color: #fff; will-change: transform; }
|
|
6
|
+
#intro .brand-line { width: 160px; background: linear-gradient(90deg, #fff, var(--brand-soft)); opacity: 0.85; }
|
|
7
|
+
#intro .brand-sub { font-size: 26px; color: rgba(255, 255, 255, 0.72); }
|
|
8
|
+
#intro .text { display: flex; flex-direction: column; align-items: flex-start; gap: 28px; padding: 0 120px 0 130px; will-change: transform, opacity; }
|
|
9
|
+
#intro .title { font-size: 96px; font-weight: 800; letter-spacing: -0.04em; line-height: 1.04; will-change: transform, opacity; }
|
|
10
|
+
#intro .subtitle { font-size: 36px; font-weight: 400; color: var(--muted); line-height: 1.35; }
|
|
11
|
+
|
|
12
|
+
/* Portrait: the brand panel on top, the text below */
|
|
13
|
+
#root.portrait #intro { grid-template-columns: 1fr; grid-template-rows: 820px 1fr; align-items: stretch; }
|
|
14
|
+
#root.portrait #intro .panel { box-shadow: 0 30px 120px rgba(0, 0, 0, 0.45), inset 0 -1px 0 rgba(255, 255, 255, 0.12); }
|
|
15
|
+
#root.portrait #intro .wordmark { font-size: 120px; }
|
|
16
|
+
#root.portrait #intro .brand-sub { font-size: 32px; }
|
|
17
|
+
#root.portrait #intro .text { justify-content: center; gap: 34px; padding: 0 90px 120px; }
|
|
18
|
+
#root.portrait #intro .title { font-size: 100px; }
|
|
19
|
+
#root.portrait #intro .subtitle { font-size: 42px; }
|
|
20
|
+
#root.portrait #intro .meta { font-size: 30px; }
|
|
21
|
+
|
|
22
|
+
/* Square: the brand panel on top, the text below */
|
|
23
|
+
#root.square #intro { grid-template-columns: 1fr; grid-template-rows: 400px 1fr; align-items: stretch; }
|
|
24
|
+
#root.square #intro .panel { gap: 20px; box-shadow: 0 30px 120px rgba(0, 0, 0, 0.45), inset 0 -1px 0 rgba(255, 255, 255, 0.12); }
|
|
25
|
+
#root.square #intro .wordmark { font-size: 96px; }
|
|
26
|
+
#root.square #intro .brand-sub { font-size: 26px; }
|
|
27
|
+
#root.square #intro .text { justify-content: center; gap: 24px; padding: 0 80px 20px; }
|
|
28
|
+
#root.square #intro .title { font-size: 78px; }
|
|
29
|
+
#root.square #intro .subtitle { font-size: 34px; }
|
|
30
|
+
#root.square #intro .meta { font-size: 24px; }
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
<section id="intro" class="clip" data-start="{{START}}" data-duration="{{DURATION}}" data-track-index="{{TRACK}}">
|
|
2
|
+
<div class="panel">
|
|
3
|
+
<div class="wordmark" data-text="{{BRAND}}" data-logo="{{BRAND_LOGO}}"></div>
|
|
4
|
+
<div class="brand-line"></div>
|
|
5
|
+
<div class="brand-sub">{{BRAND_SUB}}</div>
|
|
6
|
+
</div>
|
|
7
|
+
<div class="text">
|
|
8
|
+
<h1 class="title">{{TITLE}}</h1>
|
|
9
|
+
<p class="subtitle">{{SUBTITLE}}</p>
|
|
10
|
+
<div class="meta" data-chip></div>
|
|
11
|
+
</div>
|
|
12
|
+
</section>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Frame 0 = the poster: the panel is already in place with its wordmark a little big and
|
|
2
|
+
// 60% transparent, the title legible at 65% slightly to the right. Both settle, subtitle
|
|
3
|
+
// and chip rise in, the card holds; at section.exit the panel slides out left, the text
|
|
4
|
+
// out right, and the recording grows out of the middle.
|
|
5
|
+
// The from-states below ARE the poster — keep them sharp (no blur, no `later` at t=0).
|
|
6
|
+
const s0 = section.start + 0.08;
|
|
7
|
+
tl.fromTo('#intro .panel .wordmark', { scale: 1.18 }, { scale: 1, duration: 1.3, ease: 'sine.inOut' }, s0);
|
|
8
|
+
tl.fromTo('#intro .panel .l', { opacity: 0.6 }, { opacity: 1, duration: 1.1, ease: 'sine.inOut', stagger: { each: 0.05, from: 'center' } }, s0);
|
|
9
|
+
tl.fromTo('#intro .brand-line', { scaleX: 0.4 }, { scaleX: 1, duration: 1.1, ease: 'power3.inOut' }, s0 + 0.25);
|
|
10
|
+
tl.fromTo('#intro .brand-sub', { opacity: 0.6 }, { opacity: 1, duration: 1.0, ease: 'sine.inOut' }, s0 + 0.3);
|
|
11
|
+
tl.fromTo('#intro .title', { opacity: 0.65, x: 40 }, { opacity: 1, x: 0, duration: 1.3, ease: 'sine.inOut' }, s0 + 0.1);
|
|
12
|
+
tl.fromTo('#intro .subtitle', { opacity: 0, x: 30 }, { opacity: 1, x: 0, duration: 0.6, ease: 'power3.out', ...later }, section.start + 0.8);
|
|
13
|
+
tl.fromTo('#intro .meta', { opacity: 0, x: 24 }, { opacity: 1, x: 0, duration: 0.5, ease: 'power3.out', ...later }, section.start + 1.0);
|
|
14
|
+
// The panels are mostly gone before the frame grows in, so the text never shows through it.
|
|
15
|
+
tl.fromTo('#intro .panel', { x: 0 }, { x: -900, duration: 0.7, ease: 'power3.inOut', ...later }, section.exit - 0.25);
|
|
16
|
+
tl.fromTo('#intro .text', { x: 0, opacity: 1 }, { x: 1300, opacity: 0, duration: 0.7, ease: 'power3.inOut', ...later }, section.exit - 0.25);
|
|
17
|
+
stage.enter(section.exit + 0.2, 'zoom');
|
|
18
|
+
|
|
19
|
+
gsap.set(['#intro .subtitle', '#intro .meta'], { opacity: 0 });
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "A brand-coloured panel on the left with the wordmark (or logo) and tagline; title, subtitle and steps chip on the right. Frame 0 is the poster. At the hand-over the panel slides out left, the text out right, and the recording grows out of the middle.",
|
|
3
|
+
"timing": {
|
|
4
|
+
"duration": 4.0,
|
|
5
|
+
"exit": 3.0
|
|
6
|
+
},
|
|
7
|
+
"portrait": true,
|
|
8
|
+
"square": true
|
|
9
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
#outro { display: grid; place-items: center; }
|
|
2
|
+
#outro .row { display: flex; align-items: center; gap: 48px; will-change: transform, opacity; }
|
|
3
|
+
#outro .wordmark { font-size: 120px; }
|
|
4
|
+
#outro .divider { width: 6px; height: 132px; border-radius: 3px; background: linear-gradient(180deg, var(--brand), var(--brand-soft)); transform-origin: center; will-change: transform; }
|
|
5
|
+
#outro .right { display: flex; flex-direction: column; align-items: flex-start; gap: 14px; max-width: 820px; will-change: transform, opacity; }
|
|
6
|
+
#outro .title { font-size: 54px; font-weight: 700; letter-spacing: -0.03em; line-height: 1.1; }
|
|
7
|
+
#outro .brand-sub { font-size: 22px; letter-spacing: 0.36em; padding-left: 0; }
|
|
8
|
+
|
|
9
|
+
/* Portrait: wordmark, a short line, then the title — stacked */
|
|
10
|
+
#root.portrait #outro .row { flex-direction: column; gap: 44px; padding: 0 80px; text-align: center; }
|
|
11
|
+
#root.portrait #outro .wordmark { font-size: 132px; }
|
|
12
|
+
#root.portrait #outro .divider { width: 160px; height: 6px; background: linear-gradient(90deg, var(--brand), var(--brand-soft)); }
|
|
13
|
+
#root.portrait #outro .right { align-items: center; max-width: 920px; }
|
|
14
|
+
#root.portrait #outro .title { font-size: 64px; }
|
|
15
|
+
#root.portrait #outro .brand-sub { font-size: 30px; }
|
|
16
|
+
|
|
17
|
+
/* Square: wordmark, a short line, then the title — stacked */
|
|
18
|
+
#root.square #outro .row { flex-direction: column; gap: 40px; padding: 0 70px; text-align: center; }
|
|
19
|
+
#root.square #outro .wordmark { font-size: 120px; }
|
|
20
|
+
#root.square #outro .divider { width: 160px; height: 6px; background: linear-gradient(90deg, var(--brand), var(--brand-soft)); }
|
|
21
|
+
#root.square #outro .right { align-items: center; max-width: 940px; }
|
|
22
|
+
#root.square #outro .title { font-size: 58px; }
|
|
23
|
+
#root.square #outro .brand-sub { font-size: 26px; }
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<section id="outro" class="clip" data-start="{{START}}" data-duration="{{DURATION}}" data-track-index="{{TRACK}}">
|
|
2
|
+
<div class="row">
|
|
3
|
+
<div class="wordmark" data-text="{{BRAND}}" data-logo="{{BRAND_LOGO}}"></div>
|
|
4
|
+
<div class="divider"></div>
|
|
5
|
+
<div class="right">
|
|
6
|
+
<div class="title">{{TITLE}}</div>
|
|
7
|
+
<div class="brand-sub">{{BRAND_SUB}}</div>
|
|
8
|
+
</div>
|
|
9
|
+
</div>
|
|
10
|
+
</section>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Waits out the recap's cross-fade (0.3 s) so the brand never lands on its text, then:
|
|
2
|
+
// letters slide in from the left, the divider grows, the title follows, a short hold,
|
|
3
|
+
// and everything settles at 15% opacity: the last frame still shows the brand.
|
|
4
|
+
const o0 = section.start + 0.3;
|
|
5
|
+
tl.fromTo('#outro .l', { opacity: 0, x: -40 }, { opacity: 1, x: 0, duration: 0.6, ease: 'power3.out', stagger: 0.03, ...later }, o0);
|
|
6
|
+
tl.fromTo('#outro .divider', { scaleY: 0 }, { scaleY: 1, duration: 0.4, ease: 'power3.inOut', ...later }, o0 + 0.2);
|
|
7
|
+
tl.fromTo('#outro .right', { opacity: 0, x: -24 }, { opacity: 1, x: 0, duration: 0.5, ease: 'power3.out', ...later }, o0 + 0.3);
|
|
8
|
+
tl.fromTo('#outro .row', { opacity: 1, scale: 1 }, { opacity: 0.15, scale: 0.97, duration: 0.6, ease: 'power2.in', ...later }, section.start + section.duration - 0.6);
|
|
9
|
+
|
|
10
|
+
gsap.set(['#outro .l', '#outro .right'], { opacity: 0 });
|
|
11
|
+
gsap.set('#outro .divider', { scaleY: 0 });
|