@bycrux/editor 0.11.2 → 1.0.1

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 (268) hide show
  1. package/package.json +3 -1
  2. package/src/ControlsInfoModal.tsx +251 -61
  3. package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
  4. package/src/__tests__/adapter.test.ts +59 -1
  5. package/src/__tests__/schema-assignability.test.ts +93 -0
  6. package/src/__tests__/schema.test.ts +15 -0
  7. package/src/__tests__/video-adapter-contract.test.ts +128 -5
  8. package/src/carousel/AddElementMenu.tsx +10 -4
  9. package/src/carousel/CarouselEditor.tsx +46 -8
  10. package/src/carousel/CarouselRenderModal.tsx +15 -10
  11. package/src/carousel/OverlayPicker.tsx +10 -3
  12. package/src/carousel/SlidePropertyPanel.tsx +43 -21
  13. package/src/components/FilmstripScrubber.tsx +277 -0
  14. package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
  15. package/src/engine/__tests__/audio-clock.test.ts +655 -0
  16. package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
  17. package/src/engine/__tests__/batch-planner.test.ts +298 -0
  18. package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
  19. package/src/engine/__tests__/demux-ranged.test.ts +495 -0
  20. package/src/engine/__tests__/demux-truncated.test.ts +136 -0
  21. package/src/engine/__tests__/demux.test.ts +305 -0
  22. package/src/engine/__tests__/eligibility.test.ts +146 -0
  23. package/src/engine/__tests__/engine-recovery.test.ts +263 -0
  24. package/src/engine/__tests__/engine.test.ts +326 -0
  25. package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
  26. package/src/engine/__tests__/frame-server.test.ts +326 -0
  27. package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
  28. package/src/engine/__tests__/media-loader.test.ts +100 -0
  29. package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
  30. package/src/engine/__tests__/scheduler.test.ts +1310 -0
  31. package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
  32. package/src/engine/__tests__/scrub-source.test.ts +195 -0
  33. package/src/engine/__tests__/source-host.test.ts +455 -0
  34. package/src/engine/__tests__/time-stretch.test.ts +182 -0
  35. package/src/engine/audio-clock.ts +1179 -0
  36. package/src/engine/audio-worklet-source.ts +160 -0
  37. package/src/engine/batch-planner.ts +308 -0
  38. package/src/engine/debug-hud.tsx +54 -0
  39. package/src/engine/decode-worker-source.ts +142 -0
  40. package/src/engine/demux.ts +900 -0
  41. package/src/engine/eligibility.ts +140 -0
  42. package/src/engine/frame-server.ts +644 -0
  43. package/src/engine/index.ts +950 -0
  44. package/src/engine/media-loader.ts +380 -0
  45. package/src/engine/mp4box.d.ts +214 -0
  46. package/src/engine/scheduler.ts +1324 -0
  47. package/src/engine/scrub-resolve.ts +66 -0
  48. package/src/engine/scrub-source.ts +496 -0
  49. package/src/engine/time-stretch.ts +224 -0
  50. package/src/index.ts +83 -1
  51. package/src/preview/OverlayPreview.tsx +2 -24
  52. package/src/schema.ts +146 -3
  53. package/src/state/__tests__/use-project-sync.test.tsx +56 -0
  54. package/src/state/use-project-sync.ts +17 -0
  55. package/src/test-setup.ts +32 -0
  56. package/src/text/FontPicker.tsx +85 -51
  57. package/src/text/TextFormattingToolbar.tsx +6 -1
  58. package/src/text/__tests__/FontPicker.options.test.ts +43 -0
  59. package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
  60. package/src/theme.ts +108 -3
  61. package/src/types.ts +601 -22
  62. package/src/ui/Loader.tsx +64 -0
  63. package/src/ui/NumberField.tsx +254 -0
  64. package/src/ui/Slider.tsx +128 -0
  65. package/src/ui/Tooltip.tsx +118 -0
  66. package/src/ui/__tests__/NumberField.test.tsx +298 -0
  67. package/src/ui/__tests__/Slider.test.tsx +150 -0
  68. package/src/ui/__tests__/Tooltip.test.tsx +105 -0
  69. package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
  70. package/src/ui/badge.tsx +12 -4
  71. package/src/ui/index.ts +6 -0
  72. package/src/ui/input.tsx +1 -1
  73. package/src/ui/select.tsx +1 -1
  74. package/src/ui/switch.tsx +12 -2
  75. package/src/ui/textarea.tsx +1 -1
  76. package/src/ui/usePersistentState.ts +63 -0
  77. package/src/video/AudioPolishModal.tsx +983 -0
  78. package/src/video/CaptionListPanel.test.tsx +944 -0
  79. package/src/video/CaptionListPanel.tsx +1106 -0
  80. package/src/video/CaptionRegenModal.tsx +37 -9
  81. package/src/video/CaptionSpecimen.tsx +160 -0
  82. package/src/video/CaptionStyleGallery.tsx +466 -0
  83. package/src/video/CommandPalette.tsx +165 -0
  84. package/src/video/ImageToneMenu.tsx +137 -0
  85. package/src/video/OverlayInspector.tsx +977 -0
  86. package/src/video/RenderModal.tsx +1035 -62
  87. package/src/video/VersionCompare.tsx +258 -0
  88. package/src/video/VersionPanel.tsx +117 -42
  89. package/src/video/VideoEditor.tsx +2229 -243
  90. package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
  91. package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
  92. package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
  93. package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
  94. package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
  95. package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
  96. package/src/video/__tests__/CommandPalette.test.tsx +119 -0
  97. package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
  98. package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
  99. package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
  100. package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
  101. package/src/video/__tests__/VersionCompare.test.tsx +182 -0
  102. package/src/video/__tests__/VersionPanel.test.tsx +279 -0
  103. package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
  104. package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
  105. package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
  106. package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
  107. package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
  108. package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
  109. package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
  110. package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
  111. package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
  112. package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
  113. package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
  114. package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
  115. package/src/video/__tests__/VideoEditor.test.tsx +403 -50
  116. package/src/video/__tests__/audioMagnet.test.ts +158 -0
  117. package/src/video/__tests__/audioPolish.test.ts +1202 -0
  118. package/src/video/__tests__/captionActiveWord.test.ts +107 -0
  119. package/src/video/__tests__/captionLanes.test.ts +262 -0
  120. package/src/video/__tests__/captionPositioning.test.tsx +8 -3
  121. package/src/video/__tests__/captionWordFloor.test.ts +228 -0
  122. package/src/video/__tests__/clipboard-ops.test.ts +430 -0
  123. package/src/video/__tests__/cuts.insert.test.ts +244 -0
  124. package/src/video/__tests__/cuts.test.ts +1213 -34
  125. package/src/video/__tests__/export-limits.test.ts +195 -0
  126. package/src/video/__tests__/hover-scrub.test.ts +163 -0
  127. package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
  128. package/src/video/__tests__/keyframeOps.test.ts +643 -0
  129. package/src/video/__tests__/keymap.test.tsx +171 -0
  130. package/src/video/__tests__/render-progress.test.tsx +20 -4
  131. package/src/video/__tests__/shuttle.test.ts +210 -0
  132. package/src/video/__tests__/source-preview.test.ts +60 -0
  133. package/src/video/__tests__/timecode.test.ts +77 -0
  134. package/src/video/__tests__/use-report-context.test.tsx +101 -0
  135. package/src/video/audioMagnet.ts +72 -0
  136. package/src/video/audioPolish.ts +774 -0
  137. package/src/video/captionActiveWord.ts +74 -0
  138. package/src/video/captionLanes.ts +202 -0
  139. package/src/video/captionRepair.ts +9 -5
  140. package/src/video/captionStyleDefaults.ts +100 -0
  141. package/src/video/captionWordFloor.ts +83 -0
  142. package/src/video/clipboard-ops.ts +377 -0
  143. package/src/video/cuts.ts +713 -37
  144. package/src/video/export-limits.ts +102 -0
  145. package/src/video/hover-scrub.ts +102 -0
  146. package/src/video/imageTone.ts +58 -0
  147. package/src/video/imageToneExamples.ts +10 -0
  148. package/src/video/keyframeOps.ts +384 -0
  149. package/src/video/keymap.ts +146 -0
  150. package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
  151. package/src/video/panels/LeftPanelTabs.tsx +187 -0
  152. package/src/video/panels/OverlayContentPanel.tsx +351 -0
  153. package/src/video/panels/TabNav.tsx +60 -0
  154. package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
  155. package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
  156. package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
  157. package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
  158. package/src/video/preview/CaptionPreview.tsx +57 -69
  159. package/src/video/preview/EngineSurface.tsx +103 -0
  160. package/src/video/preview/OverlayItemsLayer.tsx +315 -103
  161. package/src/video/preview/PreviewPlayer.tsx +337 -56
  162. package/src/video/preview/SocialPreviewMenu.tsx +214 -0
  163. package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
  164. package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
  165. package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
  166. package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
  167. package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
  168. package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
  169. package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
  170. package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
  171. package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
  172. package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
  173. package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
  174. package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
  175. package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
  176. package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
  177. package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
  178. package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
  179. package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
  180. package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
  181. package/src/video/preview/audio-context.ts +111 -0
  182. package/src/video/preview/captionDragState.ts +91 -1
  183. package/src/video/preview/proxySupport.ts +86 -0
  184. package/src/video/preview/transformStyle.ts +22 -12
  185. package/src/video/preview/useDragOverlay.ts +92 -18
  186. package/src/video/preview/useEnginePlayback.ts +625 -0
  187. package/src/video/preview/useVideoPlayback.ts +211 -167
  188. package/src/video/sdrCurves.ts +56 -0
  189. package/src/video/shuttle.ts +159 -0
  190. package/src/video/source-preview.ts +66 -0
  191. package/src/video/timecode.ts +59 -0
  192. package/src/video/timeline/EditableSegment.tsx +1 -1
  193. package/src/video/timeline/Scrubber.tsx +46 -174
  194. package/src/video/timeline/SpeedControl.tsx +95 -0
  195. package/src/video/timeline/Timeline.tsx +1061 -328
  196. package/src/video/timeline/TimelineContext.ts +17 -10
  197. package/src/video/timeline/TrackGutter.tsx +560 -0
  198. package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
  199. package/src/video/timeline/VolumeControl.tsx +113 -0
  200. package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
  201. package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
  202. package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
  203. package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
  204. package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
  205. package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
  206. package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
  207. package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
  208. package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
  209. package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
  210. package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
  211. package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
  212. package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
  213. package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
  214. package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
  215. package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
  216. package/src/video/timeline/__tests__/placement.test.ts +278 -0
  217. package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
  218. package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
  219. package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
  220. package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
  221. package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
  222. package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
  223. package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
  224. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
  225. package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
  226. package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
  227. package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
  228. package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
  229. package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
  230. package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
  231. package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
  232. package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
  233. package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
  234. package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
  235. package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
  236. package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
  237. package/src/video/timeline/canvas/clip-bands.ts +56 -0
  238. package/src/video/timeline/canvas/draw.ts +2187 -0
  239. package/src/video/timeline/canvas/fade-curve.ts +111 -0
  240. package/src/video/timeline/canvas/filmstrips.ts +418 -0
  241. package/src/video/timeline/canvas/hit-test.ts +501 -0
  242. package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
  243. package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
  244. package/src/video/timeline/canvas/snap.ts +232 -0
  245. package/src/video/timeline/canvas/viewport.ts +457 -0
  246. package/src/video/timeline/canvas/waveforms.ts +664 -0
  247. package/src/video/timeline/makeCaptionEdit.ts +5 -1
  248. package/src/video/timeline/multiSelectOps.ts +214 -55
  249. package/src/video/timeline/placement.ts +282 -0
  250. package/src/video/timeline/timeline-model.ts +919 -0
  251. package/src/video/timeline/useItemDragDrop.ts +151 -178
  252. package/src/video/timeline/useTimelineZoom.ts +25 -60
  253. package/src/video/timeline/utils.ts +0 -12
  254. package/src/video/use-report-context.ts +75 -0
  255. package/src/video/preview/OverlayPropsModal.tsx +0 -292
  256. package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
  257. package/src/video/timeline/AudioTrackRow.tsx +0 -404
  258. package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
  259. package/src/video/timeline/CaptionTrackRow.tsx +0 -235
  260. package/src/video/timeline/PlayheadLine.tsx +0 -18
  261. package/src/video/timeline/TranscriptModal.tsx +0 -70
  262. package/src/video/timeline/TranscriptPanel.tsx +0 -273
  263. package/src/video/timeline/VisualTrackRow.tsx +0 -300
  264. package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
  265. package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
  266. package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
  267. package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
  268. package/src/video/timeline/__tests__/useItemDragDrop.test.ts +0 -72
