cursedbelt 3.0.2 → 4.1.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 (252) hide show
  1. package/dist/react/components/ListToolbar.d.ts +94 -0
  2. package/dist/react/components/ListToolbar.d.ts.map +1 -0
  3. package/dist/react/components/ListToolbar.js +66 -0
  4. package/dist/react/components/ListToolbar.js.map +1 -0
  5. package/dist/react/file-tree/FileTree.d.ts +282 -3
  6. package/dist/react/file-tree/FileTree.d.ts.map +1 -1
  7. package/dist/react/file-tree/FileTree.js +799 -131
  8. package/dist/react/file-tree/FileTree.js.map +1 -1
  9. package/dist/react/file-tree/fileTree.css +151 -0
  10. package/dist/react/file-tree/fileTreeSelection.d.ts +187 -0
  11. package/dist/react/file-tree/fileTreeSelection.d.ts.map +1 -0
  12. package/dist/react/file-tree/fileTreeSelection.js +264 -0
  13. package/dist/react/file-tree/fileTreeSelection.js.map +1 -0
  14. package/dist/react/file-tree/index.d.ts +1 -0
  15. package/dist/react/file-tree/index.d.ts.map +1 -1
  16. package/dist/react/file-tree/index.js +8 -0
  17. package/dist/react/file-tree/index.js.map +1 -1
  18. package/dist/react/index.d.ts +1 -0
  19. package/dist/react/index.d.ts.map +1 -1
  20. package/dist/react/index.js +1 -0
  21. package/dist/react/index.js.map +1 -1
  22. package/dist/react/lib/form.d.ts +3 -0
  23. package/dist/react/lib/form.d.ts.map +1 -1
  24. package/dist/react/lib/form.js +43 -5
  25. package/dist/react/lib/form.js.map +1 -1
  26. package/dist/react/media/DenseGalleryShell.d.ts +19 -3
  27. package/dist/react/media/DenseGalleryShell.d.ts.map +1 -1
  28. package/dist/react/media/DenseGalleryShell.js +55 -15
  29. package/dist/react/media/DenseGalleryShell.js.map +1 -1
  30. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  31. package/dist/react/media/FocusViewer.js +14 -14
  32. package/dist/react/media/FocusViewer.js.map +1 -1
  33. package/dist/react/media/FrameGrabber.d.ts +23 -0
  34. package/dist/react/media/FrameGrabber.d.ts.map +1 -1
  35. package/dist/react/media/FrameGrabber.js +67 -1
  36. package/dist/react/media/FrameGrabber.js.map +1 -1
  37. package/dist/react/media/MediaResize.d.ts +51 -19
  38. package/dist/react/media/MediaResize.d.ts.map +1 -1
  39. package/dist/react/media/MediaResize.js +20 -13
  40. package/dist/react/media/MediaResize.js.map +1 -1
  41. package/dist/react/media/VideoChapterEditor.d.ts +67 -0
  42. package/dist/react/media/VideoChapterEditor.d.ts.map +1 -0
  43. package/dist/react/media/VideoChapterEditor.js +148 -0
  44. package/dist/react/media/VideoChapterEditor.js.map +1 -0
  45. package/dist/react/media/VideoPlayer.d.ts +46 -4
  46. package/dist/react/media/VideoPlayer.d.ts.map +1 -1
  47. package/dist/react/media/VideoPlayer.js +124 -11
  48. package/dist/react/media/VideoPlayer.js.map +1 -1
  49. package/dist/react/media/VideoTurner.d.ts +15 -2
  50. package/dist/react/media/VideoTurner.d.ts.map +1 -1
  51. package/dist/react/media/VideoTurner.js +29 -2
  52. package/dist/react/media/VideoTurner.js.map +1 -1
  53. package/dist/react/media/denseGallery.css +104 -46
  54. package/dist/react/media/hlsSource.d.ts +57 -0
  55. package/dist/react/media/hlsSource.d.ts.map +1 -1
  56. package/dist/react/media/hlsSource.js +177 -1
  57. package/dist/react/media/hlsSource.js.map +1 -1
  58. package/dist/react/media/index.d.ts +2 -0
  59. package/dist/react/media/index.d.ts.map +1 -1
  60. package/dist/react/media/index.js +6 -0
  61. package/dist/react/media/index.js.map +1 -1
  62. package/dist/react/media/mediaResize.css +127 -54
  63. package/dist/react/media/pictureEditor.css +52 -6
  64. package/dist/react/media/videoChapterEditor.css +203 -0
  65. package/dist/react/media/videoChapters.d.ts +117 -0
  66. package/dist/react/media/videoChapters.d.ts.map +1 -0
  67. package/dist/react/media/videoChapters.js +95 -0
  68. package/dist/react/media/videoChapters.js.map +1 -0
  69. package/dist/react/media-gallery/GalleryTable.d.ts +52 -1
  70. package/dist/react/media-gallery/GalleryTable.d.ts.map +1 -1
  71. package/dist/react/media-gallery/GalleryTable.js +40 -9
  72. package/dist/react/media-gallery/GalleryTable.js.map +1 -1
  73. package/dist/react/media-gallery/MediaGallery.d.ts +107 -4
  74. package/dist/react/media-gallery/MediaGallery.d.ts.map +1 -1
  75. package/dist/react/media-gallery/MediaGallery.js +484 -104
  76. package/dist/react/media-gallery/MediaGallery.js.map +1 -1
  77. package/dist/react/media-gallery/MediaMetaEditor.d.ts +48 -0
  78. package/dist/react/media-gallery/MediaMetaEditor.d.ts.map +1 -0
  79. package/dist/react/media-gallery/MediaMetaEditor.js +66 -0
  80. package/dist/react/media-gallery/MediaMetaEditor.js.map +1 -0
  81. package/dist/react/media-gallery/index.d.ts +2 -0
  82. package/dist/react/media-gallery/index.d.ts.map +1 -1
  83. package/dist/react/media-gallery/index.js +5 -0
  84. package/dist/react/media-gallery/index.js.map +1 -1
  85. package/dist/react/media-gallery/mediaGallery.css +180 -4
  86. package/dist/react/media-gallery/mediaMeta.d.ts +62 -0
  87. package/dist/react/media-gallery/mediaMeta.d.ts.map +1 -0
  88. package/dist/react/media-gallery/mediaMeta.js +56 -0
  89. package/dist/react/media-gallery/mediaMeta.js.map +1 -0
  90. package/dist/react/media-gallery/playbackPreferences.d.ts +77 -0
  91. package/dist/react/media-gallery/playbackPreferences.d.ts.map +1 -1
  92. package/dist/react/media-gallery/playbackPreferences.js +42 -0
  93. package/dist/react/media-gallery/playbackPreferences.js.map +1 -1
  94. package/dist/react/media-gallery/types.d.ts +34 -0
  95. package/dist/react/media-gallery/types.d.ts.map +1 -1
  96. package/dist/scripts/guardrailsEnforce.d.ts +29 -0
  97. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -0
  98. package/dist/styles-areas/activity-bar.css +30 -0
  99. package/dist/styles-areas/analytics.css +672 -0
  100. package/dist/styles-areas/auth.css +154 -0
  101. package/dist/styles-areas/calendar.css +416 -0
  102. package/dist/styles-areas/chart-table.css +41 -0
  103. package/dist/styles-areas/charts.css +89 -0
  104. package/dist/styles-areas/chat.css +364 -0
  105. package/dist/styles-areas/clipboard.css +26 -0
  106. package/dist/styles-areas/code-editor.css +130 -0
  107. package/dist/styles-areas/companion-link.css +70 -0
  108. package/dist/styles-areas/core.css +2503 -0
  109. package/dist/styles-areas/dashboard-grid.css +84 -0
  110. package/dist/styles-areas/data-table.css +657 -0
  111. package/dist/styles-areas/deep-link.css +26 -0
  112. package/dist/styles-areas/diff-viewer.css +30 -0
  113. package/dist/styles-areas/disk-usage.css +262 -0
  114. package/dist/styles-areas/emoji.css +154 -0
  115. package/dist/styles-areas/fields.css +582 -0
  116. package/dist/styles-areas/file-tree.css +33 -0
  117. package/dist/styles-areas/filter-rail.css +235 -0
  118. package/dist/styles-areas/folder-tree.css +219 -0
  119. package/dist/styles-areas/keep-awake.css +26 -0
  120. package/dist/styles-areas/layout-engine.css +239 -0
  121. package/dist/styles-areas/markdown.css +87 -0
  122. package/dist/styles-areas/master-detail.css +239 -0
  123. package/dist/styles-areas/master-lock.css +26 -0
  124. package/dist/styles-areas/media-gallery.css +351 -0
  125. package/dist/styles-areas/media.css +658 -0
  126. package/dist/styles-areas/nav.css +488 -0
  127. package/dist/styles-areas/notifications.css +245 -0
  128. package/dist/styles-areas/overlays.css +133 -0
  129. package/dist/styles-areas/palette.css +140 -0
  130. package/dist/styles-areas/pdf-viewer.css +47 -0
  131. package/dist/styles-areas/problem-detail.css +97 -0
  132. package/dist/styles-areas/rich-text.css +167 -0
  133. package/dist/styles-areas/sharing.css +153 -0
  134. package/dist/styles-areas/spreadsheet.css +37 -0
  135. package/dist/styles-areas/stats.css +281 -0
  136. package/dist/styles-areas/test-report.css +133 -0
  137. package/dist/styles-areas/virtual.css +30 -0
  138. package/dist/styles-areas/wizard.css +381 -0
  139. package/dist/styles-areas/workbench.css +543 -0
  140. package/dist/styles-areas/workbook-viewer.css +44 -0
  141. package/dist/styles-static.css +34 -23
  142. package/dist/styles.css +34 -23
  143. package/package.json +130 -32
  144. package/scripts/checkDistExports.ts +67 -0
  145. package/scripts/cssRules.ts +125 -0
  146. package/scripts/fixtureAppCss.ts +344 -0
  147. package/scripts/generateAreaStyles.ts +253 -0
  148. package/scripts/guardrailsEnforce.spec.ts +62 -0
  149. package/scripts/guardrailsEnforce.ts +67 -3
  150. package/scripts/paths.ts +106 -0
  151. package/scripts/styleAreas.ts +585 -0
  152. package/scripts/verify.ts +5 -0
  153. package/src/barrelsReachNoOptionalPeer.spec.ts +117 -9
  154. package/src/docsMatchTheSplit.spec.ts +98 -0
  155. package/src/fixtureAppCss.spec.ts +177 -0
  156. package/src/namedSubpathsResolve.spec.ts +35 -0
  157. package/src/publishShape.spec.ts +67 -0
  158. package/src/react/components/ListToolbar.spec.tsx +172 -0
  159. package/src/react/components/ListToolbar.tsx +151 -0
  160. package/src/react/file-tree/FileTree.spec.tsx +1009 -0
  161. package/src/react/file-tree/FileTree.tsx +1459 -309
  162. package/src/react/file-tree/fileTree.css +151 -0
  163. package/src/react/file-tree/fileTreeSelection.spec.ts +327 -0
  164. package/src/react/file-tree/fileTreeSelection.ts +321 -0
  165. package/src/react/file-tree/index.ts +21 -0
  166. package/src/react/index.ts +1 -0
  167. package/src/react/lib/form.ts +51 -8
  168. package/src/react/markdown/MarkdownDoc.spec.tsx +1 -1
  169. package/src/react/media/DenseGalleryShell.tsx +108 -36
  170. package/src/react/media/FocusViewer.tsx +15 -8
  171. package/src/react/media/FrameGrabber.spec.tsx +175 -0
  172. package/src/react/media/FrameGrabber.tsx +67 -0
  173. package/src/react/media/MediaResize.spec.tsx +84 -14
  174. package/src/react/media/MediaResize.tsx +51 -19
  175. package/src/react/media/VideoChapterEditor.tsx +428 -0
  176. package/src/react/media/VideoPlayer.spec.tsx +111 -1
  177. package/src/react/media/VideoPlayer.tsx +158 -11
  178. package/src/react/media/VideoTurner.spec.tsx +67 -0
  179. package/src/react/media/VideoTurner.tsx +70 -15
  180. package/src/react/media/denseGallery.css +104 -46
  181. package/src/react/media/hlsSource.recovery.spec.ts +213 -0
  182. package/src/react/media/hlsSource.spec.ts +8 -0
  183. package/src/react/media/hlsSource.ts +189 -1
  184. package/src/react/media/index.ts +14 -0
  185. package/src/react/media/mediaResize.css +127 -54
  186. package/src/react/media/pictureEditor.css +52 -6
  187. package/src/react/media/videoChapterEditor.css +203 -0
  188. package/src/react/media/videoChapters.spec.ts +176 -0
  189. package/src/react/media/videoChapters.ts +181 -0
  190. package/src/react/media-gallery/GalleryTable.tsx +93 -3
  191. package/src/react/media-gallery/MediaGallery.spec.tsx +1156 -10
  192. package/src/react/media-gallery/MediaGallery.tsx +813 -95
  193. package/src/react/media-gallery/MediaMetaEditor.tsx +237 -0
  194. package/src/react/media-gallery/galleryTable.spec.ts +41 -1
  195. package/src/react/media-gallery/index.ts +7 -0
  196. package/src/react/media-gallery/mediaGallery.css +180 -4
  197. package/src/react/media-gallery/mediaMeta.spec.ts +95 -0
  198. package/src/react/media-gallery/mediaMeta.ts +101 -0
  199. package/src/react/media-gallery/playbackPreferences.spec.ts +172 -0
  200. package/src/react/media-gallery/playbackPreferences.ts +100 -0
  201. package/src/react/media-gallery/types.ts +34 -0
  202. package/src/shippedFilesAreTracked.spec.ts +69 -0
  203. package/src/styles-areas/activity-bar.css +30 -0
  204. package/src/styles-areas/analytics.css +672 -0
  205. package/src/styles-areas/auth.css +154 -0
  206. package/src/styles-areas/calendar.css +416 -0
  207. package/src/styles-areas/chart-table.css +41 -0
  208. package/src/styles-areas/charts.css +89 -0
  209. package/src/styles-areas/chat.css +364 -0
  210. package/src/styles-areas/clipboard.css +26 -0
  211. package/src/styles-areas/code-editor.css +130 -0
  212. package/src/styles-areas/companion-link.css +70 -0
  213. package/src/styles-areas/core.css +2503 -0
  214. package/src/styles-areas/dashboard-grid.css +84 -0
  215. package/src/styles-areas/data-table.css +657 -0
  216. package/src/styles-areas/deep-link.css +26 -0
  217. package/src/styles-areas/diff-viewer.css +30 -0
  218. package/src/styles-areas/disk-usage.css +262 -0
  219. package/src/styles-areas/emoji.css +154 -0
  220. package/src/styles-areas/fields.css +582 -0
  221. package/src/styles-areas/file-tree.css +33 -0
  222. package/src/styles-areas/filter-rail.css +235 -0
  223. package/src/styles-areas/folder-tree.css +219 -0
  224. package/src/styles-areas/keep-awake.css +26 -0
  225. package/src/styles-areas/layout-engine.css +239 -0
  226. package/src/styles-areas/markdown.css +87 -0
  227. package/src/styles-areas/master-detail.css +239 -0
  228. package/src/styles-areas/master-lock.css +26 -0
  229. package/src/styles-areas/media-gallery.css +351 -0
  230. package/src/styles-areas/media.css +658 -0
  231. package/src/styles-areas/nav.css +488 -0
  232. package/src/styles-areas/notifications.css +245 -0
  233. package/src/styles-areas/overlays.css +133 -0
  234. package/src/styles-areas/palette.css +140 -0
  235. package/src/styles-areas/pdf-viewer.css +47 -0
  236. package/src/styles-areas/problem-detail.css +97 -0
  237. package/src/styles-areas/rich-text.css +167 -0
  238. package/src/styles-areas/sharing.css +153 -0
  239. package/src/styles-areas/spreadsheet.css +37 -0
  240. package/src/styles-areas/stats.css +281 -0
  241. package/src/styles-areas/test-report.css +133 -0
  242. package/src/styles-areas/virtual.css +30 -0
  243. package/src/styles-areas/wizard.css +381 -0
  244. package/src/styles-areas/workbench.css +543 -0
  245. package/src/styles-areas/workbook-viewer.css +44 -0
  246. package/src/styles-static.css +34 -23
  247. package/src/styles.css +34 -23
  248. package/src/stylesAreas.spec.ts +247 -0
  249. package/src/stylesUtilitiesMatches.spec.ts +6 -77
  250. package/src/testFilesRunInParallel.spec.ts +79 -0
  251. package/src/typecheckCachesAreSeparate.spec.ts +128 -0
  252. package/src/verifyGraph.spec.ts +6 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The chapter-list algebra. Pure, so every rule the editor, the player and whatever store an
