cursedbelt 4.1.1 → 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 (81) hide show
  1. package/dist/react/media/CanvasVideoPlayer.d.ts +3 -2
  2. package/dist/react/media/CanvasVideoPlayer.d.ts.map +1 -1
  3. package/dist/react/media/CanvasVideoPlayer.js +4 -3
  4. package/dist/react/media/CanvasVideoPlayer.js.map +1 -1
  5. package/dist/react/media/FrameGrabber.d.ts +16 -2
  6. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  7. package/dist/react/media/FrameGrabber.js +68 -43
  8. package/dist/react/media/FrameGrabber.js.map +1 -1
  9. package/dist/react/media/PictureEditor.d.ts +17 -2
  10. package/dist/react/media/PictureEditor.d.ts.map +1 -1
  11. package/dist/react/media/PictureEditor.js +33 -7
  12. package/dist/react/media/PictureEditor.js.map +1 -1
  13. package/dist/react/media/VideoChapterEditor.d.ts +17 -2
  14. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -1
  15. package/dist/react/media/VideoChapterEditor.js +21 -3
  16. package/dist/react/media/VideoChapterEditor.js.map +1 -1
  17. package/dist/react/media/VideoCutEditor.d.ts +16 -2
  18. package/dist/react/media/VideoCutEditor.d.ts.map +1 -1
  19. package/dist/react/media/VideoCutEditor.js +21 -3
  20. package/dist/react/media/VideoCutEditor.js.map +1 -1
  21. package/dist/react/media/VideoPlayer.d.ts +37 -9
  22. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  23. package/dist/react/media/VideoPlayer.js +130 -39
  24. package/dist/react/media/VideoPlayer.js.map +1 -1
  25. package/dist/react/media/hlsSource.d.ts +24 -3
  26. package/dist/react/media/hlsSource.d.ts.map +1 -1
  27. package/dist/react/media/hlsSource.js +109 -8
  28. package/dist/react/media/hlsSource.js.map +1 -1
  29. package/dist/react/media/pictureEditor.css +2 -0
  30. package/dist/react/media/sourceFault.d.ts +146 -0
  31. package/dist/react/media/sourceFault.d.ts.map +1 -0
  32. package/dist/react/media/sourceFault.js +139 -0
  33. package/dist/react/media/sourceFault.js.map +1 -0
  34. package/dist/react/media/videoChapterEditor.css +19 -0
  35. package/dist/react/media/videoCutEditor.css +22 -0
  36. package/dist/react/media-gallery/MediaGallery.d.ts +12 -1
  37. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  38. package/dist/react/media-gallery/MediaGallery.js +166 -13
  39. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  40. package/dist/react/media-gallery/galleryItemMedia.d.ts +4 -0
  41. package/dist/react/media-gallery/galleryItemMedia.d.ts.map +1 -1
  42. package/dist/react/media-gallery/galleryItemMedia.js +27 -3
  43. package/dist/react/media-gallery/galleryItemMedia.js.map +1 -1
  44. package/dist/react/media-gallery/mediaGalleryModel.d.ts.map +1 -1
  45. package/dist/react/media-gallery/mediaGalleryModel.js +5 -0
  46. package/dist/react/media-gallery/mediaGalleryModel.js.map +1 -1
  47. package/dist/react/media-gallery/types.d.ts +23 -0
  48. package/dist/react/media-gallery/types.d.ts.map +1 -1
  49. package/dist/scripts/guardrailsEnforce.d.ts +1 -0
  50. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  51. package/dist/styles-areas/media-gallery.css +1 -1
  52. package/dist/styles-areas/media.css +1 -1
  53. package/package.json +3 -2
  54. package/scripts/guardrailsEnforce.spec.ts +244 -1
  55. package/scripts/guardrailsEnforce.ts +224 -7
  56. package/src/react/media/CanvasVideoPlayer.tsx +11 -6
  57. package/src/react/media/FrameGrabber.spec.tsx +57 -1
  58. package/src/react/media/FrameGrabber.tsx +138 -84
  59. package/src/react/media/PictureEditor.spec.tsx +90 -0
  60. package/src/react/media/PictureEditor.tsx +78 -23
  61. package/src/react/media/VideoChapterEditor.spec.tsx +86 -0
  62. package/src/react/media/VideoChapterEditor.tsx +64 -18
  63. package/src/react/media/VideoCutEditor.spec.tsx +44 -0
  64. package/src/react/media/VideoCutEditor.tsx +63 -18
  65. package/src/react/media/VideoPlayer.spec.tsx +101 -0
  66. package/src/react/media/VideoPlayer.tsx +221 -73
  67. package/src/react/media/hlsSource.answer.spec.ts +255 -0
  68. package/src/react/media/hlsSource.ts +136 -8
  69. package/src/react/media/pictureEditor.css +2 -0
  70. package/src/react/media/sourceFault.spec.tsx +224 -0
  71. package/src/react/media/sourceFault.tsx +220 -0
  72. package/src/react/media/videoChapterEditor.css +19 -0
  73. package/src/react/media/videoCutEditor.css +22 -0
  74. package/src/react/media-gallery/MediaGallery.spec.tsx +288 -0
  75. package/src/react/media-gallery/MediaGallery.tsx +356 -45
  76. package/src/react/media-gallery/galleryItemMedia.tsx +27 -4
  77. package/src/react/media-gallery/mediaGalleryModel.spec.ts +36 -0
  78. package/src/react/media-gallery/mediaGalleryModel.ts +4 -0
  79. package/src/react/media-gallery/types.ts +23 -0
  80. package/src/styles-areas/media-gallery.css +1 -1
  81. package/src/styles-areas/media.css +1 -1
