@mevdragon/vidfarm-devcli 0.11.0 → 0.13.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/SKILL.director.md +21 -3
- package/SKILL.platform.md +17 -3
- package/demo/dist/app.css +1 -1
- package/demo/dist/app.js +251 -66
- package/dist/src/app.js +788 -110
- package/dist/src/cli.js +57 -6
- package/dist/src/composition-runtime.js +136 -2
- package/dist/src/devcli/captions.js +310 -0
- package/dist/src/devcli/clips.js +5 -0
- package/dist/src/devcli/composition-edit.js +142 -0
- package/dist/src/devcli/speech.js +4 -1
- package/dist/src/editor-chat.js +4 -2
- package/dist/src/frontend/homepage-view.js +1 -1
- package/dist/src/frontend/template-editor-chat.js +3 -1
- package/dist/src/homepage.js +7 -0
- package/dist/src/hyperframes/composition.js +315 -0
- package/dist/src/primitive-registry.js +250 -49
- package/dist/src/services/captions.js +123 -0
- package/dist/src/services/hyperframes.js +113 -1
- package/dist/src/services/provider-errors.js +3 -0
- package/dist/src/services/providers.js +11 -4
- package/dist/src/services/serverless-records.js +12 -2
- package/dist/src/services/speech.js +166 -4
- package/dist/src/services/storage.js +12 -0
- package/package.json +1 -1
- package/public/assets/homepage-client-app.js +23 -23
- package/public/assets/page-runtime-client-app.js +1 -1
|
@@ -75,6 +75,269 @@ export function normalizeKenBurns(value) {
|
|
|
75
75
|
origin
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Scene transitions. Same declarative contract as Ken Burns: the INCOMING
|
|
80
|
+
// clip (the element that owns data-start) carries data-transition="<preset>"
|
|
81
|
+
// plus data-transition-duration and the inline --vf-tr-dur var, and a static
|
|
82
|
+
// vf-tr-* keyframe animates the clip IN over its first data-transition-duration
|
|
83
|
+
// seconds. Both preview runtimes and the render producer's CSS adapter scrub
|
|
84
|
+
// the animation by (time - data-start), so the motion is identical everywhere.
|
|
85
|
+
//
|
|
86
|
+
// The one thing CSS cannot express is the OUTGOING clip staying on screen
|
|
87
|
+
// beneath the incoming one: stored compositions keep scenes butt-cut (A ends
|
|
88
|
+
// exactly where B starts) so timeline edits and agent edits stay trivial. The
|
|
89
|
+
// preview runtime extends the previous same-track clip's active window live,
|
|
90
|
+
// and normalizePublishHtml materializes the same overlap into data-duration at
|
|
91
|
+
// publish/render time (recorded in data-transition-hold for idempotency),
|
|
92
|
+
// because the render engine derives visibility strictly from data-start/
|
|
93
|
+
// data-duration. Presets animate opacity / clip-path / standalone translate /
|
|
94
|
+
// scale — never `transform` — so they compose with a Ken Burns transform on
|
|
95
|
+
// the same element. Keyframes deliberately omit the `to` state so the
|
|
96
|
+
// animation settles into the element's own inline styles instead of
|
|
97
|
+
// clobbering a custom opacity.
|
|
98
|
+
export const TRANSITION_PRESETS = [
|
|
99
|
+
"crossfade",
|
|
100
|
+
"fade-black",
|
|
101
|
+
"slide-left",
|
|
102
|
+
"slide-right",
|
|
103
|
+
"slide-up",
|
|
104
|
+
"slide-down",
|
|
105
|
+
"wipe-left",
|
|
106
|
+
"wipe-right",
|
|
107
|
+
"wipe-up",
|
|
108
|
+
"wipe-down",
|
|
109
|
+
"zoom-in",
|
|
110
|
+
"zoom-out",
|
|
111
|
+
"circle-open"
|
|
112
|
+
];
|
|
113
|
+
export const TRANSITION_DEFAULT_DURATION = 0.5;
|
|
114
|
+
export const TRANSITION_MIN_DURATION = 0.1;
|
|
115
|
+
export const TRANSITION_MAX_DURATION = 2.5;
|
|
116
|
+
// Two butt-cut clips can drift by a few ms through timeline edits; treat ends
|
|
117
|
+
// within this window as adjacent when granting the outgoing clip's hold.
|
|
118
|
+
export const TRANSITION_ADJACENCY_EPSILON = 0.05;
|
|
119
|
+
export const TRANSITION_STYLE_MARKER = "__VF_TRANSITIONS_V1__";
|
|
120
|
+
export const TRANSITION_CSS = `/*${TRANSITION_STYLE_MARKER}*/
|
|
121
|
+
@keyframes vf-tr-crossfade { from { opacity: 0; } }
|
|
122
|
+
@keyframes vf-tr-fade-black { 0% { opacity: 0; filter: brightness(0); } 35% { opacity: 1; filter: brightness(0); } }
|
|
123
|
+
@keyframes vf-tr-slide-left { from { translate: 100% 0; } }
|
|
124
|
+
@keyframes vf-tr-slide-right { from { translate: -100% 0; } }
|
|
125
|
+
@keyframes vf-tr-slide-up { from { translate: 0 100%; } }
|
|
126
|
+
@keyframes vf-tr-slide-down { from { translate: 0 -100%; } }
|
|
127
|
+
@keyframes vf-tr-wipe-left { from { clip-path: inset(0 0 0 100%); } to { clip-path: inset(0 0 0 0); } }
|
|
128
|
+
@keyframes vf-tr-wipe-right { from { clip-path: inset(0 100% 0 0); } to { clip-path: inset(0 0 0 0); } }
|
|
129
|
+
@keyframes vf-tr-wipe-up { from { clip-path: inset(100% 0 0 0); } to { clip-path: inset(0 0 0 0); } }
|
|
130
|
+
@keyframes vf-tr-wipe-down { from { clip-path: inset(0 0 100% 0); } to { clip-path: inset(0 0 0 0); } }
|
|
131
|
+
@keyframes vf-tr-zoom-in { from { opacity: 0; scale: 1.18; } }
|
|
132
|
+
@keyframes vf-tr-zoom-out { from { opacity: 0; scale: 0.82; } }
|
|
133
|
+
@keyframes vf-tr-circle-open { from { clip-path: circle(0% at 50% 50%); } to { clip-path: circle(75% at 50% 50%); } }
|
|
134
|
+
[data-transition] {
|
|
135
|
+
animation-duration: var(--vf-tr-dur, 0.5s);
|
|
136
|
+
animation-timing-function: var(--vf-tr-ease, ease-in-out);
|
|
137
|
+
animation-fill-mode: both;
|
|
138
|
+
animation-play-state: paused;
|
|
139
|
+
}
|
|
140
|
+
[data-transition="crossfade"] { animation-name: vf-tr-crossfade; }
|
|
141
|
+
[data-transition="fade-black"] { animation-name: vf-tr-fade-black; }
|
|
142
|
+
[data-transition="slide-left"] { animation-name: vf-tr-slide-left; }
|
|
143
|
+
[data-transition="slide-right"] { animation-name: vf-tr-slide-right; }
|
|
144
|
+
[data-transition="slide-up"] { animation-name: vf-tr-slide-up; }
|
|
145
|
+
[data-transition="slide-down"] { animation-name: vf-tr-slide-down; }
|
|
146
|
+
[data-transition="wipe-left"] { animation-name: vf-tr-wipe-left; }
|
|
147
|
+
[data-transition="wipe-right"] { animation-name: vf-tr-wipe-right; }
|
|
148
|
+
[data-transition="wipe-up"] { animation-name: vf-tr-wipe-up; }
|
|
149
|
+
[data-transition="wipe-down"] { animation-name: vf-tr-wipe-down; }
|
|
150
|
+
[data-transition="zoom-in"] { animation-name: vf-tr-zoom-in; }
|
|
151
|
+
[data-transition="zoom-out"] { animation-name: vf-tr-zoom-out; }
|
|
152
|
+
[data-transition="circle-open"] { animation-name: vf-tr-circle-open; }
|
|
153
|
+
`;
|
|
154
|
+
export function clampTransitionDuration(value) {
|
|
155
|
+
const next = Number(value);
|
|
156
|
+
if (!Number.isFinite(next) || next <= 0)
|
|
157
|
+
return TRANSITION_DEFAULT_DURATION;
|
|
158
|
+
return Math.max(TRANSITION_MIN_DURATION, Math.min(TRANSITION_MAX_DURATION, next));
|
|
159
|
+
}
|
|
160
|
+
export function normalizeTransition(value) {
|
|
161
|
+
if (!value)
|
|
162
|
+
return null;
|
|
163
|
+
const raw = typeof value === "string" ? { preset: value } : value;
|
|
164
|
+
if (!TRANSITION_PRESETS.includes(raw.preset))
|
|
165
|
+
return null;
|
|
166
|
+
return {
|
|
167
|
+
preset: raw.preset,
|
|
168
|
+
duration: raw.duration !== undefined ? clampTransitionDuration(raw.duration) : undefined
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Animated captions. Same declarative contract as Ken Burns: the caption layer
|
|
173
|
+
// <div> (the element that owns data-start/data-duration) carries
|
|
174
|
+
// data-caption-animation="<preset>" plus per-cue CSS vars, and each word is a
|
|
175
|
+
// <span data-cap-word> whose inline animation-delay/animation-duration encode
|
|
176
|
+
// that word's window RELATIVE TO THE LAYER START. The render producer's CSS
|
|
177
|
+
// adapter seeks every animation to (frameTime - ancestor data-start), and both
|
|
178
|
+
// preview runtimes scrub the same way, so word-level motion is deterministic
|
|
179
|
+
// and identical across preview and export. Everything is pure HTML+CSS so it
|
|
180
|
+
// survives sanitizeCompositionHtml. Word timings also round-trip through
|
|
181
|
+
// data-word-start/data-word-end so editors can re-derive them without parsing
|
|
182
|
+
// CSS.
|
|
183
|
+
export const CAPTION_ANIMATIONS = [
|
|
184
|
+
"highlight-pop",
|
|
185
|
+
"karaoke-fill",
|
|
186
|
+
"word-pop",
|
|
187
|
+
"cumulative-reveal",
|
|
188
|
+
"glow-pulse",
|
|
189
|
+
"bounce-wave"
|
|
190
|
+
];
|
|
191
|
+
export const CAPTION_STYLE_MARKER = "__VF_CAPTIONS_V1__";
|
|
192
|
+
export const CAPTION_ANIM_CSS = `/*${CAPTION_STYLE_MARKER}*/
|
|
193
|
+
[data-caption-animation] [data-cap-word] {
|
|
194
|
+
display:inline-block;
|
|
195
|
+
animation-play-state:paused;
|
|
196
|
+
animation-timing-function:ease-out;
|
|
197
|
+
animation-fill-mode:none;
|
|
198
|
+
will-change:transform,opacity;
|
|
199
|
+
}
|
|
200
|
+
[data-caption-uppercase="1"] { text-transform:uppercase; }
|
|
201
|
+
@keyframes vf-cap-highlight { 14%, 100% { background-color:var(--vf-cap-hl, #7C3AED); color:var(--vf-cap-active, #FFFFFF); transform:scale(1.06); } }
|
|
202
|
+
[data-caption-animation="highlight-pop"] [data-cap-word] { animation-name:vf-cap-highlight; padding:0.08em 0.18em; margin:-0.08em -0.05em; border-radius:0.3em; }
|
|
203
|
+
@keyframes vf-cap-karaoke { to { color:var(--vf-cap-active, #FFE14D); } }
|
|
204
|
+
[data-caption-animation="karaoke-fill"] [data-cap-word] { animation-name:vf-cap-karaoke; animation-fill-mode:forwards; animation-timing-function:linear; }
|
|
205
|
+
@keyframes vf-cap-word-pop { 0% { opacity:0; transform:scale(0.45); } 22% { opacity:1; transform:scale(1.14); } 38%, 100% { opacity:1; transform:scale(1); } }
|
|
206
|
+
[data-caption-animation="word-pop"] [data-vf-text-inline] { display:grid; place-items:center; }
|
|
207
|
+
[data-caption-animation="word-pop"] [data-cap-word] { animation-name:vf-cap-word-pop; opacity:0; grid-area:1 / 1; }
|
|
208
|
+
@keyframes vf-cap-reveal { from { opacity:0; transform:translateY(0.4em); } 40%, 100% { opacity:1; transform:none; } }
|
|
209
|
+
[data-caption-animation="cumulative-reveal"] [data-cap-word] { animation-name:vf-cap-reveal; animation-fill-mode:forwards; opacity:0; }
|
|
210
|
+
@keyframes vf-cap-glow { 20%, 80% { color:var(--vf-cap-active, #9BFF57); text-shadow:0 0 0.14em var(--vf-cap-active, #9BFF57), 0 0 0.5em var(--vf-cap-active, #9BFF57); transform:scale(1.04); } }
|
|
211
|
+
[data-caption-animation="glow-pulse"] [data-cap-word] { animation-name:vf-cap-glow; }
|
|
212
|
+
@keyframes vf-cap-bounce { 25% { transform:translateY(-0.22em) scale(1.08); } 55% { transform:translateY(0.05em); } 75% { transform:none; } }
|
|
213
|
+
[data-caption-animation="bounce-wave"] [data-cap-word] { animation-name:vf-cap-bounce; }
|
|
214
|
+
`;
|
|
215
|
+
export const CAPTION_STYLE_PRESETS = [
|
|
216
|
+
{
|
|
217
|
+
id: "spotlight",
|
|
218
|
+
label: "Spotlight",
|
|
219
|
+
animation: "highlight-pop",
|
|
220
|
+
color: "#ffffff",
|
|
221
|
+
background: "#000000",
|
|
222
|
+
textBackgroundStyle: "outline",
|
|
223
|
+
highlightColor: "#7C3AED",
|
|
224
|
+
activeColor: "#ffffff",
|
|
225
|
+
fontWeight: 800,
|
|
226
|
+
uppercase: true
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
id: "karaoke",
|
|
230
|
+
label: "Karaoke",
|
|
231
|
+
animation: "karaoke-fill",
|
|
232
|
+
color: "#ffffff",
|
|
233
|
+
background: "#000000",
|
|
234
|
+
textBackgroundStyle: "outline",
|
|
235
|
+
activeColor: "#FFE14D",
|
|
236
|
+
fontWeight: 800
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "word-pop",
|
|
240
|
+
label: "Word Pop",
|
|
241
|
+
animation: "word-pop",
|
|
242
|
+
color: "#ffffff",
|
|
243
|
+
background: "#000000",
|
|
244
|
+
textBackgroundStyle: "outline",
|
|
245
|
+
fontWeight: 900,
|
|
246
|
+
uppercase: true
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "stack-up",
|
|
250
|
+
label: "Stack Up",
|
|
251
|
+
animation: "cumulative-reveal",
|
|
252
|
+
color: "#ffffff",
|
|
253
|
+
background: "#000000",
|
|
254
|
+
textBackgroundStyle: "highlight-translucent",
|
|
255
|
+
fontWeight: 700
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
id: "neon",
|
|
259
|
+
label: "Neon Glow",
|
|
260
|
+
animation: "glow-pulse",
|
|
261
|
+
color: "#eafff1",
|
|
262
|
+
background: "#04140a",
|
|
263
|
+
textBackgroundStyle: "plain",
|
|
264
|
+
activeColor: "#9BFF57",
|
|
265
|
+
fontWeight: 800,
|
|
266
|
+
uppercase: true
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
id: "bounce",
|
|
270
|
+
label: "Bounce",
|
|
271
|
+
animation: "bounce-wave",
|
|
272
|
+
color: "#FFE14D",
|
|
273
|
+
background: "#000000",
|
|
274
|
+
textBackgroundStyle: "outline",
|
|
275
|
+
activeColor: "#FFE14D",
|
|
276
|
+
fontWeight: 900,
|
|
277
|
+
uppercase: true
|
|
278
|
+
}
|
|
279
|
+
];
|
|
280
|
+
export function captionStylePresetById(id) {
|
|
281
|
+
if (!id)
|
|
282
|
+
return null;
|
|
283
|
+
const clean = String(id).trim().toLowerCase();
|
|
284
|
+
return CAPTION_STYLE_PRESETS.find((preset) => preset.id === clean) || null;
|
|
285
|
+
}
|
|
286
|
+
export function normalizeCaptionConfig(value) {
|
|
287
|
+
if (!value)
|
|
288
|
+
return null;
|
|
289
|
+
const raw = typeof value === "string" ? { animation: value } : value;
|
|
290
|
+
if (!CAPTION_ANIMATIONS.includes(raw.animation))
|
|
291
|
+
return null;
|
|
292
|
+
const words = Array.isArray(raw.words)
|
|
293
|
+
? raw.words
|
|
294
|
+
.map((word) => ({
|
|
295
|
+
text: String(word?.text ?? "").trim(),
|
|
296
|
+
start: Math.max(0, Number(word?.start) || 0),
|
|
297
|
+
end: Math.max(0, Number(word?.end) || 0)
|
|
298
|
+
}))
|
|
299
|
+
.filter((word) => word.text && word.end > word.start)
|
|
300
|
+
: undefined;
|
|
301
|
+
return {
|
|
302
|
+
animation: raw.animation,
|
|
303
|
+
words: words && words.length ? words : undefined,
|
|
304
|
+
activeColor: typeof raw.activeColor === "string" && raw.activeColor.trim() ? raw.activeColor.trim() : undefined,
|
|
305
|
+
highlightColor: typeof raw.highlightColor === "string" && raw.highlightColor.trim() ? raw.highlightColor.trim() : undefined,
|
|
306
|
+
uppercase: raw.uppercase === true ? true : undefined
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
// Char-weighted word windows across a cue when the transcript has no
|
|
310
|
+
// word-level timings (Gemini segments, hand-typed text). Leaves a hair of
|
|
311
|
+
// silence between words so pop/highlight transitions read as discrete.
|
|
312
|
+
export function estimateCaptionWords(text, durationSeconds) {
|
|
313
|
+
const tokens = String(text || "").trim().split(/\s+/).filter(Boolean);
|
|
314
|
+
const duration = Number.isFinite(durationSeconds) && durationSeconds > 0 ? durationSeconds : tokens.length * 0.32;
|
|
315
|
+
if (!tokens.length)
|
|
316
|
+
return [];
|
|
317
|
+
const weights = tokens.map((token) => Math.max(2, token.length) + 1.5);
|
|
318
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
319
|
+
const words = [];
|
|
320
|
+
let cursor = 0;
|
|
321
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
322
|
+
const span = (weights[index] / totalWeight) * duration;
|
|
323
|
+
const gap = Math.min(0.03, span * 0.1);
|
|
324
|
+
words.push({
|
|
325
|
+
text: tokens[index],
|
|
326
|
+
start: Number(cursor.toFixed(3)),
|
|
327
|
+
end: Number(Math.max(cursor + 0.05, cursor + span - gap).toFixed(3))
|
|
328
|
+
});
|
|
329
|
+
cursor += span;
|
|
330
|
+
}
|
|
331
|
+
return words;
|
|
332
|
+
}
|
|
333
|
+
export function captionInlineVars(config) {
|
|
334
|
+
const vars = [];
|
|
335
|
+
if (config.activeColor)
|
|
336
|
+
vars.push(`--vf-cap-active:${config.activeColor}`);
|
|
337
|
+
if (config.highlightColor)
|
|
338
|
+
vars.push(`--vf-cap-hl:${config.highlightColor}`);
|
|
339
|
+
return vars.join(";");
|
|
340
|
+
}
|
|
78
341
|
export function buildHyperframeCompositionHtml(input) {
|
|
79
342
|
const duration = positiveNumber(input.duration, inferDuration(input.layers));
|
|
80
343
|
const width = positiveInteger(input.width, 720);
|
|
@@ -93,6 +356,8 @@ export function buildHyperframeCompositionHtml(input) {
|
|
|
93
356
|
[data-composition-id] { position:relative; width:100vw; height:100vh; overflow:hidden; background:${background}; }
|
|
94
357
|
audio { display:none; }
|
|
95
358
|
${KEN_BURNS_CSS}
|
|
359
|
+
${CAPTION_ANIM_CSS}
|
|
360
|
+
${TRANSITION_CSS}
|
|
96
361
|
${input.css ?? ""}
|
|
97
362
|
</style>
|
|
98
363
|
</head>
|
|
@@ -118,6 +383,11 @@ export function assertHyperframeTimeline(layers) {
|
|
|
118
383
|
throw new Error(`HyperFrame layer ${layer.id} must have a positive duration.`);
|
|
119
384
|
}
|
|
120
385
|
}
|
|
386
|
+
// Public single-layer serializer so out-of-band builders (devcli composition
|
|
387
|
+
// edits) emit byte-identical layer markup to the composition builder.
|
|
388
|
+
export function renderHyperframeLayerHtml(layer) {
|
|
389
|
+
return renderLayer(layer);
|
|
390
|
+
}
|
|
121
391
|
function renderLayer(layer) {
|
|
122
392
|
const attrs = timedAttrs(layer);
|
|
123
393
|
const style = layerStyle(layer);
|
|
@@ -152,8 +422,39 @@ function renderLayer(layer) {
|
|
|
152
422
|
"white-space:pre-wrap",
|
|
153
423
|
"overflow:visible"
|
|
154
424
|
].join(";");
|
|
425
|
+
const captionConfig = layer.kind === "caption" || layer.kind === "text" ? normalizeCaptionConfig(layer.captionAnimation) : null;
|
|
426
|
+
if (captionConfig) {
|
|
427
|
+
const words = captionConfig.words ?? estimateCaptionWords(text, positiveNumber(layer.duration, 0));
|
|
428
|
+
if (words.length) {
|
|
429
|
+
const vars = captionInlineVars(captionConfig);
|
|
430
|
+
// text-transform must be INLINE: layerStyle already writes an inline
|
|
431
|
+
// text-transform:none, which would beat the [data-caption-uppercase]
|
|
432
|
+
// stylesheet rule. The attribute stays for round-trip state reads.
|
|
433
|
+
// overflow:visible (overriding layerStyle's hidden) because caption text
|
|
434
|
+
// wrapping past the cue box must never clip mid-glyph.
|
|
435
|
+
const upper = captionConfig.uppercase ? ";text-transform:uppercase" : "";
|
|
436
|
+
const divStyle = `${style}${vars ? `;${vars}` : ""}${upper};overflow:visible`;
|
|
437
|
+
const captionAttrs = `${attrs} data-caption-animation="${escapeAttr(captionConfig.animation)}"${captionConfig.uppercase ? " data-caption-uppercase=\"1\"" : ""}`;
|
|
438
|
+
return `<div ${captionAttrs} style="${escapeAttr(divStyle)}"><span data-vf-text-lines="true" style="${escapeAttr(linesStyle)}"><span data-vf-text-inline="true" style="${escapeAttr(inlineStyle)}">${captionWordSpansHtml(words)}</span></span></div>`;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
155
441
|
return `<div ${attrs} style="${escapeAttr(style)}"><span data-vf-text-lines="true" style="${escapeAttr(linesStyle)}"><span data-vf-text-inline="true" style="${escapeAttr(inlineStyle)}">${escapeHtml(text)}</span></span></div>`;
|
|
156
442
|
}
|
|
443
|
+
// Serializes word windows into the <span data-cap-word> run that the caption
|
|
444
|
+
// stylesheet animates. Delay/duration are the word window relative to the
|
|
445
|
+
// layer start — the ONLY timing source the render adapter needs; the
|
|
446
|
+
// data-word-* attrs exist so editing surfaces can re-derive timings without
|
|
447
|
+
// parsing CSS.
|
|
448
|
+
export function captionWordSpansHtml(words) {
|
|
449
|
+
return words
|
|
450
|
+
.map((word) => {
|
|
451
|
+
const start = Math.max(0, positiveNumber(word.start, 0));
|
|
452
|
+
const end = Math.max(start + 0.05, positiveNumber(word.end, start + 0.05));
|
|
453
|
+
const wordStyle = `animation-delay:${num(start)}s;animation-duration:${num(end - start)}s`;
|
|
454
|
+
return `<span data-cap-word="true" data-word-start="${num(start)}" data-word-end="${num(end)}" style="${wordStyle}">${escapeHtml(word.text)}</span>`;
|
|
455
|
+
})
|
|
456
|
+
.join(" ");
|
|
457
|
+
}
|
|
157
458
|
function timedAttrs(layer) {
|
|
158
459
|
const attrs = [
|
|
159
460
|
`id="${escapeAttr(layer.id)}"`,
|
|
@@ -170,6 +471,12 @@ function timedAttrs(layer) {
|
|
|
170
471
|
const mediaStart = positiveNumber(layer.playbackStart ?? layer.mediaStart, 0);
|
|
171
472
|
attrs.push(`data-media-start="${num(mediaStart)}"`, `data-playback-start="${num(mediaStart)}"`);
|
|
172
473
|
}
|
|
474
|
+
if (layer.kind !== "audio") {
|
|
475
|
+
const transition = normalizeTransition(layer.transition);
|
|
476
|
+
if (transition) {
|
|
477
|
+
attrs.push(`data-transition="${escapeAttr(transition.preset)}"`, `data-transition-duration="${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}"`);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
173
480
|
if (["caption", "text", "shape", "html"].includes(layer.kind)) {
|
|
174
481
|
attrs.push(`data-text-background-style="${escapeAttr(layer.textBackgroundStyle || "plain")}"`, `data-text-background-color="${escapeAttr(layer.background || "#000000")}"`, `data-font-family="${escapeAttr(layer.fontFamily || "TikTok Sans")}"`);
|
|
175
482
|
}
|
|
@@ -190,6 +497,14 @@ function layerStyle(layer) {
|
|
|
190
497
|
if (layer.kind === "video" || layer.kind === "image") {
|
|
191
498
|
styles.push(`object-fit:${layer.fit || "cover"}`);
|
|
192
499
|
}
|
|
500
|
+
if (layer.kind !== "audio") {
|
|
501
|
+
// data-transition picks the keyframes; the inline var supplies the span so
|
|
502
|
+
// the animation covers exactly the transition window.
|
|
503
|
+
const transition = normalizeTransition(layer.transition);
|
|
504
|
+
if (transition) {
|
|
505
|
+
styles.push(`--vf-tr-dur:${num(clampTransitionDuration(transition.duration ?? TRANSITION_DEFAULT_DURATION))}s`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
193
508
|
if (["caption", "text", "shape", "html"].includes(layer.kind)) {
|
|
194
509
|
const fontFamily = layer.fontFamily || "TikTok Sans";
|
|
195
510
|
styles.push("display:flex", "align-items:center", "justify-content:center", "padding:3px", `font-family:${fontCssFamily(fontFamily)}, 'TikTok Sans', Montserrat, Abel, sans-serif`, `font-weight:${positiveInteger(layer.fontWeight, 700)}`, `line-height:${positiveNumber(layer.lineHeight, 1.18)}`, "text-align:center", "text-transform:none", `font-size:${positiveInteger(layer.fontSize, 32)}px`, `color:${layer.color || "#ffffff"}`, layer.kind === "shape" && layer.textBackgroundStyle === "panel"
|