cursedbelt 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
  2. package/dist/react/master-lock/MasterLockDialog.js +25 -5
  3. package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
  4. package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
  5. package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
  6. package/dist/react/master-lock/MasterLockProvider.js +19 -0
  7. package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
  8. package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
  9. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  10. package/dist/react/media/CanvasVideoPlayer.js +4 -3
  11. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  12. package/dist/react/media/FrameGrabber.d.ts +16 -2
  13. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  14. package/dist/react/media/FrameGrabber.js +68 -43
  15. package/dist/react/media/FrameGrabber.js.map +1 -1
  16. package/dist/react/media/PictureEditor.d.ts +17 -2
  17. package/dist/react/media/PictureEditor.d.ts.map +1 -1
  18. package/dist/react/media/PictureEditor.js +33 -7
  19. package/dist/react/media/PictureEditor.js.map +1 -1
  20. package/dist/react/media/VideoChapterEditor.d.ts +17 -2
  21. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  22. package/dist/react/media/VideoChapterEditor.js +21 -3
  23. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  24. package/dist/react/media/VideoCutEditor.d.ts +16 -2
  25. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  26. package/dist/react/media/VideoCutEditor.js +21 -3
  27. package/dist/react/media/VideoCutEditor.js.map +1 -1
  28. package/dist/react/media/VideoPlayer.d.ts +37 -9
  29. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  30. package/dist/react/media/VideoPlayer.js +130 -39
  31. package/dist/react/media/VideoPlayer.js.map +1 -1
  32. package/dist/react/media/hlsSource.d.ts +24 -3
  33. package/dist/react/media/hlsSource.d.ts.map +1 -1
  34. package/dist/react/media/hlsSource.js +109 -8
  35. package/dist/react/media/hlsSource.js.map +1 -1
  36. package/dist/react/media/pictureEditor.css +2 -0
  37. package/dist/react/media/sourceFault.d.ts +146 -0
  38. package/dist/react/media/sourceFault.d.ts.map +1 -0
  39. package/dist/react/media/sourceFault.js +139 -0
  40. package/dist/react/media/sourceFault.js.map +1 -0
  41. package/dist/react/media/videoChapterEditor.css +19 -0
  42. package/dist/react/media/videoCutEditor.css +22 -0
  43. package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
  44. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  45. package/dist/react/media-gallery/MediaGallery.js +166 -13
  46. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  47. package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
  48. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  49. package/dist/react/media-gallery/galleryItemMedia.js +27 -3
  50. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  51. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  52. package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
  53. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  54. package/dist/react/media-gallery/types.d.ts +23 -0
  55. package/dist/react/media-gallery/types.d.ts.map +1 -1
  56. package/dist/scripts/guardrailsEnforce.d.ts +1 -0
  57. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  58. package/dist/styles-areas/master-lock.css +1 -1
  59. package/dist/styles-areas/media-gallery.css +1 -1
  60. package/dist/styles-areas/media.css +1 -1
  61. package/package.json +3 -2
  62. package/scripts/guardrailsEnforce.spec.ts +244 -1
  63. package/scripts/guardrailsEnforce.ts +224 -7
  64. package/src/iframesMakeNoRealRequest.spec.ts +92 -0
  65. package/src/react/components/IsolatedFrame.spec.tsx +27 -9
  66. package/src/react/master-lock/MasterLockDialog.tsx +24 -5
  67. package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
  68. package/src/react/master-lock/MasterLockProvider.tsx +19 -0
  69. package/src/react/media/CanvasVideoPlayer.tsx +11 -6
  70. package/src/react/media/FrameGrabber.spec.tsx +57 -1
  71. package/src/react/media/FrameGrabber.tsx +138 -84
  72. package/src/react/media/PictureEditor.spec.tsx +90 -0
  73. package/src/react/media/PictureEditor.tsx +78 -23
  74. package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
  75. package/src/react/media/VideoChapterEditor.tsx +64 -18
  76. package/src/react/media/VideoCutEditor.spec.tsx +44 -0
  77. package/src/react/media/VideoCutEditor.tsx +63 -18
  78. package/src/react/media/VideoPlayer.spec.tsx +101 -0
  79. package/src/react/media/VideoPlayer.tsx +221 -73
  80. package/src/react/media/hlsSource.answer.spec.ts +255 -0
  81. package/src/react/media/hlsSource.ts +136 -8
  82. package/src/react/media/pictureEditor.css +2 -0
  83. package/src/react/media/sourceFault.spec.tsx +224 -0
  84. package/src/react/media/sourceFault.tsx +220 -0
  85. package/src/react/media/videoChapterEditor.css +19 -0
  86. package/src/react/media/videoCutEditor.css +22 -0
  87. package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
  88. package/src/react/media-gallery/MediaGallery.tsx +356 -45
  89. package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
  90. package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
  91. package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
  92. package/src/react/media-gallery/types.ts +23 -0
  93. package/src/styles-areas/master-lock.css +1 -1
  94. package/src/styles-areas/media-gallery.css +1 -1
  95. package/src/styles-areas/media.css +1 -1
@@ -77,6 +77,12 @@ import {
77
77
  } from '../hooks/useCheckboxSelection';
