@bycrux/editor 1.2.0 → 1.2.2
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 +2 -2
- package/src/engine/__tests__/scheduler.test.ts +35 -0
- package/src/engine/scheduler.ts +10 -2
- package/src/index.ts +33 -0
- package/src/lib/__tests__/font-faces.test.ts +244 -0
- package/src/lib/__tests__/font-loader-parity.test.tsx +236 -0
- package/src/lib/__tests__/google-fonts.test.ts +319 -2
- package/src/lib/font-families.ts +286 -0
- package/src/lib/google-fonts.ts +132 -10
- package/src/text/FontPicker.tsx +82 -11
- package/src/text/__tests__/FontPicker.baseUrl.test.tsx +112 -0
- package/src/types.ts +68 -0
- package/src/video/CaptionRegenModal.tsx +34 -8
- package/src/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.tsx +32 -4
- package/src/video/__tests__/CaptionRegenModal.test.tsx +161 -2
- package/src/video/__tests__/VideoEditor.captionSeam.test.tsx +102 -0
- package/src/video/__tests__/captionProfileDefaults.test.ts +121 -0
- package/src/video/captionProfileDefaults.ts +86 -0
- package/src/video/panels/LeftPanelTabs.tsx +20 -2
- package/src/video/preview/PreviewPlayer.tsx +52 -2
- package/src/video/preview/__tests__/useVideoPlayback.muted.test.ts +239 -0
- package/src/video/preview/useEnginePlayback.ts +58 -8
- package/src/video/preview/useVideoPlayback.ts +48 -16
- package/src/video/timeline/canvas/TimelineCanvas.tsx +10 -8
- package/src/video/timeline/canvas/__tests__/TimelineCanvas.edgeScroll.test.tsx +38 -7
- package/src/video/timeline/canvas/draw.ts +78 -1
- package/src/video/timeline/timeline-model.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bycrux/editor",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"lint": "eslint src"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@bycrux/timeline-core": "^0.2.
|
|
20
|
+
"@bycrux/timeline-core": "^0.2.2",
|
|
21
21
|
"class-variance-authority": "^0.7",
|
|
22
22
|
"clsx": "^2",
|
|
23
23
|
"lucide-react": "^0.400",
|
|
@@ -514,6 +514,41 @@ describe('transportEndFor', () => {
|
|
|
514
514
|
})
|
|
515
515
|
expect(transportEndFor(p)).toBe(6)
|
|
516
516
|
})
|
|
517
|
+
|
|
518
|
+
it('falls back to the audio end when NOTHING visual sets a ceiling', () => {
|
|
519
|
+
// Audio-only timeline: the shape an animations-workflow project has while
|
|
520
|
+
// the music is wired but no overlay exists yet. The ceiling was 0, so the
|
|
521
|
+
// transport started and stopped on the same tick and play did nothing at
|
|
522
|
+
// all — with no feedback saying why.
|
|
523
|
+
const p = project([], [], {
|
|
524
|
+
audio: { tracks: [{ id: 'm', src: '/m.wav', start: 0, end: 15 }] },
|
|
525
|
+
})
|
|
526
|
+
expect(transportEndFor(p)).toBe(15)
|
|
527
|
+
})
|
|
528
|
+
|
|
529
|
+
it('resolves an audio-only track carrying no explicit end to its natural length', () => {
|
|
530
|
+
const p = project([], [], {
|
|
531
|
+
// `AudioTrack` types `start`/`end` as required, but project.json files on
|
|
532
|
+
// disk routinely omit them — `resolveAudioWindow` exists precisely to cope
|
|
533
|
+
// at runtime. That gap between the type and the real shape is what this
|
|
534
|
+
// test pins, so the under-specified track is the point, not a shortcut.
|
|
535
|
+
// @ts-expect-error — deliberately the under-specified on-disk shape
|
|
536
|
+
audio: { tracks: [{ id: 'm', src: '/m.wav', sourceDuration: 15 }] },
|
|
537
|
+
})
|
|
538
|
+
expect(transportEndFor(p)).toBe(15)
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it('skips muted tracks in the audio-only fallback', () => {
|
|
542
|
+
const p = project([], [], {
|
|
543
|
+
audio: {
|
|
544
|
+
tracks: [
|
|
545
|
+
{ id: 'a', src: '/a.wav', start: 0, end: 30, muted: true },
|
|
546
|
+
{ id: 'b', src: '/b.wav', start: 0, end: 15 },
|
|
547
|
+
],
|
|
548
|
+
},
|
|
549
|
+
})
|
|
550
|
+
expect(transportEndFor(p)).toBe(15)
|
|
551
|
+
})
|
|
517
552
|
})
|
|
518
553
|
|
|
519
554
|
describe('engineSrcFor — proxy-only playback is structural', () => {
|
package/src/engine/scheduler.ts
CHANGED
|
@@ -83,7 +83,7 @@ import type { ItemCrossfade, Scene, SourceWindow } from '@bycrux/timeline-core'
|
|
|
83
83
|
import type { EditorProject as Project, VisualItem, VisualTrack } from '../schema'
|
|
84
84
|
import type { ClipTimebase, MasterClock } from './audio-clock'
|
|
85
85
|
import type { FrameServer } from './frame-server'
|
|
86
|
-
import { effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
|
|
86
|
+
import { audioEnd, effectiveItemAudio, enabledTrackItems, enabledTracks, withEnabledItemTracks } from '../video/timeline/timeline-model'
|
|
87
87
|
|
|
88
88
|
// ── Tuning constants ────────────────────────────────────────────────────────
|
|
89
89
|
|
|
@@ -548,7 +548,15 @@ export function transportEndFor(project: Project): number {
|
|
|
548
548
|
(m: number, s) => Math.max(m, s.end ?? 0),
|
|
549
549
|
0,
|
|
550
550
|
)
|
|
551
|
-
|
|
551
|
+
const ceiling = Math.max(visualEnd, captionEnd)
|
|
552
|
+
if (ceiling > 0) return ceiling
|
|
553
|
+
// Nothing visual sets a ceiling: an audio-only timeline. Returning 0 here
|
|
554
|
+
// started the transport and stopped it in the same tick, so play did nothing
|
|
555
|
+
// at all. Audio is otherwise deliberately kept out of the ceiling (the
|
|
556
|
+
// canvas/video divergence over the audio tail, above) — this is the last
|
|
557
|
+
// resort that only fires when there is no visual content to measure.
|
|
558
|
+
// Mirrored in `canvasMaxEndRef` (video/preview/useVideoPlayback.ts).
|
|
559
|
+
return audioEnd(project)
|
|
552
560
|
}
|
|
553
561
|
|
|
554
562
|
/**
|
package/src/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export type {
|
|
|
37
37
|
RenderPhase,
|
|
38
38
|
CaptionEvent,
|
|
39
39
|
GenerateCaptionsOptions,
|
|
40
|
+
CaptionProfileDefaults,
|
|
40
41
|
MediaScope,
|
|
41
42
|
MediaItem,
|
|
42
43
|
GlobalOverlay,
|
|
@@ -169,6 +170,38 @@ export {
|
|
|
169
170
|
FontSizePicker,
|
|
170
171
|
} from './text/FontPicker'
|
|
171
172
|
export type { FontOption } from './text/FontPicker'
|
|
173
|
+
// Two independent Google Fonts base-URL setters, one per loader — see each
|
|
174
|
+
// file's own header for why there are two. `setFontsBaseUrl` (from
|
|
175
|
+
// `lib/google-fonts.ts`) covers the broader surface: captions, overlays and
|
|
176
|
+
// the timeline/overlay preview. `setPickerFontsBaseUrl` (renamed at this
|
|
177
|
+
// export boundary only — the source file still calls it `setFontsBaseUrl`,
|
|
178
|
+
// same as the other loader) covers just the font-family picker's own
|
|
179
|
+
// preview list. A host must call BOTH to eliminate Google Fonts egress
|
|
180
|
+
// entirely; calling only one still leaves the other loader fetching from
|
|
181
|
+
// fonts.googleapis.com.
|
|
182
|
+
//
|
|
183
|
+
// Both take the same two arguments: `(url, families)`. `families` is the list
|
|
184
|
+
// of families the stylesheet at `url` declares — the host reads its own
|
|
185
|
+
// `families.json` (or equivalent) ONCE at app init and passes the result to
|
|
186
|
+
// both setters. It is deliberately not fetched inside the loaders: they are
|
|
187
|
+
// synchronous and called from effects, and a pending fetch would leave them
|
|
188
|
+
// unable to decide the partition at the moment they have to act on it.
|
|
189
|
+
//
|
|
190
|
+
// **Passing a url with no families means NOTHING is treated as vendored** —
|
|
191
|
+
// the vendored stylesheet is not linked and every family is fetched from
|
|
192
|
+
// Google. That is the safe direction: glyphs stay correct and preview and
|
|
193
|
+
// render still agree, at the cost of the egress the base exists to remove.
|
|
194
|
+
// Assuming the stylesheet covers everything would instead preview unvendored
|
|
195
|
+
// families as a system fallback while the renderer — reading its own manifest
|
|
196
|
+
// off local disk — got them right, and nothing on screen would say so.
|
|
197
|
+
//
|
|
198
|
+
// `vendoredFamiliesDigest()` returns a short fingerprint of the family list in
|
|
199
|
+
// force. The renderers log the same digest for the manifest they read; two
|
|
200
|
+
// different strings mean the two sides are partitioning against different
|
|
201
|
+
// vendored sets, and captions will differ between editing and export.
|
|
202
|
+
export { setFontsBaseUrl, vendoredFamiliesDigest } from './lib/google-fonts'
|
|
203
|
+
export { setFontsBaseUrl as setPickerFontsBaseUrl } from './text/FontPicker'
|
|
204
|
+
export { fontFamilyKey, familiesDigest, partitionFontSpecs, vendoredKeySet } from './lib/font-families'
|
|
172
205
|
export { InlineTextEditor } from './text/InlineTextEditor'
|
|
173
206
|
export type { InlineTextEditorProps } from './text/InlineTextEditor'
|
|
174
207
|
export {
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { ensureGoogleFontsLoaded, setFontsBaseUrl } from '../google-fonts'
|
|
3
|
+
import { requiredFaces, specFacesAvailable, vendoredFaceIndex } from '../font-families'
|
|
4
|
+
|
|
5
|
+
// THE FACE-LEVEL PARTITION (L1).
|
|
6
|
+
//
|
|
7
|
+
// The vendored set is family + STYLE + WEIGHT, not family. `fonts.css` carries
|
|
8
|
+
// only the faces the vendoring pass actually received — every face is
|
|
9
|
+
// `font-style: normal`, and the weights are only the ones it asked for. A
|
|
10
|
+
// family-level partition therefore gets two shapes silently wrong:
|
|
11
|
+
//
|
|
12
|
+
// Playfair+Display:ital@1 — family vendored, no italic face exists, so the
|
|
13
|
+
// browser synthesises an oblique from the upright.
|
|
14
|
+
// Inter:wght@300 — family vendored, no 300 face exists, so the
|
|
15
|
+
// browser synthesises a light weight from the 400.
|
|
16
|
+
//
|
|
17
|
+
// Neither produces a failed request or a console error, and both change what
|
|
18
|
+
// the user sees. `skills/write-overlay/SKILL.md` uses the first as its own
|
|
19
|
+
// documented example.
|
|
20
|
+
//
|
|
21
|
+
// Every assertion here has a twin on the render side
|
|
22
|
+
// (`montaj_assets/render/test/fonts-fallthrough.test.mjs`). The two sides MUST
|
|
23
|
+
// partition identically — a caption laid out in one face while editing and
|
|
24
|
+
// another at export is the Syne bug that case study documents.
|
|
25
|
+
|
|
26
|
+
const N = (weight: number) => ({ style: 'normal', weight })
|
|
27
|
+
const I = (weight: number) => ({ style: 'italic', weight })
|
|
28
|
+
|
|
29
|
+
describe('requiredFaces: a spec resolves to the faces it asks Google for', () => {
|
|
30
|
+
for (const [spec, want] of [
|
|
31
|
+
// No axis list — Google serves the family default, which is normal 400.
|
|
32
|
+
['Anton', [N(400)]],
|
|
33
|
+
['Playfair Display', [N(400)]],
|
|
34
|
+
['Inter:wght@400;700', [N(400), N(700)]],
|
|
35
|
+
['Inter:wght@300', [N(300)]],
|
|
36
|
+
['Playfair+Display:ital@1', [I(400)]],
|
|
37
|
+
['Playfair+Display:ital@0', [N(400)]],
|
|
38
|
+
// The axes are named in one list and their values in another, positionally.
|
|
39
|
+
['Playfair+Display:ital,wght@1,700', [I(700)]],
|
|
40
|
+
['Playfair+Display:ital,wght@0,400;1,700', [N(400), I(700)]],
|
|
41
|
+
['Baloo+2:wght@400;500;600;700;800', [N(400), N(500), N(600), N(700), N(800)]],
|
|
42
|
+
] as [string, ReturnType<typeof N>[]][]) {
|
|
43
|
+
it(`${spec} → ${JSON.stringify(want)}`, () => {
|
|
44
|
+
expect(requiredFaces(spec)).toEqual(want)
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `null` means "I cannot parse this confidently" and the caller must fall
|
|
49
|
+
// through. Fetching a font we happen to have costs one request; silently
|
|
50
|
+
// dropping one we lack costs the author a wrong face in a finished export.
|
|
51
|
+
for (const spec of [
|
|
52
|
+
'Inter:', // a colon with no axis list
|
|
53
|
+
'Inter:wght', // an axis with no '@'
|
|
54
|
+
'Inter:wght@', // an '@' with no value
|
|
55
|
+
'Inter:wght@400;', // a trailing ';' leaves an empty tuple
|
|
56
|
+
'Inter:wght@100..900', // a variable RANGE is not a face
|
|
57
|
+
'Inter:ital@0..1',
|
|
58
|
+
'Inter:opsz@14', // an axis we do not model
|
|
59
|
+
'Inter:slnt@-10',
|
|
60
|
+
'Inter:GRAD@150',
|
|
61
|
+
'Inter:wght,opsz@400,14', // one modelled axis, one not
|
|
62
|
+
'Inter:wght,wght@400,700', // a duplicated axis
|
|
63
|
+
'Inter:ital,wght@1', // arity mismatch: two axes, one value
|
|
64
|
+
'Inter:wght@400@700', // two '@'
|
|
65
|
+
'Inter:ital@2', // ital is 0 or 1, nothing else
|
|
66
|
+
'Inter:wght@0', // out of range
|
|
67
|
+
'Inter:wght@abc',
|
|
68
|
+
'Inter:@400', // an empty axis name
|
|
69
|
+
]) {
|
|
70
|
+
it(`${spec} → null, so the caller falls through`, () => {
|
|
71
|
+
expect(requiredFaces(spec)).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
describe('vendoredFaceIndex: `faces` and `requested` together say what is available', () => {
|
|
77
|
+
it('merges both maps — a weight in either one counts as available', () => {
|
|
78
|
+
// Bebas Neue publishes no 700, so we asked for it and got only the 400.
|
|
79
|
+
// Falling through would fetch a stylesheet that omits it identically.
|
|
80
|
+
const index = vendoredFaceIndex(
|
|
81
|
+
{ 'Bebas Neue': { normal: [400] } },
|
|
82
|
+
{ 'Bebas Neue': { normal: [400, 700] } },
|
|
83
|
+
)!
|
|
84
|
+
expect(specFacesAvailable('Bebas+Neue:wght@400;700', index)).toBe(true)
|
|
85
|
+
expect(specFacesAvailable('Bebas+Neue:wght@500', index)).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('distinguishes NO face information from face information covering nothing', () => {
|
|
89
|
+
// Nothing supplied at all: the partition has nothing to refine against and
|
|
90
|
+
// must stay at family level, which the loader signals with `undefined`.
|
|
91
|
+
expect(vendoredFaceIndex(undefined, undefined)).toBeUndefined()
|
|
92
|
+
// An empty map IS information: it says nothing is available.
|
|
93
|
+
const empty = vendoredFaceIndex({}, undefined)
|
|
94
|
+
expect(empty).toBeInstanceOf(Map)
|
|
95
|
+
expect(specFacesAvailable('Inter', empty!)).toBe(false)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('normalises family keys the way the family list does', () => {
|
|
99
|
+
const index = vendoredFaceIndex({ 'Open+Sans': { normal: [400] } })!
|
|
100
|
+
expect(specFacesAvailable('Open+Sans', index)).toBe(true)
|
|
101
|
+
expect(specFacesAvailable('OPEN SANS', index)).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('skips malformed entries rather than throwing inside a font load', () => {
|
|
105
|
+
const index = vendoredFaceIndex({
|
|
106
|
+
Inter: { normal: [400, 'x' as unknown as number, null as unknown as number] },
|
|
107
|
+
Bad: null as unknown as { normal: number[] },
|
|
108
|
+
Worse: { normal: 'nope' as unknown as number[] },
|
|
109
|
+
})!
|
|
110
|
+
expect(specFacesAvailable('Inter', index)).toBe(true)
|
|
111
|
+
expect(specFacesAvailable('Bad', index)).toBe(false)
|
|
112
|
+
expect(specFacesAvailable('Worse', index)).toBe(false)
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('a family with no index entry is not covered', () => {
|
|
116
|
+
const index = vendoredFaceIndex({ Inter: { normal: [400] } })!
|
|
117
|
+
expect(specFacesAvailable('Anton', index)).toBe(false)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('a spec it cannot parse is not covered, however complete the index', () => {
|
|
121
|
+
const index = vendoredFaceIndex({ Inter: { normal: [100, 200, 300, 400, 700, 900] } })!
|
|
122
|
+
expect(specFacesAvailable('Inter:wght@100..900', index)).toBe(false)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Through the loader
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
function injectedHrefs(): string[] {
|
|
131
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
132
|
+
(l) => (l as HTMLLinkElement).href,
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The injected-URL Set is module state that outlives a single test, so every
|
|
137
|
+
// base and every unvendored family below must be unique or a later test is
|
|
138
|
+
// served a suppressed <link> and passes for the wrong reason.
|
|
139
|
+
const B = (name: string) => `https://example.com/faces/${name}`
|
|
140
|
+
|
|
141
|
+
const REAL = {
|
|
142
|
+
faces: {
|
|
143
|
+
'Playfair Display': { normal: [400, 700] },
|
|
144
|
+
Inter: { normal: [400, 700] },
|
|
145
|
+
'Bebas Neue': { normal: [400] },
|
|
146
|
+
},
|
|
147
|
+
requested: {
|
|
148
|
+
'Playfair Display': { normal: [400, 700] },
|
|
149
|
+
Inter: { normal: [400, 700] },
|
|
150
|
+
'Bebas Neue': { normal: [400, 700] },
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
const FAMILIES = ['Playfair Display', 'Inter', 'Bebas Neue']
|
|
154
|
+
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
157
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
158
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
159
|
+
})
|
|
160
|
+
afterEach(() => {
|
|
161
|
+
setFontsBaseUrl(undefined)
|
|
162
|
+
vi.restoreAllMocks()
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('ensureGoogleFontsLoaded: the face refinement', () => {
|
|
166
|
+
for (const [spec, vendored, why] of [
|
|
167
|
+
['Playfair+Display:ital@1', false, 'no italic face is vendored'],
|
|
168
|
+
['Playfair+Display:ital,wght@1,700', false, 'italic 700 is not on disk either'],
|
|
169
|
+
['Playfair+Display:ital@0', true, 'ital@0 is normal 400, which IS on disk'],
|
|
170
|
+
['Inter:wght@300', false, '300 was never vendored'],
|
|
171
|
+
['Inter:wght@400;700', true, 'both weights are on disk'],
|
|
172
|
+
['Bebas+Neue:wght@400;700', true, 'Google publishes no 700, so asking again cannot help'],
|
|
173
|
+
['Bebas+Neue:wght@500', false, '500 was neither vendored nor asked for'],
|
|
174
|
+
] as [string, boolean, string][]) {
|
|
175
|
+
it(`${spec} is ${vendored ? 'served locally' : 'fetched from Google'} — ${why}`, () => {
|
|
176
|
+
const base = B(spec.replace(/\W/g, ''))
|
|
177
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
178
|
+
ensureGoogleFontsLoaded([spec])
|
|
179
|
+
const hrefs = injectedHrefs()
|
|
180
|
+
if (vendored) {
|
|
181
|
+
expect(hrefs).toEqual([`${base}/fonts.css`])
|
|
182
|
+
} else {
|
|
183
|
+
expect(hrefs).toEqual([`https://fonts.googleapis.com/css2?family=${spec}&display=swap`])
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
it('a partially vendored spec falls through WHOLE, exactly as the author wrote it', () => {
|
|
189
|
+
// Splitting would mean synthesising a new spec string, and a spec is the
|
|
190
|
+
// author's — ours to honour or pass on untouched, never to rewrite.
|
|
191
|
+
const base = B('partial')
|
|
192
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
193
|
+
ensureGoogleFontsLoaded(['Inter:wght@400;300'])
|
|
194
|
+
expect(injectedHrefs()).toEqual([
|
|
195
|
+
'https://fonts.googleapis.com/css2?family=Inter:wght@400;300&display=swap',
|
|
196
|
+
])
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('a MIX puts each spec on exactly one side, by face and not by family', () => {
|
|
200
|
+
const base = B('mix')
|
|
201
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
202
|
+
// Same family on both sides: the 700 is vendored, the italic is not.
|
|
203
|
+
//
|
|
204
|
+
// The italic spec here is spelled `ital,wght@1,400` rather than `ital@1`
|
|
205
|
+
// — the same FACE, a different URL. `__injectedFontUrls` dedupes on the
|
|
206
|
+
// full URL and is module state outliving a single test, so reusing the
|
|
207
|
+
// spelling an earlier case already injected would suppress this <link>
|
|
208
|
+
// and the assertion would fail for a reason that has nothing to do with
|
|
209
|
+
// the partition.
|
|
210
|
+
ensureGoogleFontsLoaded(['Playfair+Display:wght@700', 'Playfair+Display:ital,wght@1,400'])
|
|
211
|
+
const hrefs = injectedHrefs()
|
|
212
|
+
expect(hrefs).toEqual([
|
|
213
|
+
`${base}/fonts.css`,
|
|
214
|
+
'https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@1,400&display=swap',
|
|
215
|
+
])
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('omitting face data leaves the partition at family level', () => {
|
|
219
|
+
// A manifest vendored before the refinement existed carries no face
|
|
220
|
+
// information. That is a real shape, and it must degrade to the previous
|
|
221
|
+
// behaviour rather than treating every face as missing.
|
|
222
|
+
const base = B('nofaces')
|
|
223
|
+
setFontsBaseUrl(base, FAMILIES)
|
|
224
|
+
ensureGoogleFontsLoaded(['Playfair+Display:ital@1'])
|
|
225
|
+
expect(injectedHrefs()).toEqual([`${base}/fonts.css`])
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('faces cannot promote a family the list does not declare', () => {
|
|
229
|
+
const base = B('narrowing')
|
|
230
|
+
setFontsBaseUrl(base, ['Inter'], { faces: { Inter: { normal: [400] }, Zilch: { normal: [400] } } })
|
|
231
|
+
ensureGoogleFontsLoaded(['Zilch'])
|
|
232
|
+
expect(injectedHrefs()).toEqual(['https://fonts.googleapis.com/css2?family=Zilch&display=swap'])
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('names the fallen-through spec in the warning, and not the vendored one', () => {
|
|
236
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
237
|
+
const base = B('logged')
|
|
238
|
+
setFontsBaseUrl(base, FAMILIES, REAL)
|
|
239
|
+
ensureGoogleFontsLoaded(['Inter:wght@400', 'Quire:ital@1'])
|
|
240
|
+
expect(warn).toHaveBeenCalledOnce()
|
|
241
|
+
expect(warn.mock.calls[0][0]).toContain('Quire:ital@1')
|
|
242
|
+
expect(warn.mock.calls[0][0]).not.toContain('Inter')
|
|
243
|
+
})
|
|
244
|
+
})
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, cleanup } from '@testing-library/react'
|
|
3
|
+
import { FontFamilyPicker, setFontsBaseUrl as setPickerBase } from '../../text/FontPicker'
|
|
4
|
+
import { ensureGoogleFontsLoaded, setFontsBaseUrl as setOverlayBase } from '../google-fonts'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// TWO LOADERS, ONE PARTITION
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
//
|
|
10
|
+
// This package ships two independent Google Fonts loaders — `lib/google-fonts`
|
|
11
|
+
// (captions, overlays, the timeline/overlay preview) and `text/FontPicker`
|
|
12
|
+
// (the picker's own preview list). They keep separate bases and separate
|
|
13
|
+
// injection state on purpose. They must NOT keep separate opinions about which
|
|
14
|
+
// families a vendored stylesheet covers: one loader serving a family locally
|
|
15
|
+
// while the other fetches it from Google is a caption in one face in the
|
|
16
|
+
// picker and another on the canvas, with nothing on screen to explain it.
|
|
17
|
+
//
|
|
18
|
+
// The render package pins its two duplicated copies as identical SOURCE TEXT,
|
|
19
|
+
// because its two renderers are separate CLI entry points that cannot import
|
|
20
|
+
// each other. These two can import, and do — both partition through
|
|
21
|
+
// `lib/font-families`. Textual parity would therefore be a test that cannot
|
|
22
|
+
// fail. So this pins the property that actually matters instead: given the
|
|
23
|
+
// same family list, the two loaders put the same families on the same side.
|
|
24
|
+
//
|
|
25
|
+
// Driving them is asymmetric because their inputs are: the overlay loader
|
|
26
|
+
// takes the specs it is asked for, while the picker always requests its own
|
|
27
|
+
// fixed FONT_OPTIONS set. So feed the picker's specs to the overlay loader and
|
|
28
|
+
// compare what each one emits.
|
|
29
|
+
|
|
30
|
+
function injectedHrefs(): string[] {
|
|
31
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
32
|
+
(l) => (l as HTMLLinkElement).href,
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The googleapis URL a loader emitted, reduced to its `family=` specs. */
|
|
37
|
+
function googleSpecs(hrefs: string[]): string[] {
|
|
38
|
+
const url = hrefs.find((h) => h.includes('fonts.googleapis.com'))
|
|
39
|
+
if (!url) return []
|
|
40
|
+
return [...url.matchAll(/family=([^&]+)/g)].map((m) => decodeURIComponent(m[1]))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether a loader linked the vendored stylesheet. */
|
|
44
|
+
const linkedVendored = (hrefs: string[]) => hrefs.some((h) => h.endsWith('/fonts.css'))
|
|
45
|
+
|
|
46
|
+
// The picker's own spec list, read out of the module rather than restated, so
|
|
47
|
+
// a change to FONT_OPTIONS cannot leave this comparing a stale set.
|
|
48
|
+
async function pickerSpecs(): Promise<string[]> {
|
|
49
|
+
const { FONT_OPTIONS } = await import('../../text/FontPicker')
|
|
50
|
+
return FONT_OPTIONS.filter((f) => f.isGoogleFont).map(
|
|
51
|
+
(f) => f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`,
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
57
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
58
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
59
|
+
})
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
cleanup()
|
|
62
|
+
setPickerBase(undefined)
|
|
63
|
+
setOverlayBase(undefined)
|
|
64
|
+
vi.restoreAllMocks()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
// Each case uses a base unique to itself: both loaders dedupe on the URLs they
|
|
68
|
+
// have already injected, and that state outlives a test, so a repeated base
|
|
69
|
+
// would make a loader look silent when it had merely already spoken.
|
|
70
|
+
describe('the picker loader and the overlay loader partition identically', () => {
|
|
71
|
+
const CASES: [string, (all: string[]) => string[]][] = [
|
|
72
|
+
['every picker family vendored', (all) => all.map(familyOf)],
|
|
73
|
+
['none vendored', () => []],
|
|
74
|
+
['one family missing from the vendored list', (all) => all.slice(1).map(familyOf)],
|
|
75
|
+
['half vendored', (all) => all.slice(0, 10).map(familyOf)],
|
|
76
|
+
['vendored list spelled Google-style, with +', (all) => all.map((s) => s.split(':')[0])],
|
|
77
|
+
['vendored list in a different case', (all) => all.map((s) => familyOf(s).toUpperCase())],
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
function familyOf(spec: string) {
|
|
81
|
+
return spec.split(':')[0].replace(/\+/g, ' ')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
for (const [label, listFor] of CASES) {
|
|
85
|
+
it(label, async () => {
|
|
86
|
+
const specs = await pickerSpecs()
|
|
87
|
+
const families = listFor(specs)
|
|
88
|
+
const base = `https://example.com/fonts/parity-${label.replace(/[^a-z]+/gi, '-')}`
|
|
89
|
+
|
|
90
|
+
setPickerBase(base, families)
|
|
91
|
+
render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
92
|
+
const picker = injectedHrefs()
|
|
93
|
+
|
|
94
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
95
|
+
|
|
96
|
+
setOverlayBase(base, families)
|
|
97
|
+
ensureGoogleFontsLoaded(specs)
|
|
98
|
+
const overlay = injectedHrefs()
|
|
99
|
+
|
|
100
|
+
expect(googleSpecs(overlay)).toEqual(googleSpecs(picker))
|
|
101
|
+
expect(linkedVendored(overlay)).toBe(linkedVendored(picker))
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
it('and a host that sets only ONE of the two setters still leaves the other on Google', () => {
|
|
106
|
+
// Not a defect to fix here — it is the documented contract on
|
|
107
|
+
// `src/index.ts` — but it is the mistake a host will actually make, so
|
|
108
|
+
// pin that it stays visible rather than silently half-working.
|
|
109
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
110
|
+
setPickerBase('https://example.com/fonts/only-picker', ['Inter'])
|
|
111
|
+
// The overlay loader was never pointed anywhere, so it has no base at all.
|
|
112
|
+
ensureGoogleFontsLoaded(['Inter:wght@400'])
|
|
113
|
+
const hrefs = injectedHrefs()
|
|
114
|
+
expect(hrefs.some((h) => h.includes('fonts.googleapis.com'))).toBe(true)
|
|
115
|
+
// ...and the picker's own setter said out loud what it was given.
|
|
116
|
+
expect(warn.mock.calls.flat().join(' ')).not.toContain('treating NOTHING as vendored')
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// ...AND THEY AGREE AT FACE LEVEL TOO
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
//
|
|
124
|
+
// The partition is family-level AND face-level: a spec is vendored only when
|
|
125
|
+
// its family is declared and every face it requires is available. That second
|
|
126
|
+
// half is the one that can disagree quietly, because both loaders can name the
|
|
127
|
+
// same family while resolving different weights of it — and the picker is the
|
|
128
|
+
// loader that requests `Bebas+Neue:wght@400;700`, the one shipped spec whose
|
|
129
|
+
// answer depends on the `requested` map rather than on what is on disk.
|
|
130
|
+
//
|
|
131
|
+
// This is the TS half's parity mechanism. It is behavioural on purpose: both
|
|
132
|
+
// loaders import `lib/font-families`, so there is no second copy to compare
|
|
133
|
+
// textually and such a test could not fail. What can still diverge is how each
|
|
134
|
+
// loader WIRES that shared code — which argument it passes, and whether it
|
|
135
|
+
// passes the face index at all — and only running both can catch that.
|
|
136
|
+
describe('the two loaders agree on the FACE partition, not just the family one', () => {
|
|
137
|
+
function familyOf(spec: string) {
|
|
138
|
+
return spec.split(':')[0].replace(/\+/g, ' ')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The faces the picker's own specs resolve to, with `broken` letting a case
|
|
142
|
+
* remove something and check both loaders react the same way. */
|
|
143
|
+
function faceDataFor(specs: string[], broken?: (f: Record<string, { normal: number[] }>) => void) {
|
|
144
|
+
const faces: Record<string, { normal: number[] }> = {}
|
|
145
|
+
const requested: Record<string, { normal: number[] }> = {}
|
|
146
|
+
for (const spec of specs) {
|
|
147
|
+
const family = familyOf(spec)
|
|
148
|
+
const axes = spec.split(':')[1]
|
|
149
|
+
const weights = axes ? axes.slice('wght@'.length).split(';').map(Number) : [400]
|
|
150
|
+
requested[family] = { normal: weights }
|
|
151
|
+
// Bebas Neue publishes no 700, so it received only the 400 it asked for.
|
|
152
|
+
faces[family] = { normal: family === 'Bebas Neue' ? [400] : weights }
|
|
153
|
+
}
|
|
154
|
+
broken?.(faces)
|
|
155
|
+
return { faces, requested }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type Faces = Record<string, { normal: number[] }>
|
|
159
|
+
const CASES: [string, (specs: string[]) => { faces: Faces; requested?: Faces }][] = [
|
|
160
|
+
// Every picker spec satisfied — including Bebas Neue, and only because
|
|
161
|
+
// `requested` says Google was asked for the 700 and declined.
|
|
162
|
+
['the real shipped manifest', (s) => faceDataFor(s)],
|
|
163
|
+
// Drop `requested` and Bebas Neue's missing 700 sends that spec to Google.
|
|
164
|
+
// Both loaders must send it, or neither.
|
|
165
|
+
['faces only, no requested — Bebas Neue crosses to Google', (s) => ({ faces: faceDataFor(s).faces })],
|
|
166
|
+
// A weight removed from a family every picker spec asks for at 700.
|
|
167
|
+
['a weight missing from Inter', (s) => faceDataFor(s, (f) => { f.Inter = { normal: [400] } })],
|
|
168
|
+
// Face information that covers nothing at all.
|
|
169
|
+
['an empty faces map', () => ({ faces: {} })],
|
|
170
|
+
]
|
|
171
|
+
|
|
172
|
+
for (const [label, dataFor] of CASES) {
|
|
173
|
+
it(label, async () => {
|
|
174
|
+
const specs = await pickerSpecs()
|
|
175
|
+
const families = specs.map(familyOf)
|
|
176
|
+
const faceData = dataFor(specs)
|
|
177
|
+
const base = `https://example.com/fonts/faceparity-${label.replace(/[^a-z]+/gi, '-')}`
|
|
178
|
+
|
|
179
|
+
setPickerBase(base, families, faceData)
|
|
180
|
+
render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
181
|
+
const picker = injectedHrefs()
|
|
182
|
+
|
|
183
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
184
|
+
|
|
185
|
+
setOverlayBase(base, families, faceData)
|
|
186
|
+
ensureGoogleFontsLoaded(specs)
|
|
187
|
+
const overlay = injectedHrefs()
|
|
188
|
+
|
|
189
|
+
expect(googleSpecs(overlay)).toEqual(googleSpecs(picker))
|
|
190
|
+
expect(linkedVendored(overlay)).toBe(linkedVendored(picker))
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// The property the whole feature is for, asserted on the real shape: with
|
|
195
|
+
// the shipped manifest neither loader touches Google at all.
|
|
196
|
+
it('with the shipped manifest, NEITHER loader reaches Google', async () => {
|
|
197
|
+
const specs = await pickerSpecs()
|
|
198
|
+
const families = specs.map(familyOf)
|
|
199
|
+
const faceData = faceDataFor(specs)
|
|
200
|
+
const base = 'https://example.com/fonts/faceparity-zero-egress'
|
|
201
|
+
|
|
202
|
+
setPickerBase(base, families, faceData)
|
|
203
|
+
render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
204
|
+
expect(googleSpecs(injectedHrefs())).toEqual([])
|
|
205
|
+
expect(linkedVendored(injectedHrefs())).toBe(true)
|
|
206
|
+
|
|
207
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
208
|
+
|
|
209
|
+
setOverlayBase(base, families, faceData)
|
|
210
|
+
ensureGoogleFontsLoaded(specs)
|
|
211
|
+
expect(googleSpecs(injectedHrefs())).toEqual([])
|
|
212
|
+
expect(linkedVendored(injectedHrefs())).toBe(true)
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
// Both loaders log the same digest for the same manifest — the value that
|
|
216
|
+
// pins the TS↔JS seam. If one passed the face index and the other did not,
|
|
217
|
+
// the two lines would differ and this is where it would show.
|
|
218
|
+
it('both setters log the same digest for the same manifest', async () => {
|
|
219
|
+
const info = vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
220
|
+
const specs = await pickerSpecs()
|
|
221
|
+
const families = specs.map(familyOf)
|
|
222
|
+
const faceData = faceDataFor(specs)
|
|
223
|
+
|
|
224
|
+
setPickerBase('https://example.com/fonts/digest-picker', families, faceData)
|
|
225
|
+
setOverlayBase('https://example.com/fonts/digest-overlay', families, faceData)
|
|
226
|
+
|
|
227
|
+
const digests = info.mock.calls
|
|
228
|
+
.map((c) => String(c[0]).match(/vendored set ([0-9a-f]{8})/)?.[1])
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
expect(digests).toHaveLength(2)
|
|
231
|
+
expect(digests[0]).toBe(digests[1])
|
|
232
|
+
// The same literal the render suite pins, so a divergence between the two
|
|
233
|
+
// languages surfaces here as well as there.
|
|
234
|
+
expect(digests[0]).toBe('1fcf41c1')
|
|
235
|
+
})
|
|
236
|
+
})
|