@twick/2d 0.15.16 → 0.15.18

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/dist/index.d.cts CHANGED
@@ -58,6 +58,10 @@ interface MediaProps extends RectProps {
58
58
  play?: boolean;
59
59
  awaitCanPlay?: SignalValue<boolean>;
60
60
  allowVolumeAmplificationInPreview?: SignalValue<boolean>;
61
+ /** Timeline time when this clip starts; when set, syncToCurrentTime(globalTime) uses clip-relative time. */
62
+ clipStart?: number;
63
+ /** Start offset in the media file (e.g. trim); used with clipStart for sync. */
64
+ trimStart?: number;
61
65
  }
62
66
  declare abstract class Media extends Rect {
63
67
  readonly src: SimpleSignal<string, this>;
@@ -75,6 +79,10 @@ declare abstract class Media extends Rect {
75
79
  }>;
76
80
  protected lastTime: number;
77
81
  private isSchedulingPlay;
82
+ /** When set, syncToCurrentTime(globalTime) converts to clip-relative time. */
83
+ protected clipStart: number | undefined;
84
+ /** Used with clipStart for sync (trim offset in source). */
85
+ protected trimStart: number;
78
86
  constructor(props: MediaProps);
79
87
  isPlaying(): boolean;
80
88
  getCurrentTime(): number;
@@ -86,8 +94,23 @@ declare abstract class Media extends Rect {
86
94
  protected abstract mediaElement(): HTMLMediaElement;
87
95
  protected abstract seekedMedia(): HTMLMediaElement;
88
96
  protected abstract fastSeekedMedia(): HTMLMediaElement;
97
+ /**
98
+ * Sync the underlying media element to the given time (e.g. draw/playback time).
99
+ * Used for both Video and Audio. When `time` is passed (e.g. from Scene2D.draw), that
100
+ * value is used so sync does not depend on the node's time signal.
101
+ * If this node has clipStart set, global time is converted to clip-relative time:
102
+ * syncTime = trimStart + (time - clipStart) * playbackRate.
103
+ * When omitted, falls back to this.time().
104
+ * When waitForSeek is true, returns a promise that resolves when the seek completes so
105
+ * the draw can wait and show the correct frame/sample when paused.
106
+ */
107
+ syncToCurrentTime(time?: number, options?: {
108
+ waitForSeek?: boolean;
109
+ }): void | Promise<void>;
89
110
  protected abstract draw(context: CanvasRenderingContext2D): Promise<void>;
90
- protected setCurrentTime(value: number): void;
111
+ protected setCurrentTime(value: number, options?: {
112
+ skipCollectPromise?: boolean;
113
+ }): Promise<void>;
91
114
  setVolume(volume: number): void;
92
115
  protected amplify(node: HTMLMediaElement, volume: number): void;
93
116
  protected setPlaybackRate(playbackRate: number): void;
@@ -2528,6 +2551,12 @@ declare class Scene2D extends GeneratorScene<View2D> implements Inspectable {
2528
2551
  getNode(key: any): Node | null;
2529
2552
  getDetachedNodes(): Generator<Node, void, unknown>;
2530
2553
  getMediaAssets(): Array<AssetInfo>;
2554
+ /**
2555
+ * Seek all registered Media nodes (Video and Audio) to the current playback time.
2556
+ * Passes draw time; nodes with clipStart convert it to clip-relative time.
2557
+ * When waitForSeek is true, waits for each media seek to complete so the next draw shows the correct frame/sample.
2558
+ */
2559
+ private syncAllMediaToCurrentTime;
2531
2560
  stopAllMedia(): void;
2532
2561
  adjustVolume(volumeScale: number): void;
2533
2562
  protected recreateView(): void;
package/dist/index.d.ts CHANGED
@@ -58,6 +58,10 @@ interface MediaProps extends RectProps {
58
58
  play?: boolean;
59
59
  awaitCanPlay?: SignalValue<boolean>;
60
60
  allowVolumeAmplificationInPreview?: SignalValue<boolean>;
61
+ /** Timeline time when this clip starts; when set, syncToCurrentTime(globalTime) uses clip-relative time. */
62
+ clipStart?: number;
63
+ /** Start offset in the media file (e.g. trim); used with clipStart for sync. */
64
+ trimStart?: number;
61
65
  }
62
66
  declare abstract class Media extends Rect {
63
67
  readonly src: SimpleSignal<string, this>;
@@ -75,6 +79,10 @@ declare abstract class Media extends Rect {
75
79
  }>;
76
80
  protected lastTime: number;
77
81
  private isSchedulingPlay;
82
+ /** When set, syncToCurrentTime(globalTime) converts to clip-relative time. */
83
+ protected clipStart: number | undefined;
84
+ /** Used with clipStart for sync (trim offset in source). */
85
+ protected trimStart: number;
78
86
  constructor(props: MediaProps);
79
87
  isPlaying(): boolean;
80
88
  getCurrentTime(): number;
@@ -86,8 +94,23 @@ declare abstract class Media extends Rect {
86
94
  protected abstract mediaElement(): HTMLMediaElement;
87
95
  protected abstract seekedMedia(): HTMLMediaElement;
88
96
  protected abstract fastSeekedMedia(): HTMLMediaElement;
97
+ /**
98
+ * Sync the underlying media element to the given time (e.g. draw/playback time).
99
+ * Used for both Video and Audio. When `time` is passed (e.g. from Scene2D.draw), that
100
+ * value is used so sync does not depend on the node's time signal.
101
+ * If this node has clipStart set, global time is converted to clip-relative time:
102
+ * syncTime = trimStart + (time - clipStart) * playbackRate.
103
+ * When omitted, falls back to this.time().
104
+ * When waitForSeek is true, returns a promise that resolves when the seek completes so
105
+ * the draw can wait and show the correct frame/sample when paused.
106
+ */
107
+ syncToCurrentTime(time?: number, options?: {
108
+ waitForSeek?: boolean;
109
+ }): void | Promise<void>;
89
110
  protected abstract draw(context: CanvasRenderingContext2D): Promise<void>;
90
- protected setCurrentTime(value: number): void;
111
+ protected setCurrentTime(value: number, options?: {
112
+ skipCollectPromise?: boolean;
113
+ }): Promise<void>;
91
114
  setVolume(volume: number): void;
92
115
  protected amplify(node: HTMLMediaElement, volume: number): void;
93
116
  protected setPlaybackRate(playbackRate: number): void;
@@ -2528,6 +2551,12 @@ declare class Scene2D extends GeneratorScene<View2D> implements Inspectable {
2528
2551
  getNode(key: any): Node | null;
2529
2552
  getDetachedNodes(): Generator<Node, void, unknown>;
2530
2553
  getMediaAssets(): Array<AssetInfo>;
2554
+ /**
2555
+ * Seek all registered Media nodes (Video and Audio) to the current playback time.
2556
+ * Passes draw time; nodes with clipStart convert it to clip-relative time.
2557
+ * When waitForSeek is true, waits for each media seek to complete so the next draw shows the correct frame/sample.
2558
+ */
2559
+ private syncAllMediaToCurrentTime;
2531
2560
  stopAllMedia(): void;
2532
2561
  adjustVolume(volumeScale: number): void;
2533
2562
  protected recreateView(): void;
package/dist/index.js CHANGED
@@ -5401,13 +5401,17 @@ var Media = class extends Rect {
5401
5401
  super(props);
5402
5402
  this.lastTime = -1;
5403
5403
  this.isSchedulingPlay = false;
5404
+ /** Used with clipStart for sync (trim offset in source). */
5405
+ this.trimStart = 0;
5404
5406
  if (!this.awaitCanPlay()) {
5405
- this.scheduleSeek(this.time());
5407
+ setTimeout(() => this.scheduleSeek(this.time()), 0);
5406
5408
  }
5407
5409
  if (props.play) {
5408
5410
  this.play();
5409
5411
  }
5410
5412
  this.volume = props.volume ?? 1;
5413
+ this.clipStart = props.clipStart;
5414
+ this.trimStart = props.trimStart ?? 0;
5411
5415
  if (!this.awaitCanPlay()) {
5412
5416
  this.setVolume(this.volume);
5413
5417
  }
@@ -5447,25 +5451,72 @@ var Media = class extends Rect {
5447
5451
  completion() {
5448
5452
  return this.clampTime(this.time()) / this.getDuration();
5449
5453
  }
5450
- setCurrentTime(value) {
5454
+ /**
5455
+ * Sync the underlying media element to the given time (e.g. draw/playback time).
5456
+ * Used for both Video and Audio. When `time` is passed (e.g. from Scene2D.draw), that
5457
+ * value is used so sync does not depend on the node's time signal.
5458
+ * If this node has clipStart set, global time is converted to clip-relative time:
5459
+ * syncTime = trimStart + (time - clipStart) * playbackRate.
5460
+ * When omitted, falls back to this.time().
5461
+ * When waitForSeek is true, returns a promise that resolves when the seek completes so
5462
+ * the draw can wait and show the correct frame/sample when paused.
5463
+ */
5464
+ syncToCurrentTime(time, options) {
5465
+ let syncTime;
5466
+ if (time !== void 0 && this.clipStart !== void 0) {
5467
+ syncTime = this.trimStart + (time - this.clipStart) * this.playbackRate();
5468
+ } else {
5469
+ syncTime = time ?? this.time();
5470
+ }
5471
+ const promise = this.setCurrentTime(syncTime, {
5472
+ skipCollectPromise: options?.waitForSeek ?? true
5473
+ });
5474
+ if (options?.waitForSeek) {
5475
+ return promise;
5476
+ }
5477
+ }
5478
+ setCurrentTime(value, options) {
5451
5479
  try {
5452
5480
  const media = this.mediaElement();
5453
- if (media.readyState < 2) return;
5454
- media.currentTime = value;
5455
- this.lastTime = value;
5456
- if (media.seeking) {
5457
- DependencyContext4.collectPromise(
5458
- new Promise((resolve) => {
5459
- const listener = () => {
5481
+ const key = this.key ?? "media";
5482
+ if (media.readyState < 2) {
5483
+ this.lastTime = value;
5484
+ this.time(value);
5485
+ return new Promise((resolve) => {
5486
+ this.waitForCanPlay(media, () => {
5487
+ media.currentTime = value;
5488
+ this.lastTime = value;
5489
+ this.time(value);
5490
+ const onSeeked = () => {
5491
+ media.removeEventListener("seeked", onSeeked);
5460
5492
  resolve();
5461
- media.removeEventListener("seeked", listener);
5462
5493
  };
5463
- media.addEventListener("seeked", listener);
5464
- })
5465
- );
5494
+ if (media.seeking) {
5495
+ media.addEventListener("seeked", onSeeked);
5496
+ } else {
5497
+ resolve();
5498
+ }
5499
+ });
5500
+ });
5501
+ }
5502
+ media.currentTime = value;
5503
+ this.lastTime = value;
5504
+ this.time(value);
5505
+ const seekPromise = media.seeking ? new Promise((resolve) => {
5506
+ const listener = () => {
5507
+ resolve();
5508
+ media.removeEventListener("seeked", listener);
5509
+ };
5510
+ media.addEventListener("seeked", listener);
5511
+ }) : Promise.resolve();
5512
+ if (media.seeking && !options?.skipCollectPromise) {
5513
+ DependencyContext4.collectPromise(seekPromise);
5466
5514
  }
5515
+ return seekPromise;
5467
5516
  } catch (error) {
5468
5517
  this.lastTime = value;
5518
+ this.time(value);
5519
+ return Promise.resolve();
5469
5520
  }
5470
5521
  }
5471
5522
  setVolume(volume) {
@@ -10397,11 +10448,13 @@ var Video = class extends Media {
10397
10448
  fastSeekedVideo() {
10398
10449
  const video = this.video();
10399
10450
  const time = this.clampTime(this.time());
10451
+ const playing = this.playing() && time < video.duration && video.playbackRate > 0;
10452
+ const wouldResetToZero = time < 0.5 && video.currentTime > 1 && Math.abs(video.currentTime - this.lastTime) < 0.5;
10453
+ const outOfSyncBig = Math.abs(video.currentTime - time) > 1;
10400
10454
  video.playbackRate = this.playbackRate();
10401
10455
  if (this.lastTime === time) {
10402
10456
  return video;
10403
10457
  }
10404
- const playing = this.playing() && time < video.duration && video.playbackRate > 0;
10405
10458
  if (playing) {
10406
10459
  if (video.paused) {
10407
10460
  DependencyContext8.collectPromise(video.play());
@@ -10411,10 +10464,11 @@ var Video = class extends Media {
10411
10464
  video.pause();
10412
10465
  }
10413
10466
  }
10414
- if (Math.abs(video.currentTime - time) > 1) {
10467
+ if (wouldResetToZero) {
10468
+ } else if (outOfSyncBig) {
10415
10469
  this.setCurrentTime(time);
10416
10470
  } else if (!playing) {
10417
- video.currentTime = time;
10471
+ this.setCurrentTime(time);
10418
10472
  }
10419
10473
  return video;
10420
10474
  }
@@ -10641,6 +10695,9 @@ var Scene2D = class extends GeneratorScene {
10641
10695
  context.save();
10642
10696
  this.renderLifecycle.dispatch([SceneRenderEvent.BeginRender, context]);
10643
10697
  this.getView().playbackState(this.playback.state).globalTime(this.playback.time).fps(this.playback.fps);
10698
+ if (this.playback.state === PlaybackState5.Paused) {
10699
+ await this.syncAllMediaToCurrentTime(true);
10700
+ }
10644
10701
  await this.getView().render(context);
10645
10702
  this.renderLifecycle.dispatch([SceneRenderEvent.FinishRender, context]);
10646
10703
  context.restore();
@@ -10771,6 +10828,34 @@ var Scene2D = class extends GeneratorScene {
10771
10828
  );
10772
10829
  return returnObjects;
10773
10830
  }
10831
+ /**
10832
+ * Seek all registered Media nodes (Video and Audio) to the current playback time.
10833
+ * Passes draw time; nodes with clipStart convert it to clip-relative time.
10834
+ * When waitForSeek is true, waits for each media seek to complete so the next draw shows the correct frame/sample.
10835
+ */
10836
+ async syncAllMediaToCurrentTime(waitForSeek) {
10837
+ const drawTime = this.playback.time;
10838
+ const mediaNodes = Array.from(this.registeredNodes.values()).filter(
10839
+ (node) => node instanceof Media
10840
+ );
10841
+ const results = mediaNodes.map((media) => {
10842
+ try {
10843
+ return media.syncToCurrentTime(drawTime, { waitForSeek });
10844
+ } catch (e) {
10845
+ this.logger.warn({
10846
+ message: `syncAllMediaToCurrentTime: skipped node ${media.key ?? "unknown"}`,
10847
+ object: e
10848
+ });
10849
+ return void 0;
10850
+ }
10851
+ });
10852
+ if (waitForSeek) {
10853
+ const promises = results.filter(
10854
+ (p) => p !== void 0
10855
+ );
10856
+ await Promise.all(promises);
10857
+ }
10858
+ }
10774
10859
  stopAllMedia() {
10775
10860
  const playingMedia = Array.from(this.registeredNodes.values()).filter((node) => node instanceof Media).filter((video) => video.isPlaying());
10776
10861
  for (const media of playingMedia) {