@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,478 @@
1
+ /**
2
+ * SocialSafeZoneOverlay — a preview-only viewing aid that draws a REALISTIC
3
+ * mock of a target social platform's in-app chrome (status bar, top nav,
4
+ * engagement rail, caption/credit block, and the app's own bottom tab bar /
5
+ * add-comment bar) semi-transparently over the video preview, so
6
+ * the operator can see roughly what the app's own UI will sit on top of their
7
+ * content once posted. Mirrors CapCut's "Preview your video for social media"
8
+ * picker — TikTok, YouTube Shorts and Instagram Reels, each with its own
9
+ * layout, chosen from `SocialPreviewMenu` (see that file and its trigger in
10
+ * VideoEditor.tsx).
11
+ *
12
+ * Every label/count is GENERIC placeholder content ("Your name", "2.8M",
13
+ * "Music name", …) — this never reads real project/account data, and no
14
+ * platform wordmark or logo is drawn (icons only), so it can't be mistaken
15
+ * for the real app chrome or a real creator's stats.
16
+ *
17
+ * Display-only: this never touches the project, never affects render output,
18
+ * and renders nothing when `platform` is absent/unrecognized. It is purely
19
+ * an editor viewing aid — mounted unconditionally by `PreviewPlayer` (its
20
+ * `socialPreview` prop), a no-op whenever the host doesn't pass a platform.
21
+ *
22
+ * Scaling
23
+ * -------
24
+ * The chrome is authored in a fixed 1080×1920 design canvas (matching the
25
+ * RENDER_W/RENDER_H convention CaptionPreview.tsx and OverlayItemsLayer.tsx
26
+ * use for preview-space UI) and scaled to the actual on-screen box via a
27
+ * ResizeObserver-computed factor applied as a CSS `transform: scale()` — the
28
+ * established pattern for scaling fixed-design-px preview chrome with its
29
+ * container in this codebase, followed here rather than reinvented.
30
+ *
31
+ * One deliberate deviation from that pattern: CaptionPreview/OverlayItemsLayer
32
+ * scale by width alone, because their design canvas is always driven by the
33
+ * SAME resolution as the container they fill — the two aspect ratios can
34
+ * never disagree. This chrome's 1080×1920 canvas is a fixed 9:16 assumption
35
+ * that holds regardless of the project's actual aspect ratio, so a
36
+ * landscape/square preview box must contain-fit (`Math.min` of the two
37
+ * axis ratios) rather than overflow — see the vertical-video constraint
38
+ * below. The scaled canvas is centered in its parent (not pinned top-left)
39
+ * so any leftover space is distributed evenly on both sides.
40
+ *
41
+ * Vertical-video assumption
42
+ * --------------------------
43
+ * This chrome is designed for 9:16. A non-vertical preview box does not
44
+ * break or overflow it — contain-fit just letterboxes/pillarboxes the
45
+ * scaled canvas within the box — but no attempt is made to relayout the
46
+ * chrome to look "correct" for landscape.
47
+ *
48
+ * Legibility
49
+ * ----------
50
+ * Every platform's chrome wraps its content in a single `filter:
51
+ * drop-shadow(...)` div rather than a per-element shadow: a `drop-shadow`
52
+ * filter shadows the alpha silhouette of its whole subtree as one pass, which
53
+ * for a scattering of separate icons/text glyphs reads identically to a
54
+ * per-element shadow (each disjoint shape gets its own shadow copy in place)
55
+ * for a fraction of the styling. `SHADOW_FILTER` chains two such shadows (a
56
+ * tight dark one plus a softer wider one) for a bolder, more "burned-in"
57
+ * look than a single soft blur gives.
58
+ *
59
+ * Icon/text opacity is pushed close to full white (mostly /90–/95, key
60
+ * numbers and names at full white) and every element is sized well above
61
+ * what a literal safe-zone guide needs — both deliberate: this has to read
62
+ * clearly at the NORMAL (non-fullscreen) preview size, not just zoomed in or
63
+ * fullscreened, and a translucent, small-print guide reads as "nothing
64
+ * happened" against real footage rather than as platform UI.
65
+ */
66
+
67
+ import { useEffect, useRef, useState } from 'react'
68
+ import {
69
+ BatteryFull,
70
+ Bookmark,
71
+ Camera,
72
+ ChevronLeft,
73
+ Clapperboard,
74
+ Heart,
75
+ Home,
76
+ Inbox,
77
+ MessageCircle,
78
+ MoreHorizontal,
79
+ MoreVertical,
80
+ Music,
81
+ Play,
82
+ Plus,
83
+ Repeat2,
84
+ Search,
85
+ Send,
86
+ Share,
87
+ Share2,
88
+ Signal,
89
+ ThumbsDown,
90
+ ThumbsUp,
91
+ User,
92
+ Users,
93
+ Wifi,
94
+ type LucideIcon,
95
+ } from 'lucide-react'
96
+
97
+ /** Platforms with an implemented chrome. */
98
+ export type SocialPreviewPlatform = 'tiktok' | 'youtube' | 'instagram'
99
+
100
+ export interface SocialSafeZoneOverlayProps {
101
+ /**
102
+ * Which platform's chrome to draw. Any value that isn't a key of
103
+ * `PLATFORM_CHROME` below — including `undefined`, `null`, or an
104
+ * unrecognized string — renders nothing. Typed loosely (not just the
105
+ * `SocialPreviewPlatform` union) so an unknown value is a safe no-op
106
+ * rather than a type error at call sites that pass a dynamic value.
107
+ */
108
+ platform?: SocialPreviewPlatform | string | null
109
+ }
110
+
111
+ // Design canvas the chrome is authored against — see the file-level doc
112
+ // comment for why this is fixed rather than sourced from the project's
113
+ // actual render resolution.
114
+ const DESIGN_W = 1080
115
+ const DESIGN_H = 1920
116
+
117
+ /**
118
+ * Applied once, to the whole chrome subtree — see the "Legibility" doc above.
119
+ * Two layered shadows (CSS `filter` chains multiple `drop-shadow()`s): a
120
+ * tight, dark contact shadow that reads as a hard edge against bright
121
+ * footage, plus a softer, larger ambient shadow that keeps legibility over
122
+ * busier/noisier footage — closer to how real burned-in platform UI text
123
+ * is rendered than a single soft blur.
124
+ */
125
+ const SHADOW_FILTER = 'drop-shadow(0 1px 2px rgba(0,0,0,0.85)) drop-shadow(0 4px 10px rgba(0,0,0,0.6))'
126
+
127
+ /** One platform's chrome, as a data entry — adding another platform is
128
+ * adding a render function here, not restructuring this component. */
129
+ const PLATFORM_CHROME: Partial<Record<string, typeof TikTokChrome>> = {
130
+ tiktok: TikTokChrome,
131
+ youtube: YouTubeShortsChrome,
132
+ instagram: InstagramReelsChrome,
133
+ }
134
+
135
+ export default function SocialSafeZoneOverlay({ platform }: SocialSafeZoneOverlayProps) {
136
+ const wrapRef = useRef<HTMLDivElement>(null)
137
+ const [scale, setScale] = useState<number | null>(null)
138
+
139
+ // Re-measure whenever the preview box resizes — pane drags, fullscreen
140
+ // toggles, window resizes. See the file-level doc comment for why this is
141
+ // a contain-fit (`Math.min`) rather than CaptionPreview's plain width ratio.
142
+ useEffect(() => {
143
+ const el = wrapRef.current
144
+ if (!el) return
145
+ const obs = new ResizeObserver(([entry]) => {
146
+ const { width, height } = entry.contentRect
147
+ setScale(Math.min(width / DESIGN_W, height / DESIGN_H))
148
+ })
149
+ obs.observe(el)
150
+ return () => obs.disconnect()
151
+ // Keyed on `platform`, not `[]`: the observed `wrapRef` div only exists
152
+ // while a recognized platform is active (the `!renderChrome` early-return
153
+ // below unmounts it otherwise). Tying the observer's lifecycle to
154
+ // `platform` re-attaches it the instant the chrome mounts — without this,
155
+ // switching from "None" to a platform mounts the div but never re-runs
156
+ // this effect, so `scale` stays null and the chrome never paints until a
157
+ // full reload remounts the component with the platform already set.
158
+ }, [platform])
159
+
160
+ const renderChrome = platform ? PLATFORM_CHROME[platform] : undefined
161
+ if (!renderChrome) return null
162
+
163
+ return (
164
+ <div
165
+ ref={wrapRef}
166
+ data-testid="social-safe-zone-overlay"
167
+ className="absolute inset-0 pointer-events-none overflow-hidden"
168
+ // z 48. This component is mounted by PreviewPlayer's `PreviewSurface`
169
+ // (video path) or directly by `PreviewPlayer` (carousel path) as a
170
+ // SIBLING of the video/overlay/caption layers, inside the same
171
+ // `isolation: isolate` container — see the `socialPreview` prop doc and
172
+ // its render site in preview/PreviewPlayer.tsx. That placement is load-
173
+ // bearing: z-index only orders siblings within the SAME stacking
174
+ // context, so mounting this anywhere else (e.g. as a sibling of
175
+ // `PreviewPlayer` itself, outside its isolated container) makes this
176
+ // z-index compare against that whole container's z-index instead —
177
+ // which is `auto`, so a positive value here would paint above the
178
+ // ENTIRE player, play button included, no matter what it's set to.
179
+ //
180
+ // Within this container, 48 sits above the video (z 1), the
181
+ // play-toggle click layer (z 10), the base-video transform handles
182
+ // (z 11), overlay items (z ~12–20) and captions (z 45) — a viewing aid
183
+ // over the fully-composited frame has to sit above everything that
184
+ // composites into the picture, or it wouldn't show what actually gets
185
+ // covered. It sits below the paused play glyph (z 100), which is
186
+ // editor chrome, not content, and must stay legible on top of a
187
+ // chrome preview that is itself just a viewing aid.
188
+ style={{ zIndex: 48 }}
189
+ >
190
+ {scale !== null && (
191
+ <div
192
+ data-testid="social-safe-zone-canvas"
193
+ style={{
194
+ position: 'absolute',
195
+ top: '50%',
196
+ left: '50%',
197
+ width: DESIGN_W,
198
+ height: DESIGN_H,
199
+ transform: `translate(-50%, -50%) scale(${scale})`,
200
+ }}
201
+ >
202
+ {renderChrome()}
203
+ </div>
204
+ )}
205
+ </div>
206
+ )
207
+ }
208
+
209
+ // ── Shared pieces ────────────────────────────────────────────────────────
210
+
211
+ /** Phone status bar — identical across all three platforms. Sized and
212
+ * opacity'd to read as burned-in UI, not a faint guide — see the file-level
213
+ * "Legibility" doc comment. */
214
+ function StatusBar() {
215
+ return (
216
+ <div
217
+ className="absolute flex items-center justify-between text-white/95"
218
+ style={{ top: 44, left: 48, right: 48, fontSize: 42, fontWeight: 700 }}
219
+ >
220
+ <span>9:41</span>
221
+ <div className="flex items-center" style={{ gap: 20 }}>
222
+ <Signal size={38} strokeWidth={2.5} />
223
+ <Wifi size={38} strokeWidth={2.5} />
224
+ <BatteryFull size={42} strokeWidth={2.5} />
225
+ </div>
226
+ </div>
227
+ )
228
+ }
229
+
230
+ /** One engagement-rail entry — an icon stacked over its (generic) count. */
231
+ function RailAction({ icon: Icon, label }: { icon: LucideIcon; label: string }) {
232
+ return (
233
+ <div className="flex flex-col items-center" style={{ gap: 8 }}>
234
+ <Icon size={74} strokeWidth={2.25} className="text-white/95" />
235
+ <span className="text-white/95" style={{ fontSize: 32, fontWeight: 700 }}>{label}</span>
236
+ </div>
237
+ )
238
+ }
239
+
240
+ /** App bottom tab bar — evenly spaced icon-over-label tabs pinned to the very
241
+ * bottom of the frame, the way TikTok and YouTube Shorts both draw their app
242
+ * nav. It belongs in a "what covers my content" aid precisely because it
243
+ * occupies the bottom strip a caption placed too low would collide with. The
244
+ * middle "create" tab is a filled white chip with no label, matching how both
245
+ * apps draw their + button. */
246
+ function BottomNav({ tabs }: { tabs: { icon: LucideIcon; label?: string; create?: boolean }[] }) {
247
+ return (
248
+ <div className="absolute flex items-end justify-between" style={{ left: 44, right: 44, bottom: 28 }}>
249
+ {tabs.map((t, i) => (
250
+ <div key={i} className="flex flex-col items-center" style={{ gap: 7 }}>
251
+ {t.create ? (
252
+ <div className="flex items-center justify-center rounded-xl bg-white/95" style={{ width: 78, height: 54 }}>
253
+ <t.icon size={38} strokeWidth={3} className="text-black" />
254
+ </div>
255
+ ) : (
256
+ <t.icon size={46} strokeWidth={2.25} className="text-white/95" />
257
+ )}
258
+ {t.label && <span className="text-white/90" style={{ fontSize: 25, fontWeight: 600 }}>{t.label}</span>}
259
+ </div>
260
+ ))}
261
+ </div>
262
+ )
263
+ }
264
+
265
+ // ── TikTok chrome ────────────────────────────────────────────────────────
266
+
267
+ function TikTokChrome() {
268
+ return (
269
+ <div className="absolute inset-0" style={{ filter: SHADOW_FILTER }}>
270
+ <StatusBar />
271
+
272
+ {/* Nav row — Following / For You (active), search at the far right */}
273
+ <div
274
+ className="absolute flex items-center justify-center text-white/85"
275
+ style={{ top: 150, left: 48, right: 48, fontSize: 42, fontWeight: 700, gap: 64 }}
276
+ >
277
+ <span>Following</span>
278
+ <span className="relative text-white">
279
+ For You
280
+ <span
281
+ className="absolute bg-white"
282
+ style={{ left: 0, right: 0, bottom: -16, height: 5, borderRadius: 2 }}
283
+ />
284
+ </span>
285
+ </div>
286
+ <Search size={40} strokeWidth={2.5} className="absolute text-white/90" style={{ top: 138, right: 48 }} />
287
+
288
+ {/* Right rail — avatar (+ follow badge), engagement counts, sound disc */}
289
+ <div className="absolute flex flex-col items-center" style={{ right: 40, top: 800, gap: 52 }}>
290
+ <div className="relative">
291
+ <div
292
+ className="rounded-full border-2 border-white/90 bg-white/30 flex items-center justify-center"
293
+ style={{ width: 132, height: 132 }}
294
+ >
295
+ <User size={70} strokeWidth={2.25} className="text-white/95" />
296
+ </div>
297
+ <div
298
+ className="absolute rounded-full bg-rose-500 flex items-center justify-center"
299
+ style={{ width: 44, height: 44, left: '50%', bottom: -16, transform: 'translateX(-50%)' }}
300
+ >
301
+ <Plus size={26} strokeWidth={3} className="text-white" />
302
+ </div>
303
+ </div>
304
+ <RailAction icon={Heart} label="2.8M" />
305
+ <RailAction icon={MessageCircle} label="2.8M" />
306
+ <RailAction icon={Bookmark} label="2.8M" />
307
+ <RailAction icon={Share} label="2.8M" />
308
+ <div
309
+ className="rounded-full border border-white/85 bg-white/25 flex items-center justify-center animate-spin"
310
+ style={{ width: 100, height: 100, animationDuration: '3s' }}
311
+ >
312
+ <Music size={46} strokeWidth={2.25} className="text-white/95" />
313
+ </div>
314
+ </div>
315
+
316
+ {/* Bottom-left — @handle, caption, music-note credit */}
317
+ <div className="absolute text-white/95" style={{ left: 48, right: 300, bottom: 170 }}>
318
+ <div style={{ fontSize: 50, fontWeight: 700 }}>@Your name</div>
319
+ <div className="text-white/90" style={{ fontSize: 38, marginTop: 20 }}>
320
+ Here are some descriptions about videos
321
+ </div>
322
+ <div className="flex items-center text-white/85" style={{ fontSize: 34, marginTop: 16, gap: 14 }}>
323
+ <Music size={30} strokeWidth={2.25} />
324
+ <span>Music name</span>
325
+ </div>
326
+ </div>
327
+
328
+ {/* Bottom app nav — Home / Friends / create / Inbox / Profile */}
329
+ <BottomNav
330
+ tabs={[
331
+ { icon: Home, label: 'Home' },
332
+ { icon: Users, label: 'Friends' },
333
+ { icon: Plus, create: true },
334
+ { icon: Inbox, label: 'Inbox' },
335
+ { icon: User, label: 'Profile' },
336
+ ]}
337
+ />
338
+ </div>
339
+ )
340
+ }
341
+
342
+ // ── YouTube Shorts chrome ────────────────────────────────────────────────
343
+
344
+ function YouTubeShortsChrome() {
345
+ return (
346
+ <div className="absolute inset-0" style={{ filter: SHADOW_FILTER }}>
347
+ <StatusBar />
348
+
349
+ {/* Top-left — back */}
350
+ <ChevronLeft size={48} strokeWidth={2.5} className="absolute text-white/90" style={{ top: 132, left: 40 }} />
351
+
352
+ {/* Top-right — search, more (kebab) */}
353
+ <div className="absolute flex items-center text-white/90" style={{ top: 138, right: 48, gap: 36 }}>
354
+ <Search size={40} strokeWidth={2.5} />
355
+ <MoreVertical size={40} strokeWidth={2.5} />
356
+ </div>
357
+
358
+ {/* Right rail — thumbs up/down, comments, share, remix, sound thumbnail */}
359
+ <div className="absolute flex flex-col items-center" style={{ right: 40, top: 760, gap: 52 }}>
360
+ <RailAction icon={ThumbsUp} label="2.8M" />
361
+ <ThumbsDown size={74} strokeWidth={2.25} className="text-white/95" />
362
+ <RailAction icon={MessageCircle} label="2.8M" />
363
+ <Share2 size={74} strokeWidth={2.25} className="text-white/95" />
364
+ <Repeat2 size={74} strokeWidth={2.25} className="text-white/95" />
365
+ <div
366
+ className="rounded-lg border border-white/85 bg-white/25 flex items-center justify-center"
367
+ style={{ width: 90, height: 90 }}
368
+ >
369
+ <Music size={42} strokeWidth={2.25} className="text-white/95" />
370
+ </div>
371
+ </div>
372
+
373
+ {/* Bottom-left — channel avatar, @channel, Subscribe pill, title line */}
374
+ <div className="absolute text-white/95" style={{ left: 48, right: 300, bottom: 170 }}>
375
+ <div className="flex items-center" style={{ gap: 22 }}>
376
+ <div
377
+ className="rounded-full border-2 border-white/90 bg-white/30 flex items-center justify-center shrink-0"
378
+ style={{ width: 96, height: 96 }}
379
+ >
380
+ <User size={54} strokeWidth={2.25} className="text-white/95" />
381
+ </div>
382
+ <span style={{ fontSize: 46, fontWeight: 700 }}>@channel</span>
383
+ <span
384
+ className="rounded-full bg-white text-black"
385
+ style={{ fontSize: 34, fontWeight: 700, padding: '10px 30px' }}
386
+ >
387
+ Subscribe
388
+ </span>
389
+ </div>
390
+ <div className="text-white/90" style={{ fontSize: 38, marginTop: 22 }}>
391
+ Here are some descriptions about videos
392
+ </div>
393
+ </div>
394
+
395
+ {/* Bottom app nav — Home / Shorts / create / Subscriptions / You */}
396
+ <BottomNav
397
+ tabs={[
398
+ { icon: Home, label: 'Home' },
399
+ { icon: Play, label: 'Shorts' },
400
+ { icon: Plus, create: true },
401
+ { icon: Clapperboard, label: 'Subscriptions' },
402
+ { icon: User, label: 'You' },
403
+ ]}
404
+ />
405
+ </div>
406
+ )
407
+ }
408
+
409
+ // ── Instagram Reels chrome ───────────────────────────────────────────────
410
+
411
+ function InstagramReelsChrome() {
412
+ return (
413
+ <div className="absolute inset-0" style={{ filter: SHADOW_FILTER }}>
414
+ <StatusBar />
415
+
416
+ {/* Top row — Reels title (left), camera (right) */}
417
+ <div
418
+ className="absolute flex items-center justify-between text-white/95"
419
+ style={{ top: 136, left: 48, right: 48, fontSize: 48, fontWeight: 700 }}
420
+ >
421
+ <span>Reels</span>
422
+ <Camera size={42} strokeWidth={2.5} className="text-white/90" />
423
+ </div>
424
+
425
+ {/* Right rail — likes, comments, reshare, send, more, audio thumbnail */}
426
+ <div className="absolute flex flex-col items-center" style={{ right: 40, top: 800, gap: 52 }}>
427
+ <RailAction icon={Heart} label="2.8M" />
428
+ <RailAction icon={MessageCircle} label="2.8M" />
429
+ <RailAction icon={Repeat2} label="2.8M" />
430
+ <RailAction icon={Send} label="2.8M" />
431
+ <MoreHorizontal size={74} strokeWidth={2.25} className="text-white/95" />
432
+ <div
433
+ className="rounded-lg border border-white/85 bg-white/25 flex items-center justify-center"
434
+ style={{ width: 90, height: 90 }}
435
+ >
436
+ <Music size={42} strokeWidth={2.25} className="text-white/95" />
437
+ </div>
438
+ </div>
439
+
440
+ {/* Bottom-left — avatar, username, Follow pill, caption, audio credit */}
441
+ <div className="absolute text-white/95" style={{ left: 48, right: 300, bottom: 170 }}>
442
+ <div className="flex items-center" style={{ gap: 22 }}>
443
+ <div
444
+ className="rounded-full border-2 border-white/90 bg-white/30 flex items-center justify-center shrink-0"
445
+ style={{ width: 90, height: 90 }}
446
+ >
447
+ <User size={50} strokeWidth={2.25} className="text-white/95" />
448
+ </div>
449
+ <span style={{ fontSize: 46, fontWeight: 700 }}>Your name</span>
450
+ <span
451
+ className="rounded-full border border-white/85 text-white"
452
+ style={{ fontSize: 32, fontWeight: 700, padding: '8px 26px' }}
453
+ >
454
+ Follow
455
+ </span>
456
+ </div>
457
+ <div className="text-white/90" style={{ fontSize: 38, marginTop: 22 }}>
458
+ Here are some descriptions about videos
459
+ </div>
460
+ <div className="flex items-center text-white/85" style={{ fontSize: 34, marginTop: 16, gap: 14 }}>
461
+ <Music size={30} strokeWidth={2.25} />
462
+ <span>Music name</span>
463
+ </div>
464
+ </div>
465
+
466
+ {/* Bottom — add-comment bar + save */}
467
+ <div className="absolute flex items-center" style={{ left: 44, right: 44, bottom: 34, gap: 22 }}>
468
+ <div
469
+ className="flex-1 flex items-center rounded-full border border-white/40 bg-white/10 text-white/80"
470
+ style={{ height: 84, paddingLeft: 40, fontSize: 34 }}
471
+ >
472
+ Add comment...
473
+ </div>
474
+ <Bookmark size={60} strokeWidth={2.25} className="text-white/95" />
475
+ </div>
476
+ </div>
477
+ )
478
+ }
@@ -0,0 +1,97 @@
1
+ // CaptionPreview.fonts.test.tsx
2
+ //
3
+ // `captions.googleFonts` is a render-time hint that render.js hands to
4
+ // bundleComponent, which injects the Google Fonts stylesheet into the Puppeteer
5
+ // page. Now that `captions.fontFamily` is a real prop every caption template
6
+ // honours, the editor has to fetch that same stylesheet or the preview paints a
7
+ // fallback face while the export paints the real one. These tests pin the
8
+ // injection.
9
+ //
10
+ // NOTE ON THE MODULE-LEVEL CACHE: ensureGoogleFontsLoaded (lib/google-fonts.ts)
11
+ // keeps a module-level Set of already-injected URLs, so a second positive case
12
+ // reusing the SAME family in this file would inject nothing and assert on the
13
+ // first case's <link>. If you add one, give it a distinct family rather than
14
+ // resetting modules — the cache is the behaviour under test in production, and
15
+ // distinct families keep it intact.
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
17
+ import { render, waitFor } from '@testing-library/react'
18
+ import CaptionPreview from '../CaptionPreview'
19
+ import type { Captions } from '../../../schema'
20
+ import type { OverlayFactory } from '../../../types'
21
+
22
+ /** Every Google Fonts stylesheet currently in the document. */
23
+ function fontLinks(): HTMLLinkElement[] {
24
+ return Array.from(
25
+ document.head.querySelectorAll<HTMLLinkElement>('link[rel="stylesheet"]'),
26
+ ).filter((l) => l.href.includes('fonts.googleapis.com'))
27
+ }
28
+
29
+ beforeEach(() => {
30
+ // jsdom has no ResizeObserver. A no-op stub is enough here: `scale` stays
31
+ // null, so no template content is ever painted — which is the point. The
32
+ // font effect runs unconditionally in the component body, independent of
33
+ // template compilation, so this suite needs none of the jsdom layout stubs
34
+ // captionPositioning.test.tsx sets up (Range.getBoundingClientRect et al).
35
+ ;(globalThis as unknown as { ResizeObserver: unknown }).ResizeObserver = class {
36
+ observe() {}
37
+ unobserve() {}
38
+ disconnect() {}
39
+ }
40
+ })
41
+
42
+ afterEach(() => {
43
+ vi.restoreAllMocks()
44
+ })
45
+
46
+ const compileOverlay = vi.fn(
47
+ async (): Promise<OverlayFactory> => () => null,
48
+ )
49
+
50
+ describe('CaptionPreview — Google Fonts injection', () => {
51
+ it('injects the caption track\'s googleFonts stylesheet into document.head', async () => {
52
+ const track: Captions = {
53
+ style: 'clean',
54
+ googleFonts: ['Baloo+2:wght@700'],
55
+ segments: [{ id: 'cap-0', text: 'hello', start: 0, end: 2 }],
56
+ }
57
+
58
+ render(
59
+ <CaptionPreview
60
+ track={track}
61
+ currentTime={1}
62
+ fps={30}
63
+ compileOverlay={compileOverlay}
64
+ resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
65
+ />,
66
+ )
67
+
68
+ await waitFor(() => {
69
+ expect(fontLinks().some((l) => l.href.includes('family=Baloo+2:wght@700'))).toBe(true)
70
+ })
71
+ })
72
+
73
+ it('adds no stylesheet for a track with no googleFonts', async () => {
74
+ // The <link> from the case above is still in document.head (jsdom's
75
+ // document is per-file and RTL's cleanup only removes its own container),
76
+ // so assert on the delta rather than on an empty head.
77
+ const before = fontLinks().map((l) => l.href)
78
+
79
+ const track: Captions = {
80
+ style: 'clean',
81
+ segments: [{ id: 'cap-0', text: 'hello', start: 0, end: 2 }],
82
+ }
83
+
84
+ render(
85
+ <CaptionPreview
86
+ track={track}
87
+ currentTime={1}
88
+ fps={30}
89
+ compileOverlay={compileOverlay}
90
+ resolveCaptionTemplate={(style) => `/tpl/${style}.jsx`}
91
+ />,
92
+ )
93
+
94
+ await waitFor(() => expect(compileOverlay).toHaveBeenCalled())
95
+ expect(fontLinks().map((l) => l.href)).toEqual(before)
96
+ })
97
+ })