@@ -8,13 +8,31 @@
8
8
  // invoked from each package's `lint`/`done` script, exiting non-zero on violation).
9
9
  //
10
10
  // Usage:
11
- // bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name>]
12
- // [--reimplements <Name,Name,…>] [root...]
11
+ // bun run ../cursedbelt/scripts/guardrailsEnforce.ts <rule>[,<rule>...]
12
+ // [--scope <name> | --baseline <path>]
13
+ // [--reimplements <Name,Name,…> | --reimplements-from-belt]
14
+ // [--exclude <path>[,<path>…]] [root...]
13
15
  //
14
16
  // `--scope <name>` selects the ratchet baseline row (see "Ratchet baseline" below) for
15
17
  // the calling package — `cursedbelt` and `cursedshell` each pass their own scope name
16
18
  // from their `lint` script. Omitting `--scope` defaults every rule's baseline to 0.
17
19
  //
20
+ // A CONSUMING APP uses the other three instead, and needs no wrapper script of its own:
21
+ //
22
+ // "guardrails": "bun node_modules/cursedbelt/scripts/guardrailsEnforce.ts <rules>
23
+ // --baseline scripts/guardrails-baseline.json --reimplements-from-belt src"
24
+ //
25
+ // --baseline <path> a flat `{ "<rule>": <count> }` ratchet in the CALLER's
26
+ // repo, TWO-SIDED — exceeding a row fails, and so does
27
+ // falling below one, which prints the number to paste.
28
+ // The rows and the scanned rules must agree exactly, both
29
+ // directions. See "a CONSUMER's ratchet" below.
30
+ // --reimplements-from-belt derive `no-local-reimplementation`'s name list from THIS
31
+ // package's own react index files, so no app hand-maintains
32
+ // 384 names. Fails closed on an empty list.
33
+ // --exclude <path>[,<path>…] drop build output that lives under a source root. Fails
34
+ // if it matches nothing.
35
+ //
18
36
  // Currently implemented rules:
19
37
  // no-raw-action-button — STANDARDS.md §4a: "a plugin or generated app must not render
20
38
  // a raw <button> for an action (a raw <button> is acceptable only for a purely
@@ -148,7 +166,7 @@
148
166
  // ignored (its violations still fire) and a warning is printed.
149
167
 
150
168
  import { type Dirent, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
151
- import { basename, extname, join, relative } from "node:path";
169
+ import { basename, extname, join, relative, resolve } from "node:path";
152
170
 
153
171
  const IGNORE_DIR_NAMES = new Set(["node_modules", "dist", "dist-deploy", ".git", "build", "coverage"]);
154
172
  const SOURCE_EXTENSIONS = new Set([".tsx", ".jsx", ".css"]);
@@ -1678,18 +1696,122 @@ function loadBaseline(): Baseline {
1678
1696
  }
1679
1697
  }
1680
1698
 
