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
@@ -1,7 +1,8 @@
1
1
  import { afterEach, describe, expect, test } from 'bun:test';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { cleanup, render } from '@testing-library/react';
4
+ import type React from 'react';
5
+ import { cleanup, fireEvent, render } from '@testing-library/react';
5
6
  import { FrameGrabber } from './FrameGrabber';
6
7
 
7
8
  afterEach(cleanup);
@@ -173,3 +174,58 @@ describe('FrameGrabber β€” the exit is named in the dialog itself', () => {
173
174
  expect(text, 'nothing tells the reader how to leave without saving').toContain('escape');
174
175
  });
175
176
  });
177
+
178
+ /**
179
+ * πŸ”΄ The dialog SAYS when it has no film, and keeps its hands off the save buttons.
180
+ *
181
+ * The turn dialog's report (`ideas/collections-09-14.md`: *"When I click the turn the video
182
+ * button I see a black screen"*) turned out to be true of four siblings, this one included: it
183
+ * was mounted `src={item.streamUrl ?? item.url ?? ''}` off the raw row, and an item is allowed
184
+ * to carry neither field (`resolveMedia` is the documented path for a signed URL). Here the
185
+ * consequence is a step beyond a bad preview: every output of this dialog is pixels read back
186
+ * off the `<video>`, so a black frame came with two live-looking save buttons that could only
187
+ * fail at `toBlob` β€” after somebody had scrubbed for their moment.
188
+ */
189
+ describe('FrameGrabber β€” when it cannot show the film', () => {
190
+ const openWith = (props: Partial<React.ComponentProps<typeof FrameGrabber>>) =>
191
+ render(
192
+ <FrameGrabber
193
+ src=''
194
+ name='wedding.mp4'
195
+ onClose={() => {}}
196
+ onSaveThumbnail={() => {}}
197
+ onSaveSnapshot={() => {}}
198
+ {...props}
199
+ />,
200
+ );
201
+
202
+ test('πŸ”΄ no source at all is a stated reason, not a black box', () => {
203
+ const { getByRole } = openWith({});
204
+ expect(body().querySelector('video')).toBeNull();
205
+ expect(getByRole('status').textContent).toContain('no video source');
206
+ });
207
+
208
+ test('πŸ”΄ the save button is HELD, because there is nothing to read a frame from', () => {
209
+ const { getByRole } = openWith({});
210
+ const keep = getByRole('button', { name: /thumbnail/i }) as HTMLButtonElement;
211
+ // DISABLED and still on screen, which is the `DESIGN.md` rule for a control that cannot
212
+ // work here: hiding it would make "this film has not arrived" look like "this app cannot
213
+ // choose a thumbnail", and the reason is already stated on the frame beside it.
214
+ expect(keep.disabled).toBe(true);
215
+ expect(body().contains(keep)).toBe(true);
216
+ });
217
+
218
+ test('πŸ”΄ a source that fails to load says so too', () => {
219
+ const { getByRole, queryByRole } = openWith({ src: '/films/gone.mp4' });
220
+ const film = body().querySelector('video');
221
+ expect(film).not.toBeNull();
222
+ expect(queryByRole('status')).toBeNull();
223
+ fireEvent.error(film as HTMLVideoElement);
224
+ expect(getByRole('status').textContent).toContain('could not be loaded');
225
+ });
226
+
227
+ test('a source still being resolved reads as "on its way", not as a failure', () => {
228
+ const { getByRole } = openWith({ src: '', resolving: true });
229
+ expect(getByRole('status').textContent).toContain('Finding this film');
230
+ });
231
+ });
@@ -31,8 +31,22 @@ import {
31
31
  } from './posterFraming';
32
32
 
