supervision 0.2.7 → 0.2.8

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.js CHANGED
@@ -1,4 +1,4 @@
1
- import { copySortedDetectionFrames, detectionFrameOverlapsRange, sortedDetectionFrames, projectDetectionFrameForTracking, createSortTracker, createOCSortTracker, createCBIoUTracker, createByteTrackTracker, MediaErrorKind, includeDefined, createDepthColormapLut, parseDepthManifest, validateDepthMap, resolveDepthFrameFile, PLAYHEAD_QUANTIZATION_TOLERANCE_SECONDS, decodeCompressedRleMask, decodeCompressedRleCounts, createIdMaskFrame, encodeBinaryMask, rasterizePolygonToMask, resolveIdMaskPaletteId, canReuseMaskStyleArtifacts, getBufferedDetectionTimelineFrameSnapshot, MediaSourceStatus, MediaRendererPlaybackState, MediaRendererFit, createDefaultAnnotationPresentation, createProjectedDetectionFrameSource, createArrayDetectionFrameSource, createBufferedDetectionTimeline, resolveAnnotationRendererPresentation, PlaybackGateReach, createIdleDetectionBufferState, MediaInteractionMode, MAX_ID_MASK_PALETTE_ENTRIES, StrokeAlignment, BaseBoxStyle, centerRectToTopLeftRect, BoxShape, depthPreviewTopCode, resolveDepthColorMapping, resolveDepthQuantity, computeDepthPercentileRange, BaseFocusStyle, extractDetectionMaskRectRuns, AnnotationGestureStateKind, AnnotationHandleKind, getDetectionRect, DetectionPickTarget, createDetectionPickKey, rebaseDetectionPickToFrame, pickDetectionAtPoint, followDetectionPickAcrossFrames, haveSameDetectionPickIdentities, pickAnnotationHandle, getAnnotationHandles, MAX_ID_MASK_STROKE_WIDTH, LabelPlacement, BasePolygonStyle, BasePolylineStyle, BaseKeypointStyle, ShapeInstructionKind, sampleEllipseArc, resolveEllipseSegmentCount, KeypointMarkerShape, MarkerSizeSpace, MarkerShape, resolveMarkerGeometry, BaseInteractionStyle, DetectionInteractionState, writeIdMaskPaletteEntry, resolveIdMaskStrokeTexels, DetectionBufferStatus, resolveMaskStyleOpacity, pickDetectionByMaskId, BoxStrokeAlignment, RegionRendererMediaEffectKind, RegionRendererSourceKind, RegionRendererCoverageKind, RegionRendererSizeSpace, resolveStyleValue, resolveAnnotationStyleState, lightenColor, resolveAnnotationRendererStyleFields, annotationRendererKinds, createViewportController, MediaSessionMode, DetectionFrameRetentionMode, createMemoryColdDetectionFrameStore, createWritableDetectionFrameSource, createCompositeDetectionFrameSource, MediaSessionActivityStatus, MediaSessionActivityKind, MediaSessionStatus, createSourceAwarePresentation, projectDetectionFrames, DetectionFrameSelectionMode } from 'supervision-js-core';
1
+ import { copySortedDetectionFrames, detectionFrameOverlapsRange, sortedDetectionFrames, projectDetectionFrameForTracking, createSortTracker, createOCSortTracker, createCBIoUTracker, createByteTrackTracker, MediaErrorKind, includeDefined, createDepthColormapLut, parseDepthManifest, validateDepthMap, resolveDepthFrameFile, PLAYHEAD_QUANTIZATION_TOLERANCE_SECONDS, decodeCompressedRleMask, decodeCompressedRleCounts, createIdMaskFrame, encodeBinaryMask, rasterizePolygonToMask, resolveIdMaskPaletteId, canReuseMaskStyleArtifacts, getBufferedDetectionTimelineFrameSnapshot, MediaSourceStatus, MediaRendererPlaybackState, MediaRendererFit, createDefaultAnnotationPresentation, createProjectedDetectionFrameSource, createArrayDetectionFrameSource, createBufferedDetectionTimeline, resolveAnnotationRendererPresentation, PlaybackGateReach, createIdleDetectionBufferState, MediaInteractionMode, MAX_ID_MASK_PALETTE_ENTRIES, StrokeAlignment, BaseBoxStyle, centerRectToTopLeftRect, BoxShape, depthPreviewTopCode, resolveDepthColorMapping, resolveDepthQuantity, computeDepthPercentileRange, BaseFocusStyle, extractDetectionMaskRectRuns, AnnotationGestureStateKind, AnnotationHandleKind, getDetectionRect, DetectionPickTarget, rebaseDetectionPickToFrame, pickDetectionAtPoint, createDetectionPickKey, followDetectionPickAcrossFrames, haveSameDetectionPickIdentities, pickAnnotationHandle, getAnnotationHandles, MAX_ID_MASK_STROKE_WIDTH, LabelPlacement, BasePolygonStyle, BasePolylineStyle, BaseKeypointStyle, ShapeInstructionKind, sampleEllipseArc, resolveEllipseSegmentCount, KeypointMarkerShape, MarkerSizeSpace, MarkerShape, resolveMarkerGeometry, BaseInteractionStyle, DetectionInteractionState, writeIdMaskPaletteEntry, resolveIdMaskStrokeTexels, DetectionBufferStatus, resolveMaskStyleOpacity, pickDetectionByMaskId, BoxStrokeAlignment, RegionRendererMediaEffectKind, RegionRendererSourceKind, RegionRendererCoverageKind, RegionRendererSizeSpace, resolveStyleValue, resolveAnnotationStyleState, lightenColor, resolveAnnotationRendererStyleFields, annotationRendererKinds, createViewportController, MediaSessionMode, DetectionFrameRetentionMode, createMemoryColdDetectionFrameStore, createWritableDetectionFrameSource, createCompositeDetectionFrameSource, MediaSessionActivityStatus, MediaSessionActivityKind, MediaSessionStatus, createSourceAwarePresentation, projectDetectionFrames, DetectionFrameSelectionMode } from 'supervision-js-core';
2
2
  export { BaseBoxCornerStyle, BaseBoxStyle, BaseFocusStyle, BaseInteractionStyle, BaseKeypointStyle, BaseLabelStyle, BaseMarkerStyle, BaseMaskStyle, BaseOrientedBoxStyle, BasePercentageBarStyle, BasePolygonStyle, BasePolylineStyle, BoxShape, BoxStrokeAlignment, DEFAULT_DETECTION_CLASS_STYLES, DEFAULT_DETECTION_COLOR_SEQUENCE, DepthColormap, DepthMapKind, DepthPreviewLevels, DepthQuantity, DepthSampling, DetectionBufferStatus, DetectionFrameRetentionMode, DetectionFrameSelectionMode, DetectionInteractionState, DetectionMaskEncoding, DetectionPickTarget, FocusTargetMode, KeypointMarkerShape, KeypointVisibility, LabelPlacement, LabelVisibilityMode, MarkerShape, MarkerSizeSpace, MaskRenderMode, MediaErrorKind, MediaInteractionMode, MediaRendererFit, MediaRendererPlaybackState, MediaSessionActivityKind, MediaSessionActivityStatus, MediaSessionMode, MediaSessionStatus, MediaSourceStatus, PercentageBarPlacement, PlaybackGateReach, RegionRendererComposeMode, RegionRendererCoverageKind, RegionRendererMediaEffectKind, RegionRendererRegionKind, RegionRendererSizeSpace, RegionRendererSourceKind, SUPERVISION_ROBOFLOW_COLOR, TrackingGeometry, annotationRendererKinds, annotationRenderers, computeDepthPercentileRange, createArrayDetectionFrameSource, createBufferedDetectionTimeline, createByteTrackTracker, createCBIoUTracker, createColdDetectionFrameSource, createCompositeDetectionFrameSource, createDefaultAnnotationPresentation, createMemoryColdDetectionFrameStore, createOCSortTracker, createProjectedDetectionFrameSource, createSortTracker, createWritableDetectionFrameSource, depthColormapColors, detectionPostProcessors, normalizeDetectionClassName, parseDepthManifest, pickDetectionAtPoint, projectDetectionFrame, projectDetectionFrameForTracking, projectDetectionFrames, readDepthAt, resolveDetectionClassColorStyle } from 'supervision-js-core';
