@vectoriox/iox-ui 5.75.3 → 5.76.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/fesm2022/vectoriox-iox-ui-media-core.mjs +17 -88
- package/fesm2022/vectoriox-iox-ui-media-core.mjs.map +1 -1
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs +10 -8
- package/fesm2022/vectoriox-iox-ui-scene-core.mjs.map +1 -1
- package/media-core/index.d.ts +5 -40
- package/package.json +1 -1
- package/src/media-core/frames.ts +15 -102
- package/src/scene-core/scroll-driver.ts +10 -8
package/package.json
CHANGED
package/src/media-core/frames.ts
CHANGED
|
@@ -111,23 +111,6 @@ export function framePosition(played: number, count: number): number {
|
|
|
111
111
|
/** On a Video whose pictures are drawing: the poster has done its job and steps aside. */
|
|
112
112
|
export const FRAMES_SHOWING_ATTRIBUTE = 'data-iox-frames-showing';
|
|
113
113
|
|
|
114
|
-
/**
|
|
115
|
-
* Below this many pictures a second the film stops following the scroll exactly and lands on its own. A landing scroll
|
|
116
|
-
* slows exponentially, so the last picture or two would take most of a second as one slow dissolve between two places:
|
|
117
|
-
* the subject showing twice, and then settling. Landing at least this fast keeps the end of the move a move.
|
|
118
|
-
*/
|
|
119
|
-
export const FILM_LANDING_SPEED = 10;
|
|
120
|
-
/** With no word from the driver on where the scroll is going, how fast a landing scroll is assumed to slow (per second). */
|
|
121
|
-
const FILM_LANDING_DECAY = 5;
|
|
122
|
-
/** How quickly the film catches up with a scroll that turned back while it was landing ahead of it (per second). */
|
|
123
|
-
const FILM_CATCH_RATE = 25;
|
|
124
|
-
/** How quickly the film's reading of the scroll's speed follows it (per second). */
|
|
125
|
-
const FILM_SPEED_RATE = 30;
|
|
126
|
-
/** A step this short is the same frame asked twice: it moves nothing and reads no speed. */
|
|
127
|
-
const FILM_SAME_FRAME_SECONDS = 0.002;
|
|
128
|
-
const FILM_MAX_STEP_SECONDS = 0.1;
|
|
129
|
-
const FILM_EPSILON = 1e-6;
|
|
130
|
-
|
|
131
114
|
/** How finely two neighbours mix. Finer than the eye reads, and a scroll that moves less than a step draws nothing. */
|
|
132
115
|
export const FRAME_BLEND_STEPS = 32;
|
|
133
116
|
|
|
@@ -152,59 +135,6 @@ export function frameBlendAt(loaded: ReadonlyArray<unknown>, position: number):
|
|
|
152
135
|
return nearest < 0 ? null : { from: nearest, to: nearest, mix: 0 };
|
|
153
136
|
}
|
|
154
137
|
|
|
155
|
-
/** Where the film stands and how the scroll under it moves: what one landing step reads and writes. */
|
|
156
|
-
export interface FilmMotion {
|
|
157
|
-
/** The moment drawn, in pictures (2.5 = half way from the third to the fourth). */
|
|
158
|
-
position: number;
|
|
159
|
-
/** The moment the scroll asks for. */
|
|
160
|
-
wanted: number;
|
|
161
|
-
/** The scroll's speed, in pictures a second, smoothed. */
|
|
162
|
-
speed: number;
|
|
163
|
-
/** The way the scroll last moved: 1 onward, -1 back. */
|
|
164
|
-
direction: 1 | -1;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/** Where a film stands before its first step: on the moment asked for, still. */
|
|
168
|
-
export function filmAt(wanted: number): FilmMotion {
|
|
169
|
-
return { position: wanted, wanted, speed: 0, direction: 1 };
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* One step of the film. Moving fast, it stands exactly where the scroll asks. Landing (slower than FILM_LANDING_SPEED
|
|
174
|
-
* pictures a second) it runs on at that speed to the whole picture nearest where the scroll will stop, and stands there:
|
|
175
|
-
* it never crawls through a dissolve, never stops between two pictures, and never steps back over a picture it reached
|
|
176
|
-
* unless the scroll turns. `aim` is where the scroll is heading, when the driver knows it (its undamped target); without it, it is guessed.
|
|
177
|
-
*/
|
|
178
|
-
export function stepFilm(motion: FilmMotion, wanted: number, seconds: number, last: number, aim?: number): FilmMotion {
|
|
179
|
-
const sameFrame = seconds < FILM_SAME_FRAME_SECONDS;
|
|
180
|
-
const step = Math.min(FILM_MAX_STEP_SECONDS, seconds);
|
|
181
|
-
const moved = wanted - motion.wanted;
|
|
182
|
-
const speed = sameFrame ? motion.speed : motion.speed + (moved / step - motion.speed) * (1 - Math.exp(-FILM_SPEED_RATE * step));
|
|
183
|
-
const heading = aim === undefined ? moved : aim - wanted;
|
|
184
|
-
const direction: 1 | -1 = Math.abs(heading) > FILM_EPSILON ? (heading > 0 ? 1 : -1) : Math.abs(moved) > FILM_EPSILON ? (moved > 0 ? 1 : -1) : motion.direction;
|
|
185
|
-
// Moving: with the scroll, but never back over a picture a landing already reached unless the scroll turned.
|
|
186
|
-
if (Math.abs(speed) >= FILM_LANDING_SPEED) {
|
|
187
|
-
const position = direction > 0 ? Math.max(wanted, Math.min(motion.position, Math.ceil(wanted))) : Math.min(wanted, Math.max(motion.position, Math.floor(wanted)));
|
|
188
|
-
return { position, wanted, speed, direction };
|
|
189
|
-
}
|
|
190
|
-
if (sameFrame) return { ...motion, wanted, direction };
|
|
191
|
-
const clamp = (value: number): number => Math.min(last, Math.max(0, value));
|
|
192
|
-
const stop = aim ?? wanted + speed / FILM_LANDING_DECAY;
|
|
193
|
-
// The picture nearest the stop, unless reaching it would show a step back: then the next whole one on the way.
|
|
194
|
-
const nearest = Math.round(stop);
|
|
195
|
-
const back = (motion.position - nearest) * direction > 1 / FRAME_BLEND_STEPS;
|
|
196
|
-
const goal = clamp(back ? (direction > 0 ? Math.ceil(motion.position) : Math.floor(motion.position)) : nearest);
|
|
197
|
-
const behind = (wanted - motion.position) * direction;
|
|
198
|
-
const reach = Math.max(FILM_LANDING_SPEED * step, behind > 0 ? behind * (1 - Math.exp(-FILM_CATCH_RATE * step)) : 0);
|
|
199
|
-
const position = direction > 0 ? Math.min(goal, motion.position + reach) : Math.max(goal, motion.position - reach);
|
|
200
|
-
return { position, wanted, speed, direction };
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Whether a film has landed: still, on a whole picture beside where the scroll stands. */
|
|
204
|
-
export function filmLanded(motion: FilmMotion): boolean {
|
|
205
|
-
return Math.abs(motion.speed) < 0.01 && Number.isInteger(motion.position) && Math.abs(motion.position - motion.wanted) < 1;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
138
|
/** The picture at `played` % of a set of `count`. */
|
|
209
139
|
export function frameIndexAt(played: number, count: number): number {
|
|
210
140
|
if (count <= 1) return 0;
|
|
@@ -321,10 +251,6 @@ export interface FramePlayerOptions {
|
|
|
321
251
|
*/
|
|
322
252
|
export class FramePlayer {
|
|
323
253
|
private played: number | null = null;
|
|
324
|
-
private aim: number | undefined;
|
|
325
|
-
private motion: FilmMotion | null = null;
|
|
326
|
-
private steppedAt = 0;
|
|
327
|
-
private landingFrame: number | null = null;
|
|
328
254
|
private readonly set: FrameSet;
|
|
329
255
|
private readonly info: FrameSetInfo;
|
|
330
256
|
private readonly images: Array<HTMLImageElement | null>;
|
|
@@ -360,33 +286,13 @@ export class FramePlayer {
|
|
|
360
286
|
}
|
|
361
287
|
|
|
362
288
|
/**
|
|
363
|
-
* Stand at `played` %,
|
|
364
|
-
*
|
|
289
|
+
* Stand at `played` %, exactly. The film keeps no motion of its own: the scroll's glide, and the slowing at its end,
|
|
290
|
+
* are the caller's, so the film slows with the page and stops when it stops. Between two pictures it blends them.
|
|
365
291
|
*/
|
|
366
|
-
seek(played: number
|
|
367
|
-
if (played === this.played
|
|
292
|
+
seek(played: number): void {
|
|
293
|
+
if (played === this.played) return;
|
|
368
294
|
this.played = played;
|
|
369
|
-
this.aim = aim;
|
|
370
|
-
this.step(now ?? this.host.performance?.now() ?? 0);
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/** One step toward the moment asked for, drawn now; a landing goes on by itself until it stands on a whole picture. */
|
|
374
|
-
private step(now: number): void {
|
|
375
|
-
if (this.destroyed || this.played === null) return;
|
|
376
|
-
const wanted = framePosition(this.played, this.info.count);
|
|
377
|
-
const aim = this.aim === undefined ? undefined : framePosition(this.aim, this.info.count);
|
|
378
|
-
this.motion = this.motion ? stepFilm(this.motion, wanted, (now - this.steppedAt) / 1000, this.info.count - 1, aim) : filmAt(wanted);
|
|
379
|
-
this.steppedAt = now;
|
|
380
295
|
this.draw();
|
|
381
|
-
if (!filmLanded(this.motion) && this.landingFrame === null) this.landingFrame = this.host.requestAnimationFrame(time => {
|
|
382
|
-
this.landingFrame = null;
|
|
383
|
-
this.step(time);
|
|
384
|
-
});
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
private stopLanding(): void {
|
|
388
|
-
if (this.landingFrame !== null) this.host.cancelAnimationFrame?.(this.landingFrame);
|
|
389
|
-
this.landingFrame = null;
|
|
390
296
|
}
|
|
391
297
|
|
|
392
298
|
/** Whether a re-render took the canvas away with the markup it sat in. */
|
|
@@ -396,7 +302,6 @@ export class FramePlayer {
|
|
|
396
302
|
|
|
397
303
|
destroy(): void {
|
|
398
304
|
this.destroyed = true;
|
|
399
|
-
this.stopLanding();
|
|
400
305
|
this.el.removeAttribute(FRAMES_SHOWING_ATTRIBUTE);
|
|
401
306
|
this.observer?.disconnect();
|
|
402
307
|
this.observer = null;
|
|
@@ -457,8 +362,8 @@ export class FramePlayer {
|
|
|
457
362
|
}
|
|
458
363
|
|
|
459
364
|
private draw(): void {
|
|
460
|
-
if (
|
|
461
|
-
const blend = frameBlendAt(this.images, this.
|
|
365
|
+
if (this.destroyed || this.played === null) return;
|
|
366
|
+
const blend = frameBlendAt(this.images, framePosition(this.played, this.info.count));
|
|
462
367
|
const last = this.drawn;
|
|
463
368
|
if (!blend || (last && last.from === blend.from && last.to === blend.to && last.mix === blend.mix)) return;
|
|
464
369
|
const image = this.images[blend.from]!;
|
|
@@ -466,11 +371,19 @@ export class FramePlayer {
|
|
|
466
371
|
const context = this.context!;
|
|
467
372
|
const { width, height } = this.canvas!;
|
|
468
373
|
context.clearRect(0, 0, width, height);
|
|
469
|
-
context.drawImage(image, 0, 0, width, height);
|
|
470
374
|
if (blend.mix > 0) {
|
|
375
|
+
// A true mix, (1 − mix)·from + mix·to, even where a see-through film has nothing on one side. Drawing `to` over
|
|
376
|
+
// an opaque `from` kept `from`'s outline whole until the next picture, so the subject's edge jumped a picture at
|
|
377
|
+
// a time instead of moving.
|
|
378
|
+
context.globalCompositeOperation = 'lighter';
|
|
379
|
+
context.globalAlpha = 1 - blend.mix;
|
|
380
|
+
context.drawImage(image, 0, 0, width, height);
|
|
471
381
|
context.globalAlpha = blend.mix;
|
|
472
382
|
context.drawImage(this.images[blend.to]!, 0, 0, width, height);
|
|
473
383
|
context.globalAlpha = 1;
|
|
384
|
+
context.globalCompositeOperation = 'source-over';
|
|
385
|
+
} else {
|
|
386
|
+
context.drawImage(image, 0, 0, width, height);
|
|
474
387
|
}
|
|
475
388
|
// Over a transparent film the poster would show through: a second, stiller subject behind the moving one.
|
|
476
389
|
if (!this.drawn) this.el.setAttribute(FRAMES_SHOWING_ATTRIBUTE, '');
|
|
@@ -19,7 +19,8 @@ import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
export const SCENE_DAMPING = 9;
|
|
22
|
-
|
|
22
|
+
/** Page px from the scroll at which the damping stops and stands exactly on it: a step this small never shows. */
|
|
23
|
+
const SETTLED_PX = 0.25;
|
|
23
24
|
const MAX_FRAME_SECONDS = 0.1;
|
|
24
25
|
/** The first frame after a sleep has no previous one to measure from: it counts as one frame, or a scroll would lose it. */
|
|
25
26
|
const WAKING_FRAME_SECONDS = 1 / 60;
|
|
@@ -263,11 +264,12 @@ export class ScrollDriver {
|
|
|
263
264
|
for (const { scene, local, target } of reads) {
|
|
264
265
|
if (this.options.pin && scene.stage) scene.stage.style.setProperty('translate', `0 ${local}px`);
|
|
265
266
|
const next = scene.fresh ? target : dampToward(scene.progress, target, seconds, this.options.damping);
|
|
266
|
-
|
|
267
|
+
// The step onto the scroll is what is left: taken once it is under SETTLED_PX, so it never shows.
|
|
268
|
+
const settled = scene.fresh || Math.abs(target - scene.progress) * Math.max(1, scene.travel) < SETTLED_PX;
|
|
267
269
|
scene.progress = settled ? target : next;
|
|
268
270
|
scene.target = target;
|
|
269
271
|
scene.fresh = false;
|
|
270
|
-
this.write(scene
|
|
272
|
+
this.write(scene);
|
|
271
273
|
this.options.onFrame?.(scene.root, { target, progress: scene.progress });
|
|
272
274
|
moving = moving || !settled;
|
|
273
275
|
}
|
|
@@ -283,11 +285,11 @@ export class ScrollDriver {
|
|
|
283
285
|
return { scene, local, target: progress };
|
|
284
286
|
}
|
|
285
287
|
|
|
286
|
-
private write(scene: Scene
|
|
288
|
+
private write(scene: Scene): void {
|
|
287
289
|
for (const layer of scene.layers) {
|
|
288
290
|
const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
|
|
289
291
|
this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
|
|
290
|
-
this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress)
|
|
292
|
+
this.writePlayed(layer, scene, scenePlayed(tracks, scene.progress));
|
|
291
293
|
const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
|
|
292
294
|
for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
|
|
293
295
|
if (layer.stale || frame[key] !== layer.last[key]) layer.el.style[key] = frame[key] ?? '';
|
|
@@ -305,7 +307,7 @@ export class ScrollDriver {
|
|
|
305
307
|
* The picture this moment asks for. The attribute sits on the Video's own root, which is the layer on the string path
|
|
306
308
|
* and inside it on the component path. A layer with no pictures, or no Play track, holds its poster.
|
|
307
309
|
*/
|
|
308
|
-
private writePlayed(layer: Layer, scene: Scene, played: number | null
|
|
310
|
+
private writePlayed(layer: Layer, scene: Scene, played: number | null): void {
|
|
309
311
|
const target = played === null ? null : (layer.el.hasAttribute(VideoAttribute.Frames) ? layer.el : layer.el.querySelector<HTMLElement>(`[${VideoAttribute.Frames}]`));
|
|
310
312
|
const raw = target?.getAttribute(VideoAttribute.Frames) ?? '';
|
|
311
313
|
const sequence = raw ? normalizeFrameSequence(raw) : null;
|
|
@@ -319,8 +321,8 @@ export class ScrollDriver {
|
|
|
319
321
|
layer.player = new FramePlayer(target, sequence, { phone: scene.phone, near: scene.root, host: this.host });
|
|
320
322
|
layer.playerKey = key;
|
|
321
323
|
}
|
|
322
|
-
//
|
|
323
|
-
layer.player.seek(played
|
|
324
|
+
// The damped moment, like every other layer: the film slows with the page and stops when it stops.
|
|
325
|
+
layer.player.seek(played!);
|
|
324
326
|
}
|
|
325
327
|
|
|
326
328
|
/** One opacity per word, and a write only for the few the scroll actually changed. */
|