@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.
- package/package.json +3 -1
- package/src/ControlsInfoModal.tsx +251 -61
- package/src/__tests__/ControlsInfoModal.test.tsx +43 -0
- package/src/__tests__/adapter.test.ts +59 -1
- package/src/__tests__/schema-assignability.test.ts +93 -0
- package/src/__tests__/schema.test.ts +15 -0
- package/src/__tests__/video-adapter-contract.test.ts +128 -5
- package/src/carousel/AddElementMenu.tsx +10 -4
- package/src/carousel/CarouselEditor.tsx +46 -8
- package/src/carousel/CarouselRenderModal.tsx +15 -10
- package/src/carousel/OverlayPicker.tsx +10 -3
- package/src/carousel/SlidePropertyPanel.tsx +43 -21
- package/src/components/FilmstripScrubber.tsx +277 -0
- package/src/components/__tests__/FilmstripScrubber.test.tsx +216 -0
- package/src/engine/__tests__/audio-clock.test.ts +655 -0
- package/src/engine/__tests__/audio-worklet-source.test.ts +316 -0
- package/src/engine/__tests__/batch-planner.test.ts +298 -0
- package/src/engine/__tests__/decode-worker-source.test.ts +249 -0
- package/src/engine/__tests__/demux-ranged.test.ts +495 -0
- package/src/engine/__tests__/demux-truncated.test.ts +136 -0
- package/src/engine/__tests__/demux.test.ts +305 -0
- package/src/engine/__tests__/eligibility.test.ts +146 -0
- package/src/engine/__tests__/engine-recovery.test.ts +263 -0
- package/src/engine/__tests__/engine.test.ts +326 -0
- package/src/engine/__tests__/frame-server-ranged.test.ts +261 -0
- package/src/engine/__tests__/frame-server.test.ts +326 -0
- package/src/engine/__tests__/media-loader-ranged.test.ts +289 -0
- package/src/engine/__tests__/media-loader.test.ts +100 -0
- package/src/engine/__tests__/scheduler-crop.test.ts +353 -0
- package/src/engine/__tests__/scheduler.test.ts +1310 -0
- package/src/engine/__tests__/scrub-resolve.test.ts +96 -0
- package/src/engine/__tests__/scrub-source.test.ts +195 -0
- package/src/engine/__tests__/source-host.test.ts +455 -0
- package/src/engine/__tests__/time-stretch.test.ts +182 -0
- package/src/engine/audio-clock.ts +1179 -0
- package/src/engine/audio-worklet-source.ts +160 -0
- package/src/engine/batch-planner.ts +308 -0
- package/src/engine/debug-hud.tsx +54 -0
- package/src/engine/decode-worker-source.ts +142 -0
- package/src/engine/demux.ts +900 -0
- package/src/engine/eligibility.ts +140 -0
- package/src/engine/frame-server.ts +644 -0
- package/src/engine/index.ts +950 -0
- package/src/engine/media-loader.ts +380 -0
- package/src/engine/mp4box.d.ts +214 -0
- package/src/engine/scheduler.ts +1324 -0
- package/src/engine/scrub-resolve.ts +66 -0
- package/src/engine/scrub-source.ts +496 -0
- package/src/engine/time-stretch.ts +224 -0
- package/src/index.ts +83 -1
- package/src/preview/OverlayPreview.tsx +2 -24
- package/src/schema.ts +146 -3
- package/src/state/__tests__/use-project-sync.test.tsx +56 -0
- package/src/state/use-project-sync.ts +17 -0
- package/src/test-setup.ts +32 -0
- package/src/text/FontPicker.tsx +85 -51
- package/src/text/TextFormattingToolbar.tsx +6 -1
- package/src/text/__tests__/FontPicker.options.test.ts +43 -0
- package/src/text/__tests__/TextFormattingToolbar.test.tsx +4 -4
- package/src/theme.ts +108 -3
- package/src/types.ts +601 -22
- package/src/ui/Loader.tsx +64 -0
- package/src/ui/NumberField.tsx +254 -0
- package/src/ui/Slider.tsx +128 -0
- package/src/ui/Tooltip.tsx +118 -0
- package/src/ui/__tests__/NumberField.test.tsx +298 -0
- package/src/ui/__tests__/Slider.test.tsx +150 -0
- package/src/ui/__tests__/Tooltip.test.tsx +105 -0
- package/src/ui/__tests__/usePersistentState.test.tsx +112 -0
- package/src/ui/badge.tsx +12 -4
- package/src/ui/index.ts +6 -0
- package/src/ui/input.tsx +1 -1
- package/src/ui/select.tsx +1 -1
- package/src/ui/switch.tsx +12 -2
- package/src/ui/textarea.tsx +1 -1
- package/src/ui/usePersistentState.ts +63 -0
- package/src/video/AudioPolishModal.tsx +983 -0
- package/src/video/CaptionListPanel.test.tsx +944 -0
- package/src/video/CaptionListPanel.tsx +1106 -0
- package/src/video/CaptionRegenModal.tsx +37 -9
- package/src/video/CaptionSpecimen.tsx +160 -0
- package/src/video/CaptionStyleGallery.tsx +466 -0
- package/src/video/CommandPalette.tsx +165 -0
- package/src/video/ImageToneMenu.tsx +137 -0
- package/src/video/OverlayInspector.tsx +977 -0
- package/src/video/RenderModal.tsx +1035 -62
- package/src/video/VersionCompare.tsx +258 -0
- package/src/video/VersionPanel.tsx +117 -42
- package/src/video/VideoEditor.tsx +2229 -243
- package/src/video/__tests__/AudioPolishModal.test.tsx +825 -0
- package/src/video/__tests__/CaptionListPanel.font.test.tsx +397 -0
- package/src/video/__tests__/CaptionListPanel.generate.test.tsx +153 -0
- package/src/video/__tests__/CaptionRegenModal.test.tsx +29 -0
- package/src/video/__tests__/CaptionSpecimen.test.tsx +213 -0
- package/src/video/__tests__/CaptionStyleGallery.test.tsx +362 -0
- package/src/video/__tests__/CommandPalette.test.tsx +119 -0
- package/src/video/__tests__/OverlayInspector.test.tsx +1367 -0
- package/src/video/__tests__/RenderModal.exportControls.test.tsx +319 -0
- package/src/video/__tests__/RenderModal.options.test.tsx +562 -0
- package/src/video/__tests__/RenderModal.progress.test.ts +65 -0
- package/src/video/__tests__/VersionCompare.test.tsx +182 -0
- package/src/video/__tests__/VersionPanel.test.tsx +279 -0
- package/src/video/__tests__/VideoEditor.audioPolish.test.tsx +241 -0
- package/src/video/__tests__/VideoEditor.captionDelete.test.tsx +147 -0
- package/src/video/__tests__/VideoEditor.captionGesture.test.tsx +170 -0
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +134 -0
- package/src/video/__tests__/VideoEditor.clipKeyframes.test.tsx +59 -0
- package/src/video/__tests__/VideoEditor.context.test.tsx +44 -0
- package/src/video/__tests__/VideoEditor.editFocus.test.tsx +55 -0
- package/src/video/__tests__/VideoEditor.keymap.test.tsx +725 -0
- package/src/video/__tests__/VideoEditor.layout.test.tsx +338 -0
- package/src/video/__tests__/VideoEditor.propertiesPanel.test.tsx +765 -0
- package/src/video/__tests__/VideoEditor.rippleDeleteCaptions.test.tsx +159 -0
- package/src/video/__tests__/VideoEditor.sourcePreview.test.tsx +122 -0
- package/src/video/__tests__/VideoEditor.test.tsx +403 -50
- package/src/video/__tests__/audioMagnet.test.ts +158 -0
- package/src/video/__tests__/audioPolish.test.ts +1202 -0
- package/src/video/__tests__/captionActiveWord.test.ts +107 -0
- package/src/video/__tests__/captionLanes.test.ts +262 -0
- package/src/video/__tests__/captionPositioning.test.tsx +8 -3
- package/src/video/__tests__/captionWordFloor.test.ts +228 -0
- package/src/video/__tests__/clipboard-ops.test.ts +430 -0
- package/src/video/__tests__/cuts.insert.test.ts +244 -0
- package/src/video/__tests__/cuts.test.ts +1213 -34
- package/src/video/__tests__/export-limits.test.ts +195 -0
- package/src/video/__tests__/hover-scrub.test.ts +163 -0
- package/src/video/__tests__/keyframeOps.canKeyframeProp.test.ts +61 -0
- package/src/video/__tests__/keyframeOps.test.ts +643 -0
- package/src/video/__tests__/keymap.test.tsx +171 -0
- package/src/video/__tests__/render-progress.test.tsx +20 -4
- package/src/video/__tests__/shuttle.test.ts +210 -0
- package/src/video/__tests__/source-preview.test.ts +60 -0
- package/src/video/__tests__/timecode.test.ts +77 -0
- package/src/video/__tests__/use-report-context.test.tsx +101 -0
- package/src/video/audioMagnet.ts +72 -0
- package/src/video/audioPolish.ts +774 -0
- package/src/video/captionActiveWord.ts +74 -0
- package/src/video/captionLanes.ts +202 -0
- package/src/video/captionRepair.ts +9 -5
- package/src/video/captionStyleDefaults.ts +100 -0
- package/src/video/captionWordFloor.ts +83 -0
- package/src/video/clipboard-ops.ts +377 -0
- package/src/video/cuts.ts +713 -37
- package/src/video/export-limits.ts +102 -0
- package/src/video/hover-scrub.ts +102 -0
- package/src/video/imageTone.ts +58 -0
- package/src/video/imageToneExamples.ts +10 -0
- package/src/video/keyframeOps.ts +384 -0
- package/src/video/keymap.ts +146 -0
- package/src/video/panels/ClipPropertiesPanel.tsx +706 -0
- package/src/video/panels/LeftPanelTabs.tsx +187 -0
- package/src/video/panels/OverlayContentPanel.tsx +351 -0
- package/src/video/panels/TabNav.tsx +60 -0
- package/src/video/panels/__tests__/ClipPropertiesPanel.test.tsx +645 -0
- package/src/video/panels/__tests__/LeftPanelTabs.test.tsx +189 -0
- package/src/video/panels/__tests__/OverlayContentPanel.test.tsx +222 -0
- package/src/video/panels/__tests__/TabNav.test.tsx +71 -0
- package/src/video/preview/CaptionPreview.tsx +57 -69
- package/src/video/preview/EngineSurface.tsx +103 -0
- package/src/video/preview/OverlayItemsLayer.tsx +315 -103
- package/src/video/preview/PreviewPlayer.tsx +337 -56
- package/src/video/preview/SocialPreviewMenu.tsx +214 -0
- package/src/video/preview/SocialSafeZoneOverlay.tsx +478 -0
- package/src/video/preview/__tests__/CaptionPreview.fonts.test.tsx +97 -0
- package/src/video/preview/__tests__/EngineSurface.test.tsx +114 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.edit.test.tsx +4 -2
- package/src/video/preview/__tests__/OverlayItemsLayer.keyframes.test.tsx +363 -0
- package/src/video/preview/__tests__/OverlayItemsLayer.selection.test.tsx +329 -0
- package/src/video/preview/__tests__/PreviewPlayer.engine.test.tsx +139 -0
- package/src/video/preview/__tests__/SocialPreviewMenu.test.tsx +121 -0
- package/src/video/preview/__tests__/SocialSafeZoneOverlay.test.tsx +165 -0
- package/src/video/preview/__tests__/captionDragState.test.ts +120 -1
- package/src/video/preview/__tests__/latencyCompensation.test.tsx +451 -0
- package/src/video/preview/__tests__/proxySupport.test.ts +75 -0
- package/src/video/preview/__tests__/transformStyle.test.ts +29 -1
- package/src/video/preview/__tests__/useDragOverlay.perAxis.test.ts +197 -0
- package/src/video/preview/__tests__/useEnginePlayback.test.tsx +530 -0
- package/src/video/preview/__tests__/useVideoPlayback.corpus.test.ts +313 -0
- package/src/video/preview/__tests__/useVideoPlayback.test.ts +38 -5
- package/src/video/preview/__tests__/useVideoPlayback.trackAudio.test.ts +278 -0
- package/src/video/preview/audio-context.ts +111 -0
- package/src/video/preview/captionDragState.ts +91 -1
- package/src/video/preview/proxySupport.ts +86 -0
- package/src/video/preview/transformStyle.ts +22 -12
- package/src/video/preview/useDragOverlay.ts +92 -18
- package/src/video/preview/useEnginePlayback.ts +625 -0
- package/src/video/preview/useVideoPlayback.ts +211 -167
- package/src/video/sdrCurves.ts +56 -0
- package/src/video/shuttle.ts +159 -0
- package/src/video/source-preview.ts +66 -0
- package/src/video/timecode.ts +59 -0
- package/src/video/timeline/EditableSegment.tsx +1 -1
- package/src/video/timeline/Scrubber.tsx +46 -174
- package/src/video/timeline/SpeedControl.tsx +95 -0
- package/src/video/timeline/Timeline.tsx +1061 -328
- package/src/video/timeline/TimelineContext.ts +17 -10
- package/src/video/timeline/TrackGutter.tsx +560 -0
- package/src/video/timeline/TrackSettingsPopover.tsx +228 -0
- package/src/video/timeline/VolumeControl.tsx +113 -0
- package/src/video/timeline/__tests__/Timeline.backgroundClick.test.tsx +110 -0
- package/src/video/timeline/__tests__/Timeline.crossfade.test.tsx +104 -0
- package/src/video/timeline/__tests__/Timeline.fadeCurveMenu.test.tsx +174 -0
- package/src/video/timeline/__tests__/Timeline.keyframeDelete.test.tsx +273 -0
- package/src/video/timeline/__tests__/Timeline.keyframeFollow.test.tsx +215 -0
- package/src/video/timeline/__tests__/Timeline.keyframeMenu.test.tsx +253 -0
- package/src/video/timeline/__tests__/Timeline.keymap.test.tsx +372 -0
- package/src/video/timeline/__tests__/Timeline.subcutRegen.test.tsx +351 -0
- package/src/video/timeline/__tests__/TrackGutter.test.tsx +372 -0
- package/src/video/timeline/__tests__/_canvasSelect.test.tsx +273 -0
- package/src/video/timeline/__tests__/_canvasSelect.ts +414 -0
- package/src/video/timeline/__tests__/dragdrop-math.test.ts +135 -0
- package/src/video/timeline/__tests__/effectiveItemAudio.test.ts +50 -0
- package/src/video/timeline/__tests__/enabledTrackItems.test.ts +164 -0
- package/src/video/timeline/__tests__/moveItemAcrossTracks.test.ts +363 -0
- package/src/video/timeline/__tests__/multiSelectOps.test.ts +447 -0
- package/src/video/timeline/__tests__/placement.test.ts +278 -0
- package/src/video/timeline/__tests__/resizeWindowedItem.test.ts +140 -0
- package/src/video/timeline/__tests__/timeline-model.test.ts +576 -0
- package/src/video/timeline/__tests__/visualItemLabel.test.ts +69 -0
- package/src/video/timeline/canvas/TimelineCanvas.tsx +1447 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.drop.test.tsx +439 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +346 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.panefill.test.tsx +122 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pendingDrops.test.tsx +316 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.pointer.test.tsx +606 -0
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.test.tsx +407 -0
- package/src/video/timeline/canvas/__tests__/clip-bands.test.ts +77 -0
- package/src/video/timeline/canvas/__tests__/draw.test.ts +2198 -0
- package/src/video/timeline/canvas/__tests__/fade-curve.test.ts +187 -0
- package/src/video/timeline/canvas/__tests__/filmstrips.test.ts +561 -0
- package/src/video/timeline/canvas/__tests__/hit-test.test.ts +818 -0
- package/src/video/timeline/canvas/__tests__/pending-drop.test.ts +210 -0
- package/src/video/timeline/canvas/__tests__/pointer-machine.test.ts +3358 -0
- package/src/video/timeline/canvas/__tests__/snap.test.ts +257 -0
- package/src/video/timeline/canvas/__tests__/viewport.test.ts +399 -0
- package/src/video/timeline/canvas/__tests__/waveforms.test.ts +946 -0
- package/src/video/timeline/canvas/clip-bands.ts +56 -0
- package/src/video/timeline/canvas/draw.ts +2187 -0
- package/src/video/timeline/canvas/fade-curve.ts +111 -0
- package/src/video/timeline/canvas/filmstrips.ts +418 -0
- package/src/video/timeline/canvas/hit-test.ts +501 -0
- package/src/video/timeline/canvas/keyframe-strip.ts +73 -0
- package/src/video/timeline/canvas/pointer-machine.ts +1828 -0
- package/src/video/timeline/canvas/snap.ts +232 -0
- package/src/video/timeline/canvas/viewport.ts +457 -0
- package/src/video/timeline/canvas/waveforms.ts +664 -0
- package/src/video/timeline/makeCaptionEdit.ts +5 -1
- package/src/video/timeline/multiSelectOps.ts +214 -55
- package/src/video/timeline/placement.ts +282 -0
- package/src/video/timeline/timeline-model.ts +919 -0
- package/src/video/timeline/useItemDragDrop.ts +151 -178
- package/src/video/timeline/useTimelineZoom.ts +25 -60
- package/src/video/timeline/utils.ts +0 -12
- package/src/video/use-report-context.ts +75 -0
- package/src/video/preview/OverlayPropsModal.tsx +0 -292
- package/src/video/preview/__tests__/OverlayPropsModal.test.tsx +0 -32
- package/src/video/timeline/AudioTrackRow.tsx +0 -404
- package/src/video/timeline/AudioWaveformLayer.tsx +0 -117
- package/src/video/timeline/CaptionTrackRow.tsx +0 -235
- package/src/video/timeline/PlayheadLine.tsx +0 -18
- package/src/video/timeline/TranscriptModal.tsx +0 -70
- package/src/video/timeline/TranscriptPanel.tsx +0 -273
- package/src/video/timeline/VisualTrackRow.tsx +0 -300
- package/src/video/timeline/__tests__/CaptionTrackRow.test.tsx +0 -241
- package/src/video/timeline/__tests__/PlayheadLine.test.tsx +0 -60
- package/src/video/timeline/__tests__/TranscriptModal.test.tsx +0 -41
- package/src/video/timeline/__tests__/TranscriptPanel.test.tsx +0 -184
- 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
|
+
})
|