zdymak 0.7.0 → 0.13.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/LICENSE +21 -21
- package/README.md +733 -211
- package/SKILL.md +183 -84
- package/bin/zdymak.mjs +3 -3
- package/package.json +66 -40
- package/src/canvas.mjs +116 -116
- package/src/capture/index.mjs +318 -242
- package/src/capture/web.mjs +137 -0
- package/src/cli.mjs +298 -165
- package/src/config.mjs +159 -96
- package/src/destinations.mjs +103 -0
- package/src/easings.mjs +74 -0
- package/src/effects.mjs +335 -0
- package/src/encode.mjs +70 -44
- package/src/fonts.mjs +42 -42
- package/src/frames.mjs +186 -168
- package/src/graphic.mjs +107 -77
- package/src/index.mjs +27 -12
- package/src/montage.mjs +428 -0
- package/src/png.mjs +50 -50
- package/src/premium.mjs +362 -351
- package/src/reel.mjs +366 -245
- package/src/screenshots.mjs +118 -66
- package/src/specs.mjs +123 -88
- package/src/statusbar.mjs +163 -0
- package/src/still.mjs +72 -62
- package/src/transitions.mjs +653 -0
- package/src/validate.mjs +98 -0
- package/src/video.mjs +243 -240
- package/types/index.d.ts +559 -0
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TRANSITION REGISTRY — the per-scene cut vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* A transition is a pure function of `p` (0→1 through the cut) that composites two already-painted
|
|
5
|
+
* layers onto the output frame. Nothing here knows what a scene is, how it was drawn, or where the
|
|
6
|
+
* pixels came from, so adding one is an entry in this table and nothing else.
|
|
7
|
+
*
|
|
8
|
+
* { dur, label, paint(ctx, prev, next, p, { W, H }) }
|
|
9
|
+
*
|
|
10
|
+
* Config picks one per scene: `{ id: 'study', cut: 'flip' }` — `cut` names how we get INTO that scene.
|
|
11
|
+
*
|
|
12
|
+
* ── On restraint ────────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
* The DEFAULT is deliberately the plainest entry here. A montage that reaches for a different
|
|
14
|
+
* decorative transition at every boundary is the loudest amateur tell — so hard cuts carry the rhythm,
|
|
15
|
+
* dissolves are spent where the meaning changes, and the expressive ones are opt-in. The library is
|
|
16
|
+
* broad because *social ads* legitimately want that range; the defaults stay disciplined because store
|
|
17
|
+
* previews don't. `auto` splits the difference: a deterministic rotation that stays mostly plain.
|
|
18
|
+
*
|
|
19
|
+
* Ported from uspamin's collage player, whose 33 transitions were CSS (transform / opacity / clip-path /
|
|
20
|
+
* mix-blend-mode). Here the same maths drives Skia canvas ops instead: `clip(Path2D)` for the wipes and
|
|
21
|
+
* irises, `globalCompositeOperation` for the light effects, `ctx.filter` for blur, and an X-scale about
|
|
22
|
+
* the centre as the honest 2D projection of a Y-axis rotation.
|
|
23
|
+
*/
|
|
24
|
+
import { smoothstep, smootherstep, easeInCubic, easeOutCubic, easeInOutCubic, easeOutBack, softPeak } from './easings.mjs';
|
|
25
|
+
import { hexA } from './canvas.mjs';
|
|
26
|
+
import { createCanvas } from '@napi-rs/canvas';
|
|
27
|
+
|
|
28
|
+
/** A transparent scratch canvas the size of the frame, for transitions that must mask before compositing. */
|
|
29
|
+
const scratchLike = (_ctx, W, H) => createCanvas(W, H);
|
|
30
|
+
|
|
31
|
+
/** Draw a layer under an arbitrary clip path. */
|
|
32
|
+
function clipped(ctx, layer, buildPath) {
|
|
33
|
+
ctx.save();
|
|
34
|
+
ctx.beginPath();
|
|
35
|
+
buildPath(ctx);
|
|
36
|
+
ctx.clip();
|
|
37
|
+
ctx.drawImage(layer, 0, 0);
|
|
38
|
+
ctx.restore();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Draw a layer with a transform applied about a pivot. */
|
|
42
|
+
function transformed(ctx, layer, { x = 0, y = 0, scale = 1, scaleX = 1, scaleY = 1, rotate = 0, alpha = 1, pivotX, pivotY, W, H, filter }) {
|
|
43
|
+
const px = pivotX ?? W / 2;
|
|
44
|
+
const py = pivotY ?? H / 2;
|
|
45
|
+
ctx.save();
|
|
46
|
+
ctx.globalAlpha = alpha;
|
|
47
|
+
if (filter) ctx.filter = filter;
|
|
48
|
+
ctx.translate(px + x, py + y);
|
|
49
|
+
ctx.rotate(rotate);
|
|
50
|
+
ctx.scale(scale * scaleX, scale * scaleY);
|
|
51
|
+
ctx.translate(-px, -py);
|
|
52
|
+
ctx.drawImage(layer, 0, 0);
|
|
53
|
+
ctx.restore();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Full-frame colour flash / dip, `a` = 0..1 opacity. */
|
|
57
|
+
function veil(ctx, colour, a, W, H) {
|
|
58
|
+
if (a <= 0) return;
|
|
59
|
+
ctx.fillStyle = hexA(colour, Math.min(1, a));
|
|
60
|
+
ctx.fillRect(0, 0, W, H);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Dim a turning surface with `multiply`, not an opaque black wash. Flat black at 30-40% over a light
|
|
65
|
+
* matte reads as a grey smudge sitting ON the frame; multiply darkens what is actually there, which is
|
|
66
|
+
* what a surface angling away from the light does.
|
|
67
|
+
*/
|
|
68
|
+
function shade(ctx, a, W, H) {
|
|
69
|
+
if (a <= 0) return;
|
|
70
|
+
ctx.save();
|
|
71
|
+
ctx.globalCompositeOperation = 'multiply';
|
|
72
|
+
ctx.fillStyle = hexA('#7d8a82', Math.min(1, a));
|
|
73
|
+
ctx.fillRect(0, 0, W, H);
|
|
74
|
+
ctx.restore();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Deterministic pseudo-random in [0,1) — same frame renders identically on every machine. */
|
|
78
|
+
const rnd = (n) => {
|
|
79
|
+
const x = Math.sin(n * 12.9898) * 43758.5453;
|
|
80
|
+
return x - Math.floor(x);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const TRANSITIONS = {
|
|
84
|
+
// ── Plain (the working vocabulary) ─────────────────────────────────────────────────────────────
|
|
85
|
+
cut: {
|
|
86
|
+
dur: 1 / 24,
|
|
87
|
+
label: 'Hard cut — instant change of subject (default)',
|
|
88
|
+
paint: (ctx, prev, next, p) => void ctx.drawImage(p < 0.5 ? prev : next, 0, 0),
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
dissolve: {
|
|
92
|
+
dur: 0.45,
|
|
93
|
+
label: 'Cross-dissolve — a change of meaning, or a bookend',
|
|
94
|
+
paint(ctx, prev, next, p) {
|
|
95
|
+
ctx.drawImage(prev, 0, 0);
|
|
96
|
+
// smoothstep, not linear: a linear opacity blend dips visibly darker at the midpoint.
|
|
97
|
+
ctx.globalAlpha = smoothstep(p);
|
|
98
|
+
ctx.drawImage(next, 0, 0);
|
|
99
|
+
ctx.globalAlpha = 1;
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
'cinematic-dissolve': {
|
|
104
|
+
dur: 0.7,
|
|
105
|
+
label: 'Dissolve with a slow breath of scale',
|
|
106
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
107
|
+
transformed(ctx, prev, { scale: 1 + 0.02 * p, W, H });
|
|
108
|
+
transformed(ctx, next, { scale: 1.02 - 0.02 * p, alpha: smoothstep(p), W, H });
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
'match-cut': {
|
|
113
|
+
dur: 0.28,
|
|
114
|
+
label: 'Match cut — snaps early, then holds (subjects that rhyme)',
|
|
115
|
+
paint(ctx, prev, next, p) {
|
|
116
|
+
const ACTION_END = 0.18;
|
|
117
|
+
ctx.drawImage(prev, 0, 0);
|
|
118
|
+
ctx.globalAlpha = smoothstep(Math.min(1, p / ACTION_END));
|
|
119
|
+
ctx.drawImage(next, 0, 0);
|
|
120
|
+
ctx.globalAlpha = 1;
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
// ── Chapter breaks ─────────────────────────────────────────────────────────────────────────────
|
|
125
|
+
'fade-through-black': {
|
|
126
|
+
dur: 0.7,
|
|
127
|
+
label: 'Dip to black — a chapter break',
|
|
128
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
129
|
+
ctx.drawImage(p < 0.5 ? prev : next, 0, 0);
|
|
130
|
+
veil(ctx, '#000000', 1 - Math.abs(p - 0.5) * 2, W, H);
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
'dip-to-white': {
|
|
135
|
+
dur: 0.6,
|
|
136
|
+
label: 'Dip to white — a lighter chapter break',
|
|
137
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
138
|
+
ctx.drawImage(p < 0.5 ? prev : next, 0, 0);
|
|
139
|
+
veil(ctx, '#ffffff', 1 - Math.abs(p - 0.5) * 2, W, H);
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
'soft-flash': {
|
|
144
|
+
dur: 0.35,
|
|
145
|
+
label: 'Soft white bloom over the cut',
|
|
146
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
147
|
+
ctx.drawImage(prev, 0, 0);
|
|
148
|
+
ctx.globalAlpha = smoothstep(p);
|
|
149
|
+
ctx.drawImage(next, 0, 0);
|
|
150
|
+
ctx.globalAlpha = 1;
|
|
151
|
+
veil(ctx, '#ffffff', 0.55 * softPeak(p), W, H);
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
// ── Camera-through ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
'slow-zoom-through': {
|
|
157
|
+
dur: 0.6,
|
|
158
|
+
label: 'Push in through the cut',
|
|
159
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
160
|
+
transformed(ctx, prev, { scale: 1 + 0.12 * easeInCubic(p), alpha: 1, W, H });
|
|
161
|
+
transformed(ctx, next, { scale: 1.1 - 0.1 * easeOutCubic(p), alpha: smoothstep(p), W, H });
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
'pull-out': {
|
|
166
|
+
dur: 0.6,
|
|
167
|
+
label: 'Pull back through the cut',
|
|
168
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
169
|
+
transformed(ctx, prev, { scale: 1 - 0.08 * easeInCubic(p), W, H });
|
|
170
|
+
transformed(ctx, next, { scale: 0.92 + 0.08 * easeOutCubic(p), alpha: smoothstep(p), W, H });
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
'soft-zoom-punch': {
|
|
175
|
+
dur: 0.3,
|
|
176
|
+
label: 'Fast scale punch — energy on the beat',
|
|
177
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
178
|
+
const e = easeOutCubic(p);
|
|
179
|
+
transformed(ctx, prev, { scale: 1 + 0.06 * e, W, H });
|
|
180
|
+
transformed(ctx, next, { scale: 1.08 - 0.08 * e, alpha: smoothstep(Math.min(1, p * 1.6)), W, H });
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
'frame-fill': {
|
|
185
|
+
dur: 0.5,
|
|
186
|
+
label: 'The incoming frame scales up to fill',
|
|
187
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
188
|
+
ctx.drawImage(prev, 0, 0);
|
|
189
|
+
transformed(ctx, next, { scale: 0.6 + 0.4 * easeOutBack(p), alpha: smoothstep(Math.min(1, p * 1.4)), W, H });
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// ── Movement ───────────────────────────────────────────────────────────────────────────────────
|
|
194
|
+
push: {
|
|
195
|
+
dur: 0.42,
|
|
196
|
+
label: 'Push — navigation within the app',
|
|
197
|
+
paint(ctx, prev, next, p, { W }) {
|
|
198
|
+
const e = easeInOutCubic(p);
|
|
199
|
+
ctx.drawImage(prev, -W * e, 0);
|
|
200
|
+
ctx.drawImage(next, W * (1 - e), 0);
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
|
|
204
|
+
'push-up': {
|
|
205
|
+
dur: 0.42,
|
|
206
|
+
label: 'Push upward - a feed or story advancing',
|
|
207
|
+
paint(ctx, prev, next, p, { H }) {
|
|
208
|
+
// Vertical is the first thing a social author asks for after "slide", and every other movement
|
|
209
|
+
// transition here is horizontal.
|
|
210
|
+
const e = easeInOutCubic(p);
|
|
211
|
+
ctx.drawImage(prev, 0, -H * e);
|
|
212
|
+
ctx.drawImage(next, 0, H * (1 - e));
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
'page-slide': {
|
|
217
|
+
dur: 0.5,
|
|
218
|
+
label: 'The incoming page slides over the outgoing one',
|
|
219
|
+
paint(ctx, prev, next, p, { W }) {
|
|
220
|
+
ctx.drawImage(prev, -W * 0.18 * easeInOutCubic(p), 0);
|
|
221
|
+
ctx.drawImage(next, W * (1 - easeInOutCubic(p)), 0);
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
|
|
225
|
+
'warp-slide': {
|
|
226
|
+
dur: 0.45,
|
|
227
|
+
label: 'Slide with a stretch — speed you can feel',
|
|
228
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
229
|
+
const e = easeInOutCubic(p);
|
|
230
|
+
const stretch = 1 + 0.08 * Math.sin(Math.PI * p);
|
|
231
|
+
transformed(ctx, prev, { x: -W * e, scaleX: stretch, W, H });
|
|
232
|
+
transformed(ctx, next, { x: W * (1 - e), scaleX: stretch, W, H });
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
|
|
236
|
+
'whip-pan': {
|
|
237
|
+
dur: 0.34,
|
|
238
|
+
label: 'Whip pan — a blurred swing between subjects',
|
|
239
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
240
|
+
// Both layers are on screen through the middle — the outgoing is still leaving as the incoming
|
|
241
|
+
// arrives. Handing over at exactly 0.5 leaves one empty frame, which reads as a dropped frame.
|
|
242
|
+
const blur = Math.sin(Math.PI * p) * 9;
|
|
243
|
+
const f = blur > 0.5 ? `blur(${blur.toFixed(2)}px)` : undefined;
|
|
244
|
+
// Travel exactly one frame-width and overlap the windows: at every p the incoming frame's left
|
|
245
|
+
// edge is at or behind the outgoing frame's right edge, so no band of bare matte is ever exposed.
|
|
246
|
+
const out = easeInCubic(Math.min(1, p / 0.75));
|
|
247
|
+
const inn = easeOutCubic(Math.max(0, (p - 0.25) / 0.75));
|
|
248
|
+
transformed(ctx, next, { x: W * (1 - inn), scaleX: 1.06, filter: f, W, H });
|
|
249
|
+
transformed(ctx, prev, { x: -W * out, scaleX: 1.06, filter: f, W, H });
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
|
|
253
|
+
'polaroid-drop': {
|
|
254
|
+
dur: 0.55,
|
|
255
|
+
label: 'The incoming frame drops in and settles',
|
|
256
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
257
|
+
ctx.drawImage(prev, 0, 0);
|
|
258
|
+
const e = easeOutBack(p);
|
|
259
|
+
transformed(ctx, next, { y: -H * (1 - e), rotate: (1 - e) * 0.05, alpha: smoothstep(Math.min(1, p * 2)), W, H });
|
|
260
|
+
},
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
'blur-dissolve': {
|
|
264
|
+
dur: 0.55,
|
|
265
|
+
label: 'Defocus out, focus in - the premium app-ad standard',
|
|
266
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
267
|
+
// The most common transition in polished app advertising, and the one this table was missing.
|
|
268
|
+
const b = Math.sin(Math.PI * p) * 22;
|
|
269
|
+
const f = b > 0.5 ? `blur(${b.toFixed(2)}px)` : undefined;
|
|
270
|
+
transformed(ctx, prev, { scale: 1 + 0.03 * p, filter: f, W, H });
|
|
271
|
+
transformed(ctx, next, { scale: 1.03 - 0.03 * p, alpha: smoothstep(p), filter: f, W, H });
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
|
|
275
|
+
'zoom-punch': {
|
|
276
|
+
dur: 0.4,
|
|
277
|
+
label: 'Radial zoom-blur through the cut - the beat-drop cut',
|
|
278
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
279
|
+
// Radial blur approximated by stacking scaled, fading copies - Skia has no zoom-blur filter.
|
|
280
|
+
const amt = Math.sin(Math.PI * p);
|
|
281
|
+
const layer = p < 0.5 ? prev : next;
|
|
282
|
+
ctx.drawImage(layer, 0, 0);
|
|
283
|
+
if (amt > 0.02) {
|
|
284
|
+
// BLENDED copies, not additive: `lighter` sums channels, and over a light app UI four passes
|
|
285
|
+
// saturate straight to white instead of reading as a smear.
|
|
286
|
+
ctx.save();
|
|
287
|
+
for (let i = 1; i <= 5; i++) {
|
|
288
|
+
ctx.globalAlpha = 0.3 * amt;
|
|
289
|
+
transformed(ctx, layer, { scale: 1 + 0.05 * i * amt, W, H });
|
|
290
|
+
}
|
|
291
|
+
ctx.restore();
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
|
|
296
|
+
// ── Wipes ──────────────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
'clean-line-wipe': {
|
|
298
|
+
dur: 0.45,
|
|
299
|
+
label: 'Hard-edged linear wipe',
|
|
300
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
301
|
+
ctx.drawImage(prev, 0, 0);
|
|
302
|
+
clipped(ctx, next, (c) => c.rect(0, 0, W * easeInOutCubic(p), H));
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
|
|
306
|
+
'edge-wipe-soft': {
|
|
307
|
+
dur: 0.5,
|
|
308
|
+
label: 'Soft-edged wipe — the seam is feathered',
|
|
309
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
310
|
+
ctx.drawImage(prev, 0, 0);
|
|
311
|
+
// The feather is built on a SCRATCH canvas and then composited normally. Masking with
|
|
312
|
+
// `destination-in` directly on the frame would erase `prev` and the matte underneath it — and the
|
|
313
|
+
// reel pipes unpremultiplied RGBA into ffmpeg's yuv420p conversion, which discards alpha entirely,
|
|
314
|
+
// so a "feather" left in the frame's alpha channel renders as a hard edge with a black fringe.
|
|
315
|
+
const e = easeInOutCubic(p);
|
|
316
|
+
const edge = W * (e * 1.3 - 0.15);
|
|
317
|
+
const feather = W * 0.18;
|
|
318
|
+
const scratch = scratchLike(ctx, W, H);
|
|
319
|
+
const s = scratch.getContext('2d');
|
|
320
|
+
s.drawImage(next, 0, 0);
|
|
321
|
+
s.globalCompositeOperation = 'destination-in';
|
|
322
|
+
const g = s.createLinearGradient(edge - feather, 0, edge + feather, 0);
|
|
323
|
+
g.addColorStop(0, 'rgba(255,255,255,1)');
|
|
324
|
+
g.addColorStop(1, 'rgba(255,255,255,0)');
|
|
325
|
+
s.fillStyle = g;
|
|
326
|
+
s.fillRect(0, 0, W, H);
|
|
327
|
+
ctx.drawImage(scratch, 0, 0);
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
|
|
331
|
+
'iris-circle': {
|
|
332
|
+
dur: 0.55,
|
|
333
|
+
label: 'Iris — the incoming frame opens from a point',
|
|
334
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
335
|
+
ctx.drawImage(prev, 0, 0);
|
|
336
|
+
const r = Math.hypot(W, H) * 0.5 * smootherstep(p);
|
|
337
|
+
clipped(ctx, next, (c) => c.arc(W / 2, H * 0.55, r, 0, Math.PI * 2));
|
|
338
|
+
},
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
'iris-split': {
|
|
342
|
+
dur: 0.55,
|
|
343
|
+
label: 'Two irises open and meet',
|
|
344
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
345
|
+
ctx.drawImage(prev, 0, 0);
|
|
346
|
+
const r = Math.hypot(W, H) * 0.42 * smootherstep(p);
|
|
347
|
+
clipped(ctx, next, (c) => {
|
|
348
|
+
c.arc(W * 0.3, H * 0.45, r, 0, Math.PI * 2);
|
|
349
|
+
c.arc(W * 0.7, H * 0.62, r, 0, Math.PI * 2);
|
|
350
|
+
});
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
'mirror-split': {
|
|
355
|
+
dur: 0.5,
|
|
356
|
+
label: 'The outgoing frame splits apart down the middle',
|
|
357
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
358
|
+
ctx.drawImage(next, 0, 0);
|
|
359
|
+
const e = easeInOutCubic(p);
|
|
360
|
+
ctx.save();
|
|
361
|
+
ctx.beginPath();
|
|
362
|
+
ctx.rect(0, 0, W / 2, H);
|
|
363
|
+
ctx.clip();
|
|
364
|
+
ctx.drawImage(prev, -W * 0.5 * e, 0);
|
|
365
|
+
ctx.restore();
|
|
366
|
+
ctx.save();
|
|
367
|
+
ctx.beginPath();
|
|
368
|
+
ctx.rect(W / 2, 0, W / 2, H);
|
|
369
|
+
ctx.clip();
|
|
370
|
+
ctx.drawImage(prev, W * 0.5 * e, 0);
|
|
371
|
+
ctx.restore();
|
|
372
|
+
},
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
'heart-wipe': {
|
|
376
|
+
dur: 0.6,
|
|
377
|
+
label: 'Heart-shaped reveal',
|
|
378
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
379
|
+
ctx.drawImage(prev, 0, 0);
|
|
380
|
+
const s = smootherstep(p) * Math.hypot(W, H) * 0.06;
|
|
381
|
+
clipped(ctx, next, (c) => {
|
|
382
|
+
// Parametric heart, scaled about the frame centre.
|
|
383
|
+
for (let i = 0; i <= 60; i++) {
|
|
384
|
+
const a = (i / 60) * Math.PI * 2;
|
|
385
|
+
const hx = 16 * Math.pow(Math.sin(a), 3);
|
|
386
|
+
const hy = -(13 * Math.cos(a) - 5 * Math.cos(2 * a) - 2 * Math.cos(3 * a) - Math.cos(4 * a));
|
|
387
|
+
const x = W / 2 + hx * s;
|
|
388
|
+
const y = H * 0.52 + hy * s;
|
|
389
|
+
if (i === 0) c.moveTo(x, y); else c.lineTo(x, y);
|
|
390
|
+
}
|
|
391
|
+
c.closePath();
|
|
392
|
+
});
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
|
|
396
|
+
// ── Physical (the outgoing frame is treated as an object) ──────────────────────────────────────
|
|
397
|
+
flip: {
|
|
398
|
+
dur: 0.5,
|
|
399
|
+
label: 'Card flip — same object, other side',
|
|
400
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
401
|
+
// Scaling X about the centre is the honest 2D projection of a Y-axis rotation — no 3D matrix
|
|
402
|
+
// needed, and the handover at the edge-on midpoint is where the eye expects it.
|
|
403
|
+
const half = p < 0.5;
|
|
404
|
+
const e = easeInOutCubic(half ? p * 2 : (p - 0.5) * 2);
|
|
405
|
+
const sx = Math.max(0.001, half ? 1 - e : e);
|
|
406
|
+
transformed(ctx, half ? prev : next, { scaleX: sx, pivotY: 0, W, H });
|
|
407
|
+
shade(ctx, 0.45 * (1 - Math.abs(p - 0.5) * 2), W, H);
|
|
408
|
+
},
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
'spin-3d': {
|
|
412
|
+
dur: 0.55,
|
|
413
|
+
label: 'Spin — the frame swings past the camera and the next swings in',
|
|
414
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
415
|
+
// Distinct from `flip` on purpose. A flip is a card turning in PLACE; this one TRAVELS while it
|
|
416
|
+
// turns and foreshortens as it goes — the near edge grows as the plane rotates toward the viewer,
|
|
417
|
+
// and the incoming half swings in from the opposite side. Without the travel and the vertical
|
|
418
|
+
// stretch the two transitions render as the same horizontal squeeze.
|
|
419
|
+
const half = p < 0.5;
|
|
420
|
+
const e = easeInOutCubic(half ? p * 2 : (p - 0.5) * 2);
|
|
421
|
+
const sx = Math.max(0.001, half ? 1 - e : e);
|
|
422
|
+
const dir = half ? -1 : 1;
|
|
423
|
+
const edgeOn = 1 - sx; // 0 face-on → 1 edge-on
|
|
424
|
+
transformed(ctx, half ? prev : next, {
|
|
425
|
+
scaleX: sx,
|
|
426
|
+
scaleY: 1 + 0.14 * edgeOn,
|
|
427
|
+
x: dir * W * 0.22 * edgeOn,
|
|
428
|
+
W, H,
|
|
429
|
+
});
|
|
430
|
+
shade(ctx, 0.6 * (1 - Math.abs(p - 0.5) * 2), W, H);
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
|
|
434
|
+
'page-peel': {
|
|
435
|
+
dur: 0.6,
|
|
436
|
+
label: 'The outgoing page peels away from the corner',
|
|
437
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
438
|
+
ctx.drawImage(next, 0, 0);
|
|
439
|
+
const e = smoothstep(p);
|
|
440
|
+
// `prev` must cover the WHOLE frame at p=0 and lose a growing corner — the old version clipped to
|
|
441
|
+
// the upper-left triangle from the start, so half the frame cut to `next` on frame one.
|
|
442
|
+
const reach = e * (W + H) * 1.15;
|
|
443
|
+
ctx.save();
|
|
444
|
+
ctx.beginPath();
|
|
445
|
+
ctx.moveTo(0, 0);
|
|
446
|
+
ctx.lineTo(W, 0);
|
|
447
|
+
if (reach < W) {
|
|
448
|
+
ctx.lineTo(W, H);
|
|
449
|
+
ctx.lineTo(W - reach, H); // seam still climbing the bottom edge
|
|
450
|
+
} else {
|
|
451
|
+
ctx.lineTo(W, Math.max(0, H - (reach - W))); // seam now climbing the right edge
|
|
452
|
+
}
|
|
453
|
+
ctx.lineTo(Math.max(0, W - reach), H);
|
|
454
|
+
ctx.lineTo(0, H);
|
|
455
|
+
ctx.closePath();
|
|
456
|
+
ctx.clip();
|
|
457
|
+
ctx.drawImage(prev, 0, 0);
|
|
458
|
+
ctx.restore();
|
|
459
|
+
ctx.save();
|
|
460
|
+
ctx.globalAlpha = 0.22 * (1 - Math.abs(p - 0.5) * 2);
|
|
461
|
+
ctx.strokeStyle = '#000000';
|
|
462
|
+
ctx.lineWidth = Math.max(2, W * 0.01);
|
|
463
|
+
ctx.beginPath();
|
|
464
|
+
ctx.moveTo(Math.max(0, W - reach), H);
|
|
465
|
+
ctx.lineTo(W, Math.max(0, H - Math.max(0, reach - W)));
|
|
466
|
+
ctx.stroke();
|
|
467
|
+
ctx.restore();
|
|
468
|
+
},
|
|
469
|
+
},
|
|
470
|
+
|
|
471
|
+
'tearing-paper': {
|
|
472
|
+
dur: 0.65,
|
|
473
|
+
label: 'The outgoing frame tears in two and parts',
|
|
474
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
475
|
+
ctx.drawImage(next, 0, 0);
|
|
476
|
+
const e = easeInCubic(p);
|
|
477
|
+
// One ragged seam, deterministic, shared by both halves so the tear matches.
|
|
478
|
+
const seam = (y) => W / 2 + Math.sin(y / (H / 9)) * W * 0.035 + (rnd(Math.round(y / 24)) - 0.5) * W * 0.03;
|
|
479
|
+
const half = (dir) => {
|
|
480
|
+
ctx.save();
|
|
481
|
+
ctx.beginPath();
|
|
482
|
+
if (dir < 0) {
|
|
483
|
+
ctx.moveTo(0, 0);
|
|
484
|
+
for (let y = 0; y <= H; y += 12) ctx.lineTo(seam(y), y);
|
|
485
|
+
ctx.lineTo(0, H);
|
|
486
|
+
} else {
|
|
487
|
+
ctx.moveTo(W, 0);
|
|
488
|
+
for (let y = 0; y <= H; y += 12) ctx.lineTo(seam(y), y);
|
|
489
|
+
ctx.lineTo(W, H);
|
|
490
|
+
}
|
|
491
|
+
ctx.closePath();
|
|
492
|
+
ctx.clip();
|
|
493
|
+
ctx.translate(dir * W * 0.6 * e, 0);
|
|
494
|
+
ctx.rotate(dir * 0.05 * e);
|
|
495
|
+
ctx.drawImage(prev, 0, 0);
|
|
496
|
+
ctx.restore();
|
|
497
|
+
};
|
|
498
|
+
half(-1);
|
|
499
|
+
half(1);
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
|
|
503
|
+
// ── Light (blend-mode overlays) ────────────────────────────────────────────────────────────────
|
|
504
|
+
'light-leak-wipe': {
|
|
505
|
+
dur: 0.6,
|
|
506
|
+
label: 'A warm light leak sweeps the cut',
|
|
507
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
508
|
+
ctx.drawImage(prev, 0, 0);
|
|
509
|
+
ctx.globalAlpha = smoothstep(p);
|
|
510
|
+
ctx.drawImage(next, 0, 0);
|
|
511
|
+
ctx.globalAlpha = 1;
|
|
512
|
+
const x = W * (-0.2 + 1.4 * p);
|
|
513
|
+
const g = ctx.createLinearGradient(x - W * 0.35, 0, x + W * 0.35, H);
|
|
514
|
+
g.addColorStop(0, 'rgba(255,170,90,0)');
|
|
515
|
+
g.addColorStop(0.5, 'rgba(255,196,130,0.75)');
|
|
516
|
+
g.addColorStop(1, 'rgba(255,170,90,0)');
|
|
517
|
+
ctx.save();
|
|
518
|
+
ctx.globalCompositeOperation = 'screen';
|
|
519
|
+
ctx.globalAlpha = Math.sin(Math.PI * p);
|
|
520
|
+
ctx.fillStyle = g;
|
|
521
|
+
ctx.fillRect(0, 0, W, H);
|
|
522
|
+
ctx.restore();
|
|
523
|
+
},
|
|
524
|
+
},
|
|
525
|
+
|
|
526
|
+
'glare-sweep': {
|
|
527
|
+
dur: 0.5,
|
|
528
|
+
label: 'A lens-flare streak crosses the cut',
|
|
529
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
530
|
+
ctx.drawImage(prev, 0, 0);
|
|
531
|
+
ctx.globalAlpha = smoothstep(p);
|
|
532
|
+
ctx.drawImage(next, 0, 0);
|
|
533
|
+
ctx.globalAlpha = 1;
|
|
534
|
+
const x = W * (-0.3 + 1.6 * p);
|
|
535
|
+
const g = ctx.createLinearGradient(x - W * 0.12, 0, x + W * 0.12, 0);
|
|
536
|
+
g.addColorStop(0, 'rgba(255,255,255,0)');
|
|
537
|
+
g.addColorStop(0.5, 'rgba(255,255,255,0.9)');
|
|
538
|
+
g.addColorStop(1, 'rgba(255,255,255,0)');
|
|
539
|
+
ctx.save();
|
|
540
|
+
ctx.globalCompositeOperation = 'screen';
|
|
541
|
+
ctx.globalAlpha = softPeak(p) * 0.8;
|
|
542
|
+
ctx.translate(W / 2, H / 2);
|
|
543
|
+
ctx.rotate(-0.35);
|
|
544
|
+
ctx.translate(-W / 2, -H / 2);
|
|
545
|
+
ctx.fillStyle = g;
|
|
546
|
+
ctx.fillRect(-W, 0, W * 3, H);
|
|
547
|
+
ctx.restore();
|
|
548
|
+
},
|
|
549
|
+
},
|
|
550
|
+
|
|
551
|
+
'floodlight-sweep': {
|
|
552
|
+
dur: 0.6,
|
|
553
|
+
label: 'A warm stage light swells across the cut',
|
|
554
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
555
|
+
ctx.drawImage(prev, 0, 0);
|
|
556
|
+
ctx.globalAlpha = smoothstep(p);
|
|
557
|
+
ctx.drawImage(next, 0, 0);
|
|
558
|
+
ctx.globalAlpha = 1;
|
|
559
|
+
const g = ctx.createRadialGradient(W / 2, H * 0.45, 0, W / 2, H * 0.45, Math.hypot(W, H) * 0.6);
|
|
560
|
+
g.addColorStop(0, 'rgba(255,214,160,0.85)');
|
|
561
|
+
g.addColorStop(1, 'rgba(255,214,160,0)');
|
|
562
|
+
ctx.save();
|
|
563
|
+
ctx.globalCompositeOperation = 'screen';
|
|
564
|
+
ctx.globalAlpha = softPeak(p) * 0.7;
|
|
565
|
+
ctx.fillStyle = g;
|
|
566
|
+
ctx.fillRect(0, 0, W, H);
|
|
567
|
+
ctx.restore();
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
'clouds-wipe': {
|
|
572
|
+
dur: 0.7,
|
|
573
|
+
label: 'Soft billows part to reveal',
|
|
574
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
575
|
+
ctx.drawImage(prev, 0, 0);
|
|
576
|
+
const e = smootherstep(p);
|
|
577
|
+
// Procedural billows: deterministic blobs growing along a rising front.
|
|
578
|
+
ctx.save();
|
|
579
|
+
ctx.beginPath();
|
|
580
|
+
for (let i = 0; i < 26; i++) {
|
|
581
|
+
const bx = rnd(i) * W;
|
|
582
|
+
const by = H * (1.15 - e * 1.35) + rnd(i + 99) * H * 0.22;
|
|
583
|
+
const r = (0.10 + rnd(i + 7) * 0.12) * W * (0.5 + e);
|
|
584
|
+
ctx.moveTo(bx + r, by);
|
|
585
|
+
ctx.arc(bx, by, r, 0, Math.PI * 2);
|
|
586
|
+
}
|
|
587
|
+
ctx.rect(0, H * (1.15 - e * 1.35) + H * 0.2, W, H);
|
|
588
|
+
ctx.clip();
|
|
589
|
+
ctx.drawImage(next, 0, 0);
|
|
590
|
+
ctx.restore();
|
|
591
|
+
},
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
// ── Texture ────────────────────────────────────────────────────────────────────────────────────
|
|
595
|
+
'glitch-cut': {
|
|
596
|
+
dur: 0.36,
|
|
597
|
+
label: 'RGB split + scanlines — a deliberate digital break',
|
|
598
|
+
paint(ctx, prev, next, p, { W, H }) {
|
|
599
|
+
const base = p < 0.5 ? prev : next;
|
|
600
|
+
// Gaussian bell centred slightly before the midpoint, as in the original.
|
|
601
|
+
const amt = Math.exp(-Math.pow((p - 0.45) / 0.18, 2) / 2);
|
|
602
|
+
const off = amt * W * 0.012;
|
|
603
|
+
ctx.drawImage(base, 0, 0);
|
|
604
|
+
if (amt > 0.02) {
|
|
605
|
+
ctx.save();
|
|
606
|
+
ctx.globalCompositeOperation = 'screen';
|
|
607
|
+
ctx.globalAlpha = 0.5 * amt;
|
|
608
|
+
ctx.drawImage(base, -off, 0);
|
|
609
|
+
ctx.drawImage(base, off, 0);
|
|
610
|
+
ctx.restore();
|
|
611
|
+
// Horizontal tear bands.
|
|
612
|
+
for (let i = 0; i < 5; i++) {
|
|
613
|
+
const y = rnd(i + Math.round(p * 8)) * H;
|
|
614
|
+
const h = H * 0.02;
|
|
615
|
+
ctx.drawImage(base, 0, y, W, h, (rnd(i + 3) - 0.5) * off * 4, y, W, h);
|
|
616
|
+
}
|
|
617
|
+
// Scanlines.
|
|
618
|
+
ctx.save();
|
|
619
|
+
ctx.globalCompositeOperation = 'overlay';
|
|
620
|
+
ctx.globalAlpha = 0.25 * amt;
|
|
621
|
+
ctx.fillStyle = '#000000';
|
|
622
|
+
for (let y = 0; y < H; y += 4) ctx.fillRect(0, y, W, 2);
|
|
623
|
+
ctx.restore();
|
|
624
|
+
}
|
|
625
|
+
},
|
|
626
|
+
},
|
|
627
|
+
};
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Deterministic rotation for projects that don't art-direct each cut. Mostly plain on purpose: the
|
|
631
|
+
* expressive entries appear rarely and never twice in a row.
|
|
632
|
+
*/
|
|
633
|
+
const AUTO_CYCLE = [
|
|
634
|
+
'cut', 'cut', 'dissolve', 'cut', 'push', 'cut', 'cinematic-dissolve', 'cut',
|
|
635
|
+
'cut', 'glare-sweep', 'cut', 'dissolve',
|
|
636
|
+
];
|
|
637
|
+
|
|
638
|
+
/** Resolve a cut id (`auto` picks from the rotation by scene index), with a helpful error. */
|
|
639
|
+
/** Ids kept for compatibility that resolve to another entry (deduplicated moves). */
|
|
640
|
+
const ALIASES = { 'clean-circle-wipe': 'iris-circle' };
|
|
641
|
+
|
|
642
|
+
export function transitionFor(id, index = 0) {
|
|
643
|
+
id = ALIASES[id] || id;
|
|
644
|
+
const key = id === 'auto' ? AUTO_CYCLE[index % AUTO_CYCLE.length] : id || 'cut';
|
|
645
|
+
const t = TRANSITIONS[key];
|
|
646
|
+
if (!t) {
|
|
647
|
+
throw new Error(`Unknown cut "${id}". Available: ${Object.keys(TRANSITIONS).join(', ')}, auto.`);
|
|
648
|
+
}
|
|
649
|
+
return t;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/** Every cut id, for `zdymak specs` and docs. */
|
|
653
|
+
export const TRANSITION_IDS = Object.keys(TRANSITIONS);
|