@@ -0,0 +1,353 @@
1
+ /**
2
+ * SP4 T5 — `sourceCrop` parity between the engine's `drawImage` rect and the
3
+ * legacy `<video>` CSS.
4
+ *
5
+ * `sourceCropStyle.ts` and its tests are the spec (plan decision 3: "the file
6
+ * and its tests stay as the spec"), so this suite does not re-derive the
7
+ * expected numbers — it runs `sourceCropVideoStyle` for each vector, works out
8
+ * where on the frame that CSS actually puts the crop sub-rect, and asserts the
9
+ * engine's destination rect lands in the same place. If either implementation
10
+ * drifts, the equality breaks; a shared bug in a shared formula cannot hide
11
+ * here because the two formulas are written differently (CSS oversizes the
12
+ * whole video and translates it; the canvas draws only the sub-rect).
13
+ *
14
+ * The vector table starts with the four cases `sourceCropStyle.test.ts` already
15
+ * pins, then adds the ones that file does not cover: the opposite aspect
16
+ * relationship, an offset crop, and the invalid/degenerate inputs.
17
+ */
18
+ import { describe, expect, it } from 'vitest'
19
+ import { sourceCropVideoStyle } from '../../video/preview/sourceCropStyle'
20
+ import { containFitPlan, drawPlanFor, sourceCropDrawPlan } from '../scheduler'
21
+
22
+ interface Vector {
23
+ name: string
24
+ crop: { x: number; y: number; w: number; h: number }
25
+ sourceWidth: number
26
+ sourceHeight: number
27
+ frameWidth: number
28
+ frameHeight: number
29
+ /** The engine decodes a PROXY — a different resolution, same aspect. */
30
+ codedWidth: number
31
+ codedHeight: number
32
+ }
33
+
34
+ /** Vectors that must produce a crop on BOTH paths. */
35
+ const CROPPING: Vector[] = [
36
+ {
37
+ // sourceCropStyle.test.ts: "center vertical-strip crop of a landscape
38
+ // source fills a portrait frame".
39
+ name: 'centre vertical strip, landscape source → portrait frame',
40
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
41
+ sourceWidth: 1920,
42
+ sourceHeight: 1080,
43
+ frameWidth: 1080,
44
+ frameHeight: 1920,
45
+ codedWidth: 1280,
46
+ codedHeight: 720,
47
+ },
48
+ {
49
+ // sourceCropStyle.test.ts: "crop region matching frame aspect fills the
50
+ // frame exactly (no letterbox)".
51
+ name: 'crop matching frame aspect, no letterbox',
52
+ crop: { x: 0.1, y: 0.1, w: 0.8, h: 0.8 },
53
+ sourceWidth: 1080,
54
+ sourceHeight: 1920,
55
+ frameWidth: 1080,
56
+ frameHeight: 1920,
57
+ codedWidth: 720,
58
+ codedHeight: 1280,
59
+ },
60
+ {
61
+ // The `cropAspect < frameAspect` branch, which neither existing vector
62
+ // exercises: a tall sliver of a portrait source inside a landscape frame,
63
+ // so the crop fills the frame's HEIGHT and pillarboxes horizontally.
64
+ name: 'tall sliver, portrait source → landscape frame (pillarboxed)',
65
+ crop: { x: 0.4, y: 0, w: 0.2, h: 1 },
66
+ sourceWidth: 1080,
67
+ sourceHeight: 1920,
68
+ frameWidth: 1920,
69
+ frameHeight: 1080,
70
+ codedWidth: 406,
71
+ codedHeight: 720,
72
+ },
73
+ {
74
+ name: 'offset crop in both axes',
75
+ crop: { x: 0.3, y: 0.45, w: 0.4, h: 0.35 },
76
+ sourceWidth: 3840,
77
+ sourceHeight: 2160,
78
+ frameWidth: 1080,
79
+ frameHeight: 1920,
80
+ codedWidth: 1280,
81
+ codedHeight: 720,
82
+ },
83
+ {
84
+ name: 'crop touching the far edge',
85
+ crop: { x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
86
+ sourceWidth: 1920,
87
+ sourceHeight: 1080,
88
+ frameWidth: 1080,
89
+ frameHeight: 1080,
90
+ codedWidth: 1280,
91
+ codedHeight: 720,
92
+ },
93
+ ]
94
+
95
+ /** Vectors both paths must decline to crop. */
96
+ const NO_CROP: Vector[] = [
97
+ {
98
+ // sourceCropStyle.test.ts: "returns null for a full-frame (default) crop".
99
+ name: 'full-frame default crop',
100
+ crop: { x: 0, y: 0, w: 1, h: 1 },
101
+ sourceWidth: 1920,
102
+ sourceHeight: 1080,
103
+ frameWidth: 1080,
104
+ frameHeight: 1920,
105
+ codedWidth: 1280,
106
+ codedHeight: 720,
107
+ },
108
+ {
109
+ // sourceCropStyle.test.ts: "returns null without source dims". THE parity
110
+ // guard: KNOWN-DIVERGENCES entry 8 — render drops the crop filter entirely
111
+ // when the item carries no dims, and both preview paths follow it.
112
+ name: 'no item source dims',
113
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
114
+ sourceWidth: 0,
115
+ sourceHeight: 0,
116
+ frameWidth: 1080,
117
+ frameHeight: 1920,
118
+ codedWidth: 1280,
119
+ codedHeight: 720,
120
+ },
121
+ {
122
+ name: 'zero-width crop',
123
+ crop: { x: 0.25, y: 0, w: 0, h: 1 },
124
+ sourceWidth: 1920,
125
+ sourceHeight: 1080,
126
+ frameWidth: 1080,
127
+ frameHeight: 1920,
128
+ codedWidth: 1280,
129
+ codedHeight: 720,
130
+ },
131
+ {
132
+ name: 'negative-height crop',
133
+ crop: { x: 0.25, y: 0, w: 0.5, h: -0.2 },
134
+ sourceWidth: 1920,
135
+ sourceHeight: 1080,
136
+ frameWidth: 1080,
137
+ frameHeight: 1920,
138
+ codedWidth: 1280,
139
+ codedHeight: 720,
140
+ },
141
+ {
142
+ name: 'unmeasured frame box',
143
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
144
+ sourceWidth: 1920,
145
+ sourceHeight: 1080,
146
+ frameWidth: 0,
147
+ frameHeight: 0,
148
+ codedWidth: 1280,
149
+ codedHeight: 720,
150
+ },
151
+ ]
152
+
153
+ /**
154
+ * Where the CSS actually puts the crop sub-rect, in frame pixels.
155
+ *
156
+ * The `<video>` is sized `width`/`height` and positioned `left`/`top`, all as
157
+ * percentages of the frame; the crop sub-rect is `crop.x/y/w/h` of THAT box
158
+ * (the element is `object-fit: fill`, so source ratios map linearly onto it).
159
+ */
160
+ function cssCropBox(
161
+ style: Record<string, unknown>,
162
+ crop: { x: number; y: number; w: number; h: number },
163
+ frameWidth: number,
164
+ frameHeight: number,
165
+ ) {
166
+ const pct = (v: unknown) => parseFloat(String(v)) / 100
167
+ const videoW = pct(style.width) * frameWidth
168
+ const videoH = pct(style.height) * frameHeight
169
+ const left = pct(style.left) * frameWidth
170
+ const top = pct(style.top) * frameHeight
171
+ return {
172
+ x: left + crop.x * videoW,
173
+ y: top + crop.y * videoH,
174
+ w: crop.w * videoW,
175
+ h: crop.h * videoH,
176
+ }
177
+ }
178
+
179
+ describe('sourceCropDrawPlan — parity with sourceCropStyle.ts', () => {
180
+ for (const v of CROPPING) {
181
+ it(`places the crop sub-rect exactly where the CSS does: ${v.name}`, () => {
182
+ const style = sourceCropVideoStyle({
183
+ crop: v.crop,
184
+ sourceWidth: v.sourceWidth,
185
+ sourceHeight: v.sourceHeight,
186
+ frameWidth: v.frameWidth,
187
+ frameHeight: v.frameHeight,
188
+ })
189
+ expect(style).not.toBeNull()
190
+
191
+ const plan = sourceCropDrawPlan(v)
192
+ expect(plan).not.toBeNull()
193
+
194
+ const css = cssCropBox(
195
+ style as unknown as Record<string, unknown>,
196
+ v.crop,
197
+ v.frameWidth,
198
+ v.frameHeight,
199
+ )
200
+ // 1e-6 of a frame pixel: the two formulas are algebraically identical, so
201
+ // only float association separates them.
202
+ expect(plan!.dx).toBeCloseTo(css.x, 6)
203
+ expect(plan!.dy).toBeCloseTo(css.y, 6)
204
+ expect(plan!.dw).toBeCloseTo(css.w, 6)
205
+ expect(plan!.dh).toBeCloseTo(css.h, 6)
206
+ })
207
+
208
+ it(`reads the crop sub-rect out of the decoded frame: ${v.name}`, () => {
209
+ const plan = sourceCropDrawPlan(v)!
210
+ // Source rect is expressed against the DECODED (proxy) dims, not the
211
+ // item's original `sourceWidth`/`sourceHeight` — `sourceCrop` is stored
212
+ // as ratios of the source precisely so it survives the re-encode.
213
+ expect(plan.sx).toBeCloseTo(v.crop.x * v.codedWidth, 9)
214
+ expect(plan.sy).toBeCloseTo(v.crop.y * v.codedHeight, 9)
215
+ expect(plan.sw).toBeCloseTo(v.crop.w * v.codedWidth, 9)
216
+ expect(plan.sh).toBeCloseTo(v.crop.h * v.codedHeight, 9)
217
+ // …and it stays inside the frame.
218
+ expect(plan.sx + plan.sw).toBeLessThanOrEqual(v.codedWidth + 1e-9)
219
+ expect(plan.sy + plan.sh).toBeLessThanOrEqual(v.codedHeight + 1e-9)
220
+ })
221
+
222
+ it(`fits the crop inside the frame with at most one letterbox axis: ${v.name}`, () => {
223
+ const plan = sourceCropDrawPlan(v)!
224
+ expect(plan.dx).toBeGreaterThanOrEqual(-1e-9)
225
+ expect(plan.dy).toBeGreaterThanOrEqual(-1e-9)
226
+ expect(plan.dx + plan.dw).toBeLessThanOrEqual(v.frameWidth + 1e-9)
227
+ expect(plan.dy + plan.dh).toBeLessThanOrEqual(v.frameHeight + 1e-9)
228
+ // Contain-fit: exactly one axis is flush unless the aspects match.
229
+ const flushW = Math.abs(plan.dw - v.frameWidth) < 1e-9
230
+ const flushH = Math.abs(plan.dh - v.frameHeight) < 1e-9
231
+ expect(flushW || flushH).toBe(true)
232
+ })
233
+ }
234
+
235
+ for (const v of NO_CROP) {
236
+ it(`declines to crop wherever the CSS declines: ${v.name}`, () => {
237
+ const style = sourceCropVideoStyle({
238
+ crop: v.crop,
239
+ sourceWidth: v.sourceWidth,
240
+ sourceHeight: v.sourceHeight,
241
+ frameWidth: v.frameWidth,
242
+ frameHeight: v.frameHeight,
243
+ })
244
+ expect(style).toBeNull()
245
+ expect(sourceCropDrawPlan(v)).toBeNull()
246
+ })
247
+ }
248
+
249
+ it('declines when the item carries no sourceCrop at all', () => {
250
+ expect(
251
+ sourceCropDrawPlan({
252
+ crop: undefined,
253
+ sourceWidth: 1920,
254
+ sourceHeight: 1080,
255
+ codedWidth: 1280,
256
+ codedHeight: 720,
257
+ frameWidth: 1080,
258
+ frameHeight: 1920,
259
+ }),
260
+ ).toBeNull()
261
+ })
262
+
263
+ it('declines when the decoded frame has no dims yet', () => {
264
+ expect(
265
+ sourceCropDrawPlan({
266
+ crop: { x: 0.25, y: 0, w: 0.5, h: 1 },
267
+ sourceWidth: 1920,
268
+ sourceHeight: 1080,
269
+ codedWidth: 0,
270
+ codedHeight: 0,
271
+ frameWidth: 1080,
272
+ frameHeight: 1920,
273
+ }),
274
+ ).toBeNull()
275
+ })
276
+ })
277
+
278
+ describe('containFitPlan — the no-crop default', () => {
279
+ it('letterboxes a landscape source into a portrait frame', () => {
280
+ // PreviewPlayer's `baseVideoStyle` fallback is `objectFit: 'contain'` over
281
+ // the whole frame; this is that, as a rect.
282
+ const plan = containFitPlan(1280, 720, 1080, 1920)
283
+ expect(plan.sx).toBe(0)
284
+ expect(plan.sy).toBe(0)
285
+ expect(plan.sw).toBe(1280)
286
+ expect(plan.sh).toBe(720)
287
+ expect(plan.dw).toBeCloseTo(1080, 9)
288
+ expect(plan.dh).toBeCloseTo(607.5, 9)
289
+ expect(plan.dx).toBeCloseTo(0, 9)
290
+ expect(plan.dy).toBeCloseTo((1920 - 607.5) / 2, 9)
291
+ })
292
+
293
+ it('pillarboxes a portrait source into a landscape frame', () => {
294
+ const plan = containFitPlan(720, 1280, 1920, 1080)
295
+ expect(plan.dh).toBeCloseTo(1080, 9)
296
+ expect(plan.dw).toBeCloseTo(607.5, 9)
297
+ expect(plan.dy).toBeCloseTo(0, 9)
298
+ expect(plan.dx).toBeCloseTo((1920 - 607.5) / 2, 9)
299
+ })
300
+
301
+ it('fills exactly when the aspects match', () => {
302
+ const plan = containFitPlan(720, 1280, 1080, 1920)
303
+ expect(plan.dx).toBeCloseTo(0, 9)
304
+ expect(plan.dy).toBeCloseTo(0, 9)
305
+ expect(plan.dw).toBeCloseTo(1080, 9)
306
+ expect(plan.dh).toBeCloseTo(1920, 9)
307
+ })
308
+
309
+ it('degrades to an empty rect rather than NaN when nothing is measured', () => {
310
+ expect(containFitPlan(0, 0, 1080, 1920)).toEqual({
311
+ sx: 0,
312
+ sy: 0,
313
+ sw: 0,
314
+ sh: 0,
315
+ dx: 0,
316
+ dy: 0,
317
+ dw: 0,
318
+ dh: 0,
319
+ })
320
+ })
321
+ })
322
+
323
+ describe('drawPlanFor — crop when it can, contain when it cannot', () => {
324
+ const item = {
325
+ sourceCrop: { x: 0.25, y: 0, w: 0.5, h: 1 },
326
+ sourceWidth: 1920,
327
+ sourceHeight: 1080,
328
+ }
329
+
330
+ it('uses the crop rect when the item is fully specified', () => {
331
+ expect(drawPlanFor(item, 1280, 720, 1080, 1920)).toEqual(
332
+ sourceCropDrawPlan({
333
+ crop: item.sourceCrop,
334
+ sourceWidth: item.sourceWidth,
335
+ sourceHeight: item.sourceHeight,
336
+ codedWidth: 1280,
337
+ codedHeight: 720,
338
+ frameWidth: 1080,
339
+ frameHeight: 1920,
340
+ }),
341
+ )
342
+ })
343
+
344
+ it('falls back to contain-fit when the item has a crop but no dims', () => {
345
+ const plan = drawPlanFor({ sourceCrop: item.sourceCrop }, 1280, 720, 1080, 1920)
346
+ expect(plan).toEqual(containFitPlan(1280, 720, 1080, 1920))
347
+ })
348
+
349
+ it('falls back to contain-fit when the item has no crop', () => {
350
+ const plan = drawPlanFor({ sourceWidth: 1920, sourceHeight: 1080 }, 1280, 720, 1080, 1920)
351
+ expect(plan).toEqual(containFitPlan(1280, 720, 1080, 1920))
352
+ })
353
+ })