Skip to content

Commit e1430ed

Browse files
committed
chore: merge the updated derk/sdk-motion-toolbar-presets
2 parents 62bcaea + 458a4f0 commit e1430ed

24 files changed

Lines changed: 805 additions & 40 deletions

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"dazzatron",
77
"kratos2k7"
88
],
9-
"version": "2.28.2",
9+
"version": "2.29.1",
1010
"description": "A video editing library for creating and editing videos with Shotstack",
1111
"type": "module",
1212
"main": "dist/shotstack-studio.umd.js",

‎readme.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,21 @@ const edit = new Edit(templateJson, { html5Prerender: "on-play" });
187187

188188
Captured frames are the same either way. Until the edit plays, the canvas shows each `html5` clip as a still of its frame at the playhead, in its layer and with its effects. Playback stays choppy until the frames are ready, so keep the default, `"on-load"`, for an edit that someone watches.
189189

190+
`edit.getPendingCaptures()` returns `{ pending, total, progress }`: how many `html5` clips are still preparing smooth playback, out of those that will play pre-rendered frames, and how far along they are, from 0 to 1 by frames baked. Failed clips, and an `"on-play"` edit's clips before it plays, count in neither number. The timeline shows this as a ring around its play button and a ring in each preparing clip. A player of your own can read it on the capture events, and a few times a second in between, since progress moves without them:
191+
192+
```typescript
193+
const showPreparing = () => {
194+
const { pending, progress } = edit.getPendingCaptures();
195+
ring.hidden = pending === 0;
196+
ring.style.setProperty("--progress", String(progress));
197+
};
198+
edit.events.on("clip:captureStarted", showPreparing);
199+
edit.events.on("clip:captureCompleted", showPreparing);
200+
edit.events.on("clip:captureFailed", showPreparing);
201+
setInterval(showPreparing, 250);
202+
showPreparing();
203+
```
204+
190205
#### Events
191206

192207
Listen using string event names:
@@ -221,6 +236,9 @@ Available event names:
221236
| Duration | `duration:changed` |
222237
| Output | `output:resized`, `output:resolutionChanged`, `output:aspectRatioChanged`, `output:fpsChanged`, `output:formatChanged`, `output:destinationsChanged` |
223238
| Merge fields | `mergefield:changed` |
239+
| `html5` pre-rendering | `clip:captureStarted`, `clip:captureCompleted`, `clip:captureFailed` |
240+
241+
A failed capture's `fallback` is `"static-placeholder"` when the clip shows a placeholder, or `"scaled-frames"` when a sharper pre-render failed and the clip keeps its current frames.
224242

225243
### Generating assets from prompts
226244

‎src/components/canvas/players/html5-player.ts‎

Lines changed: 83 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Player, PlayerType } from "@canvas/players/player";
22
import type { Edit } from "@core/edit-session";
3-
import { EditEvent } from "@core/events/edit-events";
3+
import { EditEvent, InternalEvent } from "@core/events/edit-events";
4+
import type { PlaybackReadiness } from "@core/pending-captures";
45
import type { ResolvedTiming } from "@core/timing/types";
56
import { type Size } from "@layouts/geometry";
67
import type { ResolvedClip } from "@schemas";
@@ -31,6 +32,7 @@ const SEEK_KEY = "__shotstackSeek" as const;
3132
const CAPTURE_SLICE_MS = 12;
3233
// A frame in flight holds its canvas until toBlob takes its pixels.
3334
const CAPTURE_MEMORY_BUDGET_BYTES = 256 * 1024 * 1024;
35+
const CAPTURE_PROGRESS_STEPS = 20;
3436
const BAKE_SCALES = [0.25, 0.5, 0.75, 1];
3537

3638
function forceLayout(el: HTMLElement): void {
@@ -227,6 +229,14 @@ export class Html5Player extends Player {
227229
}
228230
}
229231

