@remixmate/template-core 1.3.0

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/dist/index.js ADDED
@@ -0,0 +1,2153 @@
1
+ import React3, { useState, useCallback, useEffect, useMemo, useLayoutEffect } from 'react';
2
+ import { useCurrentFrame, AbsoluteFill, Video, Img, useVideoConfig, interpolate, Easing, Sequence, Audio, spring, OffthreadVideo, Loop, Freeze, delayRender, continueRender } from 'remotion';
3
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
4
+
5
+ // src/primitives/Background.tsx
6
+
7
+ // src/utils/diagnostics.ts
8
+ var SAFE_ID = /^[a-zA-Z0-9_.:-]{1,128}$/;
9
+ var reported = /* @__PURE__ */ new Set();
10
+ function sanitizeDiagnosticSrc(src) {
11
+ if (src.startsWith("data:")) {
12
+ const end = src.indexOf(";");
13
+ return src.slice(0, end > 0 ? end : 32);
14
+ }
15
+ return src.split(/[?#]/)[0] ?? src;
16
+ }
17
+ function reportSceneDegraded(report) {
18
+ const { templateId, reason, sceneId, slideId, dedupeKey, detail } = report;
19
+ const key = [templateId ?? "", sceneId ?? "", slideId ?? "", reason, dedupeKey ?? ""].join("|");
20
+ if (reported.has(key)) return;
21
+ reported.add(key);
22
+ const extra = {};
23
+ for (const [k, v] of Object.entries(detail ?? {})) {
24
+ if (v === void 0) continue;
25
+ extra[k] = k === "src" && typeof v === "string" ? sanitizeDiagnosticSrc(v) : v;
26
+ }
27
+ if (!SAFE_ID.test(reason)) {
28
+ console.warn(
29
+ `[core] degraded reason ${JSON.stringify(reason)} is not a safe id; the server will drop it`
30
+ );
31
+ }
32
+ console.warn(
33
+ "__diagnostic_v1__ " + JSON.stringify({
34
+ ...extra,
35
+ event: "template.scene.degraded",
36
+ ...templateId !== void 0 ? { template_id: templateId } : {},
37
+ ...sceneId !== void 0 ? { scene_id: sceneId } : {},
38
+ ...slideId !== void 0 ? { slide_id: slideId } : {},
39
+ reason
40
+ })
41
+ );
42
+ }
43
+ function resetSceneDegradedReports() {
44
+ reported.clear();
45
+ }
46
+ function useVideoProbe(src) {
47
+ const [status, setStatus] = useState({
48
+ src: null,
49
+ value: "pending"
50
+ });
51
+ useLayoutEffect(() => {
52
+ if (!src) return;
53
+ const handle = delayRender(`Probing background video ${src.split(/[?#]/)[0]}`);
54
+ let released = false;
55
+ const release = () => {
56
+ if (released) return;
57
+ released = true;
58
+ continueRender(handle);
59
+ };
60
+ const probe = document.createElement("video");
61
+ probe.preload = "metadata";
62
+ probe.muted = true;
63
+ const onLoaded = () => {
64
+ setStatus({ src, value: "ok" });
65
+ release();
66
+ };
67
+ const onError = () => {
68
+ setStatus({ src, value: "error" });
69
+ release();
70
+ };
71
+ probe.addEventListener("loadedmetadata", onLoaded, { once: true });
72
+ probe.addEventListener("error", onError, { once: true });
73
+ probe.src = src;
74
+ return () => {
75
+ probe.removeEventListener("loadedmetadata", onLoaded);
76
+ probe.removeEventListener("error", onError);
77
+ probe.removeAttribute("src");
78
+ probe.load();
79
+ release();
80
+ };
81
+ }, [src]);
82
+ return status.src === src ? status.value : "pending";
83
+ }
84
+ var PAN_AMPLITUDE_PCT = 5;
85
+ var PAN_SCALE = 1.12;
86
+ function backgroundMotionStyle(motion, progress) {
87
+ const p = Math.min(Math.max(progress, 0), 1);
88
+ switch (motion) {
89
+ case "kenburns-in":
90
+ return { transform: `scale(${1 + p * 0.15})` };
91
+ case "kenburns-out":
92
+ return { transform: `scale(${1 + (1 - p) * 0.15})` };
93
+ case "pan-left": {
94
+ const tx = PAN_AMPLITUDE_PCT - p * PAN_AMPLITUDE_PCT * 2;
95
+ return { transform: `translateX(${tx}%) scale(${PAN_SCALE})` };
96
+ }
97
+ case "pan-right": {
98
+ const tx = -PAN_AMPLITUDE_PCT + p * PAN_AMPLITUDE_PCT * 2;
99
+ return { transform: `translateX(${tx}%) scale(${PAN_SCALE})` };
100
+ }
101
+ default:
102
+ return {};
103
+ }
104
+ }
105
+ var FALLBACK_COLOR = "#1a1a2e";
106
+ function warnBackgroundLoadFailed(src, type) {
107
+ reportSceneDegraded({
108
+ reason: "background_load_failed",
109
+ dedupeKey: src,
110
+ detail: { component: "Background", media_type: type, src }
111
+ });
112
+ }
113
+ var Background = ({
114
+ src,
115
+ type = "image",
116
+ color,
117
+ gradient,
118
+ motion = "static",
119
+ durationFrames,
120
+ fallbackColor = FALLBACK_COLOR
121
+ }) => {
122
+ const frame = useCurrentFrame();
123
+ const progress = Math.min(frame / Math.max(durationFrames, 1), 1);
124
+ const motionStyle = backgroundMotionStyle(motion, progress);
125
+ const [failedSrc, setFailedSrc] = useState(null);
126
+ const handleError = useCallback(() => {
127
+ if (!src) return;
128
+ warnBackgroundLoadFailed(src, type === "video" ? "video" : "image");
129
+ setFailedSrc(src);
130
+ }, [src, type]);
131
+ const videoProbe = useVideoProbe(type === "video" && src ? src : null);
132
+ const videoFailed = type === "video" && videoProbe === "error";
133
+ useEffect(() => {
134
+ if (videoFailed && src) warnBackgroundLoadFailed(src, "video");
135
+ }, [videoFailed, src]);
136
+ if (color || gradient) {
137
+ return /* @__PURE__ */ jsx(
138
+ AbsoluteFill,
139
+ {
140
+ style: {
141
+ background: gradient || color || "#000"
142
+ }
143
+ }
144
+ );
145
+ }
146
+ if (!src || failedSrc === src || videoFailed) {
147
+ return /* @__PURE__ */ jsx(AbsoluteFill, { style: { backgroundColor: fallbackColor } });
148
+ }
149
+ if (type === "video") {
150
+ if (videoProbe !== "ok") return null;
151
+ return /* @__PURE__ */ jsx(AbsoluteFill, { style: { overflow: "hidden" }, children: /* @__PURE__ */ jsx(
152
+ Video,
153
+ {
154
+ src,
155
+ muted: true,
156
+ loop: true,
157
+ onError: handleError,
158
+ style: {
159
+ width: "100%",
160
+ height: "100%",
161
+ objectFit: "cover",
162
+ ...motionStyle
163
+ }
164
+ }
165
+ ) });
166
+ }
167
+ return /* @__PURE__ */ jsx(AbsoluteFill, { style: { overflow: "hidden" }, children: /* @__PURE__ */ jsx(
168
+ Img,
169
+ {
170
+ src,
171
+ onError: handleError,
172
+ style: {
173
+ width: "100%",
174
+ height: "100%",
175
+ objectFit: "cover",
176
+ ...motionStyle
177
+ }
178
+ }
179
+ ) });
180
+ };
181
+ function useAssetUrl(assetId, resolvedAssets) {
182
+ return useMemo(() => {
183
+ if (!assetId) return null;
184
+ const asset = resolvedAssets.find((a) => a.assetId === assetId);
185
+ if (!asset) return null;
186
+ return asset.url || asset.localPath || null;
187
+ }, [assetId, resolvedAssets]);
188
+ }
189
+ function resolveAssetUrl(assetId, resolvedAssets) {
190
+ if (!assetId) return null;
191
+ const asset = resolvedAssets.find((a) => a.assetId === assetId);
192
+ return asset?.url || asset?.localPath || null;
193
+ }
194
+ function resolveAsset(assetId, resolvedAssets) {
195
+ if (!assetId) return void 0;
196
+ return resolvedAssets.find((a) => a.assetId === assetId);
197
+ }
198
+ var MEDIA_OVERLAY_MASK = "linear-gradient(180deg, rgba(0,0,0,0.45) 0%, rgba(0,0,0,0.75) 45%, rgba(0,0,0,1) 78%, rgba(0,0,0,1) 100%)";
199
+ function grainDataUri(blend) {
200
+ const tint = blend === "screen" ? 1 : 0;
201
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.6' numOctaves='2' stitchTiles='stitch'/><feColorMatrix values='0 0 0 0 ${tint} 0 0 0 0 ${tint} 0 0 0 0 ${tint} 0 0 0 0.6 0'/></filter><rect width='100%' height='100%' filter='url(#n)' opacity='0.55'/></svg>`;
202
+ return `url("data:image/svg+xml,${encodeURIComponent(svg)}")`;
203
+ }
204
+ function resolveSceneBackground(background, signaturePreset, defaultMediaMotion = "kenburns-in", defaultOverlayOpacity = 0.35) {
205
+ const preset = background?.preset ?? signaturePreset;
206
+ const isMedia = preset === "image" || preset === "video";
207
+ return {
208
+ preset,
209
+ isSignaturePreset: preset === signaturePreset,
210
+ isMedia,
211
+ paintCanvas: preset !== "none",
212
+ motion: background?.motion ?? defaultMediaMotion,
213
+ blurPx: Math.max(0, background?.blurPx ?? 0),
214
+ overlayOpacity: background?.overlayOpacity ?? (isMedia ? defaultOverlayOpacity : 0)
215
+ };
216
+ }
217
+ var SceneBackgroundLayer = ({
218
+ background,
219
+ resolvedAssets,
220
+ durationFrames,
221
+ canvasBg,
222
+ noiseBlend,
223
+ signaturePreset,
224
+ defaultGradient,
225
+ defaultMediaMotion = "kenburns-in",
226
+ defaultOverlayColor = "rgba(8, 10, 28, 1)",
227
+ defaultOverlayOpacity = 0.35,
228
+ signature,
229
+ depth
230
+ }) => {
231
+ const { preset, isSignaturePreset, isMedia, paintCanvas, motion, blurPx, overlayOpacity } = resolveSceneBackground(
232
+ background,
233
+ signaturePreset,
234
+ defaultMediaMotion,
235
+ defaultOverlayOpacity
236
+ );
237
+ const imageSrc = preset === "image" ? resolveAssetUrl(background?.assetRef, resolvedAssets) || background?.imageUrl || null : null;
238
+ const videoSrc = preset === "video" ? resolveAssetUrl(background?.videoAssetRef, resolvedAssets) || background?.videoUrl || null : null;
239
+ const videoProbe = useVideoProbe(videoSrc);
240
+ const videoFailed = !!videoSrc && videoProbe === "error";
241
+ useEffect(() => {
242
+ if (videoFailed && videoSrc) warnBackgroundLoadFailed(videoSrc, "video");
243
+ }, [videoFailed, videoSrc]);
244
+ const overlayColor = background?.overlayColor ?? defaultOverlayColor;
245
+ const slotCtx = { preset, isSignaturePreset, isMedia };
246
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { children: [
247
+ paintCanvas && /* @__PURE__ */ jsx(AbsoluteFill, { style: { backgroundColor: canvasBg } }),
248
+ preset === "solid" && /* @__PURE__ */ jsx(
249
+ AbsoluteFill,
250
+ {
251
+ style: {
252
+ background: background?.cssBackground || background?.color || canvasBg
253
+ }
254
+ }
255
+ ),
256
+ preset === "gradient" && /* @__PURE__ */ jsx(
257
+ AbsoluteFill,
258
+ {
259
+ style: {
260
+ background: background?.cssBackground || (background?.color ? `linear-gradient(160deg, ${background.color} 0%, ${canvasBg} 100%)` : defaultGradient)
261
+ }
262
+ }
263
+ ),
264
+ preset === "image" && imageSrc && /* @__PURE__ */ jsx(AbsoluteFill, { style: { overflow: "hidden" }, children: blurPx > 0 ? /* @__PURE__ */ jsx(AbsoluteFill, { style: { filter: `blur(${blurPx}px)` }, children: /* @__PURE__ */ jsx(
265
+ Background,
266
+ {
267
+ src: imageSrc,
268
+ type: "image",
269
+ motion,
270
+ durationFrames
271
+ }
272
+ ) }) : /* @__PURE__ */ jsx(
273
+ Background,
274
+ {
275
+ src: imageSrc,
276
+ type: "image",
277
+ motion,
278
+ durationFrames
279
+ }
280
+ ) }),
281
+ preset === "video" && videoFailed && /* @__PURE__ */ jsx(AbsoluteFill, { style: { backgroundColor: canvasBg } }),
282
+ preset === "video" && videoSrc && videoProbe === "ok" && /* @__PURE__ */ jsx(AbsoluteFill, { style: { overflow: "hidden" }, children: /* @__PURE__ */ jsx(
283
+ Video,
284
+ {
285
+ src: videoSrc,
286
+ muted: background?.videoMuted ?? true,
287
+ loop: background?.videoLoop ?? true,
288
+ onError: () => void 0,
289
+ style: {
290
+ width: "100%",
291
+ height: "100%",
292
+ objectFit: "cover",
293
+ filter: blurPx > 0 ? `blur(${blurPx}px)` : void 0
294
+ }
295
+ }
296
+ ) }),
297
+ preset === "noise" && /* @__PURE__ */ jsx(
298
+ AbsoluteFill,
299
+ {
300
+ style: {
301
+ opacity: background?.noiseOpacity ?? 0.4,
302
+ backgroundImage: grainDataUri(noiseBlend),
303
+ mixBlendMode: noiseBlend
304
+ }
305
+ }
306
+ ),
307
+ isMedia && overlayOpacity > 0 && /* @__PURE__ */ jsx(
308
+ AbsoluteFill,
309
+ {
310
+ style: {
311
+ // 平铺一色即可。html-slide 原本写成 linear-gradient(C 0%, C 100%),
312
+ // 两种写法实测逐像素相同(2026-09-10,1920×1080 image preset),
313
+ // 取简洁的这一种。
314
+ background: overlayColor,
315
+ opacity: overlayOpacity,
316
+ maskImage: MEDIA_OVERLAY_MASK,
317
+ WebkitMaskImage: MEDIA_OVERLAY_MASK
318
+ }
319
+ }
320
+ ),
321
+ signature?.(slotCtx),
322
+ paintCanvas && depth?.(slotCtx)
323
+ ] });
324
+ };
325
+ var REFERENCE_LONG_EDGE = 1920;
326
+ function useCanvasScale() {
327
+ const { width, height } = useVideoConfig();
328
+ return Math.max(width, height) / REFERENCE_LONG_EDGE;
329
+ }
330
+
331
+ // src/utils/fontStack.ts
332
+ var WEIGHT_SUFFIXES = [
333
+ "Thin",
334
+ "ExtraLight",
335
+ "UltraLight",
336
+ "Light",
337
+ "Regular",
338
+ "Normal",
339
+ "Medium",
340
+ "Semibold",
341
+ "SemiBold",
342
+ "DemiBold",
343
+ "Bold",
344
+ "ExtraBold",
345
+ "UltraBold",
346
+ "Black",
347
+ "Heavy"
348
+ ];
349
+ function deWeighted(family) {
350
+ for (const suffix of WEIGHT_SUFFIXES) {
351
+ if (family.length > suffix.length + 1 && family.endsWith(" " + suffix)) {
352
+ return family.slice(0, -(suffix.length + 1)).trim();
353
+ }
354
+ }
355
+ return void 0;
356
+ }
357
+ var CJK_FALLBACKS = ['"Noto Sans SC"', '"Noto Sans CJK SC"'];
358
+ var GENERIC_FAMILIES = /* @__PURE__ */ new Set([
359
+ "sans-serif",
360
+ "serif",
361
+ "monospace",
362
+ "cursive",
363
+ "fantasy",
364
+ "system-ui",
365
+ "ui-sans-serif",
366
+ "ui-serif",
367
+ "ui-monospace",
368
+ "ui-rounded"
369
+ ]);
370
+ function quote(family) {
371
+ if (/^".*"$/.test(family) || /^'.*'$/.test(family)) return family;
372
+ if (/^[a-z-]+$/.test(family)) return family;
373
+ return `"${family}"`;
374
+ }
375
+ function fontStack(font) {
376
+ const entries = [];
377
+ const seen = /* @__PURE__ */ new Set();
378
+ let generic;
379
+ const push = (family) => {
380
+ const quoted = quote(family);
381
+ const key = quoted.toLowerCase();
382
+ if (seen.has(key)) return;
383
+ seen.add(key);
384
+ entries.push(quoted);
385
+ };
386
+ for (const raw of (font ?? "").split(",")) {
387
+ const family = raw.trim().replace(/^["']|["']$/g, "");
388
+ if (family.length === 0) continue;
389
+ if (GENERIC_FAMILIES.has(family.toLowerCase())) {
390
+ generic ??= family.toLowerCase();
391
+ continue;
392
+ }
393
+ push(family);
394
+ const bare = deWeighted(family);
395
+ if (bare) push(bare);
396
+ }
397
+ if (entries.length === 0) push("PingFang SC");
398
+ for (const fallback of CJK_FALLBACKS) push(fallback);
399
+ entries.push(generic ?? "sans-serif");
400
+ return entries.join(", ");
401
+ }
402
+
403
+ // src/utils/subtitleChunks.ts
404
+ var DEFAULT_SPLIT_MAX_UNITS = 20;
405
+ var DEFAULT_MIN_CHUNK_UNITS = 7;
406
+ var LINE_FILL_LIMIT = 0.9;
407
+ var BREAK_LEVELS = [
408
+ /[。!?!?]/,
409
+ // 句末
410
+ /[::;;]/,
411
+ // 子句
412
+ /[,,]/,
413
+ // 逗号
414
+ /[、]/,
415
+ // 顿号(并列项之间,最弱)
416
+ / /
417
+ // 空格 —— 只为保护英文单词不被硬切
418
+ ];
419
+ var TRAILING_PUNCT = /[。!?;,,、::..…]+$/;
420
+ var ASCII_EM = {
421
+ // 小写
422
+ a: 0.572,
423
+ b: 0.602,
424
+ c: 0.561,
425
+ d: 0.602,
426
+ e: 0.567,
427
+ f: 0.346,
428
+ g: 0.602,
429
+ h: 0.577,
430
+ i: 0.275,
431
+ j: 0.276,
432
+ k: 0.553,
433
+ l: 0.254,
434
+ m: 0.884,
435
+ n: 0.581,
436
+ o: 0.6,
437
+ p: 0.602,
438
+ q: 0.602,
439
+ r: 0.374,
440
+ s: 0.525,
441
+ t: 0.362,
442
+ u: 0.582,
443
+ v: 0.506,
444
+ w: 0.777,
445
+ x: 0.537,
446
+ y: 0.526,
447
+ z: 0.506,
448
+ // 大写
449
+ A: 0.679,
450
+ B: 0.69,
451
+ C: 0.732,
452
+ D: 0.72,
453
+ E: 0.642,
454
+ F: 0.582,
455
+ G: 0.756,
456
+ H: 0.736,
457
+ I: 0.257,
458
+ J: 0.538,
459
+ K: 0.714,
460
+ L: 0.592,
461
+ M: 0.903,
462
+ N: 0.732,
463
+ O: 0.775,
464
+ P: 0.655,
465
+ Q: 0.775,
466
+ R: 0.697,
467
+ S: 0.651,
468
+ T: 0.615,
469
+ U: 0.734,
470
+ V: 0.659,
471
+ W: 0.953,
472
+ X: 0.664,
473
+ Y: 0.691,
474
+ Z: 0.639,
475
+ // 数字
476
+ "0": 0.6,
477
+ "1": 0.41,
478
+ "2": 0.6,
479
+ "3": 0.6,
480
+ "4": 0.6,
481
+ "5": 0.6,
482
+ "6": 0.6,
483
+ "7": 0.553,
484
+ "8": 0.6,
485
+ "9": 0.6,
486
+ // 标点与符号
487
+ " ": 0.333,
488
+ "!": 0.333,
489
+ '"': 0.488,
490
+ "#": 0.6,
491
+ $: 0.6,
492
+ "%": 0.996,
493
+ "&": 0.751,
494
+ "'": 0.281,
495
+ "(": 0.333,
496
+ ")": 0.333,
497
+ "*": 0.516,
498
+ "+": 0.605,
499
+ ",": 0.275,
500
+ "-": 0.605,
501
+ ".": 0.275,
502
+ "/": 0.5,
503
+ ":": 0.275,
504
+ ";": 0.275,
505
+ "<": 0.605,
506
+ "=": 0.605,
507
+ ">": 0.605,
508
+ "?": 0.558,
509
+ "@": 0.866,
510
+ "[": 0.333,
511
+ "\\": 0.5,
512
+ "]": 0.333,
513
+ "^": 0.545,
514
+ _: 0.5,
515
+ "`": 0.333,
516
+ "{": 0.333,
517
+ "|": 0.214,
518
+ "}": 0.333,
519
+ "~": 0.5
520
+ };
521
+ var FALLBACK_NARROW_EM = 0.55;
522
+ function displayWidth(text) {
523
+ let width = 0;
524
+ for (const ch of text) {
525
+ if ((ch.codePointAt(0) ?? 0) >= 4352) {
526
+ width += 1;
527
+ continue;
528
+ }
529
+ width += ASCII_EM[ch] ?? FALLBACK_NARROW_EM;
530
+ }
531
+ return width;
532
+ }
533
+ var LATIN_RUN = /[A-Za-z]+/g;
534
+ var CJK_PUNCT = new RegExp(
535
+ "[\\u3000-\\u303F\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65]"
536
+ );
537
+ function spokenWeight(text) {
538
+ if (!text) return 0;
539
+ let weight = 0;
540
+ for (const ch of text) {
541
+ const cp = ch.codePointAt(0) ?? 0;
542
+ if (cp >= 4352) {
543
+ if (!CJK_PUNCT.test(ch)) weight += 1;
544
+ } else if (ch >= "0" && ch <= "9") weight += 1.2;
545
+ }
546
+ for (const [run] of text.matchAll(LATIN_RUN)) {
547
+ const isAcronym = run.length <= 5 && run === run.toUpperCase();
548
+ weight += isAcronym ? run.length : run.length * 0.33;
549
+ }
550
+ return weight;
551
+ }
552
+ function estimateLineCapacity(availableWidthPx, fontSizePx, letterSpacingPx = 0) {
553
+ const advance = fontSizePx + letterSpacingPx;
554
+ if (!(advance > 0) || !(availableWidthPx > 0)) return DEFAULT_SPLIT_MAX_UNITS;
555
+ return Math.max(1, Math.floor(availableWidthPx / advance));
556
+ }
557
+ function tokenize(text, breakAfter) {
558
+ const tokens = [];
559
+ let current = "";
560
+ for (const ch of text) {
561
+ current += ch;
562
+ if (breakAfter.test(ch)) {
563
+ tokens.push(current);
564
+ current = "";
565
+ }
566
+ }
567
+ if (current) tokens.push(current);
568
+ return tokens;
569
+ }
570
+ function hardSplit(token, maxUnits) {
571
+ const total = displayWidth(token);
572
+ if (total <= maxUnits) return [token];
573
+ const out = [];
574
+ let current = "";
575
+ let width = 0;
576
+ let remainingWidth = total;
577
+ let remainingPieces = Math.ceil(total / maxUnits);
578
+ for (const ch of token) {
579
+ const w = displayWidth(ch);
580
+ const target = remainingWidth / remainingPieces;
581
+ const exceedsMax = width + w > maxUnits;
582
+ const exceedsTarget = width + w > target && remainingPieces > 1;
583
+ if (current && (exceedsMax || exceedsTarget)) {
584
+ out.push(current);
585
+ remainingWidth -= width;
586
+ remainingPieces = Math.max(1, remainingPieces - 1);
587
+ current = "";
588
+ width = 0;
589
+ }
590
+ current += ch;
591
+ width += w;
592
+ }
593
+ if (current) out.push(current);
594
+ return out;
595
+ }
596
+ function packTokens(tokens, target, maxUnits) {
597
+ const parts = [];
598
+ let current = "";
599
+ let width = 0;
600
+ for (const token of tokens) {
601
+ const w = displayWidth(token);
602
+ if (!current) {
603
+ current = token;
604
+ width = w;
605
+ continue;
606
+ }
607
+ const merged = width + w;
608
+ const worseThanStopping = Math.abs(merged - target) > Math.abs(width - target);
609
+ if (merged > maxUnits || worseThanStopping) {
610
+ parts.push(current);
611
+ current = token;
612
+ width = w;
613
+ } else {
614
+ current = current + token;
615
+ width = merged;
616
+ }
617
+ }
618
+ if (current) parts.push(current);
619
+ return parts;
620
+ }
621
+ function mergeShort(parts, minChunkUnits, splitMaxUnits) {
622
+ if (parts.length <= 1) return parts;
623
+ const out = [parts[0]];
624
+ for (let i = 1; i < parts.length; i++) {
625
+ const part = parts[i];
626
+ const prev = out[out.length - 1];
627
+ const prevWidth = displayWidth(prev);
628
+ const partWidth = displayWidth(part);
629
+ const tooShort = prevWidth < minChunkUnits || partWidth < minChunkUnits;
630
+ if (tooShort && prevWidth + partWidth <= splitMaxUnits) {
631
+ out[out.length - 1] = prev + part;
632
+ } else {
633
+ out.push(part);
634
+ }
635
+ }
636
+ return out;
637
+ }
638
+ function resolveThresholds(options) {
639
+ const maxUnitsPerLine = Math.max(1, options.maxUnitsPerLine);
640
+ const splitMax = Math.max(
641
+ 1,
642
+ Math.min(
643
+ options.splitMaxUnits ?? DEFAULT_SPLIT_MAX_UNITS,
644
+ Math.floor(maxUnitsPerLine * LINE_FILL_LIMIT)
645
+ )
646
+ );
647
+ const minChunk = Math.min(
648
+ options.minChunkUnits ?? DEFAULT_MIN_CHUNK_UNITS,
649
+ splitMax
650
+ );
651
+ return { maxUnitsPerLine, splitMax, minChunk };
652
+ }
653
+ function splitSubtitleText(text, options) {
654
+ const trimmed = (text ?? "").trim();
655
+ if (!trimmed) return [];
656
+ const { splitMax, minChunk } = resolveThresholds(options);
657
+ const finish = (parts2) => parts2.map((part) => part.trim().replace(TRAILING_PUNCT, "").trim()).filter(Boolean);
658
+ if (displayWidth(trimmed) <= splitMax) return finish([trimmed]);
659
+ const tooWide = (part) => displayWidth(part) > splitMax;
660
+ const packAtLevel = (part, breakAfter) => {
661
+ const total = displayWidth(part);
662
+ const pieces = Math.max(1, Math.ceil(total / splitMax));
663
+ return packTokens(tokenize(part, breakAfter), total / pieces, splitMax);
664
+ };
665
+ let parts = [trimmed];
666
+ for (const breakAfter of BREAK_LEVELS) {
667
+ parts = parts.flatMap(
668
+ (part) => tooWide(part) ? packAtLevel(part, breakAfter) : [part]
669
+ );
670
+ }
671
+ parts = parts.flatMap(
672
+ (part) => tooWide(part) ? hardSplit(part, splitMax) : [part]
673
+ );
674
+ return finish(mergeShort(parts, minChunk, splitMax));
675
+ }
676
+ function chunkSubtitleSegments(segments, options) {
677
+ const { minFrames = 0 } = options;
678
+ const out = [];
679
+ for (const segment of segments) {
680
+ const span = segment.endFrame - segment.startFrame;
681
+ const parts = splitSubtitleText(segment.text, options);
682
+ if (parts.length === 0) continue;
683
+ if (parts.length === 1 || span < minFrames * parts.length) {
684
+ const whole = parts.length === 1 ? parts[0] : segment.text.trim().replace(TRAILING_PUNCT, "").trim();
685
+ out.push(whole === segment.text ? segment : { ...segment, text: whole });
686
+ continue;
687
+ }
688
+ const spoken = parts.map(spokenWeight);
689
+ const totalSpoken = spoken.reduce((sum, w) => sum + w, 0);
690
+ const weights = totalSpoken > 0 ? spoken : parts.map((part) => displayWidth(part));
691
+ const totalWeight = weights.reduce((sum, w) => sum + w, 0) || 1;
692
+ let cursor = segment.startFrame;
693
+ parts.forEach((part, i) => {
694
+ const remaining = parts.length - 1 - i;
695
+ let end;
696
+ if (remaining === 0) {
697
+ end = segment.endFrame;
698
+ } else {
699
+ const frames = Math.round(span * weights[i] / totalWeight);
700
+ end = Math.min(
701
+ Math.max(cursor + 1, cursor + frames),
702
+ segment.endFrame - remaining
703
+ );
704
+ }
705
+ out.push({ text: part, startFrame: cursor, endFrame: end });
706
+ cursor = end;
707
+ });
708
+ }
709
+ return out;
710
+ }
711
+ function resolveRunFadeOpacity(frame, runStart, runEnd, capFrames) {
712
+ const runLength = Math.max(0, runEnd - runStart);
713
+ const fade = Math.min(
714
+ Math.max(0, Math.floor(capFrames)),
715
+ Math.floor((runLength - 1) / 2)
716
+ );
717
+ if (fade < 1) return 1;
718
+ if (frame <= runStart || frame >= runEnd) return 0;
719
+ if (frame < runStart + fade) return (frame - runStart) / fade;
720
+ if (frame > runEnd - fade) return (runEnd - frame) / fade;
721
+ return 1;
722
+ }
723
+ function resolveChunkFadeOpacity(framesIntoChunk) {
724
+ const FLOOR = 0.35;
725
+ const FRAMES = 4;
726
+ if (framesIntoChunk <= 0) return FLOOR;
727
+ if (framesIntoChunk >= FRAMES) return 1;
728
+ return FLOOR + (1 - FLOOR) * (framesIntoChunk / FRAMES);
729
+ }
730
+ function resolveActiveChunk(chunks, frame, holdFrames) {
731
+ for (let i = 0; i < chunks.length; i++) {
732
+ const chunk = chunks[i];
733
+ if (frame >= chunk.startFrame && frame < chunk.endFrame) return chunk;
734
+ const next = chunks[i + 1];
735
+ if (next && frame >= chunk.endFrame && frame < next.startFrame && next.startFrame - chunk.endFrame <= holdFrames) {
736
+ return chunk;
737
+ }
738
+ }
739
+ return void 0;
740
+ }
741
+ var AUTO_BRIGHT_THRESHOLD = 0.55;
742
+ var AUTO_DARK_THRESHOLD = 0.35;
743
+ var SCRIM_HEIGHT_RATIO = 0.28;
744
+ var DEFAULT_MAX_WIDTH_PCT = {
745
+ dark: 85,
746
+ light: 85,
747
+ scrim: 88,
748
+ stroke: 90,
749
+ none: 85
750
+ };
751
+ var HOLD_SEC = 0.5;
752
+ var MIN_DWELL_SEC = 0.6;
753
+ function resolveFontSize(mode, baseSize, scale, fontScale) {
754
+ const size = mode === "stroke" ? Math.max(baseSize + 2, 30) : baseSize;
755
+ return size * scale * fontScale;
756
+ }
757
+ var DEFAULT_BOTTOM_PX = {
758
+ dark: 80,
759
+ light: 80,
760
+ scrim: 80,
761
+ stroke: 80,
762
+ none: 0
763
+ };
764
+ var REFERENCE_HEIGHT = 1920;
765
+ function resolveSubtitleBottomPx(mode, canvas, bottomRatio) {
766
+ if (bottomRatio !== void 0) return canvas.height * bottomRatio;
767
+ return DEFAULT_BOTTOM_PX[mode] * (canvas.height / REFERENCE_HEIGHT);
768
+ }
769
+ function resolveSubtitleMode(mode, captionStyle, backgroundLuma) {
770
+ const requested = mode ?? (captionStyle === "stroke" ? "stroke" : captionStyle === "pill" ? "dark" : void 0);
771
+ if (requested === void 0) return "dark";
772
+ if (requested !== "auto") return requested;
773
+ if (typeof backgroundLuma !== "number" || !Number.isFinite(backgroundLuma)) {
774
+ return "scrim";
775
+ }
776
+ if (backgroundLuma > AUTO_BRIGHT_THRESHOLD) return "dark";
777
+ if (backgroundLuma < AUTO_DARK_THRESHOLD) return "light";
778
+ return "scrim";
779
+ }
780
+ var SubtitleBar = ({
781
+ segments,
782
+ style = "bottom",
783
+ typography,
784
+ captionStyle,
785
+ mode,
786
+ bottomRatio,
787
+ fontScale = 1,
788
+ maxWidthPct,
789
+ backgroundLuma
790
+ }) => {
791
+ const frame = useCurrentFrame();
792
+ const scale = useCanvasScale();
793
+ const { width, height, fps } = useVideoConfig();
794
+ const resolved = resolveSubtitleMode(mode, captionStyle, backgroundLuma);
795
+ const bodyFamily = fontStack(typography?.bodyFont);
796
+ const baseSize = typography?.bodySize || 32;
797
+ const fontSize = resolveFontSize(resolved, baseSize, scale, fontScale);
798
+ const widthPct = maxWidthPct ?? DEFAULT_MAX_WIDTH_PCT[resolved];
799
+ const chunks = React3.useMemo(
800
+ () => chunkSubtitleSegments(segments, {
801
+ maxUnitsPerLine: estimateLineCapacity(
802
+ width * widthPct / 100,
803
+ fontSize
804
+ ),
805
+ minFrames: Math.round(fps * MIN_DWELL_SEC)
806
+ }),
807
+ [segments, width, widthPct, fontSize, fps]
808
+ );
809
+ const active = resolveActiveChunk(
810
+ chunks,
811
+ frame,
812
+ Math.round(fps * HOLD_SEC)
813
+ );
814
+ if (resolved === "none" || !active) return null;
815
+ const runStart = chunks[0].startFrame;
816
+ const runEnd = chunks[chunks.length - 1].endFrame;
817
+ const scrimOpacity = resolveRunFadeOpacity(frame, runStart, runEnd, 8);
818
+ const plateOpacity = scrimOpacity;
819
+ const textOpacity = resolveChunkFadeOpacity(frame - active.startFrame);
820
+ const paddingBottom = resolveSubtitleBottomPx(resolved, { height }, bottomRatio);
821
+ const positionStyle = style === "center" ? { justifyContent: "center", alignItems: "center" } : { justifyContent: "flex-end", alignItems: "center", paddingBottom };
822
+ const plate = (background, color, shadow) => ({
823
+ backgroundColor: background,
824
+ color,
825
+ fontWeight: 600,
826
+ fontSize,
827
+ fontFamily: bodyFamily,
828
+ padding: `${16 * scale}px ${32 * scale}px`,
829
+ borderRadius: 12 * scale,
830
+ maxWidth: `${widthPct}%`,
831
+ textAlign: "center",
832
+ lineHeight: 1.4,
833
+ textShadow: shadow
834
+ });
835
+ let captionBoxStyle;
836
+ switch (resolved) {
837
+ case "light":
838
+ captionBoxStyle = plate(
839
+ "rgba(255, 255, 255, 0.85)",
840
+ "#1A1A1A",
841
+ // Weaker than the dark plate's: a light plate on a light photo still
842
+ // needs an edge, but a heavy shadow reads as dirty.
843
+ "0 1px 4px rgba(0,0,0,0.18)"
844
+ );
845
+ break;
846
+ case "scrim":
847
+ captionBoxStyle = {
848
+ ...plate("transparent", "#FFFFFF", "0 2px 10px rgba(0,0,0,0.7)"),
849
+ borderRadius: 0,
850
+ padding: `0 ${32 * scale}px`
851
+ };
852
+ break;
853
+ case "stroke":
854
+ captionBoxStyle = {
855
+ backgroundColor: "transparent",
856
+ color: "#FFFFFF",
857
+ fontSize,
858
+ fontWeight: 700,
859
+ fontFamily: bodyFamily,
860
+ padding: `${8 * scale}px ${16 * scale}px`,
861
+ maxWidth: `${widthPct}%`,
862
+ textAlign: "center",
863
+ lineHeight: 1.45,
864
+ textShadow: "1px 1px 0 #000, -1px -1px 0 #000, 1px -1px 0 #000, -1px 1px 0 #000, 0 2px 10px rgba(0,0,0,0.75)"
865
+ };
866
+ break;
867
+ case "dark":
868
+ default:
869
+ captionBoxStyle = plate(
870
+ "rgba(0, 0, 0, 0.65)",
871
+ "#FFFFFF",
872
+ "0 2px 8px rgba(0,0,0,0.55)"
873
+ );
874
+ break;
875
+ }
876
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
877
+ resolved === "scrim" && style !== "center" && /* @__PURE__ */ jsx(AbsoluteFill, { style: { zIndex: 10, pointerEvents: "none" }, children: /* @__PURE__ */ jsx(
878
+ "div",
879
+ {
880
+ style: {
881
+ position: "absolute",
882
+ left: 0,
883
+ right: 0,
884
+ bottom: 0,
885
+ height: `${SCRIM_HEIGHT_RATIO * 100}%`,
886
+ opacity: scrimOpacity,
887
+ background: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.55) 45%, rgba(0,0,0,0) 100%)"
888
+ }
889
+ }
890
+ ) }),
891
+ /* @__PURE__ */ jsx(AbsoluteFill, { style: { display: "flex", zIndex: 11, ...positionStyle }, children: /* @__PURE__ */ jsx("div", { style: { ...captionBoxStyle, opacity: plateOpacity }, children: /* @__PURE__ */ jsx("span", { style: { opacity: textOpacity }, children: active.text }) }) })
892
+ ] });
893
+ };
894
+ var TextLayer = ({
895
+ role,
896
+ content,
897
+ animation = "fade-in",
898
+ typography,
899
+ startFrame,
900
+ endFrame,
901
+ verticalLayout = "center",
902
+ offsetY = 0,
903
+ textShadow
904
+ }) => {
905
+ const frame = useCurrentFrame();
906
+ const { fps } = useVideoConfig();
907
+ const scale = useCanvasScale();
908
+ const localFrame = frame - startFrame;
909
+ if (frame < startFrame || frame > endFrame) return null;
910
+ const animDuration = 20;
911
+ const getAnimationStyle = () => {
912
+ switch (animation) {
913
+ case "fade-in": {
914
+ const opacity = interpolate(localFrame, [0, animDuration], [0, 1], {
915
+ extrapolateRight: "clamp"
916
+ });
917
+ return { opacity };
918
+ }
919
+ case "slide-up": {
920
+ const opacity = interpolate(localFrame, [0, animDuration], [0, 1], {
921
+ extrapolateRight: "clamp"
922
+ });
923
+ const translateY = interpolate(
924
+ localFrame,
925
+ [0, animDuration],
926
+ [40, 0],
927
+ { extrapolateRight: "clamp" }
928
+ );
929
+ return { opacity, transform: `translateY(${translateY}px)` };
930
+ }
931
+ case "zoom-in": {
932
+ const s = spring({ fps, frame: localFrame, durationInFrames: animDuration });
933
+ return { transform: `scale(${0.5 + s * 0.5})`, opacity: s };
934
+ }
935
+ case "typewriter": {
936
+ const charsToShow = Math.floor(
937
+ interpolate(localFrame, [0, animDuration * 2], [0, content.length], {
938
+ extrapolateRight: "clamp"
939
+ })
940
+ );
941
+ return { clipPath: `inset(0 ${100 - charsToShow / content.length * 100}% 0 0)` };
942
+ }
943
+ default:
944
+ return {};
945
+ }
946
+ };
947
+ const getRoleStyle = () => {
948
+ const primary = typography?.primaryColor || "#FFFFFF";
949
+ const secondary = typography?.secondaryColor || "#E0E0E0";
950
+ const accent = typography?.accentColor || typography?.primaryColor || "#6C5CE7";
951
+ switch (role) {
952
+ case "title":
953
+ return {
954
+ fontSize: (typography?.titleSize || 56) * scale,
955
+ fontWeight: 700,
956
+ color: primary,
957
+ fontFamily: fontStack(typography?.titleFont),
958
+ textShadow: "0 2px 12px rgba(0,0,0,0.5)"
959
+ };
960
+ case "subtitle":
961
+ return {
962
+ fontSize: (typography?.bodySize || 32) * scale,
963
+ fontWeight: 500,
964
+ color: secondary,
965
+ fontFamily: fontStack(typography?.bodyFont),
966
+ textShadow: "0 1px 6px rgba(0,0,0,0.4)"
967
+ };
968
+ case "badge":
969
+ return {
970
+ fontSize: 24 * scale,
971
+ fontWeight: 600,
972
+ color: "#FFFFFF",
973
+ backgroundColor: accent,
974
+ fontFamily: fontStack(typography?.bodyFont),
975
+ padding: `${6 * scale}px ${18 * scale}px`,
976
+ borderRadius: 20 * scale,
977
+ display: "inline-block"
978
+ };
979
+ case "caption":
980
+ return {
981
+ fontSize: 28 * scale,
982
+ fontWeight: 500,
983
+ color: "#FFFFFF",
984
+ backgroundColor: "rgba(0,0,0,0.6)",
985
+ fontFamily: fontStack(typography?.bodyFont),
986
+ padding: `${8 * scale}px ${16 * scale}px`,
987
+ borderRadius: 6 * scale
988
+ };
989
+ case "callout":
990
+ return {
991
+ fontSize: 36 * scale,
992
+ fontWeight: 600,
993
+ color: primary,
994
+ fontFamily: fontStack(typography?.bodyFont),
995
+ borderLeft: `${4 * scale}px solid ${accent}`,
996
+ paddingLeft: 16 * scale
997
+ };
998
+ }
999
+ };
1000
+ const getPositionStyle = () => {
1001
+ switch (role) {
1002
+ case "title":
1003
+ if (verticalLayout === "top") {
1004
+ return {
1005
+ justifyContent: "flex-start",
1006
+ alignItems: "center",
1007
+ paddingTop: 88 * scale,
1008
+ paddingLeft: 40 * scale,
1009
+ paddingRight: 40 * scale
1010
+ };
1011
+ }
1012
+ return {
1013
+ justifyContent: "center",
1014
+ alignItems: "center"
1015
+ };
1016
+ case "subtitle":
1017
+ if (verticalLayout === "top") {
1018
+ return {
1019
+ justifyContent: "flex-start",
1020
+ alignItems: "center",
1021
+ paddingTop: 168 * scale,
1022
+ paddingLeft: 40 * scale,
1023
+ paddingRight: 40 * scale
1024
+ };
1025
+ }
1026
+ return {
1027
+ justifyContent: "center",
1028
+ alignItems: "center"
1029
+ };
1030
+ case "badge":
1031
+ return {
1032
+ justifyContent: "flex-start",
1033
+ alignItems: "flex-start",
1034
+ padding: `${80 * scale}px ${40 * scale}px`
1035
+ };
1036
+ default:
1037
+ return {
1038
+ justifyContent: "center",
1039
+ alignItems: "center"
1040
+ };
1041
+ }
1042
+ };
1043
+ return /* @__PURE__ */ jsx(
1044
+ AbsoluteFill,
1045
+ {
1046
+ style: {
1047
+ display: "flex",
1048
+ zIndex: 10,
1049
+ ...getPositionStyle(),
1050
+ ...offsetY ? { transform: `translateY(${offsetY * scale}px)` } : null
1051
+ },
1052
+ children: /* @__PURE__ */ jsx(
1053
+ "div",
1054
+ {
1055
+ style: {
1056
+ ...getRoleStyle(),
1057
+ // 放在 getRoleStyle() 之后:显式传入的阴影要盖掉 role 的默认阴影。
1058
+ // 用 !== undefined 而不是真值判断,这样传 "" 能真的去掉阴影。
1059
+ ...textShadow !== void 0 ? { textShadow } : null,
1060
+ ...getAnimationStyle(),
1061
+ textAlign: "center"
1062
+ },
1063
+ children: content
1064
+ }
1065
+ )
1066
+ }
1067
+ );
1068
+ };
1069
+ var SceneTransition = ({
1070
+ type,
1071
+ durationFrames,
1072
+ direction,
1073
+ children
1074
+ }) => {
1075
+ const frame = useCurrentFrame();
1076
+ if (type === "flip-corner" && durationFrames > 0) {
1077
+ const progress = interpolate(
1078
+ frame,
1079
+ [0, durationFrames],
1080
+ [0, 1],
1081
+ { extrapolateRight: "clamp", easing: Easing.inOut(Easing.ease) }
1082
+ );
1083
+ const t = progress * 2;
1084
+ const foldCenterX = (1 - progress) * 100;
1085
+ const foldCenterY = (1 - progress) * 100;
1086
+ let clipPath;
1087
+ if (t <= 1) {
1088
+ const ry = (1 - t) * 100;
1089
+ const bx = (1 - t) * 100;
1090
+ clipPath = `polygon(100% ${ry}%, 100% 100%, ${bx}% 100%)`;
1091
+ } else {
1092
+ const tx = (2 - t) * 100;
1093
+ const ly = (2 - t) * 100;
1094
+ clipPath = `polygon(${tx}% 0%, 100% 0%, 100% 100%, 0% 100%, 0% ${ly}%)`;
1095
+ }
1096
+ if (direction === "in") {
1097
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { clipPath, overflow: "hidden" }, children: [
1098
+ children,
1099
+ /* @__PURE__ */ jsx("div", { style: {
1100
+ position: "absolute",
1101
+ left: `${foldCenterX}%`,
1102
+ top: `${foldCenterY}%`,
1103
+ width: "250%",
1104
+ height: 70,
1105
+ transform: "translate(-50%, -50%) rotate(-45deg)",
1106
+ background: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.25) 40%, rgba(0,0,0,0.08) 70%, transparent)",
1107
+ pointerEvents: "none"
1108
+ } })
1109
+ ] });
1110
+ }
1111
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { overflow: "hidden" }, children: [
1112
+ children,
1113
+ /* @__PURE__ */ jsx("div", { style: {
1114
+ position: "absolute",
1115
+ left: `${foldCenterX}%`,
1116
+ top: `${foldCenterY}%`,
1117
+ width: "250%",
1118
+ height: 36,
1119
+ transform: "translate(-50%, -50%) rotate(-45deg)",
1120
+ background: "linear-gradient(to bottom, rgba(0,0,0,0.06), rgba(200,200,200,0.5) 18%, rgba(255,255,255,0.92) 42%, rgba(255,255,255,0.96) 58%, rgba(210,210,210,0.45) 82%, rgba(0,0,0,0.08))",
1121
+ pointerEvents: "none"
1122
+ } }),
1123
+ /* @__PURE__ */ jsx("div", { style: {
1124
+ position: "absolute",
1125
+ left: `${foldCenterX}%`,
1126
+ top: `${foldCenterY}%`,
1127
+ width: "250%",
1128
+ height: 80,
1129
+ transform: "translate(-50%, 0) rotate(-45deg)",
1130
+ background: "linear-gradient(to bottom, transparent, rgba(0,0,0,0.12))",
1131
+ pointerEvents: "none"
1132
+ } })
1133
+ ] });
1134
+ }
1135
+ const getStyle = () => {
1136
+ if (durationFrames <= 0) return {};
1137
+ switch (type) {
1138
+ case "fade": {
1139
+ const opacity = direction === "in" ? interpolate(frame, [0, durationFrames], [0, 1], {
1140
+ extrapolateRight: "clamp"
1141
+ }) : 1;
1142
+ return { opacity };
1143
+ }
1144
+ case "slide-left": {
1145
+ const tx = direction === "in" ? interpolate(frame, [0, durationFrames], [100, 0], {
1146
+ extrapolateRight: "clamp"
1147
+ }) : 0;
1148
+ return { transform: `translateX(${tx}%)` };
1149
+ }
1150
+ case "slide-right": {
1151
+ const tx = direction === "in" ? interpolate(frame, [0, durationFrames], [-100, 0], {
1152
+ extrapolateRight: "clamp"
1153
+ }) : 0;
1154
+ return { transform: `translateX(${tx}%)` };
1155
+ }
1156
+ case "zoom": {
1157
+ const scale = direction === "in" ? interpolate(frame, [0, durationFrames], [0.5, 1], {
1158
+ extrapolateRight: "clamp"
1159
+ }) : 1;
1160
+ const opacity = direction === "in" ? interpolate(frame, [0, durationFrames], [0, 1], {
1161
+ extrapolateRight: "clamp"
1162
+ }) : 1;
1163
+ return { transform: `scale(${scale})`, opacity };
1164
+ }
1165
+ case "wipe": {
1166
+ const pct = direction === "in" ? interpolate(frame, [0, durationFrames], [0, 100], {
1167
+ extrapolateRight: "clamp"
1168
+ }) : 100;
1169
+ return { clipPath: `inset(0 ${100 - pct}% 0 0)` };
1170
+ }
1171
+ case "flip": {
1172
+ if (direction === "in") {
1173
+ const rotateY = interpolate(frame, [0, durationFrames], [-120, 0], {
1174
+ extrapolateRight: "clamp"
1175
+ });
1176
+ const opacity = interpolate(
1177
+ frame,
1178
+ [0, durationFrames * 0.2],
1179
+ [0, 1],
1180
+ { extrapolateRight: "clamp" }
1181
+ );
1182
+ const scale = interpolate(
1183
+ frame,
1184
+ [0, durationFrames * 0.5, durationFrames],
1185
+ [0.92, 0.96, 1],
1186
+ { extrapolateRight: "clamp" }
1187
+ );
1188
+ return {
1189
+ transform: `perspective(1200px) rotateY(${rotateY}deg) scale(${scale})`,
1190
+ transformOrigin: "left center",
1191
+ opacity,
1192
+ backfaceVisibility: "hidden"
1193
+ };
1194
+ }
1195
+ return {};
1196
+ }
1197
+ default:
1198
+ return {};
1199
+ }
1200
+ };
1201
+ return /* @__PURE__ */ jsx(AbsoluteFill, { style: getStyle(), children });
1202
+ };
1203
+ var DEFAULT_GRID_PARTICLE_TUNING = {
1204
+ particleCount: 32,
1205
+ vignetteCenterY: "45%",
1206
+ topBand: { height: "22%", strength: 0.8 },
1207
+ bottomBand: { height: "30%", strength: 1.08, midStrength: 0.42 },
1208
+ noiseFilterId: "ki-noise"
1209
+ };
1210
+ var MEDIA_OVERLAY_MASK2 = "linear-gradient(180deg, rgba(0,0,0,0.45) 0%, rgba(0,0,0,0.75) 45%, rgba(0,0,0,1) 78%, rgba(0,0,0,1) 100%)";
1211
+ var GridParticleBackground = ({
1212
+ theme,
1213
+ config,
1214
+ resolvedAssets,
1215
+ hasBottomText = true,
1216
+ tuning: tuningOverride,
1217
+ templateId
1218
+ }) => {
1219
+ const frame = useCurrentFrame();
1220
+ const { fps, width, height } = useVideoConfig();
1221
+ const time = frame / fps;
1222
+ const tuning = { ...DEFAULT_GRID_PARTICLE_TUNING, ...tuningOverride };
1223
+ const [failedBgSrc, setFailedBgSrc] = useState(null);
1224
+ const preset = config?.preset ?? "grid-particles";
1225
+ const intensity = Math.max(0, Math.min(1.5, config?.intensity ?? 1));
1226
+ const showGrid = preset === "grid-particles" || preset === "grid-only";
1227
+ const showParticles = preset === "grid-particles" || preset === "particles-only";
1228
+ const showNoise = preset === "noise";
1229
+ if (preset === "solid") {
1230
+ const bg = config?.cssBackground || config?.color || theme.bgGradient;
1231
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { overflow: "hidden", background: bg }, children: [
1232
+ /* @__PURE__ */ jsx(CornerHalos, { theme, time, width, height, dim: 0.55 }),
1233
+ /* @__PURE__ */ jsx(ReadabilityScrim, { theme, hasBottomText, tuning })
1234
+ ] });
1235
+ }
1236
+ if (preset === "image") {
1237
+ const resolved = resolveAssetUrl(config?.assetRef, resolvedAssets ?? []);
1238
+ const url = resolved && resolved !== failedBgSrc ? resolved : null;
1239
+ const overlay = config?.overlayOpacity ?? 0.35;
1240
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { overflow: "hidden" }, children: [
1241
+ /* @__PURE__ */ jsx(AbsoluteFill, { style: { background: theme.bgGradient } }),
1242
+ url ? /* @__PURE__ */ jsx(
1243
+ Img,
1244
+ {
1245
+ src: url,
1246
+ onError: () => {
1247
+ reportSceneDegraded({
1248
+ templateId,
1249
+ reason: "background_load_failed",
1250
+ dedupeKey: url,
1251
+ detail: { component: "GridParticleBackground", src: url }
1252
+ });
1253
+ setFailedBgSrc(url);
1254
+ },
1255
+ style: {
1256
+ position: "absolute",
1257
+ inset: 0,
1258
+ width: "100%",
1259
+ height: "100%",
1260
+ objectFit: "cover",
1261
+ filter: "saturate(0.95)"
1262
+ }
1263
+ }
1264
+ ) : null,
1265
+ /* @__PURE__ */ jsx(
1266
+ AbsoluteFill,
1267
+ {
1268
+ style: {
1269
+ background: `rgba(6,9,31,${overlay})`,
1270
+ maskImage: MEDIA_OVERLAY_MASK2,
1271
+ WebkitMaskImage: MEDIA_OVERLAY_MASK2
1272
+ }
1273
+ }
1274
+ ),
1275
+ /* @__PURE__ */ jsx(CornerHalos, { theme, time, width, height, dim: 0.6 }),
1276
+ /* @__PURE__ */ jsx(ReadabilityScrim, { theme, hasBottomText, tuning })
1277
+ ] });
1278
+ }
1279
+ const bgStyle = preset === "gradient" && config?.cssBackground ? config.cssBackground : theme.bgGradient;
1280
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { overflow: "hidden" }, children: [
1281
+ /* @__PURE__ */ jsx(AbsoluteFill, { style: { background: bgStyle } }),
1282
+ /* @__PURE__ */ jsx(CornerHalos, { theme, time, width, height }),
1283
+ showGrid && /* @__PURE__ */ jsx(Grid, { theme, intensity }),
1284
+ showParticles && /* @__PURE__ */ jsx(
1285
+ Particles,
1286
+ {
1287
+ theme,
1288
+ time,
1289
+ width,
1290
+ height,
1291
+ intensity,
1292
+ count: tuning.particleCount
1293
+ }
1294
+ ),
1295
+ showNoise && /* @__PURE__ */ jsx(NoiseOverlay, { theme, opacity: 0.2 * intensity, id: tuning.noiseFilterId }),
1296
+ /* @__PURE__ */ jsx(ReadabilityScrim, { theme, hasBottomText, tuning })
1297
+ ] });
1298
+ };
1299
+ var ReadabilityScrim = ({ theme, hasBottomText, tuning }) => {
1300
+ const { vignetteCenterY, topBand, bottomBand } = tuning;
1301
+ const s = theme.scrimRGB;
1302
+ const a = theme.vignetteAlpha;
1303
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1304
+ /* @__PURE__ */ jsx(
1305
+ AbsoluteFill,
1306
+ {
1307
+ style: {
1308
+ background: `radial-gradient(ellipse 72% 62% at 50% ${vignetteCenterY}, rgba(${s},0) 0%, rgba(${s},${(a * 0.3).toFixed(3)}) 62%, rgba(${s},${a.toFixed(3)}) 100%)`,
1309
+ pointerEvents: "none"
1310
+ }
1311
+ }
1312
+ ),
1313
+ /* @__PURE__ */ jsx(
1314
+ "div",
1315
+ {
1316
+ style: {
1317
+ position: "absolute",
1318
+ top: 0,
1319
+ left: 0,
1320
+ right: 0,
1321
+ height: topBand.height,
1322
+ background: `linear-gradient(180deg, rgba(${s},${(a * topBand.strength).toFixed(3)}) 0%, rgba(${s},0) 100%)`,
1323
+ pointerEvents: "none"
1324
+ }
1325
+ }
1326
+ ),
1327
+ hasBottomText && /* @__PURE__ */ jsx(
1328
+ "div",
1329
+ {
1330
+ style: {
1331
+ position: "absolute",
1332
+ bottom: 0,
1333
+ left: 0,
1334
+ right: 0,
1335
+ height: bottomBand.height,
1336
+ background: `linear-gradient(0deg, rgba(${s},${(a * bottomBand.strength).toFixed(3)}) 0%, rgba(${s},${(a * bottomBand.midStrength).toFixed(3)}) 45%, rgba(${s},0) 100%)`,
1337
+ pointerEvents: "none"
1338
+ }
1339
+ }
1340
+ )
1341
+ ] });
1342
+ };
1343
+ var Grid = ({ theme, intensity }) => {
1344
+ const { width, height } = useVideoConfig();
1345
+ const spacing = 72;
1346
+ const thickEvery = 4;
1347
+ const cols = Math.ceil(width / spacing) + 2;
1348
+ const rows = Math.ceil(height / spacing) + 2;
1349
+ const w = (cols + 1) * spacing;
1350
+ const h = (rows + 1) * spacing;
1351
+ const cx = w / 2;
1352
+ const cy = h / 2;
1353
+ const maxDist = Math.sqrt(cx * cx + cy * cy);
1354
+ const [cR, cG, cB] = parseRGB(theme.gridCenterRGB);
1355
+ const [eR, eG, eB] = parseRGB(theme.gridEdgeRGB);
1356
+ const lines = [];
1357
+ for (let i = 0; i <= cols; i++) {
1358
+ const x = i * spacing;
1359
+ const distRatio = Math.abs(x - cx) / maxDist;
1360
+ const isThick = i % thickEvery === 0;
1361
+ const r = Math.round(cR + (eR - cR) * distRatio);
1362
+ const g = Math.round(cG + (eG - cG) * distRatio);
1363
+ const b = Math.round(cB + (eB - cB) * distRatio);
1364
+ const baseA = isThick ? 0.32 - 0.12 * distRatio : 0.16 - 0.06 * distRatio;
1365
+ const a = Math.max(0, baseA * intensity).toFixed(3);
1366
+ lines.push(
1367
+ /* @__PURE__ */ jsx(
1368
+ "line",
1369
+ {
1370
+ x1: x,
1371
+ y1: 0,
1372
+ x2: x,
1373
+ y2: h,
1374
+ stroke: `rgba(${r},${g},${b},${a})`,
1375
+ strokeWidth: isThick ? 1.6 : 0.7
1376
+ },
1377
+ `v${i}`
1378
+ )
1379
+ );
1380
+ }
1381
+ for (let j = 0; j <= rows; j++) {
1382
+ const y = j * spacing;
1383
+ const distRatio = Math.abs(y - cy) / maxDist;
1384
+ const isThick = j % thickEvery === 0;
1385
+ const r = Math.round(cR + (eR - cR) * distRatio);
1386
+ const g = Math.round(cG + (eG - cG) * distRatio);
1387
+ const b = Math.round(cB + (eB - cB) * distRatio);
1388
+ const baseA = isThick ? 0.32 - 0.12 * distRatio : 0.16 - 0.06 * distRatio;
1389
+ const a = Math.max(0, baseA * intensity).toFixed(3);
1390
+ lines.push(
1391
+ /* @__PURE__ */ jsx(
1392
+ "line",
1393
+ {
1394
+ x1: 0,
1395
+ y1: y,
1396
+ x2: w,
1397
+ y2: y,
1398
+ stroke: `rgba(${r},${g},${b},${a})`,
1399
+ strokeWidth: isThick ? 1.6 : 0.7
1400
+ },
1401
+ `h${j}`
1402
+ )
1403
+ );
1404
+ }
1405
+ return /* @__PURE__ */ jsx(
1406
+ "div",
1407
+ {
1408
+ style: {
1409
+ position: "absolute",
1410
+ top: "50%",
1411
+ left: "50%",
1412
+ width: w,
1413
+ height: h,
1414
+ transform: "translate(-50%, -50%) perspective(1400px) rotateX(6deg)",
1415
+ transformOrigin: "center center"
1416
+ },
1417
+ children: /* @__PURE__ */ jsx("svg", { width: w, height: h, xmlns: "http://www.w3.org/2000/svg", children: lines })
1418
+ }
1419
+ );
1420
+ };
1421
+ var Particles = ({ theme, time, width, height, intensity, count }) => {
1422
+ const dots = [];
1423
+ for (let i = 0; i < count; i++) {
1424
+ const seed = (i * 9301 + 49297) % 233280;
1425
+ const sx = seed / 233280 * width;
1426
+ const sy = seed * 13 % 233280 / 233280 * height;
1427
+ const driftAmpX = 14 + seed * 7 % 22;
1428
+ const driftAmpY = 18 + seed * 11 % 28;
1429
+ const phase = seed % 1e3 / 1e3 * Math.PI * 2;
1430
+ const speed = 0.18 + seed * 17 % 100 / 600;
1431
+ const x = sx + Math.sin(time * speed + phase) * driftAmpX;
1432
+ const y = sy + Math.cos(time * speed * 0.8 + phase) * driftAmpY;
1433
+ const pulse = 0.35 + 0.45 * Math.sin(time * 1.6 + phase);
1434
+ const radius = 1.2 + seed * 23 % 30 / 14;
1435
+ const a = Math.max(0, pulse * intensity).toFixed(3);
1436
+ dots.push(
1437
+ /* @__PURE__ */ jsxs(React3.Fragment, { children: [
1438
+ /* @__PURE__ */ jsx("circle", { cx: x, cy: y, r: radius, fill: `rgba(${theme.particleRGB},${a})` }),
1439
+ /* @__PURE__ */ jsx(
1440
+ "circle",
1441
+ {
1442
+ cx: x,
1443
+ cy: y,
1444
+ r: radius * 2.4,
1445
+ fill: "none",
1446
+ stroke: `rgba(${theme.particleRGB},${(Number(a) * 0.25).toFixed(3)})`,
1447
+ strokeWidth: 0.6
1448
+ }
1449
+ )
1450
+ ] }, `p${i}`)
1451
+ );
1452
+ }
1453
+ return /* @__PURE__ */ jsx(
1454
+ "svg",
1455
+ {
1456
+ width,
1457
+ height,
1458
+ xmlns: "http://www.w3.org/2000/svg",
1459
+ style: {
1460
+ position: "absolute",
1461
+ inset: 0,
1462
+ pointerEvents: "none"
1463
+ },
1464
+ children: dots
1465
+ }
1466
+ );
1467
+ };
1468
+ var CornerHalos = ({ theme, time, width, height, dim = 1 }) => {
1469
+ const size = Math.min(width, height) * 0.75;
1470
+ const corners = [
1471
+ { x: 0, y: 0, color: theme.haloColors[0], phase: 0 },
1472
+ { x: width, y: 0, color: theme.haloColors[1], phase: 1.5 },
1473
+ { x: 0, y: height, color: theme.haloColors[2], phase: 3 },
1474
+ { x: width, y: height, color: theme.haloColors[3], phase: 4.5 }
1475
+ ];
1476
+ return /* @__PURE__ */ jsx(Fragment, { children: corners.map((halo, idx) => {
1477
+ const haloOpacity = (0.18 + 0.1 * Math.sin(time * 1 + halo.phase)) * dim;
1478
+ return /* @__PURE__ */ jsx(
1479
+ "div",
1480
+ {
1481
+ style: {
1482
+ position: "absolute",
1483
+ left: halo.x - size / 2,
1484
+ top: halo.y - size / 2,
1485
+ width: size,
1486
+ height: size,
1487
+ borderRadius: "50%",
1488
+ background: `radial-gradient(circle, rgba(${halo.color},${haloOpacity.toFixed(3)}) 0%, transparent 70%)`,
1489
+ pointerEvents: "none",
1490
+ // 半径变大后 70% 那道停靠会露出可辨认的圆边,糊掉它。
1491
+ filter: "blur(24px)"
1492
+ }
1493
+ },
1494
+ idx
1495
+ );
1496
+ }) });
1497
+ };
1498
+ var NoiseOverlay = ({ theme, opacity, id }) => {
1499
+ const tint = theme.noiseBlend === "screen" ? 1 : 0;
1500
+ return /* @__PURE__ */ jsxs(
1501
+ "svg",
1502
+ {
1503
+ style: {
1504
+ position: "absolute",
1505
+ inset: 0,
1506
+ width: "100%",
1507
+ height: "100%",
1508
+ opacity,
1509
+ pointerEvents: "none",
1510
+ mixBlendMode: theme.noiseBlend
1511
+ },
1512
+ xmlns: "http://www.w3.org/2000/svg",
1513
+ children: [
1514
+ /* @__PURE__ */ jsxs("filter", { id, children: [
1515
+ /* @__PURE__ */ jsx("feTurbulence", { type: "fractalNoise", baseFrequency: "0.6", numOctaves: "2", stitchTiles: "stitch" }),
1516
+ /* @__PURE__ */ jsx("feColorMatrix", { values: `0 0 0 0 ${tint} 0 0 0 0 ${tint} 0 0 0 0 ${tint} 0 0 0 0.6 0` })
1517
+ ] }),
1518
+ /* @__PURE__ */ jsx("rect", { width: "100%", height: "100%", filter: `url(#${id})` })
1519
+ ]
1520
+ }
1521
+ );
1522
+ };
1523
+ function parseRGB(rgb) {
1524
+ const parts = rgb.split(",").map(Number);
1525
+ return [parts[0] ?? 255, parts[1] ?? 255, parts[2] ?? 255];
1526
+ }
1527
+ var FADE_IN_FRAMES = 5;
1528
+ var FADE_OUT_FRAMES = 12;
1529
+ var AudioLayer = ({
1530
+ src,
1531
+ startFrame,
1532
+ durationFrames,
1533
+ volume = 1,
1534
+ fadeIn = FADE_IN_FRAMES,
1535
+ fadeOut = FADE_OUT_FRAMES
1536
+ }) => {
1537
+ const volumeFn = useCallback(
1538
+ (f) => {
1539
+ const inVal = interpolate(f, [0, fadeIn], [0, volume], {
1540
+ extrapolateLeft: "clamp",
1541
+ extrapolateRight: "clamp"
1542
+ });
1543
+ const outVal = interpolate(
1544
+ f,
1545
+ [durationFrames - fadeOut, durationFrames],
1546
+ [volume, 0],
1547
+ { extrapolateLeft: "clamp", extrapolateRight: "clamp" }
1548
+ );
1549
+ return Math.min(inVal, outVal);
1550
+ },
1551
+ [fadeIn, fadeOut, durationFrames, volume]
1552
+ );
1553
+ if (!src) return null;
1554
+ return /* @__PURE__ */ jsx(Sequence, { from: startFrame, durationInFrames: durationFrames, children: /* @__PURE__ */ jsx(Audio, { src, volume: volumeFn }) });
1555
+ };
1556
+ var TimelineSequencer = ({
1557
+ timeline,
1558
+ resolvedAssets,
1559
+ resolve,
1560
+ globalTypography,
1561
+ variantId,
1562
+ bgm,
1563
+ spreadEntryProps = false
1564
+ }) => {
1565
+ const entries = timeline ?? [];
1566
+ const totalFrames = entries[entries.length - 1]?.endFrame ?? 0;
1567
+ return /* @__PURE__ */ jsxs(AbsoluteFill, { style: { backgroundColor: "#000" }, children: [
1568
+ entries.map((entry) => {
1569
+ const Comp = resolve(entry.compositionId);
1570
+ return /* @__PURE__ */ jsx(
1571
+ Sequence,
1572
+ {
1573
+ from: entry.startFrame,
1574
+ durationInFrames: entry.durationFrames,
1575
+ name: entry.sceneId,
1576
+ children: Comp ? /* @__PURE__ */ jsx(
1577
+ Comp,
1578
+ {
1579
+ entry,
1580
+ resolvedAssets: resolvedAssets ?? [],
1581
+ typography: globalTypography,
1582
+ variantId,
1583
+ ...spreadEntryProps ? entry.props : void 0
1584
+ }
1585
+ ) : null
1586
+ },
1587
+ entry.sceneId
1588
+ );
1589
+ }),
1590
+ bgm?.url ? /* @__PURE__ */ jsx(
1591
+ AudioLayer,
1592
+ {
1593
+ src: bgm.url,
1594
+ startFrame: 0,
1595
+ durationFrames: totalFrames,
1596
+ volume: bgm.volume ?? 0.15,
1597
+ fadeIn: 30,
1598
+ fadeOut: 45
1599
+ }
1600
+ ) : null
1601
+ ] });
1602
+ };
1603
+ function useActiveSubtitle(segments) {
1604
+ const frame = useCurrentFrame();
1605
+ return useMemo(() => {
1606
+ const active = segments.find(
1607
+ (s) => frame >= s.startFrame && frame < s.endFrame
1608
+ );
1609
+ return active?.text ?? "";
1610
+ }, [frame, segments]);
1611
+ }
1612
+ function useActiveSubtitleIndex(segments) {
1613
+ const frame = useCurrentFrame();
1614
+ return useMemo(() => {
1615
+ for (let i = 0; i < segments.length; i++) {
1616
+ const seg = segments[i];
1617
+ if (frame >= seg.startFrame && frame < seg.endFrame) {
1618
+ return i;
1619
+ }
1620
+ }
1621
+ return -1;
1622
+ }, [frame, segments]);
1623
+ }
1624
+ function localizeSubtitles(entry, options = {}) {
1625
+ const { leadFrames = 0 } = options;
1626
+ const sceneStart = entry.startFrame;
1627
+ return (entry.subtitleSegments ?? []).map((seg) => {
1628
+ const localStart = seg.startFrame - sceneStart - leadFrames;
1629
+ return {
1630
+ ...seg,
1631
+ // 只有在提前量把起点推到负数时才夹到 0。leadFrames=0 时保持原样:
1632
+ // ppt-to-video / math-principles-expl 会把 startFrame 当作「旁白起拍
1633
+ // 帧」直接消费,无条件夹 0 会悄悄改掉它们的节拍时间轴。
1634
+ startFrame: leadFrames > 0 ? Math.max(0, localStart) : localStart,
1635
+ endFrame: seg.endFrame - sceneStart
1636
+ };
1637
+ });
1638
+ }
1639
+ function useLocalSubtitles(entry, options = {}) {
1640
+ const { leadFrames = 0 } = options;
1641
+ const segments = entry.subtitleSegments;
1642
+ const sceneStart = entry.startFrame;
1643
+ return useMemo(
1644
+ () => localizeSubtitles({ subtitleSegments: segments, startFrame: sceneStart }, { leadFrames }),
1645
+ [segments, sceneStart, leadFrames]
1646
+ );
1647
+ }
1648
+ function sentenceStartFrames(segments) {
1649
+ if (segments.length === 0) return [];
1650
+ if (segments.some((s) => typeof s.sentenceIndex !== "number")) return null;
1651
+ const firstFrameOf = /* @__PURE__ */ new Map();
1652
+ for (const seg of segments) {
1653
+ const idx = seg.sentenceIndex;
1654
+ if (!firstFrameOf.has(idx)) firstFrameOf.set(idx, seg.startFrame);
1655
+ }
1656
+ return [...firstFrameOf.entries()].sort((a, b) => a[0] - b[0]).map(([, frame]) => frame);
1657
+ }
1658
+
1659
+ // src/digitalHuman/resolve.ts
1660
+ var DIGITAL_HUMAN_ANCHORS = [
1661
+ "top-left",
1662
+ "top-center",
1663
+ "top-right",
1664
+ "center-left",
1665
+ "center",
1666
+ "center-right",
1667
+ "bottom-left",
1668
+ "bottom-center",
1669
+ "bottom-right"
1670
+ ];
1671
+ var POSITIONS = [...DIGITAL_HUMAN_ANCHORS, "custom"];
1672
+ var SHAPES = ["rect", "rounded", "circle"];
1673
+ var LAYOUTS = ["overlay", "reserve"];
1674
+ var ON_END = ["freeze", "hide", "loop"];
1675
+ var ENTER = ["none", "fade", "pop", "slide"];
1676
+ var ROLES = ["only", "first", "middle", "last"];
1677
+ var DIGITAL_HUMAN_SIZE_PRESETS = {
1678
+ small: 0.28,
1679
+ medium: 0.42,
1680
+ large: 0.62,
1681
+ full: 0.92
1682
+ };
1683
+ var RANGES = {
1684
+ x: [0, 1],
1685
+ y: [0, 1],
1686
+ margin: [0, 0.5],
1687
+ offsetX: [-1, 1],
1688
+ offsetY: [-1, 1],
1689
+ size: [0.1, 1],
1690
+ aspectRatio: [0.2, 5],
1691
+ cornerRadius: [0, 0.5],
1692
+ borderWidth: [0, 40],
1693
+ focusX: [0, 1],
1694
+ focusY: [0, 1],
1695
+ zoom: [1, 3],
1696
+ startFromSec: [0, 86400]
1697
+ };
1698
+ var AVATAR_SCALE_RANGE = [0.2, 3];
1699
+ var DSL_POSITION_MAP = {
1700
+ center: "center",
1701
+ left: "center-left",
1702
+ right: "center-right",
1703
+ "bottom-left": "bottom-left",
1704
+ "bottom-right": "bottom-right"
1705
+ };
1706
+ var DIGITAL_HUMAN_PROP_KEYS = [
1707
+ "digitalHuman",
1708
+ "avatarAssetId",
1709
+ "avatarPosition",
1710
+ "avatarScale",
1711
+ "avatarOffsetFrames",
1712
+ "avatarSegmentRole"
1713
+ ];
1714
+ var MAX_UNWRAP_DEPTH = 8;
1715
+ function isRecord(v) {
1716
+ return typeof v === "object" && v !== null && !Array.isArray(v);
1717
+ }
1718
+ function isPresent(v) {
1719
+ return v !== void 0 && v !== null && v !== "";
1720
+ }
1721
+ function oneOf(v, options) {
1722
+ return typeof v === "string" && options.includes(v) ? v : void 0;
1723
+ }
1724
+ function clamp(v, lo, hi) {
1725
+ return Math.min(hi, Math.max(lo, v));
1726
+ }
1727
+ function pickDigitalHumanInput(props) {
1728
+ const out = {};
1729
+ let layer = props;
1730
+ const seen = /* @__PURE__ */ new Set();
1731
+ for (let depth = 0; depth < MAX_UNWRAP_DEPTH && isRecord(layer) && !seen.has(layer); depth++) {
1732
+ seen.add(layer);
1733
+ for (const key of DIGITAL_HUMAN_PROP_KEYS) {
1734
+ if (out[key] === void 0 && isPresent(layer[key])) out[key] = layer[key];
1735
+ }
1736
+ layer = layer.templateData;
1737
+ }
1738
+ return out;
1739
+ }
1740
+ function isLegacyAvatarInput(input) {
1741
+ return isPresent(input.avatarAssetId) && input.digitalHuman === void 0 && input.avatarOffsetFrames === void 0 && input.avatarSegmentRole === void 0 && input.avatarPosition === void 0 && input.avatarScale === void 0;
1742
+ }
1743
+ function sanitizeDigitalHumanConfig(raw) {
1744
+ const issues = [];
1745
+ const config = {};
1746
+ if (raw === void 0 || raw === null) return { config, issues };
1747
+ if (!isRecord(raw)) return { config, issues: ["digitalHuman"] };
1748
+ const takeEnum = (key, options) => {
1749
+ const v = raw[key];
1750
+ if (v === void 0) return;
1751
+ const ok = oneOf(v, options);
1752
+ if (ok === void 0) issues.push(key);
1753
+ else config[key] = ok;
1754
+ };
1755
+ const takeNumber = (key) => {
1756
+ const v = raw[key];
1757
+ if (v === void 0) return;
1758
+ if (typeof v !== "number" || !Number.isFinite(v)) {
1759
+ issues.push(key);
1760
+ return;
1761
+ }
1762
+ const [lo, hi] = RANGES[key];
1763
+ const c = clamp(v, lo, hi);
1764
+ if (c !== v) issues.push(key);
1765
+ config[key] = c;
1766
+ };
1767
+ const takeBoolean = (key) => {
1768
+ const v = raw[key];
1769
+ if (v === void 0) return;
1770
+ if (typeof v !== "boolean") issues.push(key);
1771
+ else config[key] = v;
1772
+ };
1773
+ const takeString = (key) => {
1774
+ const v = raw[key];
1775
+ if (v === void 0) return;
1776
+ if (typeof v !== "string" || v.trim() === "") issues.push(key);
1777
+ else config[key] = v;
1778
+ };
1779
+ takeBoolean("enabled");
1780
+ takeEnum("position", POSITIONS);
1781
+ takeNumber("x");
1782
+ takeNumber("y");
1783
+ takeNumber("margin");
1784
+ takeNumber("offsetX");
1785
+ takeNumber("offsetY");
1786
+ const size = raw.size;
1787
+ if (size !== void 0) {
1788
+ if (typeof size === "string") {
1789
+ const preset = oneOf(size, Object.keys(DIGITAL_HUMAN_SIZE_PRESETS));
1790
+ if (preset) config.size = preset;
1791
+ else issues.push("size");
1792
+ } else {
1793
+ takeNumber("size");
1794
+ }
1795
+ }
1796
+ takeNumber("aspectRatio");
1797
+ takeEnum("shape", SHAPES);
1798
+ takeNumber("cornerRadius");
1799
+ takeNumber("borderWidth");
1800
+ takeString("borderColor");
1801
+ takeBoolean("shadow");
1802
+ takeNumber("focusX");
1803
+ takeNumber("focusY");
1804
+ takeNumber("zoom");
1805
+ takeEnum("layout", LAYOUTS);
1806
+ takeEnum("onEnd", ON_END);
1807
+ takeEnum("enter", ENTER);
1808
+ takeBoolean("useVideoAudio");
1809
+ takeString("src");
1810
+ takeNumber("startFromSec");
1811
+ takeString("posterAssetRef");
1812
+ takeString("posterUrl");
1813
+ if (config.position === "custom" && (config.x === void 0 || config.y === void 0)) {
1814
+ issues.push("position");
1815
+ }
1816
+ return { config, issues };
1817
+ }
1818
+ function sizeToRatio(size) {
1819
+ if (size === void 0) return void 0;
1820
+ return typeof size === "number" ? size : DIGITAL_HUMAN_SIZE_PRESETS[size];
1821
+ }
1822
+ function mergeDigitalHumanConfig(config, defaults, dsl = {}) {
1823
+ const shape = config.shape ?? defaults.shape ?? "rounded";
1824
+ const defaultRatio = sizeToRatio(defaults.size) ?? DIGITAL_HUMAN_SIZE_PRESETS.medium;
1825
+ const explicitRatio = sizeToRatio(config.size);
1826
+ const sizeRatio = clamp(
1827
+ explicitRatio ?? defaultRatio * (dsl.scale ?? 1),
1828
+ RANGES.size[0],
1829
+ RANGES.size[1]
1830
+ );
1831
+ return {
1832
+ position: config.position ?? dsl.position ?? defaults.position ?? "bottom-right",
1833
+ x: config.x ?? 0.5,
1834
+ y: config.y ?? 0.5,
1835
+ margin: config.margin ?? defaults.margin ?? 0.03,
1836
+ offsetX: config.offsetX ?? 0,
1837
+ offsetY: config.offsetY ?? 0,
1838
+ sizeRatio,
1839
+ aspectRatio: shape === "circle" ? 1 : config.aspectRatio,
1840
+ shape,
1841
+ cornerRadius: config.cornerRadius ?? 0.08,
1842
+ borderWidth: config.borderWidth ?? (shape === "rect" ? 0 : 4),
1843
+ borderColor: config.borderColor ?? defaults.borderColor,
1844
+ shadow: config.shadow ?? true,
1845
+ focusX: config.focusX ?? defaults.focusX ?? 0.5,
1846
+ focusY: config.focusY ?? defaults.focusY ?? 0.3,
1847
+ zoom: config.zoom ?? 1,
1848
+ layout: config.layout ?? defaults.layout ?? "overlay",
1849
+ onEnd: config.onEnd ?? defaults.onEnd ?? "freeze",
1850
+ enter: config.enter ?? defaults.enter ?? "fade",
1851
+ useVideoAudio: config.useVideoAudio ?? false
1852
+ };
1853
+ }
1854
+ function resolveDigitalHuman(props, resolvedAssets, options) {
1855
+ const input = pickDigitalHumanInput(props);
1856
+ const { config: raw, issues } = sanitizeDigitalHumanConfig(input.digitalHuman);
1857
+ let dslPosition;
1858
+ if (input.avatarPosition !== void 0) {
1859
+ dslPosition = typeof input.avatarPosition === "string" ? DSL_POSITION_MAP[input.avatarPosition] ?? oneOf(input.avatarPosition, DIGITAL_HUMAN_ANCHORS) : void 0;
1860
+ if (!dslPosition) issues.push("avatarPosition");
1861
+ }
1862
+ let dslScale;
1863
+ if (input.avatarScale !== void 0) {
1864
+ if (typeof input.avatarScale === "number" && Number.isFinite(input.avatarScale)) {
1865
+ dslScale = clamp(input.avatarScale, AVATAR_SCALE_RANGE[0], AVATAR_SCALE_RANGE[1]);
1866
+ if (dslScale !== input.avatarScale) issues.push("avatarScale");
1867
+ } else {
1868
+ issues.push("avatarScale");
1869
+ }
1870
+ }
1871
+ const config = mergeDigitalHumanConfig(raw, options.defaults ?? {}, {
1872
+ position: dslPosition,
1873
+ scale: dslScale
1874
+ });
1875
+ const assetId = typeof input.avatarAssetId === "string" ? input.avatarAssetId : void 0;
1876
+ if (input.avatarAssetId !== void 0 && assetId === void 0) issues.push("avatarAssetId");
1877
+ const asset = assetId ? resolvedAssets.find((a) => a.assetId === assetId) : void 0;
1878
+ const assetUrl = asset?.url || asset?.localPath || null;
1879
+ const src = assetUrl || raw.src || null;
1880
+ const unresolvedAssetId = assetId && !assetUrl ? assetId : void 0;
1881
+ const posterAsset = raw.posterAssetRef ? resolvedAssets.find((a) => a.assetId === raw.posterAssetRef) : void 0;
1882
+ const posterSrc = posterAsset?.url || posterAsset?.localPath || raw.posterUrl || null;
1883
+ let startFrame = 0;
1884
+ if (raw.startFromSec !== void 0) {
1885
+ startFrame = Math.round(raw.startFromSec * options.fps);
1886
+ } else if (input.avatarOffsetFrames !== void 0) {
1887
+ const v = input.avatarOffsetFrames;
1888
+ if (typeof v === "number" && Number.isFinite(v) && v >= 0) startFrame = Math.round(v);
1889
+ else issues.push("avatarOffsetFrames");
1890
+ }
1891
+ let role = "only";
1892
+ if (input.avatarSegmentRole !== void 0) {
1893
+ const r = oneOf(input.avatarSegmentRole, ROLES);
1894
+ if (r) role = r;
1895
+ else issues.push("avatarSegmentRole");
1896
+ }
1897
+ const videoFrames = typeof asset?.duration === "number" && asset.duration > 0 ? Math.round(asset.duration / 1e3 * options.fps) : void 0;
1898
+ const videoSize = typeof asset?.width === "number" && typeof asset?.height === "number" && asset.width > 0 && asset.height > 0 ? { width: asset.width, height: asset.height } : void 0;
1899
+ return {
1900
+ active: !!src && raw.enabled !== false,
1901
+ enabled: raw.enabled !== false,
1902
+ src,
1903
+ asset,
1904
+ posterSrc,
1905
+ config,
1906
+ startFrame,
1907
+ role,
1908
+ videoFrames,
1909
+ videoSize,
1910
+ issues,
1911
+ unresolvedAssetId
1912
+ };
1913
+ }
1914
+
1915
+ // src/digitalHuman/box.ts
1916
+ var DEFAULT_DIGITAL_HUMAN_ASPECT = 3 / 4;
1917
+ function resolveDigitalHumanBox(config, canvas, options = {}) {
1918
+ const W = canvas.width;
1919
+ const H = canvas.height;
1920
+ const short = Math.min(W, H);
1921
+ const aspect = config.aspectRatio ?? (options.videoSize ? options.videoSize.width / options.videoSize.height : DEFAULT_DIGITAL_HUMAN_ASPECT);
1922
+ let height = config.sizeRatio * short;
1923
+ let width = height * aspect;
1924
+ const fit = Math.min(1, W / width, H / height);
1925
+ height *= fit;
1926
+ width *= fit;
1927
+ const inset = {
1928
+ top: options.insets?.top ?? 0,
1929
+ right: options.insets?.right ?? 0,
1930
+ bottom: options.insets?.bottom ?? 0,
1931
+ left: options.insets?.left ?? 0
1932
+ };
1933
+ const m = config.margin * short;
1934
+ let left;
1935
+ let top;
1936
+ if (config.position === "custom") {
1937
+ left = config.x * W - width / 2;
1938
+ top = config.y * H - height / 2;
1939
+ } else {
1940
+ const [v, h] = splitAnchor(config.position);
1941
+ left = h === "left" ? inset.left + m : h === "right" ? W - inset.right - m - width : (W - width) / 2;
1942
+ top = v === "top" ? inset.top + m : v === "bottom" ? H - inset.bottom - m - height : (H - height) / 2;
1943
+ }
1944
+ left += config.offsetX * W;
1945
+ top += config.offsetY * H;
1946
+ const clampedLeft = Math.min(Math.max(left, 0), W - width);
1947
+ const clampedTop = Math.min(Math.max(top, 0), H - height);
1948
+ const clamped = Math.abs(clampedLeft - left) > 1 || Math.abs(clampedTop - top) > 1;
1949
+ const cx = clampedLeft + width / 2;
1950
+ const side = cx < W * 0.4 ? "left" : cx > W * 0.6 ? "right" : "none";
1951
+ const borderRadius = config.shape === "circle" ? Math.min(width, height) / 2 : config.shape === "rounded" ? config.cornerRadius * Math.min(width, height) : 0;
1952
+ return {
1953
+ left: clampedLeft,
1954
+ top: clampedTop,
1955
+ width,
1956
+ height,
1957
+ borderRadius,
1958
+ side,
1959
+ clamped
1960
+ };
1961
+ }
1962
+ function splitAnchor(anchor) {
1963
+ if (anchor === "center") return ["center", "center"];
1964
+ const [v, h] = anchor.split("-");
1965
+ return [v, h];
1966
+ }
1967
+ function resolveSubtitleMaxWidthPct(box, canvas, opts) {
1968
+ const { bandTop, baseMaxWidthPct } = opts;
1969
+ if (!box || box.side === "none") return baseMaxWidthPct;
1970
+ if (box.top + box.height <= bandTop) return baseMaxWidthPct;
1971
+ const gap = opts.gap ?? 0;
1972
+ const half = canvas.width / 2;
1973
+ const free = box.side === "right" ? box.left - gap - half : half - (box.left + box.width) - gap;
1974
+ const pct = 2 * Math.max(0, free) / canvas.width * 100;
1975
+ return Math.max(opts.minMaxWidthPct ?? 40, Math.min(baseMaxWidthPct, pct));
1976
+ }
1977
+ function resolveReservedRegion(box, canvas, gap = 0) {
1978
+ if (!box || box.side === "none") return { left: 0, width: canvas.width };
1979
+ if (box.side === "right") {
1980
+ return { left: 0, width: Math.max(0, box.left - gap) };
1981
+ }
1982
+ const left = Math.min(canvas.width, box.left + box.width + gap);
1983
+ return { left, width: Math.max(0, canvas.width - left) };
1984
+ }
1985
+ var ENTER_FRAMES_AT_30 = 12;
1986
+ var DEFAULT_BORDER_COLOR = "rgba(255,255,255,0.85)";
1987
+ function reportDigitalHumanIssues(dh, box, templateId, sceneId) {
1988
+ if (dh.unresolvedAssetId) {
1989
+ reportSceneDegraded({
1990
+ templateId,
1991
+ sceneId,
1992
+ reason: "digital_human_unresolved",
1993
+ detail: { asset_id: dh.unresolvedAssetId }
1994
+ });
1995
+ }
1996
+ if (dh.issues.length > 0) {
1997
+ reportSceneDegraded({
1998
+ templateId,
1999
+ sceneId,
2000
+ reason: "digital_human_config_out_of_range",
2001
+ detail: { fields: dh.issues.join(",") }
2002
+ });
2003
+ }
2004
+ if (box?.clamped) {
2005
+ reportSceneDegraded({ templateId, sceneId, reason: "digital_human_box_clamped" });
2006
+ }
2007
+ if (dh.active && dh.videoFrames !== void 0 && dh.startFrame >= dh.videoFrames) {
2008
+ reportSceneDegraded({
2009
+ templateId,
2010
+ sceneId,
2011
+ reason: "digital_human_offset_beyond_video",
2012
+ detail: { start_frame: dh.startFrame, video_frames: dh.videoFrames }
2013
+ });
2014
+ }
2015
+ if (dh.active && dh.videoFrames === void 0 && dh.config.onEnd !== "freeze") {
2016
+ reportSceneDegraded({ templateId, sceneId, reason: "digital_human_duration_unknown" });
2017
+ }
2018
+ }
2019
+ function frameStyle(dh, box, scale) {
2020
+ const { config } = dh;
2021
+ const borderWidth = config.borderWidth * scale;
2022
+ return {
2023
+ position: "absolute",
2024
+ left: box.left,
2025
+ top: box.top,
2026
+ width: box.width,
2027
+ height: box.height,
2028
+ borderRadius: box.borderRadius,
2029
+ overflow: "hidden",
2030
+ boxSizing: "border-box",
2031
+ border: borderWidth > 0 ? `${borderWidth}px solid ${config.borderColor ?? DEFAULT_BORDER_COLOR}` : void 0,
2032
+ boxShadow: config.shadow ? `0 ${12 * scale}px ${36 * scale}px rgba(0,0,0,0.35)` : void 0,
2033
+ // 圆角裁切在部分 Chromium 版本里对视频层不生效,强制独立合成层。
2034
+ transform: "translateZ(0)"
2035
+ };
2036
+ }
2037
+ function mediaStyle(dh) {
2038
+ const { focusX, focusY, zoom } = dh.config;
2039
+ const origin = `${focusX * 100}% ${focusY * 100}%`;
2040
+ return {
2041
+ width: "100%",
2042
+ height: "100%",
2043
+ objectFit: "cover",
2044
+ objectPosition: origin,
2045
+ transform: zoom !== 1 ? `scale(${zoom})` : void 0,
2046
+ transformOrigin: origin
2047
+ };
2048
+ }
2049
+ var DigitalHumanLayer = ({
2050
+ dh,
2051
+ box,
2052
+ zIndex = 12
2053
+ }) => {
2054
+ const frame = useCurrentFrame();
2055
+ const { fps } = useVideoConfig();
2056
+ const scale = useCanvasScale();
2057
+ if (!dh.active || !dh.src) return null;
2058
+ const { config } = dh;
2059
+ const playEnter = config.enter !== "none" && (dh.role === "only" || dh.role === "first");
2060
+ const enterFrames = Math.max(1, Math.round(ENTER_FRAMES_AT_30 * fps / 30));
2061
+ let opacity = 1;
2062
+ let transform;
2063
+ if (playEnter) {
2064
+ opacity = interpolate(frame, [0, enterFrames], [0, 1], {
2065
+ extrapolateLeft: "clamp",
2066
+ extrapolateRight: "clamp"
2067
+ });
2068
+ if (config.enter === "pop") {
2069
+ const s = spring({ frame, fps, config: { damping: 14, stiffness: 160 } });
2070
+ transform = `scale(${0.85 + 0.15 * s})`;
2071
+ } else if (config.enter === "slide") {
2072
+ const dir = box.side === "left" ? -1 : 1;
2073
+ const dx = interpolate(frame, [0, enterFrames], [40 * scale * dir, 0], {
2074
+ extrapolateLeft: "clamp",
2075
+ extrapolateRight: "clamp"
2076
+ });
2077
+ transform = `translateX(${dx}px)`;
2078
+ }
2079
+ }
2080
+ const playable = dh.videoFrames !== void 0 ? dh.videoFrames - dh.startFrame : void 0;
2081
+ const ended = playable !== void 0 && frame >= playable;
2082
+ if (ended && config.onEnd === "hide") return null;
2083
+ const video = /* @__PURE__ */ jsx(
2084
+ OffthreadVideo,
2085
+ {
2086
+ src: dh.src,
2087
+ startFrom: dh.startFrame,
2088
+ volume: config.useVideoAudio ? 1 : 0,
2089
+ muted: !config.useVideoAudio,
2090
+ style: mediaStyle(dh)
2091
+ }
2092
+ );
2093
+ let media = video;
2094
+ if (playable !== void 0 && playable > 0) {
2095
+ if (config.onEnd === "loop") {
2096
+ media = /* @__PURE__ */ jsx(Loop, { durationInFrames: playable, layout: "none", children: video });
2097
+ } else if (ended) {
2098
+ media = /* @__PURE__ */ jsx(Freeze, { frame: playable - 1, children: video });
2099
+ }
2100
+ }
2101
+ const base = frameStyle(dh, box, scale);
2102
+ return /* @__PURE__ */ jsx(
2103
+ "div",
2104
+ {
2105
+ style: {
2106
+ position: "absolute",
2107
+ inset: 0,
2108
+ zIndex,
2109
+ pointerEvents: "none",
2110
+ opacity
2111
+ },
2112
+ children: /* @__PURE__ */ jsx(
2113
+ "div",
2114
+ {
2115
+ style: {
2116
+ ...base,
2117
+ transform: transform ? `${transform} translateZ(0)` : base.transform
2118
+ },
2119
+ children: media
2120
+ }
2121
+ )
2122
+ }
2123
+ );
2124
+ };
2125
+ var DigitalHumanPoster = ({ dh, box, zIndex = 12 }) => {
2126
+ const scale = useCanvasScale();
2127
+ if (!dh.posterSrc || !dh.enabled) return null;
2128
+ return /* @__PURE__ */ jsx("div", { style: { position: "absolute", inset: 0, zIndex, pointerEvents: "none" }, children: /* @__PURE__ */ jsx("div", { style: frameStyle(dh, box, scale), children: /* @__PURE__ */ jsx(Img, { src: dh.posterSrc, style: mediaStyle(dh) }) }) });
2129
+ };
2130
+ var ReservedContentScaler = ({ regionWidth, width, height, children }) => {
2131
+ if (regionWidth === void 0 || regionWidth >= width) return /* @__PURE__ */ jsx(Fragment, { children });
2132
+ const scale = Math.max(0, regionWidth) / width;
2133
+ return /* @__PURE__ */ jsx(
2134
+ "div",
2135
+ {
2136
+ style: {
2137
+ position: "absolute",
2138
+ top: 0,
2139
+ left: (regionWidth - width) / 2,
2140
+ width,
2141
+ height,
2142
+ display: "flex",
2143
+ alignItems: "center",
2144
+ justifyContent: "center",
2145
+ transform: `scale(${scale})`,
2146
+ transformOrigin: "center center"
2147
+ },
2148
+ children
2149
+ }
2150
+ );
2151
+ };
2152
+
2153
+ export { AudioLayer, Background, DEFAULT_DIGITAL_HUMAN_ASPECT, DEFAULT_GRID_PARTICLE_TUNING, DEFAULT_MIN_CHUNK_UNITS, DEFAULT_SPLIT_MAX_UNITS, DIGITAL_HUMAN_ANCHORS, DIGITAL_HUMAN_PROP_KEYS, DIGITAL_HUMAN_SIZE_PRESETS, DigitalHumanLayer, DigitalHumanPoster, GridParticleBackground, LINE_FILL_LIMIT, ReservedContentScaler, SceneBackgroundLayer, SceneTransition, SubtitleBar, TextLayer, TimelineSequencer, chunkSubtitleSegments, displayWidth, estimateLineCapacity, fontStack, grainDataUri, isLegacyAvatarInput, localizeSubtitles, mergeDigitalHumanConfig, pickDigitalHumanInput, reportDigitalHumanIssues, reportSceneDegraded, resetSceneDegradedReports, resolveActiveChunk, resolveAsset, resolveAssetUrl, resolveChunkFadeOpacity, resolveDigitalHuman, resolveDigitalHumanBox, resolveReservedRegion, resolveRunFadeOpacity, resolveSceneBackground, resolveSubtitleBottomPx, resolveSubtitleMaxWidthPct, resolveSubtitleMode, sanitizeDiagnosticSrc, sanitizeDigitalHumanConfig, sentenceStartFrames, splitSubtitleText, spokenWeight, useActiveSubtitle, useActiveSubtitleIndex, useAssetUrl, useCanvasScale, useLocalSubtitles };