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.
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +25 -5
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +19 -0
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +19 -0
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
- package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
- package/dist/react/media/CanvasVideoPlayer.js +4 -3
- package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
- package/dist/react/media/FrameGrabber.d.ts +16 -2
- package/dist/react/media/FrameGrabber.d.ts.map +1 -1
- package/dist/react/media/FrameGrabber.js +68 -43
- package/dist/react/media/FrameGrabber.js.map +1 -1
- package/dist/react/media/PictureEditor.d.ts +17 -2
- package/dist/react/media/PictureEditor.d.ts.map +1 -1
- package/dist/react/media/PictureEditor.js +33 -7
- package/dist/react/media/PictureEditor.js.map +1 -1
- package/dist/react/media/VideoChapterEditor.d.ts +17 -2
- package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
- package/dist/react/media/VideoChapterEditor.js +21 -3
- package/dist/react/media/VideoChapterEditor.js.map +1 -1
- package/dist/react/media/VideoCutEditor.d.ts +16 -2
- package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
- package/dist/react/media/VideoCutEditor.js +21 -3
- package/dist/react/media/VideoCutEditor.js.map +1 -1
- package/dist/react/media/VideoPlayer.d.ts +37 -9
- package/dist/react/media/VideoPlayer.d.ts.map +1 -1
- package/dist/react/media/VideoPlayer.js +130 -39
- package/dist/react/media/VideoPlayer.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts +24 -3
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +109 -8
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/pictureEditor.css +2 -0
- package/dist/react/media/sourceFault.d.ts +146 -0
- package/dist/react/media/sourceFault.d.ts.map +1 -0
- package/dist/react/media/sourceFault.js +139 -0
- package/dist/react/media/sourceFault.js.map +1 -0
- package/dist/react/media/videoChapterEditor.css +19 -0
- package/dist/react/media/videoCutEditor.css +22 -0
- package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
- package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
- package/dist/react/media-gallery/MediaGallery.js +166 -13
- package/dist/react/media-gallery/MediaGallery.js.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
- package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
- package/dist/react/media-gallery/galleryItemMedia.js +27 -3
- package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
- package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
- package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
- package/dist/react/media-gallery/types.d.ts +23 -0
- package/dist/react/media-gallery/types.d.ts.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts +1 -0
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/master-lock.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/package.json +3 -2
- package/scripts/guardrailsEnforce.spec.ts +244 -1
- package/scripts/guardrailsEnforce.ts +224 -7
- package/src/iframesMakeNoRealRequest.spec.ts +92 -0
- package/src/react/components/IsolatedFrame.spec.tsx +27 -9
- package/src/react/master-lock/MasterLockDialog.tsx +24 -5
- package/src/react/master-lock/MasterLockProvider.spec.tsx +39 -0
- package/src/react/master-lock/MasterLockProvider.tsx +19 -0
- package/src/react/media/CanvasVideoPlayer.tsx +11 -6
- package/src/react/media/FrameGrabber.spec.tsx +57 -1
- package/src/react/media/FrameGrabber.tsx +138 -84
- package/src/react/media/PictureEditor.spec.tsx +90 -0
- package/src/react/media/PictureEditor.tsx +78 -23
- package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
- package/src/react/media/VideoChapterEditor.tsx +64 -18
- package/src/react/media/VideoCutEditor.spec.tsx +44 -0
- package/src/react/media/VideoCutEditor.tsx +63 -18
- package/src/react/media/VideoPlayer.spec.tsx +101 -0
- package/src/react/media/VideoPlayer.tsx +221 -73
- package/src/react/media/hlsSource.answer.spec.ts +255 -0
- package/src/react/media/hlsSource.ts +136 -8
- package/src/react/media/pictureEditor.css +2 -0
- package/src/react/media/sourceFault.spec.tsx +224 -0
- package/src/react/media/sourceFault.tsx +220 -0
- package/src/react/media/videoChapterEditor.css +19 -0
- package/src/react/media/videoCutEditor.css +22 -0
- package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
- package/src/react/media-gallery/MediaGallery.tsx +356 -45
- package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
- package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
- package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
- package/src/react/media-gallery/types.ts +23 -0
- package/src/styles-areas/master-lock.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- 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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
? '
|
|
390
|
-
:
|
|
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=
|
|
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
|
-
{
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
491
|
+
maxWidth: '100%',
|
|
492
|
+
maxHeight: '100%',
|
|
493
|
+
filter,
|
|
494
|
+
...(grab === 'thumbnail' ? { display: 'none' } : {}),
|
|
486
495
|
}}
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
-
)
|
|
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
|
-
/**
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
+
});
|