1699
+ // ── `--baseline <path>`: a CONSUMER's ratchet, in the consumer's own repo ───────
1700
+ // The packaged baseline above cannot be a consuming app's: it lives inside
1701
+ // `node_modules/cursedbelt`, so nobody can check their debt ceiling in beside their
1702
+ // code. Nine apps each carried an identical ~140-line wrapper to work around that
1703
+ // (`apps/*/scripts/guardrails.ts`, deleted 2026-09-18 by tasks/…/202-*.md); this flag
1704
+ // is that wrapper, folded into the one place it belongs.
1705
+ //
1706
+ // The file is FLAT — `{ "<rule>": <count> }`, no scope level, because a repo's
1707
+ // baseline is about one repo. A `"$comment"` key is reserved for the why (desk's row
1708
+ // list carries fifty lines of it) and is the only non-numeric value tolerated:
1709
+ // anything else non-numeric is a typo in a row, and a typo read as `?? 0` would demand
1710
+ // a whole inherited pile in one commit, so it is refused by name instead.
1711
+ //
1712
+ // 🔴 It is TWO-SIDED, and the packaged `--scope` baseline is not. Deliberate, and the
1713
+ // asymmetry is the point: `--scope` names a row the CALLER cannot edit (it ships inside
1714
+ // the package), so failing a consumer for a number only a cursedbelt release can lower
1715
+ // would be a gate nobody can turn green. `--baseline` names a file in the caller's own
1716
+ // repo, which they lower in the same commit — so falling below a row fails, printing
1717
+ // the number to paste. A one-sided ratchet accumulates slack: fix forty colours, leave
1718
+ // the row at 353, and forty may quietly come back. Same idiom as the generation's
1719
+ // `check-paths` baseline — inherited debt only falls.
1720
+ type FlatBaseline = Record<string, number>;
1721
+
1722
+ const BASELINE_COMMENT_KEY = "$comment";
1723
+
1724
+ /** Parse a flat `{rule: count}` baseline, refusing anything that is not a count. */
1725
+ function parseFlatBaseline(json: string, label: string): FlatBaseline {
1726
+ let raw: unknown;
1727
+ try {
1728
+ raw = JSON.parse(json);
1729
+ } catch (error) {
1730
+ throw new Error(`${label}: invalid JSON — ${(error as Error).message}`);
1731
+ }
1732
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
1733
+ throw new Error(`${label}: expected an object of {"<rule>": <count>}`);
1734
+ }
1735
+ const rows: FlatBaseline = {};
1736
+ for (const [rule, value] of Object.entries(raw as Record<string, unknown>)) {
1737
+ if (rule === BASELINE_COMMENT_KEY) continue;
1738
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
1739
+ throw new Error(`${label}: "${rule}" is ${JSON.stringify(value)}, not a violation count`);
1740
+ }
1741
+ rows[rule] = value;
1742
+ }
1743
+ return rows;
1744
+ }
1745
+
1746
+ /**
1747
+ * Both directions of "the rows and the rules must agree", which is the hole this
1748
+ * closes for the wrappers that used to do it by hand. A rule with no row is scored 0
1749
+ * and demands its whole inherited pile in one commit; a row with no rule is coverage
1750
+ * that is not there — the same defect as a `check-paths` baseline entry matching
1751
+ * nothing. Returns the complaints, empty when the two lists agree.
1752
+ */
1753
+ function baselineDisagreements(rows: FlatBaseline, ruleNames: readonly string[]): string[] {
1754
+ const complaints: string[] = [];
1755
+ const missing = ruleNames.filter((name) => !(name in rows));
1756
+ const orphans = Object.keys(rows).filter((name) => !ruleNames.includes(name));
1757
+ if (missing.length > 0) complaints.push(`no row for rule(s) being scanned: ${missing.join(", ")}`);
1758
+ if (orphans.length > 0) complaints.push(`row(s) for rules nobody scans: ${orphans.join(", ")}`);
1759
+ return complaints;
1760
+ }
1761
+
1762
+ // ── `--reimplements-from-belt`: the name list, off this package's own exports ───
1763
+ // `no-local-reimplementation` matches app-declared names against the ones cursedbelt
1764
+ // exports (384 of them as this lands). A hand-kept copy of a dependency's export list
1765
+ // is a second source of truth that drifts the moment the belt publishes, so the list
1766
+ // is read off the react index files of the INSTALLED package — this file's own
1767
+ // package, whatever version resolved. `--reimplements <Name,…>` stays for a caller
1768
+ // with a list of its own (the CURSEDBELT_INVENTORY.md path in the rule's header).
1769
+ const BELT_REACT_ROOT = join(import.meta.dir, "..", "src", "react");
1770
+
1771
+ export function beltExportedComponentNames(reactRoot: string = BELT_REACT_ROOT): string[] {
1772
+ const names = new Set<string>();
1773
+ const indexFiles: string[] = [];
1774
+ // `withFileTypes` rather than a `statSync` per entry — the react tree is ~1,300
1775
+ // entries, and one syscall each is 4s of a gate that is meant to be under a minute.
1776
+ const collect = (dir: string) => {
1777
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
1778
+ const full = join(dir, entry.name);
1779
+ if (entry.isDirectory()) collect(full);
1780
+ else if (entry.name === "index.ts" || entry.name === "index.tsx") indexFiles.push(full);
1781
+ }
1782
+ };
1783
+ collect(reactRoot);
1784
+ for (const file of indexFiles) {
1785
+ const source = readFileSync(file, "utf8");
1786
+ for (const block of source.matchAll(/export\s*\{([^}]*)\}/gs)) {
1787
+ for (const part of block[1]?.split(",") ?? []) {
1788
+ const trimmed = part.trim();
1789
+ if (!trimmed || trimmed.startsWith("type ")) continue;
1790
+ const name = (trimmed.split(/\s+as\s+/).pop() ?? "").trim();
1791
+ if (/^[A-Z][A-Za-z0-9]*$/.test(name)) names.add(name);
1792
+ }
1793
+ }
1794
+ }
1795
+ return [...names];
1796
+ }
1797
+
1681
1798
  function main() {
1682
1799
  const [ruleArg, ...rest] = process.argv.slice(2);
1683
1800
  const ruleNames = ruleArg ? ruleArg.split(",") : [];
1684
1801
  const rules = ruleNames.map((n) => RULES[n]).filter((r): r is Rule => Boolean(r));
1685
1802
  if (ruleNames.length === 0 || rules.length !== ruleNames.length) {
1686
1803
  console.error(
1687
- `Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name>] [--reimplements <Name,…>] [root...]\nKnown rules: ${Object.keys(RULES).join(", ")}`,
1804
+ `Usage: bun run guardrailsEnforce.ts <rule>[,<rule>...] [--scope <name> | --baseline <path>]\n` +
1805
+ ` [--reimplements <Name,…> | --reimplements-from-belt] [--exclude <path>[,<path>…]] [root...]\n` +
1806
+ `Known rules: ${Object.keys(RULES).join(", ")}`,
1688
1807
  );
1689
1808
  process.exit(2);
1690
1809
  }
1691
1810
 
1692
1811
  let scope: string | undefined;
1812
+ let baselinePath: string | undefined;
1813
+ let fromBelt = false;
1814
+ const excludes: string[] = [];
1693
1815
  const roots: string[] = [];
1694
1816
  for (let i = 0; i < rest.length; i++) {
1695
1817
  if (rest[i] === "--scope") {
@@ -1697,6 +1819,12 @@ function main() {
1697
1819
  i++;
1698
1820
  continue;
1699
1821
  }
1822
+ // A flat `{rule: count}` ratchet in the CALLER's repo — see "a CONSUMER's ratchet".
1823
+ if (rest[i] === "--baseline") {
1824
+ baselinePath = rest[i + 1];
1825
+ i++;
1826
+ continue;
1827
+ }
1700
1828
  // The inventoried component names `no-local-reimplementation` looks for. Passed
1701
1829
  // in rather than hardcoded so the list lives beside the inventory it comes from
1702
1830
  // (fleet/standards/CURSEDBELT_INVENTORY.md) — see that rule's header.
@@ -1705,10 +1833,73 @@ function main() {
1705
1833
  i++;
1706
1834
  continue;
1707
1835
  }
1836
+ if (rest[i] === "--reimplements-from-belt") {
1837
+ fromBelt = true;
1838
+ continue;
1839
+ }
1840
+ // Build output that lives under a source root (desk's `src/generated/assets.ts` is
1841
+ // the compiled browser app, inlined as a module and rewritten by every build). Named
1842
+ // here rather than by listing every sibling root, because "name the siblings" fails
1843
+ // OPEN — a directory added later is silently unscanned.
1844
+ if (rest[i] === "--exclude") {
1845
+ for (const part of (rest[i + 1] ?? "").split(",")) {
1846
+ const trimmed = part.trim();
1847
+ if (trimmed) excludes.push(resolve(trimmed));
1848
+ }
1849
+ i++;
1850
+ continue;
1851
+ }
1708
1852
  roots.push(rest[i]);
1709
1853
  }
1854
+
1855
+ if (scope && baselinePath) {
1856
+ console.error("--scope and --baseline are two different ratchets; pass one.");
1857
+ process.exit(2);
1858
+ }
1859
+
1860
+ let flatBaseline: FlatBaseline | undefined;
1861
+ if (baselinePath) {
1862
+ if (!existsSync(baselinePath)) {
1863
+ console.error(`--baseline ${baselinePath}: no such file (relative to ${process.cwd()})`);
1864
+ process.exit(2);
1865
+ }
1866
+ try {
1867
+ flatBaseline = parseFlatBaseline(readFileSync(baselinePath, "utf8"), baselinePath);
1868
+ } catch (error) {
1869
+ console.error((error as Error).message);
1870
+ process.exit(2);
1871
+ }
1872
+ const complaints = baselineDisagreements(flatBaseline, ruleNames);
1873
+ if (complaints.length > 0) {
1874
+ console.error(`${baselinePath} and the rules being scanned disagree:`);
1875
+ for (const complaint of complaints) console.error(` ${complaint}`);
1876
+ process.exit(2);
1877
+ }
1878
+ }
1879
+
1880
+ if (fromBelt) {
1881
+ // 🔴 Fails CLOSED. An empty list makes `no-local-reimplementation` inert — it would
1882
+ // print "0 violations" for a check that never ran, and a ratchet row of 0 would then
1883
+ // look earned. If the react tree has moved, the run stops instead.
1884
+ let names: string[] = [];
1885
+ try {
1886
+ names = beltExportedComponentNames();
1887
+ } catch (error) {
1888
+ console.error(`--reimplements-from-belt: cannot read ${BELT_REACT_ROOT} — ${(error as Error).message}`);
1889
+ process.exit(2);
1890
+ }
1891
+ if (names.length === 0) {
1892
+ console.error(`--reimplements-from-belt: ${BELT_REACT_ROOT} exports no component names — refusing to scan against an empty list.`);
1893
+ process.exit(2);
1894
+ }
1895
+ setReimplementedNames(names);
1896
+ }
1897
+
1710
1898
  const baseline = loadBaseline();
1711
- const scopeBaseline = (scope && baseline[scope]) || {};
1899
+ const scopeBaseline: Record<string, number> = flatBaseline ?? (scope ? (baseline[scope] ?? {}) : {});
1900
+ // `--baseline` names a file the caller owns and can lower; `--scope` does not. That is
1901
+ // the whole reason one is two-sided and the other suggests.
1902
+ const twoSided = flatBaseline !== undefined;
1712
1903
 
1713
1904
  const searchRoots = roots.length > 0 ? roots : ["."];
1714
1905
  const files: string[] = [];
@@ -1721,6 +1912,17 @@ function main() {
1721
1912
  // every directory, e.g. a plugin package with no `apps/` dir)
1722
1913
  }
1723
1914
  }
1915
+ if (excludes.length > 0) {
1916
+ const kept = files.filter((file) => !excludes.some((dir) => resolve(file) === dir || resolve(file).startsWith(`${dir}/`)));
1917
+ if (kept.length === files.length) {
1918
+ // An exclusion matching nothing is either a typo or a directory that has moved,
1919
+ // and both read as "that build output is handled" while it is being scanned.
1920
+ console.error(`--exclude matched no scanned file: ${excludes.join(", ")}`);
1921
+ process.exit(2);
1922
+ }
1923
+ files.length = 0;
1924
+ files.push(...kept);
1925
+ }
1724
1926
 
1725
1927
  const sourcesByFile = new Map<string, string>();
1726
1928
  for (const file of files) sourcesByFile.set(file, readFileSync(file, "utf8"));
@@ -1751,13 +1953,28 @@ function main() {
1751
1953
  }
1752
1954
 
1753
1955
  const allowedByBaseline = scopeBaseline[rule.name] ?? 0;
1754
- if (violations.length <= allowedByBaseline) {
1956
+ if (violations.length === allowedByBaseline) {
1755
1957
  console.log(
1756
1958
  `✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
1757
1959
  );
1758
- if (violations.length < allowedByBaseline) {
1960
+ continue;
1961
+ }
1962
+ if (violations.length < allowedByBaseline) {
1963
+ // The ratchet's other side. Under `--scope` this is a suggestion printed at the
1964
+ // end; under `--baseline` it is a failure, because the caller owns that file and
1965
+ // leaving slack in it is how a row stops meaning anything.
1966
+ if (!twoSided) {
1967
+ console.log(
1968
+ `✓ ${rule.name}: ${violations.length} violation(s) across ${files.length} files (baseline allows ${allowedByBaseline})`,
1969
+ );
1759
1970
  lowerableBaselines[rule.name] = violations.length;
1971
+ continue;
1760
1972
  }
1973
+ anyExceededBaseline = true;
1974
+ console.error(
1975
+ `✗ ${rule.name}: ${violations.length} violation(s), below the baseline of ${allowedByBaseline} — lower "${rule.name}" ` +
1976
+ `to ${violations.length} in ${baselinePath} (the ratchet only falls)`,
1977
+ );
1761
1978
  continue;
1762
1979
  }
1763
1980
 
@@ -1,9 +1,8 @@
1
1
  import React from 'react';
2
- import type { PlayableVideo } from 'cursedbelt-core/media';
3
2
  import { Button } from '../components/Button';
4
3
  import { IconButton } from '../components/IconButton';
5
4
  import { useHlsSource } from './hlsSource';
6
- import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
5
+ import { DEFAULT_VIDEO_ASPECT_RATIO, type PlayerVideo, ProcessingIndicator } from './VideoPlayer';
7
6
 
8
7
  // ── The green-frame-immune fallback player ───────────────────────────────────────
9
8
  // On some GPUs Chrome composites a hardware-decoded <video> as a solid GREEN frame when it's
@@ -12,7 +11,7 @@ import { DEFAULT_VIDEO_ASPECT_RATIO, ProcessingIndicator } from './VideoPlayer';
12
11
  // reintroduced it). The decode is correct — drawing the video to a <canvas> yields right pixels.
13
12
  // So this keeps a HIDDEN <video> as the decode/audio/seek engine and paints each presented frame
14
13
  // to a <canvas> (composited as an ordinary texture, immune to the bug). It's a drop-in swap for
15
- // the Vidstack <VideoPlayer> (same PlayableVideo + props); switch to it if you see green frames.
14
+ // the Vidstack <VideoPlayer> (same PlayerVideo + props); switch to it if you see green frames.
16
15
  // Ported from ca's CanvasVideoPlayer (Tailwind, themed off cursedbelt's --accent token). The
17
16
  // source attach — native HLS / hls.js / progressive — is `hlsSource.ts`, shared with the
18
17
  // gallery's native stage.
@@ -26,7 +25,8 @@ function fmtTime(s: number): string {
26
25
  }
27
26
 
28
27
  export interface CanvasVideoPlayerProps {
29
- video: PlayableVideo;
28
+ /** Same clip shape as {@link VideoPlayer} — see {@link PlayerVideo}. */
29
+ video: PlayerVideo;
30
30
  autoPlay?: boolean;
31
31
  muted?: boolean;
32
32
  className?: string;
@@ -204,7 +204,8 @@ export function CanvasVideoPlayer({
204
204
  };
205
205
 
206
206
  const status = video.processingStatus;
207
- const showProcessing = status != null && status !== 'ready';
207
+ // An unreachable ladder is its own reason to draw — see `VideoPlayer`'s copy of this line.
208
+ const showProcessing = (status != null && status !== 'ready') || video.playbackDegraded === true;
208
209
 
209
210
  return (
210
211
  // Same intrinsic-sizing contract as <VideoPlayer>: aspect-ratio keeps the surface visible
@@ -234,7 +235,11 @@ export function CanvasVideoPlayer({
234
235
  />
235
236
 
236
237
  {showProcessing ? (
237
- <ProcessingIndicator status={status} progress={video.processingProgress} />
238
+ <ProcessingIndicator
239
+ status={status}
240
+ progress={video.processingProgress}
241
+ playbackDegraded={video.playbackDegraded}
242
+ />
238
243
  ) : null}
239
244
 
240
245
  {/* Controls */}
@@ -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
+ });