@bycrux/editor 1.2.0 → 1.2.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 +1 -1
- package/src/engine/__tests__/scheduler.test.ts +35 -0
- package/src/engine/scheduler.ts +10 -2
- package/src/index.ts +32 -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/video/VersionPanel.tsx +1 -1
- package/src/video/VideoEditor.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/src/lib/google-fonts.ts
CHANGED
|
@@ -5,14 +5,112 @@
|
|
|
5
5
|
// (bundle.js) fetches. Used by both the video overlay layer and the carousel
|
|
6
6
|
// overlay render path. Resilient by design: a font-load failure must never
|
|
7
7
|
// break the render (we only append a <link>; the browser handles the fetch).
|
|
8
|
+
import {
|
|
9
|
+
familiesDigest,
|
|
10
|
+
partitionFontSpecs,
|
|
11
|
+
reportUnvendoredFonts,
|
|
12
|
+
reportVendoredSet,
|
|
13
|
+
vendoredFaceIndex,
|
|
14
|
+
vendoredKeySet,
|
|
15
|
+
} from './font-families'
|
|
16
|
+
import type { FaceIndex, FaceMap } from './font-families'
|
|
8
17
|
|
|
9
18
|
// Track Google Fonts URLs already injected so we don't add the same <link>
|
|
10
19
|
// twice when multiple overlays declare overlapping fonts. Keyed by the full
|
|
11
20
|
// stylesheet URL — the same URL never produces a duplicate fetch from
|
|
12
21
|
// Chromium regardless, but the duplicate <link> tags would still clutter
|
|
13
22
|
// document.head across long editing sessions.
|
|
23
|
+
//
|
|
24
|
+
// Keying on the full URL (rather than e.g. the family list) also means a
|
|
25
|
+
// setFontsBaseUrl() call transparently invalidates this cache: the base is
|
|
26
|
+
// part of the URL, so switching it produces a URL this Set has never seen
|
|
27
|
+
// and the new <link> is injected regardless of what was injected before.
|
|
14
28
|
const __injectedFontUrls = new Set<string>()
|
|
15
29
|
|
|
30
|
+
// Unset (OSS default): ensureGoogleFontsLoaded builds a per-family
|
|
31
|
+
// fonts.googleapis.com/css2 URL, exactly as before. Set: it links
|
|
32
|
+
// `${base}/fonts.css` for the families `vendoredFamilies` declares and falls
|
|
33
|
+
// through to fonts.googleapis.com for the rest.
|
|
34
|
+
//
|
|
35
|
+
// This setter is private to this module; FontPicker.tsx's own loader has its
|
|
36
|
+
// own, separate setter. The two loaders keep separate injection state by
|
|
37
|
+
// design — but they share the partition itself (`lib/font-families.ts`), so
|
|
38
|
+
// they cannot disagree about which families a base covers.
|
|
39
|
+
let fontsBaseUrl: string | undefined
|
|
40
|
+
let vendoredFamilies = new Set<string>()
|
|
41
|
+
// `undefined` means "no face information was supplied", which leaves the
|
|
42
|
+
// partition at family level. An EMPTY index is different: it says the host
|
|
43
|
+
// supplied face data that covers nothing, so nothing is vendored.
|
|
44
|
+
let vendoredFaces: FaceIndex | undefined
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Point the loader at a vendored stylesheet.
|
|
48
|
+
*
|
|
49
|
+
* @param url Base URL holding `fonts.css`, or undefined for the OSS
|
|
50
|
+
* default (everything from fonts.googleapis.com).
|
|
51
|
+
* @param families The families that stylesheet declares, spelled as its
|
|
52
|
+
* `font-family` rules spell them.
|
|
53
|
+
* @param faceData The manifest's `faces` and `requested` maps, which say
|
|
54
|
+
* WHICH FACES of each family are actually available. The
|
|
55
|
+
* family list alone cannot answer that, and getting it wrong
|
|
56
|
+
* is silent: `Playfair+Display:ital@1` names a vendored
|
|
57
|
+
* family whose italic does not exist, so the browser
|
|
58
|
+
* synthesises an oblique and nothing reports a problem.
|
|
59
|
+
* Omitting it leaves the partition at family level and keeps
|
|
60
|
+
* that hazard — the host should always pass it.
|
|
61
|
+
*
|
|
62
|
+
* **The family list is passed IN, never fetched here.** The host reads its
|
|
63
|
+
* `families.json` once at app init and hands both values over. That keeps this
|
|
64
|
+
* loader fully synchronous, which it has to be: `ensureGoogleFontsLoaded` is
|
|
65
|
+
* called from effects and must decide the partition before it can act. A fetch
|
|
66
|
+
* inside it would create a "manifest has not arrived yet" state with no good
|
|
67
|
+
* exit — it cannot block (it is sync), guessing is silently wrong, and
|
|
68
|
+
* re-injecting once the answer lands leaves two competing stylesheets on the
|
|
69
|
+
* page with the stale one never pruned.
|
|
70
|
+
*
|
|
71
|
+
* Omitting `families`, or passing an empty list, means NOTHING is treated as
|
|
72
|
+
* vendored: the vendored stylesheet is not linked and every requested family
|
|
73
|
+
* comes from Google. See `reportVendoredSet` for why that direction, and not
|
|
74
|
+
* the tempting opposite.
|
|
75
|
+
*/
|
|
76
|
+
export function setFontsBaseUrl(
|
|
77
|
+
url: string | undefined,
|
|
78
|
+
families?: readonly string[],
|
|
79
|
+
faceData?: { faces?: FaceMap; requested?: FaceMap },
|
|
80
|
+
): void {
|
|
81
|
+
fontsBaseUrl = url
|
|
82
|
+
vendoredFamilies = vendoredKeySet(families)
|
|
83
|
+
vendoredFaces = faceData ? vendoredFaceIndex(faceData.faces, faceData.requested) : undefined
|
|
84
|
+
if (url) reportVendoredSet(url, vendoredFamilies, vendoredFaces)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The digest of the family list currently in force, for a host that wants to
|
|
88
|
+
* compare it against the renderer's own logged digest without scraping the
|
|
89
|
+
* console. Empty string when no base is set. */
|
|
90
|
+
export function vendoredFamiliesDigest(): string {
|
|
91
|
+
return fontsBaseUrl ? familiesDigest(vendoredFamilies, vendoredFaces) : ''
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function googleFontsUrl(specs: string[]): string {
|
|
95
|
+
return `https://fonts.googleapis.com/css2?${specs.map((f) => `family=${f}`).join('&')}&display=swap`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Append a stylesheet <link> unless this exact URL was already requested.
|
|
99
|
+
* Returns whether it was newly requested, so a caller can log once per
|
|
100
|
+
* distinct URL rather than once per overlay. The Set is updated before the
|
|
101
|
+
* `document` guard, matching the original loader: a server-side call still
|
|
102
|
+
* counts as "requested". */
|
|
103
|
+
function injectStylesheet(url: string): boolean {
|
|
104
|
+
if (__injectedFontUrls.has(url)) return false
|
|
105
|
+
__injectedFontUrls.add(url)
|
|
106
|
+
if (typeof document === 'undefined') return false
|
|
107
|
+
const link = document.createElement('link')
|
|
108
|
+
link.rel = 'stylesheet'
|
|
109
|
+
link.href = url
|
|
110
|
+
document.head.appendChild(link)
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
|
|
16
114
|
export function ensureGoogleFontsLoaded(googleFonts: string[] | string | undefined): void {
|
|
17
115
|
// Defensive coercion: persisted project items have occasionally stored the
|
|
18
116
|
// `googleFonts` field as a bare string (e.g. "Anton") instead of the typed
|
|
@@ -26,14 +124,38 @@ export function ensureGoogleFontsLoaded(googleFonts: string[] | string | undefin
|
|
|
26
124
|
? googleFonts.split(',').map((s) => s.trim()).filter(Boolean)
|
|
27
125
|
: googleFonts
|
|
28
126
|
if (!Array.isArray(families) || !families.length) return
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
127
|
+
|
|
128
|
+
// Trailing slashes are stripped so `/fonts/editor` and `/fonts/editor/`
|
|
129
|
+
// resolve identically. This mirrors `montaj_assets/render/bundle.js`'s
|
|
130
|
+
// `vendoredFontsHref`, which applies the same `.replace(/\/+$/, '')` — and
|
|
131
|
+
// that symmetry is the point, not tidiness. The render base and this one
|
|
132
|
+
// are set by DIFFERENT mechanisms (an env var there, this setter here) and
|
|
133
|
+
// must name the same stylesheet; if one tolerates a trailing slash and the
|
|
134
|
+
// other emits `//fonts.css`, preview and render disagree about the URL for
|
|
135
|
+
// a base a host reasonably considers the same.
|
|
136
|
+
const base = fontsBaseUrl ? fontsBaseUrl.replace(/\/+$/, '') : ''
|
|
137
|
+
|
|
138
|
+
// The vendored set covers the families the host declared — and only those.
|
|
139
|
+
// It comes from the editor's picker list, while `googleFonts` comes out of
|
|
140
|
+
// project data, and skills/write-overlay documents arbitrary Google families
|
|
141
|
+
// as first-class (its own worked example names "Anton", which the picker does
|
|
142
|
+
// not carry). Linking the vendored sheet and dropping the requested entries
|
|
143
|
+
// would render those in a fallback face with nothing on screen to say so.
|
|
144
|
+
//
|
|
145
|
+
// With no base, `vendoredFamilies` is empty, everything falls through, and
|
|
146
|
+
// the emitted URL is byte-identical to the pre-vendoring one — the format
|
|
147
|
+
// bundle.js emits for the render pipeline, so preview and render fetch
|
|
148
|
+
// identical CSS and therefore identical glyphs / metrics.
|
|
149
|
+
const { vendored, fellThrough } = partitionFontSpecs(
|
|
150
|
+
families,
|
|
151
|
+
base ? vendoredFamilies : new Set(),
|
|
152
|
+
base ? vendoredFaces : undefined,
|
|
153
|
+
)
|
|
154
|
+
if (vendored.length) injectStylesheet(`${base}/fonts.css`)
|
|
155
|
+
if (!fellThrough.length) return
|
|
156
|
+
// Log once per distinct URL rather than once per overlay — a project with
|
|
157
|
+
// fifty overlays naming the same unvendored family should say so once.
|
|
158
|
+
// `base &&`: with no base nothing has "fallen through" to report, that is
|
|
159
|
+
// simply how the OSS default works.
|
|
160
|
+
if (injectStylesheet(googleFontsUrl(fellThrough)) && base) reportUnvendoredFonts(fellThrough)
|
|
39
161
|
}
|
package/src/text/FontPicker.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { ChevronDown } from 'lucide-react'
|
|
3
3
|
import { NumberField, stepValue } from '../ui'
|
|
4
|
+
import { partitionFontSpecs, reportUnvendoredFonts, reportVendoredSet, vendoredFaceIndex, vendoredKeySet } from '../lib/font-families'
|
|
5
|
+
import type { FaceIndex, FaceMap } from '../lib/font-families'
|
|
4
6
|
|
|
5
7
|
export type FontOption = {
|
|
6
8
|
label: string
|
|
@@ -53,22 +55,91 @@ export const FONT_OPTIONS: FontOption[] = [
|
|
|
53
55
|
// 300/500/600/800), but that's the correct trade: the picker preview and
|
|
54
56
|
// the persisted spec fetch identical weights, so what you see in the
|
|
55
57
|
// dropdown is what actually renders.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
+
//
|
|
59
|
+
// This loader is private to FontPicker.tsx and unrelated to the one in
|
|
60
|
+
// `lib/google-fonts.ts` despite sharing a name — see that file's header. Each
|
|
61
|
+
// keeps its own setFontsBaseUrl() and its own injection state. What they do
|
|
62
|
+
// NOT keep separate is the partition itself: both import it from
|
|
63
|
+
// `lib/font-families.ts`, so they cannot disagree about which families a
|
|
64
|
+
// vendored stylesheet covers. Two loaders reaching different answers is a
|
|
65
|
+
// caption in one face while editing and another at export.
|
|
66
|
+
|
|
67
|
+
// Unset (OSS default): the per-family fonts.googleapis.com/css2 URL below,
|
|
68
|
+
// exactly as before. Set: `${base}/fonts.css` for the families the host says
|
|
69
|
+
// that stylesheet declares, plus a googleapis URL for any picker family it
|
|
70
|
+
// does not.
|
|
71
|
+
//
|
|
72
|
+
// The picker's own families are the vendored set's reason for existing, so
|
|
73
|
+
// in a correctly configured app nothing here ever falls through. It is
|
|
74
|
+
// guarded anyway: the day a family is added to FONT_OPTIONS and not yet
|
|
75
|
+
// vendored, the alternative is a picker preview silently showing a system
|
|
76
|
+
// fallback — which is the exact rot a declared family list exists to catch.
|
|
77
|
+
let fontsBaseUrl: string | undefined
|
|
78
|
+
let vendoredFamilies = new Set<string>()
|
|
79
|
+
let vendoredFaces: FaceIndex | undefined
|
|
80
|
+
|
|
81
|
+
/** Point the picker's preview loader at a vendored stylesheet. Same contract
|
|
82
|
+
* as `lib/google-fonts.ts`'s setter of the same name, including that the
|
|
83
|
+
* family list is passed IN rather than fetched — see that file for why. A
|
|
84
|
+
* host must call BOTH setters; neither covers the other's surface.
|
|
85
|
+
*
|
|
86
|
+
* `faceData` matters more here than anywhere else: the picker requests
|
|
87
|
+
* `Bebas+Neue:wght@400;700`, and only the 400 is on disk because Bebas Neue
|
|
88
|
+
* publishes no 700. Without the `requested` half of `faceData` that spec
|
|
89
|
+
* falls through and a picker-only project stops being zero-egress. */
|
|
90
|
+
export function setFontsBaseUrl(
|
|
91
|
+
url: string | undefined,
|
|
92
|
+
families?: readonly string[],
|
|
93
|
+
faceData?: { faces?: FaceMap; requested?: FaceMap },
|
|
94
|
+
): void {
|
|
95
|
+
fontsBaseUrl = url
|
|
96
|
+
vendoredFamilies = vendoredKeySet(families)
|
|
97
|
+
vendoredFaces = faceData ? vendoredFaceIndex(faceData.faces, faceData.requested) : undefined
|
|
98
|
+
if (url) reportVendoredSet(url, vendoredFamilies, vendoredFaces)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every Google family the picker previews, as fetch specs. Falls back to a
|
|
102
|
+
* label-derived guess only if `spec` is somehow missing, so a stale/partial
|
|
103
|
+
* FontOption still resolves. */
|
|
104
|
+
function pickerFontSpecs(): string[] {
|
|
105
|
+
return FONT_OPTIONS
|
|
58
106
|
.filter((f) => f.isGoogleFont)
|
|
59
|
-
.map((f) =>
|
|
60
|
-
|
|
61
|
-
return `https://fonts.googleapis.com/css2?${params}&display=swap`
|
|
62
|
-
})()
|
|
107
|
+
.map((f) => f.spec ?? `${f.label.replace(/ /g, '+')}:wght@400;700`)
|
|
108
|
+
}
|
|
63
109
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
110
|
+
// Keyed on the URLs actually injected (not a bare boolean, and not a single
|
|
111
|
+
// last-URL string) so that neither a base change after a prior injection nor
|
|
112
|
+
// the two-stylesheet partition case is suppressed by a stale "already done"
|
|
113
|
+
// flag. A new base produces URLs this Set has never seen, so it injects again.
|
|
114
|
+
const injectedFontsUrls = new Set<string>()
|
|
115
|
+
function injectOnce(url: string): boolean {
|
|
116
|
+
if (injectedFontsUrls.has(url)) return false
|
|
117
|
+
injectedFontsUrls.add(url)
|
|
118
|
+
if (typeof document === 'undefined') return false
|
|
68
119
|
const link = document.createElement('link')
|
|
69
120
|
link.rel = 'stylesheet'
|
|
70
|
-
link.href =
|
|
121
|
+
link.href = url
|
|
71
122
|
document.head.appendChild(link)
|
|
123
|
+
return true
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ensureGoogleFontsLoaded(): void {
|
|
127
|
+
if (typeof document === 'undefined') return
|
|
128
|
+
// Trailing slash stripped, matching `lib/google-fonts.ts` and
|
|
129
|
+
// `montaj_assets/render/bundle.js`'s `vendoredFontsHref`. All three build a
|
|
130
|
+
// URL from a base set elsewhere, and a host that passes `/fonts/editor/` to
|
|
131
|
+
// one and `/fonts/editor` to another must not get two different stylesheets.
|
|
132
|
+
const base = fontsBaseUrl ? fontsBaseUrl.replace(/\/+$/, '') : ''
|
|
133
|
+
const { vendored, fellThrough } = partitionFontSpecs(
|
|
134
|
+
pickerFontSpecs(),
|
|
135
|
+
base ? vendoredFamilies : new Set(),
|
|
136
|
+
base ? vendoredFaces : undefined,
|
|
137
|
+
)
|
|
138
|
+
if (vendored.length) injectOnce(`${base}/fonts.css`)
|
|
139
|
+
if (!fellThrough.length) return
|
|
140
|
+
if (injectOnce(`https://fonts.googleapis.com/css2?${fellThrough.map((f) => `family=${f}`).join('&')}&display=swap`) && base) {
|
|
141
|
+
reportUnvendoredFonts(fellThrough)
|
|
142
|
+
}
|
|
72
143
|
}
|
|
73
144
|
|
|
74
145
|
function firstFontToken(value: string): string {
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { render, cleanup } from '@testing-library/react'
|
|
3
|
+
import { FontFamilyPicker, FONT_OPTIONS, setFontsBaseUrl } from '../FontPicker'
|
|
4
|
+
|
|
5
|
+
// FontFamilyPicker's own `ensureGoogleFontsLoaded` is private to FontPicker.tsx
|
|
6
|
+
// (unrelated to, and independent from, `lib/google-fonts.ts`'s loader of the
|
|
7
|
+
// same name — see that file's header comment). It has no exported entry point,
|
|
8
|
+
// so we drive it the way the app does: mount the component, whose first-mount
|
|
9
|
+
// useEffect calls it.
|
|
10
|
+
function mountPicker() {
|
|
11
|
+
return render(<FontFamilyPicker value="" onChange={() => {}} />)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function injectedHrefs(): string[] {
|
|
15
|
+
return Array.from(document.head.querySelectorAll('link[rel="stylesheet"]')).map(
|
|
16
|
+
(l) => (l as HTMLLinkElement).href,
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Every Google family the picker previews, as the labels a vendored family
|
|
21
|
+
* list would spell. Derived from FONT_OPTIONS rather than hardcoded, so
|
|
22
|
+
* adding a family to the picker cannot leave this test asserting a stale set. */
|
|
23
|
+
const PICKER_FAMILIES = FONT_OPTIONS.filter((f) => f.isGoogleFont).map((f) => f.label)
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((l) => l.remove())
|
|
27
|
+
vi.spyOn(console, 'info').mockImplementation(() => {})
|
|
28
|
+
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
cleanup()
|
|
33
|
+
setFontsBaseUrl(undefined)
|
|
34
|
+
vi.restoreAllMocks()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('FontFamilyPicker google fonts base URL', () => {
|
|
38
|
+
// Must run before any other test in this file sets a base: the injection
|
|
39
|
+
// guard is keyed on the computed URL, and the default URL can only be
|
|
40
|
+
// observed as "not yet injected" once per module lifetime.
|
|
41
|
+
it('unset produces exactly the default per-family googleapis URL (byte-identical to pre-base behaviour)', () => {
|
|
42
|
+
mountPicker()
|
|
43
|
+
const hrefs = injectedHrefs()
|
|
44
|
+
expect(hrefs).toEqual([
|
|
45
|
+
'https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=Roboto:wght@400;700&family=Open+Sans:wght@400;700&family=Lato:wght@400;700&family=Montserrat:wght@400;700&family=Poppins:wght@400;700&family=Raleway:wght@400;700&family=Nunito:wght@400;700&family=Work+Sans:wght@400;700&family=DM+Sans:wght@400;700&family=Rubik:wght@400;700&family=Oswald:wght@400;700&family=Bebas+Neue:wght@400;700&family=Playfair+Display:wght@400;700&family=Merriweather:wght@400;700&family=Source+Serif+4:wght@400;700&family=JetBrains+Mono:wght@400;700&family=Baloo+2:wght@400;500;600;700;800&family=Fredoka:wght@300;400;500;600;700&family=Sniglet:wght@400;800&display=swap',
|
|
46
|
+
])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('a base whose family list covers the picker produces <base>/fonts.css ONLY — zero egress', () => {
|
|
50
|
+
setFontsBaseUrl('https://example.com/fonts/editor', PICKER_FAMILIES)
|
|
51
|
+
mountPicker()
|
|
52
|
+
expect(injectedHrefs()).toEqual(['https://example.com/fonts/editor/fonts.css'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('setting a base after a prior injection still injects — a stale guard must not suppress it', () => {
|
|
56
|
+
// Bases unique to this test so it doesn't depend on injection history left
|
|
57
|
+
// by the tests above (module state, not DOM state, outlives a test — the
|
|
58
|
+
// beforeEach above only clears document.head).
|
|
59
|
+
setFontsBaseUrl('https://example.com/fonts/one', PICKER_FAMILIES)
|
|
60
|
+
mountPicker()
|
|
61
|
+
expect(injectedHrefs()).toEqual(['https://example.com/fonts/one/fonts.css'])
|
|
62
|
+
|
|
63
|
+
setFontsBaseUrl('https://example.com/fonts/two', PICKER_FAMILIES)
|
|
64
|
+
mountPicker()
|
|
65
|
+
const hrefs = injectedHrefs()
|
|
66
|
+
expect(hrefs).toHaveLength(2)
|
|
67
|
+
expect(hrefs).toContain('https://example.com/fonts/two/fonts.css')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
// The picker's families ARE the vendored set's reason for existing, so in a
|
|
71
|
+
// correctly configured app this never fires. It is guarded because the day a
|
|
72
|
+
// family is added to FONT_OPTIONS and not yet vendored, the alternative is a
|
|
73
|
+
// picker preview silently showing a system fallback — the exact rot a
|
|
74
|
+
// declared family list exists to catch.
|
|
75
|
+
it('a family the vendored list omits falls through to Google, alongside the vendored sheet', () => {
|
|
76
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
77
|
+
const partial = PICKER_FAMILIES.filter((f) => f !== 'Sniglet')
|
|
78
|
+
setFontsBaseUrl('https://example.com/fonts/partial', partial)
|
|
79
|
+
mountPicker()
|
|
80
|
+
|
|
81
|
+
const hrefs = injectedHrefs()
|
|
82
|
+
expect(hrefs).toHaveLength(2)
|
|
83
|
+
expect(hrefs[0]).toBe('https://example.com/fonts/partial/fonts.css')
|
|
84
|
+
expect(hrefs[1]).toBe('https://fonts.googleapis.com/css2?family=Sniglet:wght@400;800&display=swap')
|
|
85
|
+
expect(warn.mock.calls[0][0]).toContain('Sniglet')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('a base with no family list links nothing vendored and emits exactly the default googleapis URL', async () => {
|
|
89
|
+
// A fresh module instance, because the URL this produces IS the default
|
|
90
|
+
// URL — every picker family falls through — and the module-level dedupe
|
|
91
|
+
// would (correctly) suppress it after the first test above already
|
|
92
|
+
// injected it. Asserting against the live module would therefore pass
|
|
93
|
+
// vacuously, on an empty list.
|
|
94
|
+
vi.resetModules()
|
|
95
|
+
const fresh = await import('../FontPicker')
|
|
96
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
97
|
+
|
|
98
|
+
fresh.setFontsBaseUrl('https://example.com/fonts/nolist')
|
|
99
|
+
render(<fresh.FontFamilyPicker value="" onChange={() => {}} />)
|
|
100
|
+
|
|
101
|
+
const hrefs = injectedHrefs()
|
|
102
|
+
expect(hrefs).toHaveLength(1)
|
|
103
|
+
expect(hrefs[0]).toContain('fonts.googleapis.com')
|
|
104
|
+
expect(hrefs[0]).not.toContain('fonts.css')
|
|
105
|
+
// Every picker family is in it — nothing was withheld on the strength of
|
|
106
|
+
// a base we were given no family list for.
|
|
107
|
+
for (const label of PICKER_FAMILIES) expect(hrefs[0]).toContain(`family=${label.replace(/ /g, '+')}`)
|
|
108
|
+
expect(warn.mock.calls[0][0]).toContain('treating NOTHING as vendored')
|
|
109
|
+
|
|
110
|
+
fresh.setFontsBaseUrl(undefined)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -111,7 +111,7 @@ export default function VersionPanel({ versions, restoring, onRestore, onSaveVer
|
|
|
111
111
|
<button
|
|
112
112
|
onClick={handleSaveClick}
|
|
113
113
|
disabled={saving || !onSaveVersion}
|
|
114
|
-
className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-
|
|
114
|
+
className="shrink-0 flex items-center gap-1.5 h-8 px-3 rounded-md bg-[var(--editor-accent)] text-[var(--editor-accent-foreground)] text-xs font-medium hover:opacity-90 transition-opacity disabled:opacity-40 disabled:hover:opacity-40"
|
|
115
115
|
>
|
|
116
116
|
<Save size={13} />
|
|
117
117
|
{saving ? 'Saving…' : 'Save version'}
|
|
@@ -16,7 +16,7 @@ import { repairCaptionWords } from './captionRepair'
|
|
|
16
16
|
import { maxCaptionLane, normalizeCaptionLanes } from './captionLanes'
|
|
17
17
|
import Timeline, { type TimelineActions, type TimelineMode } from './timeline/Timeline'
|
|
18
18
|
import { visualDuration } from '@bycrux/timeline-core'
|
|
19
|
-
import { computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
|
|
19
|
+
import { audioEnd, computeAutoCrossfade, computeDerivedTiming, computeVisualCrossfade, enabledTrackItems, mapTrackItems, normalizeAudioTracks, trackItems, withEnabledItemTracks } from './timeline/timeline-model'
|
|
20
20
|
import { makeCaptionEdit, type CaptionEditPatch } from './timeline/makeCaptionEdit'
|
|
21
21
|
import PreviewPlayer, { type TransportHandle, type ScrubHandle } from './preview/PreviewPlayer'
|
|
22
22
|
import SocialPreviewMenu, { PlatformGlyph, platformOption } from './preview/SocialPreviewMenu'
|
|
@@ -1307,7 +1307,25 @@ function ReviewSurface<P extends Project>({
|
|
|
1307
1307
|
}, [project.id, project.captions])
|
|
1308
1308
|
|
|
1309
1309
|
const clips = trackItems(project)[0] ?? []
|
|
1310
|
-
|
|
1310
|
+
// Gates the ENTIRE preview region — transport, click-to-play surface and the
|
|
1311
|
+
// multi-track audio elements all live inside it, so anything this misses is
|
|
1312
|
+
// not merely invisible, it is unplayable with no UI saying why.
|
|
1313
|
+
//
|
|
1314
|
+
// Audio counts. An audio-only timeline is the normal state of an animations
|
|
1315
|
+
// project between wiring music and authoring the first overlay, and gating
|
|
1316
|
+
// the region out there left the operator a populated timeline, a drawn
|
|
1317
|
+
// waveform lane, and no transport to press: space did nothing at all.
|
|
1318
|
+
//
|
|
1319
|
+
// `trackItems` is deliberately NOT sliced to `[1:]`. Track 0 is a content
|
|
1320
|
+
// track in a canvas project — an animations-workflow project is frequently
|
|
1321
|
+
// ONE track holding nothing but overlays — and `clips` only counts track-0
|
|
1322
|
+
// *video* items, so a track-0 overlay fell through both terms. Same blind
|
|
1323
|
+
// spot `transportEndFor` and `canvasMaxEndRef` were fixed for.
|
|
1324
|
+
const hasContent =
|
|
1325
|
+
clips.length > 0
|
|
1326
|
+
|| trackItems(project).flat().length > 0
|
|
1327
|
+
|| (project.captions?.segments?.length ?? 0) > 0
|
|
1328
|
+
|| audioEnd(project) > 0
|
|
1311
1329
|
|
|
1312
1330
|
// Preview controls row's timecode readout. `currentTime` is the same
|
|
1313
1331
|
// `usePlaybackTime(clock)` subscription `CaptionListPanelWithClock` uses
|
|
@@ -112,6 +112,47 @@ interface PreviewPlayerProps {
|
|
|
112
112
|
* note at its render site below.
|
|
113
113
|
*/
|
|
114
114
|
socialPreview?: SocialPreviewPlatform | string | null
|
|
115
|
+
/**
|
|
116
|
+
* A second rider on this same pin bump (operator decision, 2026-09-20) —
|
|
117
|
+
* not font work, riding because it is the same package and the same
|
|
118
|
+
* release. Silences every audio path this component owns. Default `false`
|
|
119
|
+
* — today's behavior, unchanged, for every existing host.
|
|
120
|
+
*
|
|
121
|
+
* Exists for hosts that mount a live timeline preview somewhere audio isn't
|
|
122
|
+
* wanted (the project-card hover preview: moving the pointer across a grid
|
|
123
|
+
* must not play each project's audio in turn). `<video muted>` is NOT
|
|
124
|
+
* sufficient on its own — see `ensureVideoGain` and the "Multi-track audio
|
|
125
|
+
* management" section in `useVideoPlayback.ts`: once a slot or a lane is
|
|
126
|
+
* wired `MediaElementSource → GainNode → ctx.destination`, the element's
|
|
127
|
+
* own `muted`/`volume` stop having any audible effect and the GainNode is
|
|
128
|
+
* the only real lever. This prop zeroes every such GainNode (video slots
|
|
129
|
+
* AND background audio-track lanes) via `mutedRef` in `useVideoPlayback.ts`
|
|
130
|
+
* — the `muted` attribute set on the `<video>` slots below is defense in
|
|
131
|
+
* depth for the brief pre-wire window, not the mechanism.
|
|
132
|
+
*
|
|
133
|
+
* Read the scope of that precisely: this silences every audio path these
|
|
134
|
+
* hooks INSTANTIATE, which is not the same as every path reachable through
|
|
135
|
+
* a seam this component EXPOSES. The audible drag-scrub source
|
|
136
|
+
* (`engine/scrub-source.ts`) owns its own gain → destination chain on the
|
|
137
|
+
* same shared AudioContext, and `muted` does not touch it — it is
|
|
138
|
+
* constructed and driven by `VideoEditor`, and only reached here via the
|
|
139
|
+
* `ScrubHandle` seam below. That is sound today because a scrub needs a
|
|
140
|
+
* timeline-drag gesture that a thumbnail-hover mount has no UI for, so a
|
|
141
|
+
* silent host cannot reach it. A host that both passes `muted` and wires
|
|
142
|
+
* scrubbing would hear it, and would be right to call that a bug.
|
|
143
|
+
*
|
|
144
|
+
* Engine-mode (`engine.enabled`) coverage is PARTIAL: `useEnginePlayback.ts`
|
|
145
|
+
* zeroes the same background-lane GainNodes, but track-0 video-item audio
|
|
146
|
+
* is not reached, because this rider did not extend to the engine's own
|
|
147
|
+
* clock. It is a scope boundary, NOT a hard one — the clip's level already
|
|
148
|
+
* rides a per-session output `GainNode` with a live `MasterClock.setVolume`
|
|
149
|
+
* lever wired through `engine/index.ts`, so closing it means pushing 0 down
|
|
150
|
+
* that existing path. See that hook's own comment, which spells out why and
|
|
151
|
+
* corrects a stale claim in its file header about PCM-level scaling. No
|
|
152
|
+
* current host combines `engine.enabled` with `muted`, so this is a
|
|
153
|
+
* documented gap, not a live bug.
|
|
154
|
+
*/
|
|
155
|
+
muted?: boolean
|
|
115
156
|
}
|
|
116
157
|
|
|
117
158
|
export default function PreviewPlayer(props: PreviewPlayerProps) {
|
|
@@ -245,12 +286,12 @@ type SurfaceProps = PreviewPlayerProps & {
|
|
|
245
286
|
}
|
|
246
287
|
|
|
247
288
|
function LegacyPreview(props: SurfaceProps) {
|
|
248
|
-
const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
|
|
289
|
+
const playback = useVideoPlayback(props.project, props.currentTime, props.timeSink, props.fileUrl, !!props.muted)
|
|
249
290
|
return <PreviewSurface {...props} playback={{ mode: 'legacy', ...playback }} />
|
|
250
291
|
}
|
|
251
292
|
|
|
252
293
|
function EnginePreview(props: SurfaceProps) {
|
|
253
|
-
const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl)
|
|
294
|
+
const playback = useEnginePlayback(props.project, props.currentTime, props.timeSink, props.fileUrl, !!props.muted)
|
|
254
295
|
return <PreviewSurface {...props} playback={{ mode: 'engine', ...playback }} />
|
|
255
296
|
}
|
|
256
297
|
|
|
@@ -275,6 +316,7 @@ function PreviewSurface({
|
|
|
275
316
|
transportRef,
|
|
276
317
|
scrubHandleRef,
|
|
277
318
|
socialPreview,
|
|
319
|
+
muted,
|
|
278
320
|
}: SurfaceProps & { playback: PlaybackBinding }) {
|
|
279
321
|
const [RENDER_W, RENDER_H] = getOverlayDesignCanvas(project.settings?.resolution)
|
|
280
322
|
|
|
@@ -497,6 +539,12 @@ function PreviewSurface({
|
|
|
497
539
|
onPlay={() => { if (playback.activeSlotRef.current === 0) playback.setIsPlaying(true) }}
|
|
498
540
|
onPause={() => { if (playback.activeSlotRef.current === 0) playback.handlePause() }}
|
|
499
541
|
playsInline
|
|
542
|
+
// Defense in depth, not the mechanism — see the `muted` prop
|
|
543
|
+
// doc above. Once `ensureVideoGain` wires this element through
|
|
544
|
+
// Web Audio (on first play), this attribute stops having any
|
|
545
|
+
// audible effect; the GainNode zeroed via `mutedRef` in
|
|
546
|
+
// `useVideoPlayback.ts` is what actually silences it.
|
|
547
|
+
muted={!!muted}
|
|
500
548
|
style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 0 ? 1 : 0, pointerEvents: playback.activeSlot === 0 ? 'auto' : 'none', zIndex: playback.activeSlot === 0 ? 1 : 0 }}
|
|
501
549
|
/>
|
|
502
550
|
{/* Slot 1 */}
|
|
@@ -513,6 +561,8 @@ function PreviewSurface({
|
|
|
513
561
|
onPlay={() => { if (playback.activeSlotRef.current === 1) playback.setIsPlaying(true) }}
|
|
514
562
|
onPause={() => { if (playback.activeSlotRef.current === 1) playback.handlePause() }}
|
|
515
563
|
playsInline
|
|
564
|
+
// See slot 0.
|
|
565
|
+
muted={!!muted}
|
|
516
566
|
style={{ ...baseVideoStyle, opacity: showVideo && playback.activeSlot === 1 ? 1 : 0, pointerEvents: playback.activeSlot === 1 ? 'auto' : 'none', zIndex: playback.activeSlot === 1 ? 1 : 0 }}
|
|
517
567
|
/>
|
|
518
568
|
</>
|