3
3
  import { c as createMediaFrameClock, M as MediaSourceError, t as toMediaSourceError, r as resolveDisplayPixelRatio } from './video-engine-media-source-DOFsuGS4.js';
4
4
  export { a as createWebVideoEngineMediaRendererSource, g as getMediaErrorKind, i as isMediaSourceError, o as openWebVideoEngineMediaSource } from './video-engine-media-source-DOFsuGS4.js';
@@ -10132,6 +10132,9 @@ async function createMediaRendererCore(options, providers) {
10132
10132
  polygonStyle: currentPresentation.polygonStyle,
10133
10133
  polylineStyle: currentPresentation.polylineStyle,
10134
10134
  presentedFrames: protectedPresentedFrames?.source,
10135
+ // A source with no duration, such as a still image, has one frame to
10136
+ // show and nothing to play.
10137
+ stillFrame: metadata.duration === 0,
10135
10138
  regionRenderers: resolveRegionRenderers(currentPresentation),
10136
10139
  heatmapRenderers: resolveHeatmapRenderers(currentPresentation),
10137
10140
  depthRenderers: resolveDepthRenderers(currentPresentation),
@@ -13562,7 +13565,6 @@ function createPixiInteractionLayer(options) {
13562
13565
  let hoveredPick = null;
13563
13566
  let hoveredPickKey = null;
13564
13567
  let selectedPick = null;
13565
- let selectedPickKey = null;
13566
13568
  let selectedPicks = [];
13567
13569
  let marqueeStart = null;
13568
13570
  let marqueeRect = null;
@@ -13615,9 +13617,19 @@ function createPixiInteractionLayer(options) {
13615
13617
  setSelectedPick(null);
13616
13618
  return null;
13617
13619
  }
13618
- const nextPick = createPickFromSelection(selection);
13619
- setSelectedPick(nextPick);
13620
- return nextPick;
13620
+ const nextPicks = selection.detectionIds
13621
+ ? [...new Set(selection.detectionIds)].flatMap((detectionId) => {
13622
+ const pick = createPickFromSelection({
13623
+ detectionId,
13624
+ mediaTime: selection.mediaTime,
13625
+ point: selection.point,
13626
+ target: selection.target,
13627
+ });
13628
+ return pick ? [pick] : [];
13629
+ })
13630
+ : [createPickFromSelection(selection)].filter((pick) => pick !== null);
13631
+ setSelectedPicks(nextPicks);
13632
+ return nextPicks.at(-1) ?? null;
13621
13633
  },
13622
13634
  getState() {
13623
13635
  return createState();
@@ -13744,6 +13756,18 @@ function createPixiInteractionLayer(options) {
13744
13756
  if (event.button !== undefined && event.button !== 0) {
13745
13757
  return;
13746
13758
  }
13759
+ if (options.interaction.multiSelect &&
13760
+ event.shiftKey &&
13761
+ pick &&
13762
+ !options.editingEngine.hasCreationTool() &&
13763
+ options.editingEngine.getState().kind ===
13764
+ AnnotationGestureStateKind.Idle) {
13765
+ // Selection modifiers must run before the editing engine captures a
13766
+ // move. The following pointertap belongs to this same toggle.
13767
+ toggleSelectedPick(pick);
13768
+ suppressNextTap = true;
13769
+ return;
13770
+ }
13747
13771
  // Keep selection owned by this layer. The editing engine deliberately
13748
13772
  // treats a click as a cancelled move, so it cannot establish selection
13749
13773
  // itself; selecting here also makes resize handles available on the next
@@ -13783,7 +13807,7 @@ function createPixiInteractionLayer(options) {
13783
13807
  if (!marqueeStart)
13784
13808
  return;
13785
13809
  if (didDragMarquee && marqueeRect && activeFrame) {
13786
- selectedPicks = activeFrame.detections.flatMap((detection, detectionIndex) => {
13810
+ const nextPicks = activeFrame.detections.flatMap((detection, detectionIndex) => {
13787
13811
  if (!canPickDetection(detection))
13788
13812
  return [];
13789
13813
  const rect = getDetectionRect(detection);
@@ -13804,10 +13828,7 @@ function createPixiInteractionLayer(options) {
13804
13828
  },
13805
13829
  ];
13806
13830
  });
13807
- selectedPick = selectedPicks.at(-1) ?? null;
13808
- selectedPickKey = createDetectionPickKey(selectedPick);
13809
- options.interaction.onSelect?.(selectedPick);
13810
- options.interaction.onSelectionChange?.(selectedPicks);
13831
+ setSelectedPicks(nextPicks);
13811
13832
  }
13812
13833
  marqueeStart = null;
13813
13834
  marqueeRect = null;
@@ -13903,7 +13924,6 @@ function createPixiInteractionLayer(options) {
13903
13924
  if (nextKeys === currentKeys) {
13904
13925
  return;
13905
13926
  }
13906
- selectedPickKey = createDetectionPickKey(selectedPick);
13907
13927
  // A followed pick re-bases onto every new frame, so its full pick key
13908
13928
  // changes at playback rate. The scene's normal frame path consumes this
13909
13929
  // refreshed state immediately; emitting onStateChange here would redraw
@@ -13935,15 +13955,17 @@ function createPixiInteractionLayer(options) {
13935
13955
  return true;
13936
13956
  }
13937
13957
  function setSelectedPick(nextPick) {
13938
- const nextKey = createDetectionPickKey(nextPick);
13939
- if (nextKey === selectedPickKey) {
13940
- selectedPick = nextPick;
13958
+ setSelectedPicks(nextPick ? [nextPick] : []);
13959
+ }
13960
+ function setSelectedPicks(nextPicks) {
13961
+ const unchanged = selectedPicks.length === nextPicks.length &&
13962
+ selectedPicks.every((pick, index) => createDetectionPickKey(pick) ===
13963
+ createDetectionPickKey(nextPicks[index]));
13964
+ selectedPicks = nextPicks;
13965
+ selectedPick = nextPicks.at(-1) ?? null;
13966
+ if (unchanged)
13941
13967
  return;
13942
- }
13943
- selectedPick = nextPick;
13944
- selectedPicks = nextPick ? [nextPick] : [];
13945
- selectedPickKey = nextKey;
13946
- options.interaction.onSelect?.(nextPick);
13968
+ options.interaction.onSelect?.(selectedPick);
13947
13969
  options.interaction.onSelectionChange?.(selectedPicks);
13948
13970
  notifyStateChange();
13949
13971
  }
@@ -13962,17 +13984,20 @@ function createPixiInteractionLayer(options) {
13962
13984
  function toggleSelectedPick(pick) {
13963
13985
  if (!pick)
13964
13986
  return;
13965
- const key = createDetectionPickKey(pick);
13966
- const index = selectedPicks.findIndex((candidate) => createDetectionPickKey(candidate) === key);
13967
- selectedPicks =
13968
- index >= 0
13969
- ? selectedPicks.filter((_, candidateIndex) => candidateIndex !== index)
13970
- : [...selectedPicks, pick];
13971
- selectedPick = selectedPicks.at(-1) ?? null;
13972
- selectedPickKey = createDetectionPickKey(selectedPick);
13973
- options.interaction.onSelect?.(selectedPick);
13974
- options.interaction.onSelectionChange?.(selectedPicks);
13975
- notifyStateChange();
13987
+ const key = createSelectionKey(pick);
13988
+ const index = selectedPicks.findIndex((candidate) => createSelectionKey(candidate) === key);
13989
+ setSelectedPicks(index >= 0
13990
+ ? selectedPicks.filter((_, candidateIndex) => candidateIndex !== index)
13991
+ : [...selectedPicks, pick]);
13992
+ }
13993
+ function createSelectionKey(pick) {
13994
+ // Selection toggles the detection, regardless of whether its body, label,
13995
+ // or keypoint was hit. Keep the picker's frame/index fallback for duplicate ids.
13996
+ return createDetectionPickKey({
13997
+ ...pick,
13998
+ geometryIndex: undefined,
13999
+ target: DetectionPickTarget.Box,
14000
+ });
13976
14001
  }
13977
14002
  function getPoint(event) {
13978
14003
  if (!container)
@@ -17438,6 +17463,13 @@ function createPixiRegionLayer(options) {
17438
17463
  }
17439
17464
  return { activeDetectionIndexes: [...activeDetectionIndexes] };
17440
17465
  },
17466
+ hasAnimatedDisplay() {
17467
+ for (const entry of entries.values()) {
17468
+ if (isAnimatedDisplay(entry.display))
17469
+ return true;
17470
+ }
17471
+ return false;
17472
+ },
17441
17473
  releaseMaskTexture(texture) {
17442
17474
  if (destroyed)
17443
17475
  return;
@@ -17760,12 +17792,15 @@ function destroyEntry(entry) {
17760
17792
  // lightweight crop texture; the scene destroys the shared source.
17761
17793
  entry.ownedTexture?.destroy(false);
17762
17794
  }
17795
+ function isAnimatedDisplay(display) {
17796
+ return "play" in display && typeof display.play === "function";
17797
+ }
17763
17798
  function pauseAnimatedDisplay(display) {
17764
17799
  if ("stop" in display && typeof display.stop === "function")
17765
17800
  display.stop();
17766
17801
  }
17767
17802
  function resumeAnimatedDisplay(display) {
17768
- if ("play" in display && typeof display.play === "function")
17803
+ if (isAnimatedDisplay(display))
17769
17804
  display.play();
17770
17805
  }
17771
17806
  function matchesTarget(renderer, detection, context) {
@@ -18466,6 +18501,11 @@ function observePixiContainerResize(container, resize) {
18466
18501
  * sample it wants, hands it to `presentSample`, and Pixi's ticker paints the
18467
18502
  * scene every frame.
18468
18503
  *
18504
+ * A still frame is pulled once and then renders on change the way push
18505
+ * presentation does, with the sample it presents as one more change. The
18506
+ * ticker runs only while a looping GIF region or a loading spinner is on the
18507
+ * frame.
18508
+ *
18469
18509
  * Push presentation engages when the opened source carries a presented-frame
18470
18510
  * channel. The producer then decides which frame is on screen and announces it,
18471
18511
  * so the ticker paints nothing and every render is an explicit answer to a
@@ -18496,6 +18536,18 @@ async function createPixiMediaScene(options) {
18496
18536
  let maxTextureSide;
18497
18537
  const maxTextureSize = () => (maxTextureSide ??= queryMaxTextureSize(app.renderer));
18498
18538
  const frameChannel = options.presentedFrames;
18539
+ /**
18540
+ * A still frame has nothing to play, so it renders on change exactly as push
18541
+ * presentation does. The ticker runs only while something on the frame
18542
+ * animates by the clock.
18543
+ */
18544
+ const stillFrame = frameChannel === undefined && options.stillFrame === true;
18545
+ const rendersOnChange = frameChannel !== undefined || stillFrame;
18546
+ let stillFrameTickerRunning = false;
18547
+ /** The content of the `previewOverlay` answer the overlay on screen drew. */
18548
+ let drawnPreviewOverlayContent = null;
18549
+ /** The editing state the overlay on screen was drawn from. */
18550
+ let drawnEditingState;
18499
18551
  let currentFocusStyle = options.focusStyle ?? null;
18500
18552
  let currentLabelStyle = options.labelStyle ?? null;
18501
18553
  let currentMaskStyle = options.maskStyle ?? null;
@@ -18736,7 +18788,7 @@ async function createPixiMediaScene(options) {
18736
18788
  : undefined;
18737
18789
  await app.init({
18738
18790
  autoDensity: true,
18739
- autoStart: frameChannel === undefined,
18791
+ autoStart: !rendersOnChange,
18740
18792
  backgroundColor: options.backgroundColor ?? 0x111111,
18741
18793
  /* Only the video-engine media path supplies a presented-frame channel, so
18742
18794
  * which reader opens the clip is also what decides which backend draws. */
@@ -18755,7 +18807,10 @@ async function createPixiMediaScene(options) {
18755
18807
  if (!stagingContext) {
18756
18808
  throw new Error("Unable to create staging canvas context.");
18757
18809
  }
18758
- const renderScheduler = createSceneRenderScheduler(() => app.render());
18810
+ const renderScheduler = createSceneRenderScheduler(() => {
18811
+ app.render();
18812
+ syncStillFrameTicker();
18813
+ });
18759
18814
  const maskBrushPreview = options.maskBrush
18760
18815
  ? createPixiMaskBrushPreview({
18761
18816
  CanvasSource,
@@ -18764,7 +18819,7 @@ async function createPixiMediaScene(options) {
18764
18819
  Sprite,
18765
18820
  Texture,
18766
18821
  preview: options.maskBrush,
18767
- onInvalidate: frameChannel ? renderNow : undefined,
18822
+ onInvalidate: rendersOnChange ? renderNow : undefined,
18768
18823
  })
18769
18824
  : undefined;
18770
18825
  let appliedPresentation;
@@ -18933,6 +18988,17 @@ async function createPixiMediaScene(options) {
18933
18988
  labelLayer?.drawFrame(currentMediaTime, viewportScale);
18934
18989
  renderNow();
18935
18990
  }
18991
+ // With no ticker to pick it up, an edit the host drives itself, such as a
18992
+ // cancel or a commit, reaches the screen through this notification. A
18993
+ // pointer gesture draws and renders its own state before the microtask
18994
+ // runs, which then finds nothing left to draw or render.
18995
+ if (rendersOnChange) {
18996
+ queueMicrotask(() => {
18997
+ if (isDestroyed || isPresenting)
18998
+ return;
18999
+ renderOnChange();
19000
+ });
19001
+ }
18936
19002
  });
18937
19003
  const handleKeyDown = (event) => {
18938
19004
  if (event.key === "Tab" && interactionLayer) {
@@ -19122,15 +19188,17 @@ async function createPixiMediaScene(options) {
19122
19188
  renderScene();
19123
19189
  };
19124
19190
  let unsubscribeDetectionTimeline;
19125
- if (frameChannel) {
19126
- frameChannel.onPresentedFrame(handlePresentedFrame);
19191
+ if (rendersOnChange) {
19127
19192
  document.addEventListener?.("visibilitychange", handleVisibilityChange);
19128
19193
  // A detection load landing moves what the window covers exactly as a cook
19129
19194
  // landing does. Nothing else brings that to a resting playhead: the layers
19130
19195
  // read the timeline only while drawing, and this is what makes the draw.
19131
19196
  unsubscribeDetectionTimeline = options.detectionTimeline.subscribe?.(handlePreparedWindowChange);
19132
19197
  }
19133
- else {
19198
+ if (frameChannel) {
19199
+ frameChannel.onPresentedFrame(handlePresentedFrame);
19200
+ }
19201
+ else if (!stillFrame) {
19134
19202
  app.ticker.add(updateMediaSceneFit);
19135
19203
  app.ticker.add(drawAnnotationOverlayNow);
19136
19204
  app.ticker.add(tickFocusLayer);
@@ -19200,7 +19268,9 @@ async function createPixiMediaScene(options) {
19200
19268
  advanceFocus: () => undefined,
19201
19269
  drawAnnotationOverlay: frameChannel
19202
19270
  ? presentLayers.drawAnnotationOverlay
19203
- : () => undefined,
19271
+ : stillFrame
19272
+ ? (mediaTime) => drawAnnotationOverlay(mediaTime, overlayNow())
19273
+ : () => undefined,
19204
19274
  };
19205
19275
  // A viewport-style redraw covers the layers whose geometry is a function of
19206
19276
  // the viewport scale. Mask outlines reuse the artifact already on screen;
@@ -19250,7 +19320,7 @@ async function createPixiMediaScene(options) {
19250
19320
  };
19251
19321
  return {
19252
19322
  getRenderCount() {
19253
- return frameChannel ? renderScheduler.getRenderCount() : null;
19323
+ return rendersOnChange ? renderScheduler.getRenderCount() : null;
19254
19324
  },
19255
19325
  getPreparedAnnotationWindow() {
19256
19326
  return frameChannel ? annotationWindow.getSnapshot() : null;
@@ -19363,6 +19433,7 @@ async function createPixiMediaScene(options) {
19363
19433
  stagingTexture?.update();
19364
19434
  const { boxState, regionState } = drawFramePresentLayers(annotationDrawLayers, sample.timestamp);
19365
19435
  updateMediaSceneFit();
19436
+ renderOnChange();
19366
19437
  return {
19367
19438
  ...createPresentedSampleState(sample.timestamp, boxState, regionState),
19368
19439
  duration: sample.duration,
@@ -19378,6 +19449,7 @@ async function createPixiMediaScene(options) {
19378
19449
  });
19379
19450
  const { boxState, regionState } = drawFramePresentLayers(measuredAnnotationDrawLayers, sample.timestamp);
19380
19451
  const fitMs = measure(updateMediaSceneFit);
19452
+ renderOnChange();
19381
19453
  return {
19382
19454
  ...createPresentedSampleState(sample.timestamp, boxState, regionState),
19383
19455
  duration: sample.duration,
@@ -20256,7 +20328,7 @@ async function createPixiMediaScene(options) {
20256
20328
  return;
20257
20329
  }
20258
20330
  const { boxState, regionState } = drawAnnotationFrame(currentMediaTime);
20259
- if (frameChannel) {
20331
+ if (rendersOnChange) {
20260
20332
  const signature = describeSceneRender();
20261
20333
  if (forceRender)
20262
20334
  renderScheduler.render(signature);
@@ -20313,6 +20385,13 @@ async function createPixiMediaScene(options) {
20313
20385
  labelLayer?.drawCreationPreview(editingState?.kind === AnnotationGestureStateKind.Creating
20314
20386
  ? editingState.preview
20315
20387
  : null, mediaTime, viewportScale);
20388
+ const previewOverlay = options.previewOverlay?.() ?? null;
20389
+ drawnEditingState = editingState;
20390
+ // Only a scene that renders on change compares signatures.
20391
+ if (rendersOnChange) {
20392
+ drawnPreviewOverlayContent =
20393
+ describePreviewOverlayContent(previewOverlay);
20394
+ }
20316
20395
  annotationOverlayLayer.draw({
20317
20396
  frame,
20318
20397
  marquee: interactionState?.marqueeRect ?? null,
@@ -20320,7 +20399,7 @@ async function createPixiMediaScene(options) {
20320
20399
  mediaWidth,
20321
20400
  now: overlayNowMs,
20322
20401
  pointer: interactionState?.pointerPoint ?? null,
20323
- previewOverlay: options.previewOverlay?.() ?? null,
20402
+ previewOverlay,
20324
20403
  selectedDetectionIds: (interactionState?.selectedPicks ?? []).flatMap(({ detection }) => (detection.id === undefined ? [] : [detection.id])),
20325
20404
  viewportScale,
20326
20405
  visibility: currentVisibility,
@@ -20328,8 +20407,9 @@ async function createPixiMediaScene(options) {
20328
20407
  }
20329
20408
  /**
20330
20409
  * Milliseconds for animations that are not annotation geometry. A pull scene
20331
- * has a ticker and reads the wall clock; a push scene has the presented media
20332
- * time and nothing else, so its animations move only when the media does.
20410
+ * has a ticker and reads the wall clock, as does a still frame while its
20411
+ * ticker runs; a push scene has the presented media time and nothing else,
20412
+ * so its animations move only when the media does.
20333
20413
  */
20334
20414
  function overlayNow() {
20335
20415
  return frameChannel ? currentMediaTime * MILLISECONDS_PER_SECOND : now();
@@ -20378,8 +20458,21 @@ async function createPixiMediaScene(options) {
20378
20458
  focusLayer.tick(focusAnimationTimeMs);
20379
20459
  }
20380
20460
  function renderScene() {
20461
+ drawOverlayForEditingState();
20381
20462
  renderScheduler.render(describeSceneRender());
20382
20463
  }
20464
+ /**
20465
+ * The render signature names the editing state, so a render must not record
20466
+ * a state the overlay has not drawn: the redraw that follows would match the
20467
+ * recorded signature and be skipped, leaving the old preview on screen.
20468
+ */
20469
+ function drawOverlayForEditingState() {
20470
+ const editingState = options.editingEngine?.getState();
20471
+ if (editingState === undefined || editingState === drawnEditingState) {
20472
+ return;
20473
+ }
20474
+ drawAnnotationOverlay(currentMediaTime, overlayNow());
20475
+ }
20383
20476
  /**
20384
20477
  * Renders a present at most once per display refresh. Frames arrive as
20385
20478
  * messages from the producer's own thread, so a main thread that falls
@@ -20405,15 +20498,46 @@ async function createPixiMediaScene(options) {
20405
20498
  presentFrameNow(deferred);
20406
20499
  }
20407
20500
  function renderNow() {
20408
- if (frameChannel)
20501
+ if (rendersOnChange)
20409
20502
  renderScene();
20410
20503
  }
20411
20504
  function renderOnChange() {
20412
- if (!frameChannel)
20505
+ if (!rendersOnChange)
20413
20506
  return;
20414
20507
  settleFocusAnimation();
20508
+ drawOverlayForEditingState();
20415
20509
  renderScheduler.renderOnChange(describeSceneRender());
20416
20510
  }
20511
+ /**
20512
+ * Runs the ticker on a still frame for as long as something on it moves by
20513
+ * the clock: a looping GIF region or a loading spinner. Pixi renders on
20514
+ * every tick it runs, and the overlay redraws so the spinner turns. Each
20515
+ * render checks again, so the ticker stops with the render that removes the
20516
+ * last of them.
20517
+ */
20518
+ function syncStillFrameTicker() {
20519
+ if (!stillFrame || isDestroyed)
20520
+ return;
20521
+ const animating = hasPresentedSample &&
20522
+ (regionLayer.hasAnimatedDisplay() || hasLoadingDetections());
20523
+ if (animating === stillFrameTickerRunning)
20524
+ return;
20525
+ stillFrameTickerRunning = animating;
20526
+ if (animating) {
20527
+ app.ticker.add(drawAnnotationOverlayNow);
20528
+ app.ticker.start();
20529
+ }
20530
+ else {
20531
+ app.ticker.stop();
20532
+ app.ticker.remove(drawAnnotationOverlayNow);
20533
+ }
20534
+ }
20535
+ function hasLoadingDetections() {
20536
+ const loading = currentVisibility?.loadingDetectionIds;
20537
+ if (!loading)
20538
+ return false;
20539
+ return "size" in loading ? loading.size > 0 : loading.length > 0;
20540
+ }
20417
20541
  /**
20418
20542
  * Everything a render would put on screen, as values that can be compared.
20419
20543
  * Styles are opaque resolvers, so they are compared by identity: handing the
@@ -20451,6 +20575,9 @@ async function createPixiMediaScene(options) {
20451
20575
  interactionState?.marqueeRect,
20452
20576
  options.editingEngine?.getState(),
20453
20577
  options.editingEngine ? interactionState?.pointerPoint : null,
20578
+ // The host's preview is read when the overlay draws, and a host may edit
20579
+ // its arrays in place, so its content is what tells a redraw it changed.
20580
+ drawnPreviewOverlayContent,
20454
20581
  ];
20455
20582
  }
20456
20583
  /**
@@ -20789,6 +20916,37 @@ function elapsedSince(start) {
20789
20916
  function now() {
20790
20917
  return globalThis.performance?.now?.() ?? Date.now();
20791
20918
  }
20919
+ /**
20920
+ * A key for what a preview overlay draws, hashed from its coordinates rather
20921
+ * than its arrays, so a preview the host edits in place still changes it.
20922
+ * Coordinates count to a 256th of a media pixel, finer than anything drawn.
20923
+ */
20924
+ function describePreviewOverlayContent(overlay) {
20925
+ if (!overlay)
20926
+ return null;
20927
+ let hash = 0x811c9dc5;
20928
+ const mix = (value) => {
20929
+ hash = Math.imul(hash ^ value, 0x01000193);
20930
+ };
20931
+ const mixPoint = (point) => {
20932
+ mix(Math.round(point.x * 256));
20933
+ mix(Math.round(point.y * 256));
20934
+ };
20935
+ for (const polygons of [overlay.hoverPolygons, overlay.draftPolygons]) {
20936
+ mix(polygons?.length ?? -1);
20937
+ for (const polygon of polygons ?? []) {
20938
+ mix(polygon.length);
20939
+ for (const point of polygon)
20940
+ mixPoint(point);
20941
+ }
20942
+ }
20943
+ mix(overlay.points?.length ?? -1);
20944
+ for (const point of overlay.points ?? []) {
20945
+ mixPoint(point);
20946
+ mix(point.positive ? 1 : 0);
20947
+ }
20948
+ return hash;
20949
+ }
20792
20950
  /**
20793
20951
  * Fills the container and paints the margin around the canvas in the colour the
20794
20952
  * renderer clears to. The scene owns this element so the canvas is positioned