78
78
  import { DenseGalleryDetail, DenseGalleryShell } from '../media/DenseGalleryShell';
79
79
  import { useHlsSource } from '../media/hlsSource';
80
+ import {
81
+ isRecoverableFault,
82
+ probeSourceFault,
83
+ type SourceFault,
84
+ sourceFaultMessage,
85
+ } from '../media/sourceFault';
80
86
  import { HoverScrubVideoThumb } from '../media/HoverScrubVideoThumb';
81
87
  import { isPlayPauseSpace } from '../media/playerSpaceKey';
82
88
  import {
@@ -218,6 +224,45 @@ function TurnVideoDialog({
218
224
  );
219
225
  }
220
226
 
227
+ /**
228
+ * The picture editor, over the item's RESOLVED media — never over the raw row.
229
+ *
230
+ * 🔴 The same defect as {@link TurnVideoDialog}'s, and the worst of the five to leave in place:
231
+ * this dialog BAKES pixels. Mounted as `src={item.url ?? ''}` it showed an empty frame on any
232
+ * app that signs its URLs, with a live-looking row of sliders over it and a Save that could
233
+ * only write a blank or silently do nothing. The editor now states the reason and holds its
234
+ * saves (see `PictureEditor`'s `cannotShow`), and the source it is handed is the one
235
+ * {@link useItemMedia} resolved — `url`, because a picture has no stream and a thumbnail is
236
+ * the wrong bytes to re-encode.
237
+ */
238
+ function EditPictureDialog({
239
+ item,
240
+ cache,
241
+ allowReplace,
242
+ onClose,
243
+ onSave,
244
+ }: {
245
+ item: MediaGalleryItem;
246
+ cache: MediaCache;
247
+ allowReplace: boolean;
248
+ onClose: () => void;
249
+ onSave: (result: PictureEditResult) => void | Promise<void>;
250
+ }) {
251
+ const { media, resolving } = useItemMedia(item, cache);
252
+ return (
253
+ <LazyPictureEditor
254
+ src={media.url ?? ''}
255
+ // So an unresolved picture reads as "still finding it" for the moment it takes, rather
256
+ // than flashing the "cannot show it" reason at somebody whose photograph is on its way.
257
+ resolving={resolving}
258
+ fileName={item.name}
259
+ allowReplace={allowReplace}
260
+ onClose={onClose}
261
+ onSave={onSave}
262
+ />
263
+ );
264
+ }
265
+
221
266
  // Marking sections of a clip to cut out. Lazy for the usual reason and one extra: an app that
222
267
  // has no pipeline to execute a cut list never passes `onTrimVideo`, so it never loads a byte
223
268
  // of this — which is what makes "enabled for collections only, for now" a fact about the
@@ -225,12 +270,128 @@ function TurnVideoDialog({
225
270
  const LazyVideoCutEditor = React.lazy(() =>
226
271
  import('../media/VideoCutEditor').then((m) => ({ default: m.VideoCutEditor })),
227
272
  );
273
+ /**
274
+ * The trim dialog, over the item's RESOLVED media — never over the raw row. Same defect, same
275
+ * fix and the same shared cache as {@link TurnVideoDialog}, whose header carries the whole
276
+ * story; what is particular to this one is that marking a cut means scrubbing to a moment, so
277
+ * a source-less player does not merely look broken, it makes the dialog's one output
278
+ * unreachable — which is why `VideoCutEditor` now says so rather than painting black.
279
+ */
280
+ function TrimVideoDialog({
281
+ item,
282
+ cache,
283
+ note,
284
+ onClose,
285
+ onSubmit,
286
+ }: {
287
+ item: MediaGalleryItem;
288
+ cache: MediaCache;
289
+ note?: React.ReactNode;
290
+ onClose: () => void;
291
+ onSubmit: (cuts: VideoCut[]) => void | Promise<void>;
292
+ }) {
293
+ const { media, resolving } = useItemMedia(item, cache);
294
+ // `streamUrl ?? url`, the same preference the stage and the turner make: a plain `url` is not
295
+ // always a streamable source, and a scrubber given the wrong one cannot seek.
296
+ const src = media.streamUrl ?? media.url ?? '';
297
+ const poster = media.posterUrl ?? media.thumbnailUrl;
298
+ return (
299
+ <LazyVideoCutEditor
300
+ src={src}
301
+ {...(poster ? { posterUrl: poster } : {})}
302
+ resolving={resolving}
303
+ name={item.name}
304
+ durationSeconds={item.durationSeconds ?? null}
305
+ {...(note ? { note } : {})}
306
+ onClose={onClose}
307
+ onSubmit={onSubmit}
308
+ />
309
+ );
310
+ }
228
311
  // Naming the parts of a clip. The same bargain as the cut editor one line up: an app that has
229
312
  // nowhere to PUT a chapter list never passes `onSaveChapters`, so it never loads a byte of
230
313
  // this dialog — the capability and the bundle cost turn on together, with no flag between them.
231
314
  const LazyVideoChapterEditor = React.lazy(() =>
232
315
  import('../media/VideoChapterEditor').then((m) => ({ default: m.VideoChapterEditor })),
233
316
  );
317
+ /**
318
+ * The sections dialog, over the item's RESOLVED media — never over the raw row. Same defect,
319
+ * same fix and the same shared cache as {@link TurnVideoDialog}; what is particular here is
320
+ * that a section is metadata stored BESIDE the film, so a clip nobody can play is still a clip
321
+ * whose existing sections can be renamed, removed and saved — `VideoChapterEditor` says that
322
+ * in the same breath as it says there is nothing to scrub.
323
+ */
324
+ function ChapterVideoDialog({
325
+ item,
326
+ cache,
327
+ onClose,
328
+ onSubmit,
329
+ }: {
330
+ item: MediaGalleryItem;
331
+ cache: MediaCache;
332
+ onClose: () => void;
333
+ onSubmit: (chapters: VideoChapter[]) => void | Promise<void>;
334
+ }) {
335
+ const { media, resolving } = useItemMedia(item, cache);
336
+ const src = media.streamUrl ?? media.url ?? '';
337
+ const poster = media.posterUrl ?? media.thumbnailUrl;
338
+ return (
339
+ <LazyVideoChapterEditor
340
+ src={src}
341
+ {...(poster ? { posterUrl: poster } : {})}
342
+ resolving={resolving}
343
+ name={item.name}
344
+ durationSeconds={item.durationSeconds ?? null}
345
+ {...(item.chapters ? { chapters: item.chapters } : {})}
346
+ onClose={onClose}
347
+ onSubmit={onSubmit}
348
+ />
349
+ );
350
+ }
351
+ /**
352
+ * The frame grabber, over the item's RESOLVED media — never over the raw row. Same defect,
353
+ * same fix and the same shared cache as {@link TurnVideoDialog}; what is particular here is
354
+ * that every output of that dialog is pixels read back off the `<video>`, so an unresolved
355
+ * source leaves it with nothing to save at all — which is why `FrameGrabber` now states the
356
+ * reason and holds its two save buttons rather than offering them over a black frame.
357
+ *
358
+ * `hasPoster` is read off the RESOLVED media too: an app that mints its poster URL alongside
359
+ * its stream would otherwise be told this film has no picture, and the thumbnail button would
360
+ * offer to "choose" one it is in fact about to replace.
361
+ */
362
+ function GrabFrameDialog({
363
+ item,
364
+ at,
365
+ cache,
366
+ onClose,
367
+ onSaveThumbnail,
368
+ onSaveSnapshot,
369
+ }: {
370
+ item: MediaGalleryItem;
371
+ at: number;
372
+ cache: MediaCache;
373
+ onClose: () => void;
374
+ onSaveThumbnail?: ((poster: Blob) => void | Promise<void>) | undefined;
375
+ onSaveSnapshot?: ((still: Blob, atSeconds: number) => void | Promise<void>) | undefined;
376
+ }) {
377
+ const { media, resolving } = useItemMedia(item, cache);
378
+ const src = media.streamUrl ?? media.url ?? '';
379
+ return (
380
+ <LazyFrameGrabber
381
+ src={src}
382
+ resolving={resolving}
383
+ name={item.name}
384
+ startAt={at}
385
+ // So the dialog can SAY that saving replaces the picture this film shows now, rather
386
+ // than overwriting somebody's chosen poster silently — the owner's copy-not-overwrite
387
+ // ruling applied to the one case in that dialog where a replacement is unavoidable.
388
+ hasPoster={Boolean(media.posterUrl)}
389
+ onClose={onClose}
390
+ {...(onSaveThumbnail ? { onSaveThumbnail } : {})}
391
+ {...(onSaveSnapshot ? { onSaveSnapshot } : {})}
392
+ />
393
+ );
394
+ }
234
395
  // Tagging and describing, on one file or on a ticked selection. Same bargain again: an app
235
396
  // with nowhere to PUT a tag never passes `onSaveMeta`, so it never loads a byte of this
236
397
  // dialog — and the gallery keeps reading whatever tags and descriptions its items already
@@ -800,12 +961,21 @@ function BytesStage({
800
961
  * listed here, so the feature was invisible with nothing anywhere reporting a fault. It is a
801
962
  * pure function so a test can read it directly; the rendered player is a Vidstack custom
802
963
  * element that happy-dom never brings to life, so no DOM assertion can see any of this.
964
+ *
965
+ * 🔴 The return type is spelled out rather than imported as `PlayerVideo` from
966
+ * `../media/VideoPlayer`, and the reason is BYTES, not taste. The player is reached from here
967
+ * through `React.lazy(() => import(…))` on purpose, and `scripts/styleAreas.ts`'s closure walk
968
+ * reads STATIC specifiers — an `import type` is one, and tsc erases it long after that walk has
969
+ * decided. Measured 2026-09-18: naming the type pulled the player's whole static closure into
970
+ * the `media-gallery` area stylesheet, 2023 candidates to 2190, for a line that emits nothing
971
+ * at runtime. The two definitions are structurally identical, so tsc still joins them at the
972
+ * one call site that matters (`<LazyVideoPlayer video={…}>` below).
803
973
  */
804
974
  export function stagePlayableVideo(
805
975
  item: MediaGalleryItem,
806
976
  media: MediaGalleryItemMedia,
807
977
  streamUrl: string,
808
- ): ChapteredVideo {
978
+ ): ChapteredVideo & { playbackDegraded?: boolean } {
809
979
  return {
810
980
  id: item.id,
811
981
  name: item.name,
@@ -817,6 +987,14 @@ export function stagePlayableVideo(
817
987
  * upload, so its absence is the ordinary case, not an error.
818
988
  */
819
989
  ...(media.hlsUrl ? { hlsUrl: media.hlsUrl } : {}),
990
+ /*
991
+ * 🔴 …and the other half of that sentence: the app ASKED for the ladder and its store did
992
+ * not answer, so the line above is absent for a reason worth saying out loud. It rides the
993
+ * same mapping as `hlsUrl` because it is the same fact seen from the other side, and
994
+ * because a field that is not listed here is a field that reaches the player dead — which
995
+ * is exactly how `vttUrl`, `processingStatus` and the ladder itself each shipped invisible.
996
+ */
997
+ ...(media.playbackDegraded ? { playbackDegraded: true } : {}),
820
998
  ...(media.posterUrl ? { posterUrl: media.posterUrl } : {}),
821
999
  ...(item.durationSeconds != null ? { durationSeconds: item.durationSeconds } : {}),
822
1000
  // Scrub-preview thumbnails and the "still optimizing" indicator. Both were already
@@ -888,15 +1066,18 @@ function NativeVideoStage({
888
1066
  poster,
889
1067
  repeat,
890
1068
  onEnded,
1069
+ onFault,
891
1070
  }: {
892
1071
  hlsUrl?: string | undefined;
893
1072
  streamUrl: string;
894
1073
  poster?: string | undefined;
895
1074
  repeat?: boolean;
896
1075
  onEnded?: () => void;
1076
+ /** The source ANSWERED and there is nothing left to play — see {@link NativeVideoSurface}. */
1077
+ onFault?: (fault: SourceFault) => void;
897
1078
  }) {
898
1079
  const ref = React.useRef<HTMLVideoElement>(null);
899
- useHlsSource(ref, { hlsUrl, streamUrl });
1080
+ useHlsSource(ref, { hlsUrl, streamUrl }, { ...(onFault ? { onFault } : {}) });
900
1081
  useStageVolume(ref);
901
1082
  return (
902
1083
  // biome-ignore lint/a11y/useMediaCaption: arbitrary user-stored files carry no captions
@@ -915,6 +1096,155 @@ function NativeVideoStage({
915
1096
  );
916
1097
  }
917
1098
 
1099
+ /**
1100
+ * The stage for a source that ANSWERED — `404`, `410`, a spent `403`, anything that is not
1101
+ * the bytes and is not still in flight.
1102
+ *
1103
+ * ── 🔴 Why this is a panel and not a longer spinner ─────────────────────────────────────
1104
+ * Owner, 2026-09-17: *"make a 404 stop the media player spinner so I don't think it's going
1105
+ * to eventually load."* One item in his catalogue has no ladder, so the first request the
1106
+ * stage made was answered `404` in 0.2 s — and that was the one status every surface here
1107
+ * treated as "still loading". **A hang and an answer must not look the same**, which is the
1108
+ * whole report: a timeout may keep spinning, because something might still arrive; an answer
1109
+ * may not, because nothing will.
1110
+ *
1111
+ * It borrows `.cbmg-stage-panel`, the same frame the audio stage and the no-viewer fallback
1112
+ * already use, so a stage that cannot show its file looks like one thing across every
1113
+ * category rather than three near-misses. `role='status'` because the sentence REPLACED
1114
+ * something the viewer was waiting on, and a screen reader that was told "loading" has to be
1115
+ * told how that ended.
1116
+ */
1117
+ function StageSourceFault({
1118
+ item,
1119
+ category,
1120
+ fault,
1121
+ subject,
1122
+ }: {
1123
+ item: MediaGalleryItem;
1124
+ category: FileCategory;
1125
+ fault: SourceFault;
1126
+ /** The noun that leads the sentence — see {@link sourceFaultMessage}. */
1127
+ subject: string;
1128
+ }) {
1129
+ return (
1130
+ <div className='cbmg-stage-panel' role='status'>
1131
+ <FileTypeIcon category={category} size={48} />
1132
+ <span className='cbmg-stage-panel-name'>{item.name}</span>
1133
+ <span>{sourceFaultMessage(fault, subject)}</span>
1134
+ </div>
1135
+ );
1136
+ }
1137
+
1138
+ /**
1139
+ * One fault, and the single decision every stage makes about it: re-ask, or say it.
1140
+ *
1141
+ * 🔴 A `403` is the one fault that comes back on its own — this fleet's signed URLs carry
1142
+ * `exp = iat + 2 × window`, so a page left open across a window boundary meets a stale token
1143
+ * legitimately and the app can simply mint another. So the recoverable kind is spent on a
1144
+ * re-ask FIRST and only becomes a sentence if there is no re-ask left ({@link useItemMedia}
1145
+ * allows exactly one per item, which is what stops this being a loop).
1146
+ */
1147
+ function useSourceFault(reAsk: () => boolean): {
1148
+ fault: SourceFault | null;
1149
+ report: (fault: SourceFault) => void;
1150
+ } {
1151
+ const [fault, setFault] = useState<SourceFault | null>(null);
1152
+ const report = useCallback(
1153
+ (next: SourceFault) => {
1154
+ if (isRecoverableFault(next) && reAsk()) return;
1155
+ setFault(next);
1156
+ },
1157
+ [reAsk],
1158
+ );
1159
+ return { fault, report };
1160
+ }
1161
+
1162
+ /**
1163
+ * The `nativeVideoStage` element plus the one thing a `<video>` cannot say for itself.
1164
+ *
1165
+ * Mounted with a `key` on its source by {@link GalleryStage}, so stepping to another clip —
1166
+ * or getting a freshly signed URL out of the re-ask — starts from no fault rather than
1167
+ * inheriting the previous source's.
1168
+ */
1169
+ function NativeVideoSurface({
1170
+ item,
1171
+ media,
1172
+ streamUrl,
1173
+ repeat,
1174
+ onEnded,
1175
+ reAsk,
1176
+ }: {
1177
+ item: MediaGalleryItem;
1178
+ media: MediaGalleryItemMedia;
1179
+ streamUrl: string;
1180
+ repeat?: boolean;
1181
+ onEnded?: () => void;
1182
+ reAsk: () => boolean;
1183
+ }) {
1184
+ const { fault, report } = useSourceFault(reAsk);
1185
+ if (fault) {
1186
+ return <StageSourceFault item={item} category='video' fault={fault} subject='This video' />;
1187
+ }
1188
+ return (
1189
+ /*
1190
+ * 🔴 The source is ATTACHED, not declared, because a ladder is not a `src`.
1191
+ *
1192
+ * An `.m3u8` on `video.src` plays on Safari and every iPhone and on nothing else, so
1193
+ * the choice between native HLS, hls.js and the progressive remux has to be made
1194
+ * against the element — see `NativeVideoStage` / `hlsSource.ts`. A plain `src={src}`
1195
+ * here is what made `hlsUrl` reach this stage and do nothing.
1196
+ */
1197
+ <NativeVideoStage
1198
+ hlsUrl={media.hlsUrl}
1199
+ streamUrl={streamUrl}
1200
+ poster={media.posterUrl}
1201
+ repeat={repeat}
1202
+ onFault={report}
1203
+ {...(onEnded ? { onEnded } : {})}
1204
+ />
1205
+ );
1206
+ }
1207
+
1208
+ /**
1209
+ * The picture stage, and the same distinction for an `<img>`.
1210
+ *
1211
+ * 🔴 An `<img>` that 404s is the image half of the owner's report: `onError` carries no
1212
+ * status, so "the file is gone", "the token went stale" and "these bytes are not a picture"
1213
+ * arrived as one undifferentiated failure, and the stage drew a torn-page glyph next to a
1214
+ * title — which reads as the archive being broken. {@link probeSourceFault} is the only way
1215
+ * to tell them apart and it runs solely on this path, after the browser has already given up.
1216
+ */
1217
+ function ImageSurface({
1218
+ item,
1219
+ url,
1220
+ reAsk,
1221
+ }: {
1222
+ item: MediaGalleryItem;
1223
+ url: string;
1224
+ reAsk: () => boolean;
1225
+ }) {
1226
+ const { fault, report } = useSourceFault(reAsk);
1227
+ if (fault) {
1228
+ return <StageSourceFault item={item} category='image' fault={fault} subject='This picture' />;
1229
+ }
1230
+ return (
1231
+ <div className='cbgd-stage-img' data-turned={isTurned(item) ? 'true' : undefined}>
1232
+ {/* A stored orientation is applied as a transform, never baked — see
1233
+ `MediaGalleryItem.orientation`. `data-turned` lets the stylesheet swap the fit
1234
+ constraint: a quarter-turned picture is laid out in its ORIGINAL box, so without it
1235
+ a portrait scan turned upright overflows the frame it is centred in. */}
1236
+ <img
1237
+ src={url}
1238
+ alt={item.name}
1239
+ style={orientationStyle(item)}
1240
+ onError={() => {
1241
+ void probeSourceFault(url).then(report);
1242
+ }}
1243
+ />
1244
+ </div>
1245
+ );
1246
+ }
1247
+
918
1248
  /** The big stage — the right viewer per category. */
919
1249
  function GalleryStage({
920
1250
  item,
@@ -947,7 +1277,7 @@ function GalleryStage({
947
1277
  fullscreenOnDoubleClick?: boolean;
948
1278
  }) {
949
1279
  const category = galleryItemCategory(item);
950
- const { media, resolving } = useItemMedia(item, mediaCache);
1280
+ const { media, resolving, refresh } = useItemMedia(item, mediaCache);
951
1281
 
952
1282
  if (category === 'video') {
953
1283
  const src = media.streamUrl ?? media.url;
@@ -983,20 +1313,16 @@ function GalleryStage({
983
1313
  );
984
1314
  if (nativeVideoStage) {
985
1315
  return frame(
986
- /*
987
- * 🔴 The source is ATTACHED, not declared, because a ladder is not a `src`.
988
- *
989
- * An `.m3u8` on `video.src` plays on Safari and every iPhone and on nothing else, so
990
- * the choice between native HLS, hls.js and the progressive remux has to be made
991
- * against the element — see `NativeVideoStage` / `hlsSource.ts`. A plain `src={src}`
992
- * here is what made `hlsUrl` reach this stage and do nothing.
993
- */
994
- <NativeVideoStage
1316
+ // `key`ed on the source: stepping to the next clip remounts rather than re-attaching
1317
+ // into an element mid-buffer on the previous one — and it is what clears a recorded
1318
+ // fault when a re-ask comes back with a freshly signed URL.
1319
+ <NativeVideoSurface
995
1320
  key={media.hlsUrl ?? src}
996
- hlsUrl={media.hlsUrl}
1321
+ item={item}
1322
+ media={media}
997
1323
  streamUrl={src}
998
- poster={media.posterUrl}
999
1324
  repeat={repeat}
1325
+ reAsk={refresh}
1000
1326
  {...(onEnded ? { onEnded } : {})}
1001
1327
  />,
1002
1328
  );
@@ -1021,15 +1347,9 @@ function GalleryStage({
1021
1347
 
1022
1348
  if (category === 'image') {
1023
1349
  if (!media.url) return <StageLoading label={resolving ? 'Loading…' : 'Image unavailable.'} />;
1024
- return (
1025
- <div className='cbgd-stage-img' data-turned={isTurned(item) ? 'true' : undefined}>
1026
- {/* A stored orientation is applied as a transform, never baked — see
1027
- `MediaGalleryItem.orientation`. `data-turned` lets the stylesheet swap the fit
1028
- constraint: a quarter-turned picture is laid out in its ORIGINAL box, so without it
1029
- a portrait scan turned upright overflows the frame it is centred in. */}
1030
- <img src={media.url} alt={item.name} style={orientationStyle(item)} />
1031
- </div>
1032
- );
1350
+ // `key`ed on the URL for the reason the video stage is: a new URL — including the one a
1351
+ // re-ask mints — is a new attempt and must not inherit the previous one's fault.
1352
+ return <ImageSurface key={media.url} item={item} url={media.url} reAsk={refresh} />;
1033
1353
  }
1034
1354
 
1035
1355
  if (category === 'audio') {
@@ -2122,9 +2442,9 @@ export function MediaGallery({
2122
2442
  const capabilityDialogs = (
2123
2443
  <React.Suspense fallback={null}>
2124
2444
  {editingPicture ? (
2125
- <LazyPictureEditor
2126
- src={editingPicture.url ?? ''}
2127
- fileName={editingPicture.name}
2445
+ <EditPictureDialog
2446
+ item={editingPicture}
2447
+ cache={mediaCache}
2128
2448
  allowReplace={allowPictureReplace}
2129
2449
  onClose={() => setEditingPicture(null)}
2130
2450
  onSave={async (result: PictureEditResult) => {
@@ -2143,11 +2463,9 @@ export function MediaGallery({
2143
2463
  />
2144
2464
  ) : null}
2145
2465
  {trimmingVideo && onTrimVideo ? (
2146
- <LazyVideoCutEditor
2147
- src={trimmingVideo.streamUrl ?? trimmingVideo.url ?? ''}
2148
- {...(trimmingVideo.posterUrl ? { posterUrl: trimmingVideo.posterUrl } : {})}
2149
- name={trimmingVideo.name}
2150
- durationSeconds={trimmingVideo.durationSeconds ?? null}
2466
+ <TrimVideoDialog
2467
+ item={trimmingVideo}
2468
+ cache={mediaCache}
2151
2469
  {...(trimNote ? { note: trimNote } : {})}
2152
2470
  onClose={() => setTrimmingVideo(null)}
2153
2471
  onSubmit={async (cuts: VideoCut[]) => {
@@ -2156,12 +2474,9 @@ export function MediaGallery({
2156
2474
  />
2157
2475
  ) : null}
2158
2476
  {chapteringVideo && onSaveChapters ? (
2159
- <LazyVideoChapterEditor
2160
- src={chapteringVideo.streamUrl ?? chapteringVideo.url ?? ''}
2161
- {...(chapteringVideo.posterUrl ? { posterUrl: chapteringVideo.posterUrl } : {})}
2162
- name={chapteringVideo.name}
2163
- durationSeconds={chapteringVideo.durationSeconds ?? null}
2164
- {...(chapteringVideo.chapters ? { chapters: chapteringVideo.chapters } : {})}
2477
+ <ChapterVideoDialog
2478
+ item={chapteringVideo}
2479
+ cache={mediaCache}
2165
2480
  onClose={() => setChapteringVideo(null)}
2166
2481
  onSubmit={async (chapters: VideoChapter[]) => {
2167
2482
  await onSaveChapters(chapteringVideo, chapters);
@@ -2182,14 +2497,10 @@ export function MediaGallery({
2182
2497
  />
2183
2498
  ) : null}
2184
2499
  {grabbing ? (
2185
- <LazyFrameGrabber
2186
- src={grabbing.item.streamUrl ?? grabbing.item.url ?? ''}
2187
- name={grabbing.item.name}
2188
- startAt={grabbing.at}
2189
- // So the dialog can SAY that saving replaces the picture this film shows now, rather
2190
- // than overwriting somebody's chosen poster silently — the owner's copy-not-overwrite
2191
- // ruling applied to the one case in that dialog where a replacement is unavoidable.
2192
- hasPoster={Boolean(grabbing.item.posterUrl)}
2500
+ <GrabFrameDialog
2501
+ item={grabbing.item}
2502
+ at={grabbing.at}
2503
+ cache={mediaCache}
2193
2504
  onClose={() => setGrabbing(null)}
2194
2505
  {...(onSaveThumbnail
2195
2506
  ? { onSaveThumbnail: (poster: Blob) => onSaveThumbnail(grabbing.item, poster) }
@@ -8,7 +8,7 @@
8
8
  * signed-URL app is a request per row per toggle. A module both import is the only shape
9
9
  * that cannot drift; re-implementing either inside the table is what this file prevents.
10
10
  */
11
- import { type MutableRefObject, useEffect, useState } from 'react';
11
+ import { type MutableRefObject, useCallback, useEffect, useRef, useState } from 'react';
12
12
  import { staticItemMedia } from './mediaGalleryModel';
13
13
  import type { MediaGalleryItem, MediaGalleryItemMedia } from './types';
14
14
 
@@ -16,15 +16,37 @@ export type MediaCache = MutableRefObject<Map<string, Promise<MediaGalleryItemMe
16
16
  /**
17
17
  * The item's resolved media: static URL fields immediately, `resolveMedia` results merged
18
18
  * in when the item needs them (cached per id for the gallery's lifetime).
19
+ *
20
+ * `refresh` throws the cached answer away and asks the app again — see its own note; it is
21
+ * what makes a `403` from a stale signed URL recoverable instead of a sentence on screen.
19
22
  */
20
23
  export function useItemMedia(
21
24
  item: MediaGalleryItem,
22
25
  cache: MediaCache,
23
- ): { media: MediaGalleryItemMedia; resolving: boolean } {
26
+ ): { media: MediaGalleryItemMedia; resolving: boolean; refresh: () => boolean } {
24
27
  const staticMedia = staticItemMedia(item);
25
28
  const needsResolve = Boolean(item.resolveMedia) && !item.url;
26
29
  const [resolved, setResolved] = useState<MediaGalleryItemMedia | null>(null);
27
30
  const [resolving, setResolving] = useState(needsResolve);
31
+ /** Bumped by {@link refresh} so the effect below re-runs against an emptied cache. */
32
+ const [attempt, setAttempt] = useState(0);
33
+ /**
34
+ * 🔴 ONE re-ask per item, for the life of this mount.
35
+ *
36
+ * The whole reason to re-ask is a signed URL that went stale, and the app's answer to that
37
+ * is a fresh token — so if the second URL fails the same way, asking a third time is an
38
+ * unbounded loop against something that is not going to change, which is the same spinner
39
+ * this batch removed with more requests in it. `RECOVERY_BUDGET` in `hlsSource.ts` bounds
40
+ * its retries for exactly this reason; this is that rule one layer up.
41
+ */
42
+ const reAsked = useRef<Set<string>>(new Set());
43
+ const refresh = useCallback((): boolean => {
44
+ if (!needsResolve || reAsked.current.has(item.id)) return false;
45
+ reAsked.current.add(item.id);
46
+ cache.current.delete(item.id);
47
+ setAttempt((n) => n + 1);
48
+ return true;
49
+ }, [item.id, needsResolve, cache]);
28
50
 
29
51
  useEffect(() => {
30
52
  if (!needsResolve) return;
@@ -49,9 +71,10 @@ export function useItemMedia(
49
71
  return () => {
50
72
  live = false;
51
73
  };
52
- }, [item.id, needsResolve, cache, item.resolveMedia]);
74
+ // `attempt` is the re-ask: it is not read in the body, it is what re-runs it.
75
+ }, [item.id, needsResolve, cache, item.resolveMedia, attempt]);
53
76
 
54
- return { media: { ...(resolved ?? {}), ...staticMedia }, resolving };
77
+ return { media: { ...(resolved ?? {}), ...staticMedia }, resolving, refresh };
55
78
  }
56
79
 
57
80
  /**
@@ -8,6 +8,7 @@ import {
8
8
  galleryTags,
9
9
  isMarkdownFile,
10
10
  sortGalleryItems,
11
+ staticItemMedia,
11
12
  } from './mediaGalleryModel';
12
13
  import type { MediaGalleryItem, MediaGalleryOrder } from './types';
13
14
 
@@ -180,3 +181,38 @@ describe('every sort pill is a two-way toggle', () => {
180
181
  expect(sortGalleryItems(ITEMS, 'oldest').map((i) => i.id)).toEqual(['b', 'c', 'a', 'd']);
181
182
  });
182
183
  });
184
+
185
+ /**
186
+ * 🔴 A field inlined on the item has to survive `staticItemMedia`, because that is the other
187
+ * road into the stage — the one an app takes when it has no `resolveMedia` and simply puts the
188
+ * URLs on the row. A field missing from this function is a field that is silently gone for
189
+ * every such app, exactly as it would be missing from `stagePlayableVideo`.
190
+ */
191
+ describe('staticItemMedia carries the transport facts, and only the true ones', () => {
192
+ test('an unreachable ladder rides along with the URLs', () => {
193
+ const media = staticItemMedia(
194
+ item({
195
+ id: 'v',
196
+ name: 'clip.mp4',
197
+ streamUrl: '/files/v.web.mp4',
198
+ playbackDegraded: true,
199
+ }),
200
+ );
201
+ expect(media.streamUrl).toBe('/files/v.web.mp4');
202
+ expect(media.playbackDegraded).toBe(true);
203
+ });
204
+
205
+ test('a video that simply has no ladder leaves the key ABSENT, so a spread cannot clobber it', () => {
206
+ // Every entry here is dropped when falsy on purpose: `{...staticMedia, ...resolved}` merges
207
+ // the two roads, and an explicit `playbackDegraded: false` would overwrite a resolver that
208
+ // had just found out otherwise.
209
+ const media = staticItemMedia(item({ id: 'v', name: 'clip.mp4', streamUrl: '/files/v.mp4' }));
210
+ expect('playbackDegraded' in media).toBe(false);
211
+ expect(
212
+ 'playbackDegraded' in
213
+ staticItemMedia(
214
+ item({ id: 'v', name: 'clip.mp4', streamUrl: '/f.mp4', playbackDegraded: false }),
215
+ ),
216
+ ).toBe(false);
217
+ });
218
+ });
@@ -209,6 +209,10 @@ export function staticItemMedia(item: MediaGalleryItem): MediaGalleryItemMedia {
209
209
  if (item.streamUrl) media.streamUrl = item.streamUrl;
210
210
  // Additional to `streamUrl`, never instead of it — see `MediaGalleryItemMedia.hlsUrl`.
211
211
  if (item.hlsUrl) media.hlsUrl = item.hlsUrl;
212
+ // "We could not ASK about the ladder", which is not the same as not having one — see
213
+ // `MediaGalleryItemMedia.playbackDegraded`. Forwarded only when true, so the ordinary
214
+ // ladder-less video carries nothing and the stage has nothing to say about it.
215
+ if (item.playbackDegraded) media.playbackDegraded = true;
212
216
  if (item.vttUrl) media.vttUrl = item.vttUrl;
213
217
  if (item.processingStatus) media.processingStatus = item.processingStatus;
214
218
  return media;
@@ -55,6 +55,29 @@ export interface MediaGalleryItemMedia {
55
55
  * and `preferBundledHls`, which decides where the engine comes from.
56
56
  */
57
57
  hlsUrl?: string;
58
+ /**
59
+ * The ladder was not ASKED ABOUT successfully — so this play is the progressive fallback and
60
+ * the app knows it, rather than merely having no ladder.
61
+ *
62
+ * 🔴 It is NOT "there is no ladder". A video whose ladder has simply never been built is the
63
+ * ordinary case and stays quiet: {@link hlsUrl} is absent, `streamUrl` plays, nothing is
64
+ * wrong. This flag is the other thing — the app's probe of its own media store did not
65
+ * answer, so it cannot tell a ladder that is missing from one it could not reach, and it has
66
+ * served the progressive copy on that ignorance.
67
+ *
68
+ * 🔴 And it is NOT {@link processingStatus} `'failed'`. That badge says the optimization
69
+ * failed; here the optimization may well have succeeded and we could not ask. Different
70
+ * cause, different fix, different lifetime — this one usually clears on the next open — so
71
+ * the player draws a SECOND badge rather than borrowing that one's sentence.
72
+ *
73
+ * Worth a badge because the fallback is expensive and silent: in `apps/collections` the
74
+ * progressive `.web.mp4` averages 467 MB and reaches 4.4 GB, which is past what Cloudflare
75
+ * will cache (`x-bs-edge-cache: MISS-TOO-LARGE`), so every play and every seek is pulled off
76
+ * the owner's own Mac; a ladder segment is ~1 MB and comes from the edge. For eight days
77
+ * nothing in the fleet could say which of the two it was serving, and the owner found out by
78
+ * watching a video stall.
79
+ */
80
+ playbackDegraded?: boolean;
58
81
  /**
59
82
  * WebVTT scrub-preview track — the sprite-sheet index that puts real frame thumbnails on
60
83
  * the scrub bar instead of a bare timecode. Passed straight to the player's `thumbnails`.
@@ -15,7 +15,7 @@
15
15
  *
16
16
  * An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
17
17
  * emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
18
- * when this file is stale. 125 candidate(s) scanned.
18
+ * when this file is stale. 147 candidate(s) scanned.
19
19
  *
20
20
  * Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
21
21
  * `bun run build` runs. The map of what is in which area, and why the areas are