33
33
  export interface FrameGrabberProps {
34
- /** The clip. πŸ”΄ Must be reachable with CORS or the canvas is tainted β€” see below. */
34
+ /**
35
+ * The clip. πŸ”΄ Must be reachable with CORS or the canvas is tainted β€” see below.
36
+ *
37
+ * πŸ”΄ The RESOLVED source, not whatever URL field a record happens to carry β€” see
38
+ * `MediaGallery`'s `GrabFrameDialog`. A gallery item is allowed to carry no URL at all
39
+ * (`resolveMedia` is the documented path for a signed URL), and a `<video>` with an empty
40
+ * source never loads, never fires `error` and paints black forever, which is the owner's
41
+ * *"I see a black screen"* wearing a different dialog's clothes.
42
+ */
35
43
  src: string;
44
+ /**
45
+ * True while the app is still finding the source (a signed URL being minted, bytes being
46
+ * fetched). It is the difference between "on its way" and "cannot be shown", and only the
47
+ * caller knows which.
48
+ */
49
+ resolving?: boolean;
36
50
  name?: string;
37
51
  /** Open at this moment, in seconds β€” normally the playhead the stage had reached. */
38
52
  startAt?: number;
@@ -128,6 +142,7 @@ type Grab = 'thumbnail' | 'picture';
128
142
  */
129
143
  export function FrameGrabber({
130
144
  src,
145
+ resolving = false,
131
146
  name,
132
147
  startAt = 0,
133
148
  open = true,
@@ -148,6 +163,25 @@ export function FrameGrabber({
148
163
  const [busy, setBusy] = React.useState<null | 'thumb' | 'snap'>(null);
149
164
  const [error, setError] = React.useState<string | null>(null);
150
165
  const [dragging, setDragging] = React.useState(false);
166
+ // A source that changes is a fresh attempt: it must not inherit the previous one's failure,
167
+ // the same rule `VideoTurner` and the gallery's own `ThumbImg` keep.
168
+ const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
169
+
170
+ /**
171
+ * Why there are no frames to pick from β€” `null` when there are.
172
+ *
173
+ * πŸ”΄ Said out loud instead of drawn as a black rectangle. Every output of this dialog is
174
+ * pixels read back off the `<video>`, so an unresolved source is not a degraded dialog, it
175
+ * is an empty one β€” and the failure it used to present was a black frame with live-looking
176
+ * sliders, which is the shape of the owner's *"I see a black screen"* report.
177
+ */
178
+ const cannotShow: string | null = resolving
179
+ ? 'Finding this film…'
180
+ : !src
181
+ ? 'This file has no video source to play here, so there are no frames to pick from.'
182
+ : failedSrc === src
183
+ ? 'The film could not be loaded, so there are no frames to pick from.'
184
+ : null;
151
185
 
152
186
  const canThumb = Boolean(onSaveThumbnail);
153
187
  const canSnap = Boolean(onSaveSnapshot);
@@ -382,12 +416,16 @@ export function FrameGrabber({
382
416
  grab === 'thumbnail' && canThumb ? (
383
417
  <Button
384
418
  variant='primary'
385
- disabled={busy !== null}
419
+ // πŸ”΄ Held while there is no film: every output here is pixels read back off the
420
+ // `<video>`, so a press with nothing decoded can only fail. See `cannotShow`.
421
+ disabled={busy !== null || cannotShow !== null}
386
422
  onClick={() => void run('thumb')}
387
423
  tooltip={
388
- hasPoster
389
- ? 'Replace the picture this film currently shows in the gallery'
390
- : 'Use this moment as the picture the gallery shows'
424
+ cannotShow !== null
425
+ ? 'The film has to load before a frame of it can be kept'
426
+ : hasPoster
427
+ ? 'Replace the picture this film currently shows in the gallery'
428
+ : 'Use this moment as the picture the gallery shows'
391
429
  }
392
430
  >
393
431
  {busy === 'thumb'
@@ -399,9 +437,13 @@ export function FrameGrabber({
399
437
  ) : canSnap ? (
400
438
  <Button
401
439
  variant='primary'
402
- disabled={busy !== null}
440
+ disabled={busy !== null || cannotShow !== null}
403
441
  onClick={() => void run('snap')}
404
- tooltip='Keep this frame as a full-size picture of its own'
442
+ tooltip={
443
+ cannotShow !== null
444
+ ? 'The film has to load before a frame of it can be kept'
445
+ : 'Keep this frame as a full-size picture of its own'
446
+ }
405
447
  >
406
448
  {busy === 'snap' ? 'Saving…' : 'πŸ“· Keep this picture'}
407
449
  </Button>
@@ -425,89 +467,101 @@ export function FrameGrabber({
425
467
  onPointerUp={onPointerUp}
426
468
  onPointerCancel={onPointerUp}
427
469
  >
428
- {/* biome-ignore lint/a11y/useMediaCaption: an arbitrary stored film carries no caption
429
- track, and an empty <track> would advertise captions that do not exist. */}
430
- <video
431
- ref={videoRef}
432
- src={src}
433
- crossOrigin='anonymous'
434
- playsInline
435
- preload='metadata'
436
- // Hidden β€” never unmounted β€” while the poster canvas is the visible surface. It is
437
- // still the element every frame is decoded by, so removing it from the tree would
438
- // stop the canvas having anything to draw.
439
- style={{
440
- maxWidth: '100%',
441
- maxHeight: '100%',
442
- filter,
443
- ...(grab === 'thumbnail' ? { display: 'none' } : {}),
444
- }}
445
- draggable={false}
446
- onLoadedMetadata={(event) => {
447
- const el = event.currentTarget;
448
- setDuration(Number.isFinite(el.duration) ? el.duration : 0);
449
- // Seek to the playhead the stage had reached rather than to zero: real footage
450
- // opens on black, on a lens cap or mid-motion-blur, and scrubbing back to the
451
- // moment you were already looking at is the step this is meant to remove.
452
- if (startAt > 0) el.currentTime = startAt;
453
- }}
454
- onTimeUpdate={(event) => setAt(event.currentTarget.currentTime)}
455
- />
456
- {grab === 'thumbnail' ? (
457
- <canvas
458
- ref={posterRef}
459
- width={640}
460
- height={Math.round(640 / POSTER_ASPECT)}
461
- aria-label='How this film will look in the gallery'
462
- />
463
- ) : null}
464
- {grab === 'picture' && !isFullCrop(crop) ? (
470
+ {cannotShow !== null ? (
471
+ <p className='cbpe-frame-note' role='status'>
472
+ {cannotShow}
473
+ </p>
474
+ ) : (
465
475
  <>
466
- <div
467
- className='cbpe-shade'
468
- style={{ left: 0, right: 0, top: 0, height: `${marquee.y * 100}%` }}
469
- />
470
- <div
471
- className='cbpe-shade'
472
- style={{
473
- left: 0,
474
- right: 0,
475
- top: `${(marquee.y + marquee.height) * 100}%`,
476
- bottom: 0,
477
- }}
478
- />
479
- <div
480
- className='cbpe-shade'
476
+ {/* biome-ignore lint/a11y/useMediaCaption: an arbitrary stored film carries no
477
+ caption track, and an empty <track> would advertise captions that do not
478
+ exist. */}
479
+ <video
480
+ ref={videoRef}
481
+ // A new source is a new attempt β€” see `failedSrc`.
482
+ key={src}
483
+ src={src}
484
+ crossOrigin='anonymous'
485
+ playsInline
486
+ preload='metadata'
487
+ // Hidden β€” never unmounted β€” while the poster canvas is the visible surface. It
488
+ // is still the element every frame is decoded by, so removing it from the tree
489
+ // would stop the canvas having anything to draw.
481
490
  style={{
482
- left: 0,
483
- width: `${marquee.x * 100}%`,
484
- top: `${marquee.y * 100}%`,
485
- height: `${marquee.height * 100}%`,
491
+ maxWidth: '100%',
492
+ maxHeight: '100%',
493
+ filter,
494
+ ...(grab === 'thumbnail' ? { display: 'none' } : {}),
486
495
  }}
487
- />
488
- <div
489
- className='cbpe-shade'
490
- style={{
491
- left: `${(marquee.x + marquee.width) * 100}%`,
492
- right: 0,
493
- top: `${marquee.y * 100}%`,
494
- height: `${marquee.height * 100}%`,
495
- }}
496
- />
497
- <div
498
- className='cbpe-marquee'
499
- style={{
500
- left: `${marquee.x * 100}%`,
501
- top: `${marquee.y * 100}%`,
502
- width: `${marquee.width * 100}%`,
503
- height: `${marquee.height * 100}%`,
496
+ draggable={false}
497
+ onLoadedMetadata={(event) => {
498
+ const el = event.currentTarget;
499
+ setDuration(Number.isFinite(el.duration) ? el.duration : 0);
500
+ // Seek to the playhead the stage had reached rather than to zero: real
501
+ // footage opens on black, on a lens cap or mid-motion-blur, and scrubbing
502
+ // back to the moment you were already looking at is the step this removes.
503
+ if (startAt > 0) el.currentTime = startAt;
504
504
  }}
505
+ onTimeUpdate={(event) => setAt(event.currentTarget.currentTime)}
506
+ onError={() => setFailedSrc(src)}
505
507
  />
508
+ {grab === 'thumbnail' ? (
509
+ <canvas
510
+ ref={posterRef}
511
+ width={640}
512
+ height={Math.round(640 / POSTER_ASPECT)}
513
+ aria-label='How this film will look in the gallery'
514
+ />
515
+ ) : null}
516
+ {grab === 'picture' && !isFullCrop(crop) ? (
517
+ <>
518
+ <div
519
+ className='cbpe-shade'
520
+ style={{ left: 0, right: 0, top: 0, height: `${marquee.y * 100}%` }}
521
+ />
522
+ <div
523
+ className='cbpe-shade'
524
+ style={{
525
+ left: 0,
526
+ right: 0,
527
+ top: `${(marquee.y + marquee.height) * 100}%`,
528
+ bottom: 0,
529
+ }}
530
+ />
531
+ <div
532
+ className='cbpe-shade'
533
+ style={{
534
+ left: 0,
535
+ width: `${marquee.x * 100}%`,
536
+ top: `${marquee.y * 100}%`,
537
+ height: `${marquee.height * 100}%`,
538
+ }}
539
+ />
540
+ <div
541
+ className='cbpe-shade'
542
+ style={{
543
+ left: `${(marquee.x + marquee.width) * 100}%`,
544
+ right: 0,
545
+ top: `${marquee.y * 100}%`,
546
+ height: `${marquee.height * 100}%`,
547
+ }}
548
+ />
549
+ <div
550
+ className='cbpe-marquee'
551
+ style={{
552
+ left: `${marquee.x * 100}%`,
553
+ top: `${marquee.y * 100}%`,
554
+ width: `${marquee.width * 100}%`,
555
+ height: `${marquee.height * 100}%`,
556
+ }}
557
+ />
558
+ </>
559
+ ) : null}
560
+ {grab === 'picture' && isFullCrop(crop) ? (
561
+ <span className='cbpe-hint'>Drag across the frame to keep just part of it</span>
562
+ ) : null}
506
563
  </>
507
- ) : null}
508
- {grab === 'picture' && isFullCrop(crop) ? (
509
- <span className='cbpe-hint'>Drag across the frame to keep just part of it</span>
510
- ) : null}
564
+ )}
511
565
  </div>
512
566
 
513
567
  <label className='cbpe-slider'>
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The picture editor, at the one edge that turned out to be reportable: WHAT IT DOES WHEN IT
3
+ * HAS NO PICTURE.
4
+ *
5
+ * ── Why this file exists ───────────────────────────────────────────────────────────────
6
+ * Owner, `ideas/collections-09-14.md`: *"When I click the turn the video button I see a black
7
+ * screen."* That was `VideoTurner`, and the cause β€” a dialog mounted over the RAW gallery row
8
+ * rather than over the item's resolved media β€” was shared by four siblings, this one included.
9
+ * It was `src={item.url ?? ''}`, and a `MediaGalleryItem` is allowed to carry no `url` at all:
10
+ * `resolveMedia` is the documented path for a signed URL, and it is what the reporting app
11
+ * uses. An `<img>` with an empty source draws nothing and, on a picture that never existed,
12
+ * never even fires `error`.
13
+ *
14
+ * πŸ”΄ And here the stake is a step above a bad preview, because this dialog BAKES PIXELS. An
15
+ * empty frame came with a live row of sliders over it and a Save that read the `<img>` back
16
+ * through a canvas β€” which either did nothing at all or wrote a blank over a photograph. So
17
+ * the editor states the reason AND holds the saves; the gestures themselves are
18
+ * `pictureEdit.ts`'s and are unit-tested there.
19
+ *
20
+ * That the gallery hands over the RESOLVED source is the other half, asserted in
21
+ * `MediaGallery.spec.tsx` ("the PICTURE EDITOR is handed the RESOLVED source").
22
+ */
23
+ import { afterEach, describe, expect, test } from 'bun:test';
24
+ import type React from 'react';
25
+ import { cleanup, fireEvent, render } from '@testing-library/react';
26
+ import { PictureEditor } from './PictureEditor';
27
+
28
+ afterEach(cleanup);
29
+
30
+ // The dialog is a portal, so everything it renders is queried off `document.body`.
31
+ const body = () => document.body;
32
+
33
+ const openWith = (props: Partial<React.ComponentProps<typeof PictureEditor>>) =>
34
+ render(
35
+ <PictureEditor
36
+ src=''
37
+ fileName='scan.jpg'
38
+ onClose={() => {}}
39
+ onSave={() => {}}
40
+ allowReplace
41
+ {...props}
42
+ />,
43
+ );
44
+
45
+ describe('PictureEditor β€” when it cannot show the picture', () => {
46
+ test('πŸ”΄ no source at all is a stated reason, not an empty frame', () => {
47
+ const { getByRole } = openWith({});
48
+ expect(body().querySelector('.cbpe-frame img')).toBeNull();
49
+ expect(getByRole('status').textContent).toContain('no picture to show');
50
+ });
51
+
52
+ test('πŸ”΄ a source that fails to load says so too', () => {
53
+ const { getByRole, queryByRole } = openWith({ src: '/files/gone.jpg' });
54
+ const picture = body().querySelector('.cbpe-frame img');
55
+ expect(picture).not.toBeNull();
56
+ expect(queryByRole('status')).toBeNull();
57
+ fireEvent.error(picture as HTMLImageElement);
58
+ expect(getByRole('status').textContent).toContain('could not be loaded');
59
+ });
60
+
61
+ test('a source still being resolved reads as "on its way", not as a failure', () => {
62
+ // A signed URL takes a round trip to mint. Flashing "there is nothing to edit" at somebody
63
+ // whose photograph is on its way would be a false report the dialog invented itself.
64
+ const { getByRole } = openWith({ src: '', resolving: true });
65
+ expect(getByRole('status').textContent).toContain('Finding this picture');
66
+ });
67
+
68
+ test('πŸ”΄ BOTH saves are held β€” a bake with no picture can only write a blank', () => {
69
+ const { getByRole } = openWith({});
70
+ // Turn it, so the only thing standing between the press and a save is the missing picture.
71
+ fireEvent.click(getByRole('button', { name: '↻ Right' }));
72
+ for (const name of ['Save as a new copy', 'Replace the original']) {
73
+ const button = getByRole('button', { name }) as HTMLButtonElement;
74
+ // Disabled and still on screen β€” `DESIGN.md`'s rule for a control that cannot work here.
75
+ expect(button.disabled, name).toBe(true);
76
+ expect(body().contains(button)).toBe(true);
77
+ }
78
+ });
79
+
80
+ test('a picture that loads leaves the saves exactly as they were', () => {
81
+ // The ordinary path must be untouched by any of the above: no note, and a Save that is
82
+ // available the moment there is an edit to save.
83
+ const { getByRole, queryByRole } = openWith({ src: '/files/scan.jpg' });
84
+ expect(queryByRole('status')).toBeNull();
85
+ fireEvent.click(getByRole('button', { name: '↻ Right' }));
86
+ expect((getByRole('button', { name: 'Save as a new copy' }) as HTMLButtonElement).disabled).toBe(
87
+ false,
88
+ );
89
+ });
90
+ });
@@ -44,8 +44,23 @@ export interface PictureEditResult {
44
44
  }
45
45
 
46
46
  export interface PictureEditorProps {
47
- /** The picture to edit. πŸ”΄ Must be reachable with CORS β€” see {@link bakePicture}. */
47
+ /**
48
+ * The picture to edit. πŸ”΄ Must be reachable with CORS β€” see {@link bakePicture}.
49
+ *
50
+ * πŸ”΄ The RESOLVED source, not whatever URL field a record happens to carry β€” see
51
+ * `MediaGallery`'s `EditPictureDialog`. A gallery item is allowed to carry no `url` at all
52
+ * (`resolveMedia` is the documented path for a signed URL), and an `<img>` with an empty
53
+ * source never loads, never errors, and leaves this editor showing a blank frame it would
54
+ * then BAKE. An empty string here is a stated reason and a disabled Save rather than a
55
+ * silent one.
56
+ */
48
57
  src: string;
58
+ /**
59
+ * True while the app is still finding the picture (a signed URL being minted, bytes becoming
60
+ * an object URL). It is the difference between "on its way" and "cannot be shown", and only
61
+ * the caller knows which.
62
+ */
63
+ resolving?: boolean;
49
64
  fileName?: string;
50
65
  /** The MIME to bake to. Defaults to JPEG, which is what a photograph should come back as. */
51
66
  mimeType?: string;
@@ -75,6 +90,7 @@ export interface PictureEditorProps {
75
90
  */
76
91
  export function PictureEditor({
77
92
  src,
93
+ resolving = false,
78
94
  fileName,
79
95
  mimeType,
80
96
  open = true,
@@ -93,6 +109,26 @@ export function PictureEditor({
93
109
  const frameRef = React.useRef<HTMLDivElement | null>(null);
94
110
  const dragFrom = React.useRef<{ x: number; y: number } | null>(null);
95
111
  const [natural, setNatural] = React.useState<{ width: number; height: number } | null>(null);
112
+ // A source that changes is a fresh attempt: it must not inherit the previous one's failure,
113
+ // the same rule the gallery's own `ThumbImg` and `VideoTurner` keep.
114
+ const [failedSrc, setFailedSrc] = React.useState<string | null>(null);
115
+
116
+ /**
117
+ * Why there is no picture on the frame β€” `null` when there is one.
118
+ *
119
+ * πŸ”΄ The editor SAYS this instead of showing an empty frame, and here the stakes are a step
120
+ * above the turner's: this dialog BAKES pixels. An empty `src` used to mean a blank frame,
121
+ * sliders that appeared to work, and a Save that either did nothing (no `<img>` to read) or
122
+ * wrote a blank picture over somebody's photograph. So the reason is stated AND the saves
123
+ * are held β€” there is no edit that can honestly be applied to a picture nobody has.
124
+ */
125
+ const cannotShow: string | null = resolving
126
+ ? 'Finding this picture…'
127
+ : !src
128
+ ? 'This file has no picture to show here, so there is nothing to edit. Nothing has been changed.'
129
+ : failedSrc === src
130
+ ? 'This picture could not be loaded, so there is nothing to edit. Nothing has been changed.'
131
+ : null;
96
132
 
97
133
  const dirty = isEdited(edit);
98
134
  const crop = edit.crop ?? FULL_CROP;
@@ -289,9 +325,15 @@ export function PictureEditor({
289
325
  primary={
290
326
  <Button
291
327
  variant='primary'
292
- disabled={!dirty || saving !== null}
328
+ // πŸ”΄ Held while there is no picture: the bake reads pixels back off the `<img>`,
329
+ // so a save here would either do nothing or write a blank. See `cannotShow`.
330
+ disabled={!dirty || saving !== null || cannotShow !== null}
293
331
  onClick={() => void save('copy')}
294
- tooltip='Keep the original and save this version beside it'
332
+ tooltip={
333
+ cannotShow !== null
334
+ ? 'The picture has to load before an edit of it can be saved'
335
+ : 'Keep the original and save this version beside it'
336
+ }
295
337
  >
296
338
  {saving === 'copy' ? 'Saving…' : 'Save as a new copy'}
297
339
  </Button>
@@ -300,9 +342,13 @@ export function PictureEditor({
300
342
  {allowReplace ? (
301
343
  <Button
302
344
  variant='ghost'
303
- disabled={!dirty || saving !== null}
345
+ disabled={!dirty || saving !== null || cannotShow !== null}
304
346
  onClick={() => void save('replace')}
305
- tooltip='Overwrite the stored file with this version'
347
+ tooltip={
348
+ cannotShow !== null
349
+ ? 'The picture has to load before an edit of it can be saved'
350
+ : 'Overwrite the stored file with this version'
351
+ }
306
352
  >
307
353
  {saving === 'replace' ? 'Saving…' : 'Replace the original'}
308
354
  </Button>
@@ -320,24 +366,33 @@ export function PictureEditor({
320
366
  onPointerUp={onPointerUp}
321
367
  onPointerCancel={onPointerUp}
322
368
  >
323
- <img
324
- ref={imgRef}
325
- src={src}
326
- alt={fileName ?? 'The picture being edited'}
327
- /*
328
- * πŸ”΄ Unconditional, not "only when a save is coming". Whether a canvas may be read
329
- * back is decided when the browser FETCHES the picture, so a copy already cached
330
- * without CORS stays unreadable and the refusal only surfaces at `toBlob`. Same
331
- * trap `posterFraming.ts` documents for video.
332
- */
333
- crossOrigin='anonymous'
334
- draggable={false}
335
- onLoad={(event) => {
336
- const el = event.currentTarget;
337
- setNatural({ width: el.naturalWidth, height: el.naturalHeight });
338
- }}
339
- style={{ transform: previewTransform(edit), filter: lightFilter(edit.light) }}
340
- />
369
+ {cannotShow === null ? (
370
+ <img
371
+ ref={imgRef}
372
+ // A new source is a new attempt β€” see `failedSrc`.
373
+ key={src}
374
+ src={src}
375
+ alt={fileName ?? 'The picture being edited'}
376
+ /*
377
+ * πŸ”΄ Unconditional, not "only when a save is coming". Whether a canvas may be
378
+ * read back is decided when the browser FETCHES the picture, so a copy already
379
+ * cached without CORS stays unreadable and the refusal only surfaces at
380
+ * `toBlob`. Same trap `posterFraming.ts` documents for video.
381
+ */
382
+ crossOrigin='anonymous'
383
+ draggable={false}
384
+ onLoad={(event) => {
385
+ const el = event.currentTarget;
386
+ setNatural({ width: el.naturalWidth, height: el.naturalHeight });
387
+ }}
388
+ onError={() => setFailedSrc(src)}
389
+ style={{ transform: previewTransform(edit), filter: lightFilter(edit.light) }}
390
+ />
391
+ ) : (
392
+ <p className='cbpe-frame-note' role='status'>
393
+ {cannotShow}
394
+ </p>
395
+ )}
341
396
  {/*
342
397
  * The sharpened copy, clipped to the seam. `clip-path` rather than a width, so the
343
398
  * two pictures stay laid out identically and the seam moves across ONE photograph
@@ -0,0 +1,86 @@
1
+ /**
2
+ * The sections dialog, at the one edge that turned out to be reportable: WHAT IT DOES WHEN IT
3
+ * HAS NO FILM.
4
+ *
5
+ * ── Why this file exists ───────────────────────────────────────────────────────────────
6
+ * Owner, `ideas/collections-09-14.md`: *"When I click the turn the video button I see a black
7
+ * screen."* That was `VideoTurner`, and the cause β€” a dialog mounted over the RAW gallery row
8
+ * rather than over the item's resolved media β€” was shared by four siblings, this one included.
9
+ * It was `src={item.streamUrl ?? item.url ?? ''}`, and a `MediaGalleryItem` is allowed to carry
10
+ * neither field: `resolveMedia` is the documented path for a signed URL, and it is what the
11
+ * reporting app uses. A `<video>` with an empty source never loads, never fires `error` and
12
+ * paints black forever β€” which is why the only report anybody could file was "a black screen".
13
+ *
14
+ * πŸ”΄ The chaptering rules are `videoChapters.ts`'s and are unit-tested there. What is asserted
15
+ * here is this dialog's own bargain when the film does not arrive: it says so, and it keeps the
16
+ * sections editable β€” a section is a name and a timestamp stored BESIDE the film, so refusing
17
+ * to let somebody fix a typo because the player is empty would turn a preview failure into a
18
+ * lost capability.
19
+ *
20
+ * That the gallery hands over the RESOLVED source is the other half, asserted in
21
+ * `MediaGallery.spec.tsx` ("the SECTIONS dialog is handed the RESOLVED source").
22
+ */
23
+ import { afterEach, describe, expect, test } from 'bun:test';
24
+ import type React from 'react';
25
+ import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
26
+ import { VideoChapterEditor } from './VideoChapterEditor';
27
+ import type { VideoChapter } from './videoChapters';
28
+
29
+ afterEach(cleanup);
30
+
31
+ // The dialog is a portal, so everything it renders is queried off `document.body`.
32
+ const body = () => document.body;
33
+
34
+ const openWith = (props: Partial<React.ComponentProps<typeof VideoChapterEditor>>) =>
35
+ render(
36
+ <VideoChapterEditor
37
+ src=''
38
+ name='clip.mp4'
39
+ durationSeconds={120}
40
+ onClose={() => {}}
41
+ onSubmit={() => {}}
42
+ {...props}
43
+ />,
44
+ );
45
+
46
+ describe('VideoChapterEditor β€” when it cannot show the film', () => {
47
+ test('πŸ”΄ no source at all is a stated reason, not a black box', () => {
48
+ const { getByRole } = openWith({});
49
+ expect(body().querySelector('video')).toBeNull();
50
+ const said = getByRole('status').textContent ?? '';
51
+ expect(said).toContain('no video source');
52
+ // …and it says what is still true: the sections are metadata beside the film.
53
+ expect(said).toContain('still be edited and saved');
54
+ });
55
+
56
+ test('πŸ”΄ a source that fails to load says so too', () => {
57
+ const { getByRole, queryByRole } = openWith({ src: '/films/gone.mp4' });
58
+ const film = body().querySelector('video');
59
+ expect(film).not.toBeNull();
60
+ expect(queryByRole('status')).toBeNull();
61
+ fireEvent.error(film as HTMLVideoElement);
62
+ expect(getByRole('status').textContent).toContain('could not be loaded');
63
+ });
64
+
65
+ test('a source still being resolved reads as "on its way", not as a failure', () => {
66
+ const { getByRole } = openWith({ src: '', resolving: true });
67
+ expect(getByRole('status').textContent).toContain('Finding this film');
68
+ });
69
+
70
+ test('πŸ”΄ the sections it already has can still be renamed and SAVED with no film at all', async () => {
71
+ const chapters: VideoChapter[] = [{ startSeconds: 12, title: 'The speeches' }];
72
+ const saved: VideoChapter[][] = [];
73
+ const { getByRole, getByLabelText } = openWith({
74
+ chapters,
75
+ onSubmit: (next) => {
76
+ saved.push(next);
77
+ },
78
+ });
79
+ fireEvent.change(getByLabelText('Name of the section at 0:12.0'), {
80
+ target: { value: 'The toasts' },
81
+ });
82
+ fireEvent.click(getByRole('button', { name: 'Save sections' }));
83
+ // `waitFor`, because the save is async β€” and with it the state it sets lands inside act().
84
+ await waitFor(() => expect(saved).toEqual([[{ startSeconds: 12, title: 'The toasts' }]]));
85
+ });
86
+ });