232+
private emitCaptureProgress(): void {
233+
try {
234+
this.edit.getInternalEvents().emit(InternalEvent.CaptureProgressChanged, { clipId: this.clipId });
235+
} catch (emitErr) {
236+
console.warn("[Html5Player] failed to emit CaptureProgressChanged:", emitErr);
237+
}
238+
}
239+
230240
private emitCaptureCompleted(frameCount: number): void {
231241
try {
232242
this.edit.getInternalEvents().emit(EditEvent.ClipCaptureCompleted, {
@@ -341,6 +351,8 @@ export class Html5Player extends Player {
341351

342352
private transitionToCapturing(): void {
343353
this.mode = "capturing";
354+
this.captureFramesDone = 0;
355+
this.captureFramesTotal = 0;
344356
if (!this.liveStill) this.mountLoadingGraphic();
345357
this.emitCaptureStarted();
346358
}
@@ -360,21 +372,51 @@ export class Html5Player extends Player {
360372
return this.captureInFlight;
361373
}
362374
const queuedFor = this.contentHash;
363-
const capture: Promise<Blob[] | null> = Html5Player.captureChain
364-
.then(() => {
365-
// A content change while this waited in the queue would bake the new content at this capture's floor.
366-
if (this.disposed || this.contentHash !== queuedFor) return null;
367-
return this.runCapture(minScale);
368-
})
375+
const previous = Html5Player.captureChain;
376+
// Cached frames cost no bake, so they never wait in the queue behind one.
377+
const capture: Promise<Blob[] | null> = this.bakeScale(minScale)
378+
.then(scale => this.adoptCachedBake(scale))
379+
.then(
380+
cached =>
381+
cached ??
382+
previous.then(() => {
383+
// A content change while this waited in the queue would bake the new content at this capture's floor.
384+
if (this.disposed || this.contentHash !== queuedFor) return null;
385+
return this.runCapture(minScale);
386+
})
387+
)
369388
.finally(() => {
370389
// A content change frees the slot, and a newer capture may hold it by now.
371390
if (this.captureInFlight === capture) this.captureInFlight = null;
372391
});
373392
this.captureInFlight = capture;
374-
Html5Player.captureChain = capture.catch(() => undefined);
393+
// A cache hit settles early, so the next bake waits on the whole queue, not just this capture.
394+
Html5Player.captureChain = Promise.allSettled([previous, capture]).then(() => undefined);
375395
return capture;
376396
}
377397

398+
// After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
399+
// transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
400+
private async bakeScale(minScale: number): Promise<number> {
401+
await nextFrame();
402+
return Math.max(bakeScaleFor(this.onScreenScale()), minScale);
403+
}
404+
405+
private async adoptCachedBake(scale: number): Promise<Blob[] | null> {
406+
const { contentHash } = this;
407+
if (!contentHash) return null;
408+
const stale = (): boolean => this.disposed || this.contentHash !== contentHash;
409+
const cacheKey = `${contentHash}@${scale}`;
410+
const cached = await html5CacheGet(cacheKey);
411+
if (stale() || !cached) return null;
412+
this.captureFramesTotal = cached.frameCount;
413+
this.captureFramesDone = cached.frameCount;
414+
this.captureFps = cached.fps;
415+
if (!(await this.adoptBake(cached.pngs, cacheKey, scale, stale))) return null;
416+
this.emitCaptureCompleted(cached.frameCount);
417+
return cached.pngs;
418+
}
419+
378420
// A re-bake passes the scale it was started for, so a later, smaller sample can't bake softer frames.
379421
private async runCapture(minScale: number = 0): Promise<Blob[] | null> {
380422
if (!this.iframe || !this.iframe.contentDocument || !this.iframe.contentWindow) return null;
@@ -383,23 +425,13 @@ export class Html5Player extends Player {
383425
if (!contentHash) return null;
384426
const stale = (): boolean => this.disposed || this.contentHash !== contentHash;
385427

386-
// After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
387-
// transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
388-
await nextFrame();
428+
const scale = await this.bakeScale(minScale);
389429
if (stale()) return null;
390-
const scale = Math.max(bakeScaleFor(this.onScreenScale()), minScale);
391-
const cacheKey = `${contentHash}@${scale}`;
392-
393-
const cached = await html5CacheGet(cacheKey);
430+
// The display scale can change while a clip waits in the queue, which can turn up a cached bake at the new scale.
431+
const cached = await this.adoptCachedBake(scale);
432+
if (cached) return cached;
394433
if (stale()) return null;
395-
if (cached) {
396-
this.captureFramesTotal = cached.frameCount;
397-
this.captureFramesDone = cached.frameCount;
398-
this.captureFps = cached.fps;
399-
if (!(await this.adoptBake(cached.pngs, cacheKey, scale, stale))) return null;
400-
this.emitCaptureCompleted(cached.frameCount);
401-
return cached.pngs;
402-
}
434+
const cacheKey = `${contentHash}@${scale}`;
403435

404436
// An earlier capture may have seeked this iframe, and a seeked animation can leave state
405437
// a fresh page lacks (GSAP writes start values inline once a tween has
@@ -425,6 +457,7 @@ export class Html5Player extends Player {
425457
let lastSvg = "";
426458
let lastFrame: Promise<Blob> | null = null;
427459
let issued = 0;
460+
let progressStep = 0;
428461
let sliceStart = -Infinity;
429462
// Frames publish in order, so the oldest in flight gates the window.
430463
while (blobs.length < frameCount) {
@@ -450,6 +483,12 @@ export class Html5Player extends Player {
450483
if (stale()) return null;
451484
blobs.push(frame);
452485
this.captureFramesDone += 1;
486+
// Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count.
487+
const step = Math.floor((this.captureFramesDone / frameCount) * CAPTURE_PROGRESS_STEPS);
488+
if (step > progressStep) {
489+
progressStep = step;
490+
this.emitCaptureProgress();
491+
}
453492
}
454493
}
455494

@@ -761,8 +800,8 @@ export class Html5Player extends Player {
761800
} catch (err) {
762801
if (this.disposed || this.contentHash !== newHash) return;
763802
console.warn("[Html5Player] reload iframe load failed:", err);
764-
this.emitCaptureFailed(err, "static-placeholder");
765803
this.transitionToFailed();
804+
this.emitCaptureFailed(err, "static-placeholder");
766805
}
767806
}
768807

@@ -795,6 +834,21 @@ export class Html5Player extends Player {
795834
this.fallbackGraphic = null;
796835
}
797836

837+
public override getPlaybackReadiness(): PlaybackReadiness {
838+
if (this.disposed || this.fallbackGraphic) return null;
839+
// Frames count from adoption, when captureCompleted fires; playback takes over a decode later, with no event.
840+
const adopted = !!this.capturedFrames && this.capturedHash === `${this.contentHash}@${this.capturedScale}`;
841+
if (adopted || this.mode === "playback") return "ready";
842+
if (this.mode === "capturing") return "pending";
843+
// An edit that pre-renders on play bakes nothing until it plays.
844+
return this.edit.getHtml5Prerender() === "on-load" || this.hasTriggeredCapture ? "pending" : null;
845+
}
846+
847+
public override getCaptureProgress(): number {
848+
if (this.getPlaybackReadiness() !== "pending") return 1;
849+
return this.captureFramesTotal > 0 ? this.captureFramesDone / this.captureFramesTotal : 0;
850+
}
851+
798852
public override update(deltaTime: number, elapsed: number): void {
799853
super.update(deltaTime, elapsed);
800854
this.triggerCaptureIfNeeded();
@@ -831,16 +885,19 @@ export class Html5Player extends Player {
831885
if (this.disposed || this.contentHash !== hashAtStart) return;
832886
const fresh = !!frames && frames.length > 0 && this.capturedHash === `${hashAtStart}@${this.capturedScale}`;
833887
if (!fresh) {
834-
if (this.mode === "capturing") this.transitionToFailed();
888+
if (this.mode !== "capturing") return;
889+
this.transitionToFailed();
890+
this.emitCaptureFailed(new Error("The capture produced no frames"), "static-placeholder");
835891
return;
836892
}
837893
await this.transitionToPlayback();
838894
})
839895
.catch(err => {
840896
if (this.disposed || this.contentHash !== hashAtStart) return;
841897
console.warn("[Html5Player] capture failed:", err);
842-
this.emitCaptureFailed(err, "static-placeholder");
898+
// Listeners redraw on the failure, so the clip shows it first.
843899
if (this.mode === "capturing") this.transitionToFailed();
900+
this.emitCaptureFailed(err, "static-placeholder");
844901
});
845902
}
846903

‎src/components/canvas/players/player.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { WipeFilter } from "@animations/wipe-filter";
55
import { type Edit } from "@core/edit-session";
66
import { InternalEvent } from "@core/events/edit-events";
77
import { calculateContainerScale, calculateFitScale, calculateSpriteTransform, type FitMode } from "@core/layout/fit-system";
8+
import { type PlaybackReadiness } from "@core/pending-captures";
89
import {
910
type AliasReference,
1011
type ResolvedTiming,
@@ -575,6 +576,15 @@ export abstract class Player extends Entity {
575576
};
576577
}
577578

579+
// Only players that pre-render frames can play choppily while they prepare.
580+
public getPlaybackReadiness(): PlaybackReadiness {
581+
return null;
582+
}
583+
584+
public getCaptureProgress(): number {
585+
return 1;
586+
}
587+
578588
public isActive(): boolean {
579589
// playbackTime is in seconds, matching clip start/end
580590
return this.edit.playbackTime >= this.getStart() && this.edit.playbackTime < this.getEnd();

‎src/components/canvas/shotstack-canvas.ts‎

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -172,8 +172,8 @@ export class Canvas {
172172
this.currentZoom = Math.min(Math.max(newZoom, this.minZoom), this.maxZoom);
173173

174174
const stageCenter = {
175-
x: this.application.canvas.width / 2,
176-
y: this.application.canvas.height / 2
175+
x: this.viewportSize.width / 2,
176+
y: this.viewportSize.height / 2
177177
};
178178

179179
const distanceFromCenter = {
@@ -239,9 +239,10 @@ export class Canvas {
239239
return;
240240
}
241241

242+
// In CSS pixels: the canvas element's own width counts device pixels.
242243
this.viewportContainer.position = {
243-
x: this.application.canvas.width / 2 - (this.edit.size.width * this.currentZoom) / 2,
244-
y: this.application.canvas.height / 2 - (this.edit.size.height * this.currentZoom) / 2
244+
x: this.viewportSize.width / 2 - (this.edit.size.width * this.currentZoom) / 2,
245+
y: this.viewportSize.height / 2 - (this.edit.size.height * this.currentZoom) / 2
245246
};
246247

247248
this.syncContentTransforms();
@@ -380,7 +381,9 @@ export class Canvas {
380381
target,
381382
...(frame ? { frame } : {}),
382383
format: pixiFormat,
383-
quality: options.quality ?? 0.85
384+
quality: options.quality ?? 0.85,
385+
// Captures stay in output pixels whatever the screen's density.
386+
resolution: 1
384387
});
385388
}
386389

@@ -623,6 +626,9 @@ export class Canvas {
623626
width: this.viewportSize.width,
624627
height: this.viewportSize.height,
625628
antialias: true,
629+
// Draws at the screen's pixel density, so text and frames stay sharp on phones and retina screens. 2 caps the fill cost on 3x phones.
630+
resolution: Math.min(window.devicePixelRatio || 1, 2),
631+
autoDensity: true,
626632
powerPreference: "high-performance",
627633
eventFeatures: {
628634
globalMove: true, // Required for drag handling in SelectionHandles

‎src/components/timeline/components/clip/clip-component.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export interface ClipComponentOptions {
1919
getClipError?: (trackIndex: number, clipIndex: number) => { error: string; assetType: string } | null;
2020
/** Get generation state for a clip (while an AI asset is being generated) */
2121
getClipGenerationState?: (clipId: string) => { status: "generating" | "failed"; error?: string } | undefined;
22+
getClipCaptureProgress?: (clipId: string) => number | null;
2223
/** Reference to attached luma (if this clip has a mask) */
2324
attachedLuma?: LumaRef;
2425
/** Callback when mask badge is clicked - passes the CONTENT clip indices */
@@ -235,12 +236,19 @@ export class ClipComponent {
235236
const state = clipId ? this.options.getClipGenerationState?.(clipId) : undefined;
236237
const generating = state?.status === "generating";
237238

239+
const progress = clipId ? (this.options.getClipCaptureProgress?.(clipId) ?? null) : null;
240+
const preparing = progress !== null;
241+
238242
this.element.classList.toggle("ss-clip--generating", generating);
239-
if (generating) this.element.setAttribute("aria-busy", "true");
243+
this.element.classList.toggle("ss-clip--preparing", preparing);
244+
if (preparing) this.element.style.setProperty("--ss-capture-progress", String(progress));
245+
if (generating || preparing) this.element.setAttribute("aria-busy", "true");
240246
else this.element.removeAttribute("aria-busy");
241247

242248
this.element.classList.toggle("ss-clip--error", state?.status === "failed" || this.currentError !== null);
243-
this.element.title = state?.status === "failed" ? (state.error ?? "Generation failed") : "";
249+
let title = preparing ? "Preparing smooth playback" : "";
250+
if (state?.status === "failed") title = state.error ?? "Generation failed";
251+
this.element.title = title;
244252
}
245253

246254
/** Show/hide error state based on clip error */

0 commit comments

Comments
 (0)