@bendyline/squisq-react 2.11.7 → 2.11.9

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.
@@ -1,7 +1,11 @@
1
1
  import {
2
2
  BlockRenderer,
3
3
  LinearDocView
4
- } from "./chunk-5UGIE2QO.js";
4
+ } from "./chunk-LDXH4FOY.js";
5
+ import {
6
+ isCaptureDrivenVideo,
7
+ seekVideoToFrame
8
+ } from "./chunk-W6MKRWL6.js";
5
9
  import {
6
10
  useAudioSync,
7
11
  useDocPlayback,
@@ -13,7 +17,7 @@ import {
13
17
  } from "./chunk-TT6ENR6T.js";
14
18
  import {
15
19
  MarkdownRenderer
16
- } from "./chunk-ZY5N3JPM.js";
20
+ } from "./chunk-6PUMLLJH.js";
17
21
  import {
18
22
  useMediaProvider,
19
23
  useMediaUrl,
@@ -22,8 +26,74 @@ import {
22
26
 
23
27
  // src/MediaClipLayer.tsx
24
28
  import { useEffect, useRef } from "react";
29
+ import { serializeMediaCrop } from "@bendyline/squisq/mediaEdit";
30
+
31
+ // src/mediaClipPlayback.ts
32
+ function clipLevelAt(clip, time) {
33
+ let level = clip.gain ? Math.pow(10, clip.gain / 20) : 1;
34
+ if (clip.fadeIn && time < clip.absoluteStart + clip.fadeIn) {
35
+ level *= Math.max(0, (time - clip.absoluteStart) / clip.fadeIn);
36
+ }
37
+ if (clip.fadeOut && time > clip.absoluteEnd - clip.fadeOut) {
38
+ level *= Math.max(0, (clip.absoluteEnd - time) / clip.fadeOut);
39
+ }
40
+ return level;
41
+ }
42
+ function mountedClips(clips, time) {
43
+ const out = [];
44
+ const groups = /* @__PURE__ */ new Map();
45
+ for (const clip of clips) {
46
+ if (!clip.segment) {
47
+ out.push({ key: clip.id, clip });
48
+ continue;
49
+ }
50
+ const group = groups.get(clip.segment.groupId) ?? [];
51
+ group.push(clip);
52
+ groups.set(clip.segment.groupId, group);
53
+ }
54
+ for (const [groupId, segments] of groups) {
55
+ segments.sort((a, b) => (a.segment?.index ?? 0) - (b.segment?.index ?? 0));
56
+ let current = segments.findIndex((s) => time < s.absoluteEnd);
57
+ if (current === -1) current = segments.length - 1;
58
+ for (const index of [current, current + 1]) {
59
+ const clip = segments[index];
60
+ if (clip) out.push({ key: `${groupId}#slot${index % 2}`, clip });
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // src/MediaClipLayer.tsx
25
67
  import { Fragment, jsx } from "react/jsx-runtime";
26
68
  var DRIFT = 0.25;
69
+ var PRESEEK = 0.75;
70
+ var boostContext = null;
71
+ var boostNodes = /* @__PURE__ */ new WeakMap();
72
+ function boostNodeFor(el) {
73
+ const existing = boostNodes.get(el);
74
+ if (existing) return existing;
75
+ if (typeof AudioContext === "undefined") return null;
76
+ try {
77
+ boostContext ?? (boostContext = new AudioContext());
78
+ const gain = boostContext.createGain();
79
+ boostContext.createMediaElementSource(el).connect(gain).connect(boostContext.destination);
80
+ boostNodes.set(el, gain);
81
+ return gain;
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ function applyLevel(el, level) {
87
+ const boost = level > 1 ? boostNodeFor(el) : boostNodes.get(el);
88
+ if (boost) {
89
+ el.volume = 1;
90
+ boost.gain.value = level;
91
+ if (boostContext?.state === "suspended") void boostContext.resume().catch(() => {
92
+ });
93
+ return;
94
+ }
95
+ el.volume = Math.min(1, Math.max(0, level));
96
+ }
27
97
  function mediaGroupStyle(presentation) {
28
98
  return {
29
99
  position: "absolute",
@@ -76,6 +146,12 @@ function mediaVideoStyle({
76
146
  ...pipCornerStyle(pipPosition)
77
147
  };
78
148
  }
149
+ function cropStyle(crop) {
150
+ if (!crop) return {};
151
+ const pct = (n) => `${(n * 100).toFixed(3)}%`;
152
+ const inset = `inset(${pct(crop.y)} ${pct(1 - crop.x - crop.w)} ${pct(1 - crop.y - crop.h)} ${pct(crop.x)})`;
153
+ return { objectViewBox: inset };
154
+ }
79
155
  function MediaClipLayer({
80
156
  schedule,
81
157
  currentTime,
@@ -93,8 +169,10 @@ function MediaClipLayer({
93
169
  }) {
94
170
  const { renderClips, activeIds } = useMediaSchedule(schedule, currentTime);
95
171
  if (renderClips.length === 0) return null;
172
+ const mounted = mountedClips(renderClips, currentTime);
96
173
  const groups = /* @__PURE__ */ new Map();
97
- for (const clip of renderClips) {
174
+ for (const entry of mounted) {
175
+ const { clip } = entry;
98
176
  const clipPresentation = honorClipPresentation && clip.kind === "video" ? clip.placement === "picture-in-picture" ? "picture-in-picture" : clip.placement === "overlay" ? "full-frame" : presentation : presentation;
99
177
  const clipPipSize = clip.pipSize ?? pipSize;
100
178
  const clipPipShape = clip.pipShape ?? pipShape;
@@ -107,7 +185,7 @@ function MediaClipLayer({
107
185
  pipPosition: clipPipPosition,
108
186
  clips: []
109
187
  };
110
- group.clips.push(clip);
188
+ group.clips.push(entry);
111
189
  groups.set(key, group);
112
190
  }
113
191
  return /* @__PURE__ */ jsx(Fragment, { children: [...groups].map(([key, group]) => /* @__PURE__ */ jsx(
@@ -120,7 +198,7 @@ function MediaClipLayer({
120
198
  "data-pip-position": group.pipPosition,
121
199
  "aria-hidden": true,
122
200
  style: mediaGroupStyle(group.presentation),
123
- children: group.clips.map((clip) => /* @__PURE__ */ jsx(
201
+ children: group.clips.map(({ key: elementKey, clip }) => /* @__PURE__ */ jsx(
124
202
  MediaClipElement,
125
203
  {
126
204
  clip,
@@ -137,7 +215,7 @@ function MediaClipLayer({
137
215
  pipOrientation,
138
216
  pipFrameStyle
139
217
  },
140
- clip.id
218
+ elementKey
141
219
  ))
142
220
  },
143
221
  key
@@ -160,15 +238,29 @@ function MediaClipElement({
160
238
  }) {
161
239
  const ref = useRef(null);
162
240
  const src = useMediaUrl(clip.src, basePath);
241
+ const { sourceIn, absoluteStart, absoluteEnd, gain, fadeIn, fadeOut } = clip;
163
242
  useEffect(() => {
164
243
  const el = ref.current;
165
244
  if (!el) return;
166
245
  if (!active) {
167
246
  if (!el.paused) el.pause();
247
+ const startsIn = absoluteStart - currentTime;
248
+ if (!renderMode && startsIn > 0 && startsIn <= PRESEEK && !isCaptureDrivenVideo(el) && Math.abs(el.currentTime - sourceIn) > DRIFT / 5) {
249
+ try {
250
+ el.currentTime = sourceIn;
251
+ } catch {
252
+ }
253
+ }
168
254
  return;
169
255
  }
170
- const target = Math.max(0, clip.sourceIn + (currentTime - clip.absoluteStart));
171
- if (el.dataset.captureSequential !== "true" && (renderMode || !isPlaying || Math.abs(el.currentTime - target) > DRIFT)) {
256
+ if (!renderMode) {
257
+ applyLevel(
258
+ el,
259
+ clipLevelAt({ absoluteStart, absoluteEnd, gain, fadeIn, fadeOut }, currentTime)
260
+ );
261
+ }
262
+ const target = Math.max(0, sourceIn + (currentTime - absoluteStart));
263
+ if (!isCaptureDrivenVideo(el) && (renderMode || !isPlaying || Math.abs(el.currentTime - target) > DRIFT)) {
172
264
  try {
173
265
  el.currentTime = target;
174
266
  } catch {
@@ -178,10 +270,22 @@ function MediaClipElement({
178
270
  const p = el.play();
179
271
  if (p) p.catch(() => {
180
272
  });
181
- } else if (el.dataset.captureSequential !== "true") {
273
+ } else if (!isCaptureDrivenVideo(el)) {
182
274
  el.pause();
183
275
  }
184
- }, [active, currentTime, isPlaying, renderMode, clip.sourceIn, clip.absoluteStart, src]);
276
+ }, [
277
+ active,
278
+ currentTime,
279
+ isPlaying,
280
+ renderMode,
281
+ sourceIn,
282
+ absoluteStart,
283
+ absoluteEnd,
284
+ gain,
285
+ fadeIn,
286
+ fadeOut,
287
+ src
288
+ ]);
185
289
  const isVideo = clip.kind === "video";
186
290
  const common = {
187
291
  ref,
@@ -200,17 +304,21 @@ function MediaClipElement({
200
304
  ...common,
201
305
  className: `doc-player__media-video${active ? " doc-player__media-video--active" : ""}`,
202
306
  "data-video-placement": clip.placement ?? "default",
203
- muted: renderMode || muted,
307
+ muted: renderMode || muted || clip.audioMuted === true,
204
308
  playsInline: true,
205
- style: mediaVideoStyle({
206
- presentation,
207
- pipSize,
208
- pipShape,
209
- pipPosition,
210
- pipOrientation,
211
- pipFrameStyle,
212
- active
213
- })
309
+ ...clip.crop ? { "data-crop": serializeMediaCrop(clip.crop) } : {},
310
+ style: {
311
+ ...mediaVideoStyle({
312
+ presentation,
313
+ pipSize,
314
+ pipShape,
315
+ pipPosition,
316
+ pipOrientation,
317
+ pipFrameStyle,
318
+ active
319
+ }),
320
+ ...cropStyle(clip.crop)
321
+ }
214
322
  }
215
323
  );
216
324
  }
@@ -2349,247 +2457,6 @@ function buildSegmentTitleMap(doc) {
2349
2457
  return map;
2350
2458
  }
2351
2459
 
2352
- // src/docPlayer/renderReadiness.ts
2353
- var DEFAULT_VIDEO_FRAME_TIMEOUT_MS = 2e3;
2354
- var VIDEO_FRAME_READINESS_POLL_MS = 16;
2355
- var VIDEO_TIME_TOLERANCE_SECONDS = 0.01;
2356
- var MAX_SEQUENTIAL_CAPTURE_STEP_SECONDS = 0.5;
2357
- var SEQUENTIAL_CAPTURE_PLAYBACK_RATE = 1;
2358
- function formatMediaTime(time) {
2359
- return Number.isFinite(time) ? `${time.toFixed(3)}s` : String(time);
2360
- }
2361
- function reachableMediaTime(video, targetTime) {
2362
- const duration = video.duration;
2363
- if (!Number.isFinite(duration)) return targetTime;
2364
- return Math.max(0, Math.min(targetTime, duration));
2365
- }
2366
- function isAtReachableMediaTime(video, targetTime) {
2367
- return Math.abs(video.currentTime - reachableMediaTime(video, targetTime)) <= VIDEO_TIME_TOLERANCE_SECONDS;
2368
- }
2369
- function isVisiblyPresented(video) {
2370
- if (!video.isConnected) return false;
2371
- const view = video.ownerDocument.defaultView;
2372
- if (!view) return false;
2373
- for (let element = video; element; element = element.parentElement) {
2374
- const style = view.getComputedStyle(element);
2375
- if (style.display === "none" || style.visibility === "hidden" || style.visibility === "collapse" || Number.parseFloat(style.opacity || "1") <= 0) {
2376
- return false;
2377
- }
2378
- }
2379
- return true;
2380
- }
2381
- function createCaptureWatchdog(ownerDocument, timeoutMs, expire) {
2382
- let timeout = null;
2383
- const clear = () => {
2384
- if (timeout === null) return;
2385
- clearTimeout(timeout);
2386
- timeout = null;
2387
- };
2388
- return {
2389
- arm: () => {
2390
- clear();
2391
- if (ownerDocument.visibilityState !== "visible") return;
2392
- timeout = setTimeout(expire, timeoutMs);
2393
- },
2394
- clear
2395
- };
2396
- }
2397
- function seekVideoByAssignment(video, targetTime, timeoutMs = DEFAULT_VIDEO_FRAME_TIMEOUT_MS) {
2398
- video.pause();
2399
- return new Promise((resolve, reject) => {
2400
- let settled = false;
2401
- let videoFrameRequest = null;
2402
- const ownerDocument = video.ownerDocument;
2403
- const isInactive = () => ownerDocument.visibilityState !== "visible";
2404
- const cleanup = () => {
2405
- watchdog.clear();
2406
- clearInterval(readinessPoll);
2407
- video.removeEventListener("seeked", handleMediaReady);
2408
- video.removeEventListener("loadeddata", handleMediaReady);
2409
- video.removeEventListener("canplay", handleMediaReady);
2410
- ownerDocument.removeEventListener("visibilitychange", handleVisibilityChange);
2411
- if (videoFrameRequest !== null && typeof video.cancelVideoFrameCallback === "function") {
2412
- video.cancelVideoFrameCallback(videoFrameRequest);
2413
- }
2414
- };
2415
- const finish = () => {
2416
- if (settled) return;
2417
- settled = true;
2418
- cleanup();
2419
- resolve();
2420
- };
2421
- const fail = () => {
2422
- if (settled) return;
2423
- settled = true;
2424
- cleanup();
2425
- reject(
2426
- new Error(
2427
- `Video frame did not become ready at ${formatMediaTime(targetTime)} within ${timeoutMs}ms (currentTime=${formatMediaTime(video.currentTime)}, reachableTime=${formatMediaTime(reachableMediaTime(video, targetTime))}, duration=${formatMediaTime(video.duration)}, readyState=${video.readyState}, seeking=${String(video.seeking)}, visible=${String(isVisiblyPresented(video))}).`
2428
- )
2429
- );
2430
- };
2431
- const requestPresentedFrame = () => {
2432
- if (settled || video.seeking || video.readyState < HTMLMediaElement.HAVE_CURRENT_DATA || !isAtReachableMediaTime(video, targetTime)) {
2433
- return;
2434
- }
2435
- if (typeof video.requestVideoFrameCallback !== "function" || !isVisiblyPresented(video)) {
2436
- finish();
2437
- return;
2438
- }
2439
- if (videoFrameRequest !== null) return;
2440
- videoFrameRequest = video.requestVideoFrameCallback(() => {
2441
- videoFrameRequest = null;
2442
- finish();
2443
- });
2444
- };
2445
- function handleMediaReady() {
2446
- requestPresentedFrame();
2447
- }
2448
- const watchdog = createCaptureWatchdog(ownerDocument, timeoutMs, fail);
2449
- const assignTargetTime = () => {
2450
- try {
2451
- video.currentTime = reachableMediaTime(video, targetTime);
2452
- queueMicrotask(requestPresentedFrame);
2453
- return true;
2454
- } catch (error) {
2455
- settled = true;
2456
- cleanup();
2457
- reject(error);
2458
- return false;
2459
- }
2460
- };
2461
- function handleVisibilityChange() {
2462
- if (settled) return;
2463
- if (isInactive()) {
2464
- watchdog.clear();
2465
- return;
2466
- }
2467
- requestPresentedFrame();
2468
- if (settled) return;
2469
- if (!video.seeking && !isAtReachableMediaTime(video, targetTime) && !assignTargetTime()) {
2470
- return;
2471
- }
2472
- watchdog.arm();
2473
- }
2474
- const readinessPoll = setInterval(requestPresentedFrame, VIDEO_FRAME_READINESS_POLL_MS);
2475
- video.addEventListener("seeked", handleMediaReady);
2476
- video.addEventListener("loadeddata", handleMediaReady);
2477
- video.addEventListener("canplay", handleMediaReady);
2478
- ownerDocument.addEventListener("visibilitychange", handleVisibilityChange);
2479
- watchdog.arm();
2480
- assignTargetTime();
2481
- });
2482
- }
2483
- function advanceSequentialCaptureVideo(video, targetTime, timeoutMs) {
2484
- return new Promise((resolve, reject) => {
2485
- let settled = false;
2486
- const ownerDocument = video.ownerDocument;
2487
- const isInactive = () => ownerDocument.visibilityState !== "visible";
2488
- const cleanup = () => {
2489
- watchdog.clear();
2490
- clearInterval(readinessPoll);
2491
- video.removeEventListener("timeupdate", check);
2492
- video.removeEventListener("loadeddata", check);
2493
- video.removeEventListener("canplay", check);
2494
- video.removeEventListener("error", fail);
2495
- ownerDocument.removeEventListener("visibilitychange", handleVisibilityChange);
2496
- };
2497
- const finish = () => {
2498
- if (!settled) {
2499
- settled = true;
2500
- cleanup();
2501
- resolve();
2502
- }
2503
- };
2504
- const fallbackToSeek = () => {
2505
- if (settled) return;
2506
- settled = true;
2507
- video.pause();
2508
- cleanup();
2509
- void seekVideoByAssignment(video, targetTime, timeoutMs).then(resolve, reject);
2510
- };
2511
- const fail = () => {
2512
- if (settled) return;
2513
- settled = true;
2514
- video.pause();
2515
- cleanup();
2516
- reject(
2517
- new Error(
2518
- `Video frame did not advance to ${formatMediaTime(targetTime)} within ${timeoutMs}ms (currentTime=${formatMediaTime(video.currentTime)}, readyState=${video.readyState}).`
2519
- )
2520
- );
2521
- };
2522
- function check() {
2523
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && video.currentTime >= reachableMediaTime(video, targetTime)) {
2524
- video.pause();
2525
- finish();
2526
- }
2527
- }
2528
- const handlePlaybackFailure = () => {
2529
- if (settled || isInactive()) return;
2530
- fallbackToSeek();
2531
- };
2532
- const startPlayback = () => {
2533
- if (settled || isInactive()) return;
2534
- try {
2535
- video.playbackRate = SEQUENTIAL_CAPTURE_PLAYBACK_RATE;
2536
- if (video.paused) {
2537
- void video.play().catch(handlePlaybackFailure);
2538
- }
2539
- } catch {
2540
- handlePlaybackFailure();
2541
- }
2542
- };
2543
- const watchdog = createCaptureWatchdog(ownerDocument, timeoutMs, fail);
2544
- const pauseWhileInactive = () => {
2545
- video.pause();
2546
- watchdog.clear();
2547
- };
2548
- const resumeWhenActive = () => {
2549
- if (settled || isInactive()) return;
2550
- check();
2551
- if (settled) return;
2552
- startPlayback();
2553
- watchdog.arm();
2554
- };
2555
- function handleVisibilityChange() {
2556
- if (ownerDocument.visibilityState !== "visible") {
2557
- pauseWhileInactive();
2558
- return;
2559
- }
2560
- resumeWhenActive();
2561
- }
2562
- const readinessPoll = setInterval(check, VIDEO_FRAME_READINESS_POLL_MS);
2563
- video.addEventListener("timeupdate", check);
2564
- video.addEventListener("loadeddata", check);
2565
- video.addEventListener("canplay", check);
2566
- video.addEventListener("error", fail, { once: true });
2567
- ownerDocument.addEventListener("visibilitychange", handleVisibilityChange);
2568
- resumeWhenActive();
2569
- });
2570
- }
2571
- function seekVideoToFrame(video, targetTime, timeoutMs = DEFAULT_VIDEO_FRAME_TIMEOUT_MS) {
2572
- if (video.readyState >= HTMLMediaElement.HAVE_METADATA && video.videoWidth <= 0 && video.videoHeight <= 0) {
2573
- video.pause();
2574
- return Promise.resolve();
2575
- }
2576
- const reachableTargetTime = reachableMediaTime(video, targetTime);
2577
- const step = reachableTargetTime - video.currentTime;
2578
- if (video.dataset.captureSequential === "true") {
2579
- if (video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && step <= VIDEO_TIME_TOLERANCE_SECONDS) {
2580
- if (step < -VIDEO_TIME_TOLERANCE_SECONDS) video.pause();
2581
- return Promise.resolve();
2582
- }
2583
- if (step > 0 && step <= MAX_SEQUENTIAL_CAPTURE_STEP_SECONDS && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
2584
- return advanceSequentialCaptureVideo(video, targetTime, timeoutMs);
2585
- }
2586
- }
2587
- video.pause();
2588
- const alreadyReady = !video.seeking && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA && isAtReachableMediaTime(video, targetTime);
2589
- if (alreadyReady) return Promise.resolve();
2590
- return seekVideoByAssignment(video, targetTime, timeoutMs);
2591
- }
2592
-
2593
2460
  // src/DocPlayer.tsx
2594
2461
  import { jsx as jsx9, jsxs as jsxs6 } from "react/jsx-runtime";
2595
2462
  function isDevEnvironment() {
@@ -2667,6 +2534,8 @@ function DocPlayerContent({
2667
2534
  onPlaybackStateChange,
2668
2535
  onControlsReady,
2669
2536
  onRenderAPIReady,
2537
+ renderVideoFrameSelector,
2538
+ processedAudio,
2670
2539
  isFullscreen = false,
2671
2540
  onFullscreenToggle,
2672
2541
  onBlockMarkers,
@@ -2759,8 +2628,11 @@ function DocPlayerContent({
2759
2628
  const rawSchedule = useMemo5(() => resolveMediaSchedule(doc), [doc]);
2760
2629
  const clipDurations = useMediaClipDurations(rawSchedule, basePath);
2761
2630
  const mediaSchedule = useMemo5(
2762
- () => resolveMediaSchedule(doc, { intrinsicDuration: (clip) => clipDurations.get(clip.src) }),
2763
- [doc, clipDurations]
2631
+ () => resolveMediaSchedule(doc, {
2632
+ intrinsicDuration: (clip) => clipDurations.get(clip.src),
2633
+ ...processedAudio ? { processedAudio } : {}
2634
+ }),
2635
+ [doc, clipDurations, processedAudio]
2764
2636
  );
2765
2637
  const startActiveTimelineMedia = useCallback5(() => {
2766
2638
  if (renderMode) return;
@@ -2787,6 +2659,9 @@ function DocPlayerContent({
2787
2659
  currentTimeRef.current = currentTime;
2788
2660
  const committedRenderTimeRef = useRef7(currentTime);
2789
2661
  const renderCommitWaitersRef = useRef7([]);
2662
+ const renderVideoFrameSelectorRef = useRef7(renderVideoFrameSelector);
2663
+ renderVideoFrameSelectorRef.current = renderVideoFrameSelector;
2664
+ const presentedBlocksRef = useRef7(null);
2790
2665
  const totalDurationRef = useRef7(totalDuration);
2791
2666
  totalDurationRef.current = totalDuration;
2792
2667
  const expandedBlocksLenRef = useRef7(0);
@@ -3041,6 +2916,11 @@ function DocPlayerContent({
3041
2916
  }
3042
2917
  }
3043
2918
  const elapsedMs = (time - blockStartTime) * 1e3;
2919
+ const selectVideoFrame = async (video, targetTime) => {
2920
+ const selector = renderMode ? renderVideoFrameSelectorRef.current : void 0;
2921
+ if (selector && await selector(video, targetTime)) return;
2922
+ await seekVideoToFrame(video, targetTime);
2923
+ };
3044
2924
  (root.getAnimations?.() ?? []).forEach((anim) => {
3045
2925
  const target = anim.effect?.target;
3046
2926
  if (!target) return;
@@ -3061,7 +2941,7 @@ function DocPlayerContent({
3061
2941
  const clipEnd = parseFloat(video.dataset.clipEnd || "0");
3062
2942
  const startAt = parseFloat(video.dataset.startAt || "0");
3063
2943
  const targetTime = Math.min(clipStart + Math.max(0, blockElapsed - startAt), clipEnd);
3064
- videoSeekPromises.push(seekVideoToFrame(video, targetTime));
2944
+ videoSeekPromises.push(selectVideoFrame(video, targetTime));
3065
2945
  });
3066
2946
  }
3067
2947
  root.querySelectorAll("video[data-clip-id]").forEach((el) => {
@@ -3072,10 +2952,19 @@ function DocPlayerContent({
3072
2952
  video.pause();
3073
2953
  if (time < absStart || time >= absEnd) return;
3074
2954
  const targetTime = sourceIn + (time - absStart);
3075
- videoSeekPromises.push(seekVideoToFrame(video, targetTime));
2955
+ videoSeekPromises.push(selectVideoFrame(video, targetTime));
3076
2956
  });
3077
2957
  await Promise.all(videoSeekPromises);
3078
- await waitForVisualUpdate();
2958
+ const presentedBlocks = Array.from(
2959
+ root.querySelectorAll(".doc-player__block"),
2960
+ (element) => ({ element, className: element.className })
2961
+ );
2962
+ const previousBlocks = presentedBlocksRef.current;
2963
+ presentedBlocksRef.current = presentedBlocks;
2964
+ const blocksUnchanged = previousBlocks !== null && previousBlocks.length === presentedBlocks.length && presentedBlocks.every(
2965
+ (block, index) => block.element === previousBlocks[index].element && block.className === previousBlocks[index].className
2966
+ );
2967
+ if (!renderMode || !blocksUnchanged) await waitForVisualUpdate();
3079
2968
  };
3080
2969
  const getDuration = () => {
3081
2970
  const mediaDuration = getDocPlaybackDuration(doc);
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { S as SquisqRenderAPI, V as VideoPresentation$1, P as PipSize, a as PipShape, b as PipPosition } from './player-BlD8zREs.js';
2
- export { B as BlockMarker, c as BlockRenderer, C as CaptionMode, d as CaptionOverlay, e as CaptionPosition, f as CaptionStyle, g as ControlsLayout, D as DisplayMode, h as DocControlsBottom, i as DocControlsOverlay, j as DocControlsSidebar, k as DocControlsSlideshow, l as DocPlayer, m as DocPlayerProps, n as DocPlayerWithSidebar, o as DocProgressBar, F as FlashcardFaceView, p as FlashcardFaceViewProps, q as FlashcardView, r as FlashcardViewProps, I as InlineAudioPlayer, s as InlineAudioPlayerProps, t as InlineVideoPlayer, u as InlineVideoPlayerProps, M as MediaClipLayer, v as MediaClipLayerProps, w as PlaybackActions, x as PlaybackState, R as RenderAudioSegmentInfo, y as RenderBlockInfo, z as RenderCaptionInfo, A as RenderChapterInfo, E as SlideNavActions, G as SocialCaptionOverlay, H as formatTime } from './player-BlD8zREs.js';
1
+ import { S as SquisqRenderAPI, V as VideoPresentation$1, P as PipSize, a as PipShape, b as PipPosition } from './player-5j10MhBq.js';
2
+ export { B as BlockMarker, c as BlockRenderer, C as CaptionMode, d as CaptionOverlay, e as CaptionPosition, f as CaptionStyle, g as ControlsLayout, D as DisplayMode, h as DocControlsBottom, i as DocControlsOverlay, j as DocControlsSidebar, k as DocControlsSlideshow, l as DocPlayer, m as DocPlayerProps, n as DocPlayerWithSidebar, o as DocProgressBar, F as FlashcardFaceView, p as FlashcardFaceViewProps, q as FlashcardView, r as FlashcardViewProps, I as InlineAudioPlayer, s as InlineAudioPlayerProps, t as InlineVideoPlayer, u as InlineVideoPlayerProps, M as MediaClipLayer, v as MediaClipLayerProps, w as PlaybackActions, x as PlaybackState, R as RenderAudioSegmentInfo, y as RenderBlockInfo, z as RenderCaptionInfo, A as RenderChapterInfo, E as RenderVideoFrameSelector, G as SlideNavActions, H as SocialCaptionOverlay, J as formatTime } from './player-5j10MhBq.js';
3
3
  import { Theme, VideoPresentation, VideoPipSize, VideoPipShape, VideoPipPosition, Doc, ScheduledClip, MediaProvider } from '@bendyline/squisq/schemas';
4
4
  import { DashboardStyleId, ViewportConfig, CoverSlideTemplate, CoverSlidePlayback } from '@bendyline/squisq/doc';
5
5
  export { getAnimationStyle, getTransitionClass } from '@bendyline/squisq/doc';
@@ -8,13 +8,14 @@ export { a as CodeBlockCopyContext, M as MarkdownRenderer, b as MarkdownRenderer
8
8
  export { FenceRendererContext, MermaidDiagram, MermaidDiagramProps, useFenceRenderers } from './markdown/index.js';
9
9
  export { CanvasSection, CanvasSectionProps, ImageDisplayMode, LinearDocView, LinearDocViewProps, PageSectionView, PageSectionViewProps, PageViewContext, PageViewContextValue, PaginatedDocPage, PaginatedDocView, PaginatedDocViewProps, usePageView } from './page/index.js';
10
10
  import * as react_jsx_runtime from 'react/jsx-runtime';
11
+ import { RefObject } from 'react';
12
+ import { AnimatedImageInfo } from '@bendyline/squisq/imageEdit';
11
13
  export { ImageLayer, MapLayer, MermaidLayer, PathLayer, ShapeLayer, TextLayer, TreeLayer, VideoLayer } from './layers/index.js';
12
14
  export { T as TableLayer, a as TableLayerContentRenderer, b as TableLayerContentRendererProps } from './TableLayer-B7ADuUGc.js';
13
15
  export { MediaContext, MediaScheduleController, ModalDialogOptions, ResourcePolicyContext, UseDocPlaybackOptions, useAudioSync, useAutoSurface, useDocPlayback, useMediaProvider, useMediaSchedule, useMediaUrl, useModalDialog, useResourcePolicy, useViewportOrientation } from './hooks/index.js';
14
16
  export { FenceRenderContext, FenceRenderer, FenceRendererMap } from '@bendyline/squisq/fence';
15
17
  export { A as AudioActions, a as AudioController, b as AudioState } from './AudioController-DwMsPe38.js';
16
18
  export { JsonFormFenceAction, JsonFormFenceRendererOptions, JsonView, JsonViewProps, createJsonFormFenceRenderer } from './json-view/index.js';
17
- import 'react';
18
19
  import '@bendyline/squisq/markdown';
19
20
  import '@bendyline/squisq/jsonForm';
20
21
 
@@ -156,6 +157,37 @@ interface DashboardViewProps {
156
157
  }
157
158
  declare function DashboardView({ doc, theme, viewport, layout, showTitle, style, documentTitle, basePath, animationsEnabled, muted, renderMode, onRenderAPIReady, className, }: DashboardViewProps): react_jsx_runtime.JSX.Element;
158
159
 
160
+ type AnimatedImageStatus = 'playing' | 'paused' | 'ended';
161
+ interface AnimatedImagePlayback {
162
+ /** Inspection result: null while pending, for sources that cannot animate,
163
+ * and when the bytes cannot be read. */
164
+ info: AnimatedImageInfo | null;
165
+ /** True once the image is known to animate. */
166
+ animated: boolean;
167
+ /** The URL to render — `resolvedUrl`, or a private copy after a replay. */
168
+ displaySrc: string;
169
+ status: AnimatedImageStatus;
170
+ /** Play from the first frame. */
171
+ play: () => void;
172
+ /** Freeze on a still frame. */
173
+ pause: () => void;
174
+ }
175
+ /**
176
+ * @param src The authored source (a relative path, URL or data URL). Its
177
+ * extension or MIME decides whether the image could animate at all.
178
+ * @param resolvedUrl The URL the image is actually loaded from, e.g. a blob
179
+ * URL from a MediaProvider. Pass '' while it is still resolving.
180
+ */
181
+ declare function useAnimatedImage(src: string, resolvedUrl: string): AnimatedImagePlayback;
182
+
183
+ interface AnimatedImageControlsProps {
184
+ playback: AnimatedImagePlayback;
185
+ /** The `<img>` showing `playback.displaySrc`. Its parent must be the
186
+ * positioned box the image fills. */
187
+ imageRef: RefObject<HTMLImageElement | null>;
188
+ }
189
+ declare function AnimatedImageControls({ playback, imageRef }: AnimatedImageControlsProps): react_jsx_runtime.JSX.Element | null;
190
+
159
191
  interface DocPlayerAppearanceOverrides {
160
192
  theme?: Theme;
161
193
  videoPresentation?: VideoPresentation$1;
@@ -206,4 +238,4 @@ declare function resolveDocPlayerAppearance(doc: Doc, overrides?: DocPlayerAppea
206
238
  */
207
239
  declare function useMediaClipDurations(schedule: ScheduledClip[], basePath: string, mediaProviderOverride?: MediaProvider | null): Map<string, number>;
208
240
 
209
- export { CodeBlockCopyHandler, DashboardView, type DashboardViewProps, type DocPlayerAppearanceOverrides, type MountOptions, PipPosition, PipShape, PipSize, type ResolvedDocPlayerAppearance, type SquisqPlayerHandle, SquisqRenderAPI, VideoPresentation$1 as VideoPresentation, resolveDocPlayerAppearance, useMediaClipDurations };
241
+ export { AnimatedImageControls, type AnimatedImageControlsProps, type AnimatedImagePlayback, type AnimatedImageStatus, CodeBlockCopyHandler, DashboardView, type DashboardViewProps, type DocPlayerAppearanceOverrides, type MountOptions, PipPosition, PipShape, PipSize, type ResolvedDocPlayerAppearance, type SquisqPlayerHandle, SquisqRenderAPI, VideoPresentation$1 as VideoPresentation, resolveDocPlayerAppearance, useAnimatedImage, useMediaClipDurations };
package/dist/index.js CHANGED
@@ -15,10 +15,10 @@ import {
15
15
  formatTime,
16
16
  resolveDocPlayerAppearance,
17
17
  useMediaClipDurations
18
- } from "./chunk-KGWT454C.js";
18
+ } from "./chunk-Y2ANJRT3.js";
19
19
  import {
20
20
  PaginatedDocView
21
- } from "./chunk-7QA6EW6Y.js";
21
+ } from "./chunk-SESRQ7XH.js";
22
22
  import {
23
23
  BlockRenderer,
24
24
  CanvasSection,
@@ -26,7 +26,7 @@ import {
26
26
  PageSectionView,
27
27
  PageViewContext,
28
28
  usePageView
29
- } from "./chunk-5UGIE2QO.js";
29
+ } from "./chunk-LDXH4FOY.js";
30
30
  import {
31
31
  ImageLayer,
32
32
  MapLayer,
@@ -39,7 +39,7 @@ import {
39
39
  VideoLayer,
40
40
  getAnimationStyle,
41
41
  getTransitionClass
42
- } from "./chunk-XSOMFBH2.js";
42
+ } from "./chunk-W6MKRWL6.js";
43
43
  import {
44
44
  useModalDialog
45
45
  } from "./chunk-65POMKHR.js";
@@ -52,17 +52,19 @@ import {
52
52
  import {
53
53
  JsonView,
54
54
  createJsonFormFenceRenderer
55
- } from "./chunk-ITQ4K5N6.js";
55
+ } from "./chunk-KDTKUE53.js";
56
56
  import {
57
57
  useAutoSurface
58
58
  } from "./chunk-TT6ENR6T.js";
59
59
  import {
60
+ AnimatedImageControls,
60
61
  FenceRendererContext,
61
62
  InlineAudioPlayer,
62
63
  InlineVideoPlayer,
63
64
  MarkdownRenderer,
65
+ useAnimatedImage,
64
66
  useFenceRenderers
65
- } from "./chunk-ZY5N3JPM.js";
67
+ } from "./chunk-6PUMLLJH.js";
66
68
  import {
67
69
  MermaidDiagram
68
70
  } from "./chunk-WLUZTUNZ.js";
@@ -74,6 +76,7 @@ import {
74
76
  useResourcePolicy
75
77
  } from "./chunk-LR3AIGDD.js";
76
78
  export {
79
+ AnimatedImageControls,
77
80
  BlockRenderer,
78
81
  CanvasSection,
79
82
  CaptionOverlay,
@@ -115,6 +118,7 @@ export {
115
118
  getAnimationStyle,
116
119
  getTransitionClass,
117
120
  resolveDocPlayerAppearance,
121
+ useAnimatedImage,
118
122
  useAudioSync,
119
123
  useAutoSurface,
120
124
  useDocPlayback,