3
+ * app puts these in all depend on is asserted once here rather than three times, differently.
4
+ *
5
+ * The same shape as `videoCuts.spec.ts` in `cursedbelt-core`, and for the same reason — see
6
+ * the header of `videoChapters.ts` for why the model itself is in THIS package today.
7
+ */
8
+ import { describe, expect, test } from 'bun:test';
9
+ import {
10
+ type ChapterSpan,
11
+ chapterProblem,
12
+ chapterSpans,
13
+ MIN_CHAPTER_SECONDS,
14
+ normalizeChapters,
15
+ type VideoChapter,
16
+ } from './videoChapters';
17
+
18
+ const at = (startSeconds: number, title: string): VideoChapter => ({ startSeconds, title });
19
+ const shape = (chapters: readonly VideoChapter[]): string =>
20
+ chapters.map((chapter) => `${chapter.startSeconds}:${chapter.title}`).join(' ');
21
+ const spanShape = (spans: readonly ChapterSpan[]): string =>
22
+ spans.map((span) => `${span.startSeconds}-${span.endSeconds}:${span.title}`).join(' ');
23
+
24
+ describe('normalizeChapters', () => {
25
+ test('🔴 an out-of-order list comes back in timeline order', () => {
26
+ // Marks get made in the order somebody notices them, which is never the order they play
27
+ // in — you name the ending, then go back for the part you skipped.
28
+ expect(shape(normalizeChapters([at(40, 'Ending'), at(5, 'Intro'), at(20, 'Middle')], 60))).toBe(
29
+ '5:Intro 20:Middle 40:Ending',
30
+ );
31
+ });
32
+
33
+ test('🔴 a start past the end of the video is dropped', () => {
34
+ // The usual source is a stored list read back against a re-encoded (shorter) copy. A
35
+ // chapter out there is unreachable: nothing can seek to it and no divider can be drawn.
36
+ expect(shape(normalizeChapters([at(10, 'Real'), at(90, 'Past the end')], 60))).toBe('10:Real');
37
+ });
38
+
39
+ test('a start at the very end leaves no section, so it is dropped too', () => {
40
+ expect(normalizeChapters([at(60, 'On the last frame')], 60)).toEqual([]);
41
+ expect(normalizeChapters([at(60 - MIN_CHAPTER_SECONDS, 'Just fits')], 60)).toHaveLength(1);
42
+ });
43
+
44
+ test('a negative start is dropped rather than clamped to zero', () => {
45
+ // Clamping would invent a chapter at 0:00 that nobody placed — and 0:00 is the one start
46
+ // that is most likely to already be taken.
47
+ expect(shape(normalizeChapters([at(-5, 'Before the film'), at(3, 'Real')], 60))).toBe('3:Real');
48
+ });
49
+
50
+ test('🔴 a DUPLICATE start is dropped, and the first one placed wins', () => {
51
+ /*
52
+ * Two chapters at one instant make a section of zero length: unreachable from the menu,
53
+ * and two dividers on the same pixel of the scrub bar. The list would say two sections
54
+ * and the player could only ever show one.
55
+ */
56
+ expect(shape(normalizeChapters([at(20, 'Kept'), at(20, 'Duplicate')], 60))).toBe('20:Kept');
57
+ });
58
+
59
+ test('…and so is one closer than a section, which is the same defect with a gap in it', () => {
60
+ expect(shape(normalizeChapters([at(20, 'Kept'), at(20.5, 'Too close')], 60))).toBe('20:Kept');
61
+ expect(
62
+ shape(normalizeChapters([at(20, 'Kept'), at(20 + MIN_CHAPTER_SECONDS, 'Far enough')], 60)),
63
+ ).toBe('20:Kept 21:Far enough');
64
+ });
65
+
66
+ test('🔴 an EMPTY title is dropped — so is one that is only whitespace', () => {
67
+ // A marker with no name is a divider on the scrub bar that says nothing, which is strictly
68
+ // worse than no divider: it interrupts the bar and answers no question.
69
+ expect(shape(normalizeChapters([at(5, ''), at(10, ' '), at(20, 'Named')], 60))).toBe(
70
+ '20:Named',
71
+ );
72
+ });
73
+
74
+ test('titles are trimmed, because a leading space is invisible in the menu', () => {
75
+ expect(shape(normalizeChapters([at(5, ' The walk home ')], 60))).toBe('5:The walk home');
76
+ });
77
+
78
+ test('🔴 a NaN start drops its chapter instead of poisoning the sort', () => {
79
+ /*
80
+ * The usual source is the playhead of a `<video>` whose metadata has not loaded. Every
81
+ * comparison against NaN is false, so leaving it in produces an array `sort` calls sorted
82
+ * and is not — and the de-duplication pass then compares each chapter to the wrong
83
+ * neighbour and starts dropping real ones.
84
+ */
85
+ expect(
86
+ shape(normalizeChapters([at(40, 'Ending'), at(Number.NaN, 'Nowhere'), at(5, 'Intro')], 60)),
87
+ ).toBe('5:Intro 40:Ending');
88
+ });
89
+
90
+ test('an unknown duration means no chapter can be placed', () => {
91
+ expect(normalizeChapters([at(5, 'Intro')], Number.NaN)).toEqual([]);
92
+ expect(normalizeChapters([at(5, 'Intro')], 0)).toEqual([]);
93
+ });
94
+
95
+ test('a chapter at 0:00 is ordinary — it is where YouTube puts the first one', () => {
96
+ expect(shape(normalizeChapters([at(0, 'Cold open')], 60))).toBe('0:Cold open');
97
+ });
98
+ });
99
+
100
+ describe('🔴 chapterProblem — the same refusal in the editor and on whatever stores this', () => {
101
+ const existing = [at(20, 'The middle bit')];
102
+
103
+ test('an empty title is refused before anything else is looked at', () => {
104
+ expect(chapterProblem(at(5, ' '), existing, 60)).toContain('Name the section');
105
+ });
106
+
107
+ test('a start past the end is refused, and says what is wrong with it', () => {
108
+ expect(chapterProblem(at(59.5, 'Too late'), existing, 60)).toContain('very end');
109
+ });
110
+
111
+ test('a start on top of an existing chapter is refused BY NAME', () => {
112
+ // Naming the chapter that is in the way is the difference between "no" and "no, and here
113
+ // is the one you already made" — the second is actionable without hunting the list.
114
+ const problem = chapterProblem(at(20.2, 'Second thoughts'), existing, 60);
115
+ expect(problem).toContain('The middle bit');
116
+ });
117
+
118
+ test('an unknown duration is refused with the reason, not with a crash', () => {
119
+ expect(chapterProblem(at(5, 'Intro'), existing, Number.NaN)).toContain('length is not known');
120
+ });
121
+
122
+ test('a negative start is refused', () => {
123
+ expect(chapterProblem(at(-1, 'Intro'), existing, 60)).toContain('not before it');
124
+ });
125
+
126
+ test('an ordinary chapter has no problem', () => {
127
+ expect(chapterProblem(at(35, 'The walk home'), existing, 60)).toBeNull();
128
+ });
129
+
130
+ test('🔴 the refusals and the drops agree — what one rejects, the other does not keep', () => {
131
+ // One definition of valid. If these two ever disagree, a person is told "saved" about a
132
+ // chapter the read path throws away.
133
+ for (const draft of [at(-1, 'a'), at(59.9, 'b'), at(20.1, 'c'), at(30, ' ')]) {
134
+ expect(chapterProblem(draft, existing, 60)).not.toBeNull();
135
+ expect(normalizeChapters([...existing, draft], 60)).toHaveLength(existing.length);
136
+ }
137
+ });
138
+ });
139
+
140
+ describe('chapterSpans — where each section ends', () => {
141
+ test('each chapter runs until the next begins, and the last runs to the end', () => {
142
+ expect(spanShape(chapterSpans([at(0, 'One'), at(20, 'Two'), at(45, 'Three')], 60))).toBe(
143
+ '0-20:One 20-45:Two 45-60:Three',
144
+ );
145
+ });
146
+
147
+ test('🔴 the clip before the first chapter is NOT a span', () => {
148
+ // A video whose first mark is at 0:20 has an unnamed opening. Inventing a section called
149
+ // nothing for it would put a row in the chapters menu that says less than no row at all.
150
+ expect(spanShape(chapterSpans([at(20, 'The middle bit')], 60))).toBe('20-60:The middle bit');
151
+ });
152
+
153
+ test('one chapter is one section, the whole clip', () => {
154
+ expect(spanShape(chapterSpans([at(0, 'All of it')], 60))).toBe('0-60:All of it');
155
+ });
156
+
157
+ test('no chapters is no sections — the ordinary video, with nothing added to its bar', () => {
158
+ expect(chapterSpans([], 60)).toEqual([]);
159
+ });
160
+
161
+ test('🔴 a key is the START, so renaming a row does not remount the field being typed in', () => {
162
+ // The editor renders a text input per row. A key derived from the title would change on
163
+ // every keystroke, unmounting the input and throwing the caret out of it on the first
164
+ // letter typed.
165
+ const before = chapterSpans([at(20, 'Middle')], 60);
166
+ const after = chapterSpans([at(20, 'Middl')], 60);
167
+ expect(after[0]?.key).toBe(before[0]?.key as string);
168
+ });
169
+
170
+ test('the spans cover the clip from the first mark onward with no gaps', () => {
171
+ const spans = chapterSpans([at(5, 'A'), at(20, 'B'), at(50, 'C')], 60);
172
+ expect(spans[spans.length - 1]?.endSeconds).toBe(60);
173
+ for (let i = 1; i < spans.length; i++)
174
+ expect(spans[i]?.startSeconds).toBe(spans[i - 1]?.endSeconds as number);
175
+ });
176
+ });
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Marking points in a video and NAMING them — YouTube chapters, as a model, with no browser
3
+ * and no encoder in it.
4
+ *
5
+ * ── The owner's ask, 2026-09-15 ────────────────────────────────────────────────────────
6
+ * *"allow putting markers on videos at points in time and giving them a title for the section
7
+ * for knowing what that part of the video is about, like youtube chapters."*
8
+ *
9
+ * 🔴 This is the OTHER HALF of the gesture `videoCuts.ts` models, and it is deliberately not
10
+ * threaded through it. A cut is a point that says *remove what follows*; a chapter is a point
11
+ * that says *this part is called X, and it stays*. They look alike on a timeline and they have
12
+ * nothing else in common: a cut list is a REQUEST that costs a full re-encode and arrives back
13
+ * as a second file, while a chapter list is metadata that needs no encoder at all, applies the
14
+ * instant it is saved, and is free to be wrong. Putting a chapter on the cut list would make
15
+ * naming a section queue an ffmpeg job.
16
+ *
17
+ * ── Why the model is separate from the dialog ─────────────────────────────────────────
18
+ * Same reason as the cut list: a chapter list is DATA. It is produced by `VideoChapterEditor`,
19
+ * travels to whatever the app persists it in, and is read back by the player. A server that
20
+ * accepts one will want to re-check it, and "what makes a chapter list valid" must have exactly
21
+ * one definition or the editor and the store will disagree about a list neither of them
22
+ * refused. Nothing in this file imports React, and nothing in it touches the DOM.
23
+ *
24
+ * ── 🔴 Why it is HERE and not in `cursedbelt-core/media`, beside `videoCuts.ts` ────────
25
+ * Because `cursedbelt-core` is consumed BY PUBLISHED VERSION (forge rule 1; `src/noPathDeps.
26
+ * spec.ts` refuses a `file:`/`link:` dependency outright), so a module added to that package
27
+ * is unreachable from this one until it is published — `demo/vite.config.ts` says the same
28
+ * thing in its own header: *"a new subpath importable here the moment it is published"*. A
29
+ * chapter model in core today would mean this package could not typecheck against it, so the
30
+ * feature could not ship at all. The same reasoning, in the same words, put
31
+ * `fileTreeSelection.ts` in this package on 2026-09-15 — see `publicSurface.baseline`.
32
+ *
33
+ * It is written to MOVE: framework-free, no local imports but a type, and the only thing
34
+ * standing between it and `core/media/videoChapters.ts` is a published `cursedbelt-core`. When
35
+ * that happens the migration is a delete plus a re-export, exactly as `react/media/index.ts`
36
+ * already re-exports the cut model.
37
+ */
38
+ import type { PlayableVideo } from 'cursedbelt-core/media';
39
+
40
+ /** One named section, by the point in the source it BEGINS at. Seconds from the start. */
41
+ export interface VideoChapter {
42
+ startSeconds: number;
43
+ /** What that part is about. Trimmed, and never empty — an unnamed mark is unreadable. */
44
+ title: string;
45
+ }
46
+
47
+ /**
48
+ * The shortest section worth naming, in seconds — and therefore how far apart two chapters
49
+ * have to be to be two chapters.
50
+ *
51
+ * 🔴 It is the anti-duplicate rule and the anti-sliver rule at once, which is why there is one
52
+ * number rather than an equality test plus a floor. Two chapters at the same instant make a
53
+ * section of zero length: it is unreachable from the chapters menu, it renders as a divider
54
+ * with no room between it and the next, and on the scrub bar the two dividers land on the same
55
+ * pixel — so the list says two sections and the player can only ever show one. A second is
56
+ * generous (YouTube's own floor is ten) and still far below anything a person marks on purpose.
57
+ */
58
+ export const MIN_CHAPTER_SECONDS = 1;
59
+
60
+ /** A chapter with the end it runs to — the next one's start, or the end of the clip. */
61
+ export interface ChapterSpan extends VideoChapter {
62
+ endSeconds: number;
63
+ /**
64
+ * Stable across re-renders and safe as a React key.
65
+ *
66
+ * 🔴 The START, never the title. The editor renders a text field per row: keying on the
67
+ * title would give every keystroke a new key, so React would unmount the field being typed
68
+ * in and the caret would jump out of it on the first letter.
69
+ */
70
+ key: string;
71
+ }
72
+
73
+ /**
74
+ * A {@link PlayableVideo} that carries its named sections.
75
+ *
76
+ * 🔴 An intersection rather than a field on `PlayableVideo` itself for the reason in this
77
+ * file's header: that type lives in the published `cursedbelt-core`, which this package cannot
78
+ * edit and read in the same commit. It widens, so every existing consumer of `PlayableVideo` —
79
+ * every source, every stage, the player's own props — keeps typechecking untouched, and the
80
+ * day core carries `chapters` this alias becomes `PlayableVideo` and nothing else changes.
81
+ */
82
+ export type ChapteredVideo = PlayableVideo & { chapters?: VideoChapter[] };
83
+
84
+ /** Whitespace off, and a non-string (an app's row with a null in it) reads as empty. */
85
+ const cleanTitle = (title: unknown): string => (typeof title === 'string' ? title.trim() : '');
86
+
87
+ /**
88
+ * Put a raw list into the one canonical form everything downstream may assume: inside the
89
+ * clip, ordered, named, and never two chapters closer than a section.
90
+ *
91
+ * Invalid entries are DROPPED rather than reported — this is the read path, and a list that
92
+ * came back from a store against a slightly different duration must still play. The editor
93
+ * refuses the same things out loud through {@link chapterProblem}, so nothing a person types
94
+ * is silently discarded; the two agree because the second is written in terms of the first.
95
+ *
96
+ * A non-finite start (the usual source is a playhead read from a `<video>` whose metadata has
97
+ * not loaded, which reports `NaN`) drops its chapter instead of poisoning the sort: every
98
+ * comparison against `NaN` is false, which produces an array `sort` calls sorted and is not,
99
+ * and then the de-duplication pass below compares against the wrong neighbour.
100
+ */
101
+ export function normalizeChapters(
102
+ chapters: readonly VideoChapter[],
103
+ duration: number,
104
+ ): VideoChapter[] {
105
+ if (!Number.isFinite(duration) || duration <= 0) return [];
106
+ const clean = chapters
107
+ .map((chapter) => ({ startSeconds: chapter.startSeconds, title: cleanTitle(chapter.title) }))
108
+ .filter(
109
+ (chapter) =>
110
+ chapter.title.length > 0 &&
111
+ Number.isFinite(chapter.startSeconds) &&
112
+ chapter.startSeconds >= 0 &&
113
+ chapter.startSeconds <= duration - MIN_CHAPTER_SECONDS,
114
+ )
115
+ .sort((a, b) => a.startSeconds - b.startSeconds);
116
+
117
+ const out: VideoChapter[] = [];
118
+ for (const chapter of clean) {
119
+ const last = out[out.length - 1];
120
+ // First one wins. The alternative — last one wins — makes a marker somebody placed
121
+ // deliberately disappear when a later, sloppier one lands next to it.
122
+ if (last && chapter.startSeconds - last.startSeconds < MIN_CHAPTER_SECONDS) continue;
123
+ out.push(chapter);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * Why this chapter cannot be placed, or `null` when it can.
130
+ *
131
+ * 🔴 A SENTENCE, not a boolean, and the same sentence in the editor and in whatever route ends
132
+ * up storing one. All four refusals are reachable in a few seconds of marking, and each names
133
+ * the thing to do about it rather than the rule it broke.
134
+ */
135
+ export function chapterProblem(
136
+ draft: VideoChapter,
137
+ existing: readonly VideoChapter[],
138
+ duration: number,
139
+ ): string | null {
140
+ if (!Number.isFinite(duration) || duration <= 0)
141
+ return "This video's length is not known yet, so a chapter cannot be placed in it.";
142
+ if (cleanTitle(draft.title).length === 0)
143
+ return 'Name the section first — a chapter with no title is a mark nobody can read.';
144
+ const at = draft.startSeconds;
145
+ if (!Number.isFinite(at) || at < 0)
146
+ return 'A chapter starts at a point in the video, not before it.';
147
+ if (at > duration - MIN_CHAPTER_SECONDS)
148
+ return 'That is at the very end of the clip — a section needs at least a second of video after it.';
149
+ const clash = normalizeChapters(existing, duration).find(
150
+ (chapter) => Math.abs(chapter.startSeconds - at) < MIN_CHAPTER_SECONDS,
151
+ );
152
+ if (clash) return `There is already a chapter within a second of there — "${clash.title}".`;
153
+ return null;
154
+ }
155
+
156
+ /**
157
+ * The sections themselves: every chapter runs until the next one begins, and the last runs to
158
+ * the end of the clip.
159
+ *
160
+ * This is what both surfaces read — the editor's strip and the player's chapters track — from
161
+ * one function, because the alternative is two independent readings of "where does this
162
+ * section end" and a scrub bar whose dividers disagree with the list beside it.
163
+ *
164
+ * The clip before the FIRST chapter is deliberately not a span. A video whose first mark is at
165
+ * 2:00 has an unnamed opening, and inventing a section called nothing for it would put a row
166
+ * in the chapters menu that says less than no row at all.
167
+ */
168
+ export function chapterSpans(
169
+ chapters: readonly VideoChapter[],
170
+ duration: number,
171
+ ): ChapterSpan[] {
172
+ const ordered = normalizeChapters(chapters, duration);
173
+ return ordered.map((chapter, index) => {
174
+ const next = ordered[index + 1];
175
+ return {
176
+ ...chapter,
177
+ endSeconds: next ? next.startSeconds : duration,
178
+ key: chapter.startSeconds.toFixed(3),
179
+ };
180
+ });
181
+ }
@@ -90,6 +90,34 @@ function nextOrderFor(column: SortableColumn, order: MediaGalleryOrder): MediaGa
90
90
  return current === 'asc' ? pair.desc : pair.asc;
91
91
  }
92
92
 
93
+ /**
94
+ * "1,402,653,184" — the size as the EXACT integer it is, grouped.
95
+ *
96
+ * ── 🔴 Why a second size column exists at all ──────────────────────────────────────────
97
+ * Owner, `ideas/collections-09-14.md`: *"In collections I want to see the exact file size in
98
+ * bytes as a column option so I can easily tell if two files are the exact same byte size to
99
+ * help me know if they are duplicates or not."*
100
+ *
101
+ * `formatBytes` — the Size column beside this one — rounds to one decimal, so two files that
102
+ * differ by 400 MB read `1.4 GB` and `1.4 GB`, and two that differ by a single byte are
103
+ * indistinguishable at every scale above a kilobyte. That is not a display preference: the
104
+ * rounded column cannot do the one job he named for it, and no amount of squinting at it will
105
+ * ever separate a duplicate from a near-duplicate. So the exact count is offered BESIDE the
106
+ * rounded one ("as a column option", his words) rather than replacing it — `1.4 GB` is still
107
+ * the faster read for "is this big", and both questions are real.
108
+ *
109
+ * Grouped rather than a bare run of digits, because `1402653184` and `1402653284` are the same
110
+ * ten characters to a reader scanning a column, which is the failure this exists to fix. The
111
+ * separator is the reader's LOCALE's, exactly as `createdLabel` takes its month name from the
112
+ * locale — the digits are the fact, the grouping is typography.
113
+ *
114
+ * Null for a row with no size, so the cell shows `—` like every other absent measurement.
115
+ */
116
+ export function exactBytesLabel(bytes: number | undefined): string | null {
117
+ if (bytes == null || !Number.isFinite(bytes)) return null;
118
+ return Math.round(bytes).toLocaleString();
119
+ }
120
+
93
121
  /** "1:04:12" / "3:07" — a duration a person reads, never a raw seconds count. */
94
122
  export function durationLabel(seconds: number | undefined): string | null {
95
123
  if (seconds == null || !Number.isFinite(seconds) || seconds <= 0) return null;
@@ -149,6 +177,30 @@ export interface GalleryTableProps {
149
177
  * which view is on screen, so the button was present, lit, and had nothing wired to it here.
150
178
  */
151
179
  autoPreview?: boolean;
180
+ /**
181
+ * The row the STAGE is showing, and what to call it — `'Now playing'` on a film, `'On
182
+ * screen'` on a still. Absent in the browse view, where nothing is on a stage.
183
+ *
184
+ * 🔴 The detail view's related list is this same table over the same items the grid shows,
185
+ * and since 2026-09-16 that list INCLUDES the open one (see `MediaGallery`'s strip). A row
186
+ * that is in the list and unmarked is the list lying about which file you are looking at,
187
+ * so the two views of the strip mark it the same way or they are two different features.
188
+ */
189
+ playing?: { id: string; label: string };
190
+ /**
191
+ * Offer the EXACT byte count as its own column, beside the rounded Size one.
192
+ *
193
+ * Off by default, so every consumer that passes nothing renders the six columns it already
194
+ * had. See {@link exactBytesLabel} for what the column is for and why it is an addition
195
+ * rather than a replacement.
196
+ *
197
+ * 🔴 It is deliberately NOT a sort header, though it is a number. It holds the same value
198
+ * `size` already sorts on, so a second sortable header would put two `aria-sort` states on
199
+ * one ordering — a table where clicking Size and clicking Bytes are the same act, announced
200
+ * as two. Sort by Size and the exact counts land in the same order; that is what brings two
201
+ * candidates for a duplicate next to each other.
202
+ */
203
+ exactBytes?: boolean;
152
204
  }
153
205
 
154
206
  export function GalleryTable({
@@ -163,6 +215,8 @@ export function GalleryTable({
163
215
  selection,
164
216
  drag,
165
217
  autoPreview,
218
+ playing,
219
+ exactBytes = false,
166
220
  }: GalleryTableProps) {
167
221
  const viewportRef = useRef<HTMLDivElement | null>(null);
168
222
  const [scrollTop, setScrollTop] = useState(0);
@@ -183,8 +237,10 @@ export function GalleryTable({
183
237
  setScrollTop(event.currentTarget.scrollTop);
184
238
  }, []);
185
239
 
186
- // check? + thumb + name + kind + size + length + date
187
- const columnCount = selection ? 7 : 6;
240
+ // check? + thumb + name + kind + size + bytes? + length + date. 🔴 The spacer rows span
241
+ // this, so a column added without it is a virtualized table whose geometry is one cell
242
+ // narrower than its data — see the spacer rows below.
243
+ const columnCount = 6 + (selection ? 1 : 0) + (exactBytes ? 1 : 0);
188
244
  const above = start * GALLERY_TABLE_ROW_HEIGHT;
189
245
  const below = Math.max(0, (items.length - end) * GALLERY_TABLE_ROW_HEIGHT);
190
246
 
@@ -234,6 +290,15 @@ export function GalleryTable({
234
290
  onOrderChange={onOrderChange}
235
291
  className='cbgt-cell--num'
236
292
  />
293
+ {/* The exact count, next to the rounded one it exists to disambiguate — a
294
+ duplicate hunt reads the two columns together, so putting it at the far end
295
+ of the row beside the date would be the same data and a worse instrument.
296
+ Not a sort header: see `GalleryTableProps.exactBytes`. */}
297
+ {exactBytes ? (
298
+ <th className='cbgt-cell--num cbgt-cell--bytes' scope='col'>
299
+ Bytes
300
+ </th>
301
+ ) : null}
237
302
  <th className='cbgt-cell--num cbgt-cell--length' scope='col'>
238
303
  Length
239
304
  </th>
@@ -266,6 +331,8 @@ export function GalleryTable({
266
331
  selection={selection?.(item)}
267
332
  drag={drag?.(item)}
268
333
  autoPreview={autoPreview === true}
334
+ exactBytes={exactBytes}
335
+ {...(playing?.id === item.id ? { stageMark: playing.label } : {})}
269
336
  />
270
337
  ))}
271
338
  {below > 0 ? <tr className='cbgt-spacer' style={{ height: below }}><td colSpan={columnCount} /></tr> : null}
@@ -375,6 +442,8 @@ function GalleryTableRow({
375
442
  selection,
376
443
  drag,
377
444
  autoPreview,
445
+ exactBytes,
446
+ stageMark,
378
447
  }: {
379
448
  item: MediaGalleryItem;
380
449
  cache: MediaCache;
@@ -383,12 +452,17 @@ function GalleryTableRow({
383
452
  selection?: { selected: boolean; onToggle: (shift: boolean) => void } | undefined;
384
453
  drag?: { onDragStart: (event: DragEvent<HTMLElement>) => void; onDragEnd?: () => void } | undefined;
385
454
  autoPreview: boolean;
455
+ /** Draw the exact-bytes cell — see {@link GalleryTableProps.exactBytes}. */
456
+ exactBytes: boolean;
457
+ /** Set only on the row the stage is showing — see {@link GalleryTableProps.playing}. */
458
+ stageMark?: string;
386
459
  }) {
387
460
  const { media } = useItemMedia(item, cache);
388
461
  const category = galleryItemCategory(item);
389
462
  const thumb =
390
463
  media.thumbnailUrl ?? media.posterUrl ?? (category === 'image' ? media.url : undefined);
391
464
  const size = item.sizeBytes != null ? formatBytes(item.sizeBytes) : null;
465
+ const bytes = exactBytesLabel(item.sizeBytes);
392
466
  const length = durationLabel(item.durationSeconds);
393
467
  const date = createdLabel(item.createdAt);
394
468
 
@@ -407,7 +481,11 @@ function GalleryTableRow({
407
481
 
408
482
  return (
409
483
  <tr
410
- className={`cbgt-row${selection?.selected ? ' is-selected' : ''}`}
484
+ className={`cbgt-row${selection?.selected ? ' is-selected' : ''}${stageMark ? ' is-playing' : ''}`}
485
+ // The same two hooks the grid tile carries: the attribute is what the gallery's reveal
486
+ // scrolls to, and `aria-current` is what says "this is the one" to a screen reader.
487
+ data-playing={stageMark ? 'true' : undefined}
488
+ aria-current={stageMark ? 'true' : undefined}
411
489
  {...(previewSrc
412
490
  ? {
413
491
  onMouseEnter: () => setHovering(true),
@@ -484,12 +562,24 @@ function GalleryTableRow({
484
562
  >
485
563
  {item.name}
486
564
  </button>
565
+ {/*
566
+ * 🔴 The mark is the ROW, not a capsule beside the name, and that is a table
567
+ * constraint rather than a preference. `.cbgt-cell--name` is a fixed-width cell in a
568
+ * `table-layout: fixed` table holding a `display: block` button at 100% width — a
569
+ * second visible child there wraps to a line the 52px row clips, and making the cell
570
+ * a flex box takes it out of the table layout so that one row's columns stop lining
571
+ * up with every other row's. So the row is tinted and barred (`.cbgt-row.is-playing`,
572
+ * the same shape `.is-selected` uses), which is the table idiom for "this one", and
573
+ * the name it is given lives here where a screen reader reads it with the file name.
574
+ */}
575
+ {stageMark ? <span className='cbgd-sr-only'>{stageMark}</span> : null}
487
576
  </td>
488
577
  <td className='cbgt-cell--kind'>
489
578
  {/* Filled capsule, per DESIGN.md §3 — never a thin outline. */}
490
579
  <span className='cbgt-kind'>{CATEGORY_LABEL[category]}</span>
491
580
  </td>
492
581
  <td className='cbgt-cell--num'>{size ?? '—'}</td>
582
+ {exactBytes ? <td className='cbgt-cell--num cbgt-cell--bytes'>{bytes ?? '—'}</td> : null}
493
583
  <td className='cbgt-cell--num cbgt-cell--length'>{length ?? '—'}</td>
494
584
  <td className='cbgt-cell--num cbgt-cell--date'>{date ?? '—'}</td>
495
585
  </tr>