mulmocast 2.4.1 → 2.4.3
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/assets/html/tailwind_animated.html +5 -5
- package/lib/actions/image_agents.js +3 -1
- package/lib/actions/movie.d.ts +1 -1
- package/lib/actions/movie.js +32 -12
- package/lib/types/type.d.ts +1 -0
- package/lib/utils/image_plugins/html_tailwind.js +2 -2
- package/package.json +1 -1
- package/scripts/test/test_html_animation.json +77 -0
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
|
|
56
56
|
// === MulmoAnimation Helper Class ===
|
|
57
57
|
|
|
58
|
-
const TRANSFORM_PROPS = { translateX: 'px', translateY: 'px', scale: '', rotate: 'deg' };
|
|
58
|
+
const TRANSFORM_PROPS = { translateX: 'px', translateY: 'px', scale: '', rotate: 'deg', rotateX: 'deg', rotateY: 'deg', rotateZ: 'deg' };
|
|
59
59
|
const SVG_PROPS = ['r', 'cx', 'cy', 'x', 'y', 'x1', 'y1', 'x2', 'y2', 'rx', 'ry',
|
|
60
60
|
'width', 'height', 'stroke-width', 'stroke-dashoffset', 'stroke-dasharray', 'opacity'];
|
|
61
61
|
|
|
@@ -176,7 +176,7 @@
|
|
|
176
176
|
|
|
177
177
|
if (entry.kind === 'animate') {
|
|
178
178
|
const startFrame = (opts.start || 0) * fps;
|
|
179
|
-
const endFrame = (opts.end || 0) * fps;
|
|
179
|
+
const endFrame = (opts.end === 'auto' ? window.__MULMO.totalFrames / fps : (opts.end || 0)) * fps;
|
|
180
180
|
const progress = Math.max(0, Math.min(1, endFrame === startFrame ? 1 : (frame - startFrame) / (endFrame - startFrame)));
|
|
181
181
|
const el = document.querySelector(entry.selector);
|
|
182
182
|
this._applyProps(el, entry.props, progress, easingFn);
|
|
@@ -196,7 +196,7 @@
|
|
|
196
196
|
|
|
197
197
|
} else if (entry.kind === 'typewriter') {
|
|
198
198
|
const twStart = (opts.start || 0) * fps;
|
|
199
|
-
const twEnd = (opts.end || 0) * fps;
|
|
199
|
+
const twEnd = (opts.end === 'auto' ? window.__MULMO.totalFrames / fps : (opts.end || 0)) * fps;
|
|
200
200
|
const twProgress = Math.max(0, Math.min(1, twEnd === twStart ? 1 : (frame - twStart) / (twEnd - twStart)));
|
|
201
201
|
const charCount = Math.floor(twProgress * entry.text.length);
|
|
202
202
|
const twEl = document.querySelector(entry.selector);
|
|
@@ -204,7 +204,7 @@
|
|
|
204
204
|
|
|
205
205
|
} else if (entry.kind === 'counter') {
|
|
206
206
|
const cStart = (opts.start || 0) * fps;
|
|
207
|
-
const cEnd = (opts.end || 0) * fps;
|
|
207
|
+
const cEnd = (opts.end === 'auto' ? window.__MULMO.totalFrames / fps : (opts.end || 0)) * fps;
|
|
208
208
|
const cProgress = Math.max(0, Math.min(1, cEnd === cStart ? 1 : (frame - cStart) / (cEnd - cStart)));
|
|
209
209
|
const cVal = entry.range[0] + easingFn(cProgress) * (entry.range[1] - entry.range[0]);
|
|
210
210
|
const decimals = opts.decimals || 0;
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
|
|
215
215
|
} else if (entry.kind === 'codeReveal') {
|
|
216
216
|
const crStart = (opts.start || 0) * fps;
|
|
217
|
-
const crEnd = (opts.end || 0) * fps;
|
|
217
|
+
const crEnd = (opts.end === 'auto' ? window.__MULMO.totalFrames / fps : (opts.end || 0)) * fps;
|
|
218
218
|
const crProgress = Math.max(0, Math.min(1, crEnd === crStart ? 1 : (frame - crStart) / (crEnd - crStart)));
|
|
219
219
|
const lineCount = Math.floor(crProgress * entry.lines.length);
|
|
220
220
|
const crEl = document.querySelector(entry.selector);
|
|
@@ -123,7 +123,9 @@ export const imagePluginAgent = async (namedInputs) => {
|
|
|
123
123
|
const effectiveImagePath = isAnimatedHtml ? getBeatAnimatedVideoPath(context, index) : imagePath;
|
|
124
124
|
try {
|
|
125
125
|
MulmoStudioContextMethods.setBeatSessionState(context, "image", index, beat.id, true);
|
|
126
|
-
const
|
|
126
|
+
const studioBeat = context.studio.beats[index];
|
|
127
|
+
const beatDuration = beat.duration ?? studioBeat?.duration;
|
|
128
|
+
const processorParams = { beat, context, imagePath: effectiveImagePath, imageRefs, beatDuration, ...htmlStyle(context, beat) };
|
|
127
129
|
await plugin.process(processorParams);
|
|
128
130
|
MulmoStudioContextMethods.setBeatSessionState(context, "image", index, beat.id, false);
|
|
129
131
|
}
|
package/lib/actions/movie.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { MulmoStudioContext, MulmoBeat, MulmoTransition, MulmoCanvasDimension, MulmoFillOption, MulmoVideoFilter } from "../types/index.js";
|
|
2
2
|
import { FfmpegContext } from "../utils/ffmpeg_utils.js";
|
|
3
3
|
type VideoId = string | undefined;
|
|
4
|
-
export declare const getVideoPart: (inputIndex: number, isMovie: boolean, duration: number, canvasInfo: MulmoCanvasDimension, fillOption: MulmoFillOption, speed: number, filters?: MulmoVideoFilter[]) => {
|
|
4
|
+
export declare const getVideoPart: (inputIndex: number, isMovie: boolean, duration: number, canvasInfo: MulmoCanvasDimension, fillOption: MulmoFillOption, speed: number, filters?: MulmoVideoFilter[], frameCount?: number) => {
|
|
5
5
|
videoId: string;
|
|
6
6
|
videoPart: string;
|
|
7
7
|
};
|
package/lib/actions/movie.js
CHANGED
|
@@ -8,7 +8,8 @@ import { MulmoStudioContextMethods } from "../methods/mulmo_studio_context.js";
|
|
|
8
8
|
import { convertVideoFilterToFFmpeg } from "../utils/video_filter.js";
|
|
9
9
|
// const isMac = process.platform === "darwin";
|
|
10
10
|
const videoCodec = "libx264"; // "h264_videotoolbox" (macOS only) is too noisy
|
|
11
|
-
|
|
11
|
+
const VIDEO_FPS = 30;
|
|
12
|
+
export const getVideoPart = (inputIndex, isMovie, duration, canvasInfo, fillOption, speed, filters, frameCount) => {
|
|
12
13
|
const videoId = `v${inputIndex}`;
|
|
13
14
|
const videoFilters = [];
|
|
14
15
|
// Handle different media types
|
|
@@ -21,8 +22,20 @@ export const getVideoPart = (inputIndex, isMovie, duration, canvasInfo, fillOpti
|
|
|
21
22
|
else {
|
|
22
23
|
videoFilters.push("loop=loop=-1:size=1:start=0");
|
|
23
24
|
}
|
|
24
|
-
//
|
|
25
|
-
|
|
25
|
+
// Normalize framerate first so trim=end_frame counts frames at VIDEO_FPS,
|
|
26
|
+
// regardless of the input's native framerate.
|
|
27
|
+
videoFilters.push(`fps=${VIDEO_FPS}`);
|
|
28
|
+
// Use frame-exact trimming when frameCount is provided to prevent cumulative drift
|
|
29
|
+
// between video and audio tracks. trim=duration=X rounds up to next frame boundary,
|
|
30
|
+
// causing ~0.03s extra per beat that accumulates over many beats.
|
|
31
|
+
if (frameCount !== undefined && frameCount > 0) {
|
|
32
|
+
// Account for speed: setpts compresses timestamps, so we need more input frames
|
|
33
|
+
const inputFrameCount = Math.max(1, Math.round(frameCount * speed));
|
|
34
|
+
videoFilters.push(`trim=end_frame=${inputFrameCount}`);
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
videoFilters.push(`trim=duration=${originalDuration}`);
|
|
38
|
+
}
|
|
26
39
|
// Apply speed if specified
|
|
27
40
|
if (speed === 1.0) {
|
|
28
41
|
videoFilters.push("setpts=PTS-STARTPTS");
|
|
@@ -79,7 +92,7 @@ const getOutputOption = (audioId, videoId) => {
|
|
|
79
92
|
"4M", // Reduced buffer size
|
|
80
93
|
"-maxrate",
|
|
81
94
|
"3M", // Reduced from 7M to 3M
|
|
82
|
-
|
|
95
|
+
`-r ${VIDEO_FPS}`, // Set frame rate
|
|
83
96
|
"-pix_fmt yuv420p", // Set pixel format for better compatibility
|
|
84
97
|
"-c:a aac", // Audio codec
|
|
85
98
|
"-b:a 128k", // Audio bitrate
|
|
@@ -284,7 +297,7 @@ const getClampedTransitionDuration = (transitionDuration, prevBeatDuration, curr
|
|
|
284
297
|
return Math.min(transitionDuration, maxDuration);
|
|
285
298
|
};
|
|
286
299
|
export const getTransitionFrameDurations = (context, index) => {
|
|
287
|
-
const minFrame = 1 /
|
|
300
|
+
const minFrame = 1 / VIDEO_FPS;
|
|
288
301
|
const beats = context.studio.beats;
|
|
289
302
|
const scriptBeats = context.studio.script.beats;
|
|
290
303
|
const getTransitionDuration = (transition, prevBeatIndex, currentBeatIndex) => {
|
|
@@ -320,22 +333,22 @@ export const addSplitAndExtractFrames = (ffmpegContext, videoId, firstDuration,
|
|
|
320
333
|
if (needFirst) {
|
|
321
334
|
// Create static frame using nullsrc as base for proper framerate/timebase
|
|
322
335
|
// Note: setpts must NOT be used here as it loses framerate metadata needed by xfade
|
|
323
|
-
ffmpegContext.filterComplex.push(`nullsrc=size=${canvasInfo.width}x${canvasInfo.height}:duration=${firstDuration}:rate
|
|
336
|
+
ffmpegContext.filterComplex.push(`nullsrc=size=${canvasInfo.width}x${canvasInfo.height}:duration=${firstDuration}:rate=${VIDEO_FPS}[${videoId}_first_null]`);
|
|
324
337
|
ffmpegContext.filterComplex.push(`[${videoId}_first_src]select='eq(n,0)',scale=${canvasInfo.width}:${canvasInfo.height}[${videoId}_first_frame]`);
|
|
325
|
-
ffmpegContext.filterComplex.push(`[${videoId}_first_null][${videoId}_first_frame]overlay=format=auto,fps
|
|
338
|
+
ffmpegContext.filterComplex.push(`[${videoId}_first_null][${videoId}_first_frame]overlay=format=auto,fps=${VIDEO_FPS}[${videoId}_first]`);
|
|
326
339
|
}
|
|
327
340
|
if (needLast) {
|
|
328
341
|
if (isMovie) {
|
|
329
342
|
// Movie beats: extract actual last frame
|
|
330
|
-
ffmpegContext.filterComplex.push(`nullsrc=size=${canvasInfo.width}x${canvasInfo.height}:duration=${lastDuration}:rate
|
|
343
|
+
ffmpegContext.filterComplex.push(`nullsrc=size=${canvasInfo.width}x${canvasInfo.height}:duration=${lastDuration}:rate=${VIDEO_FPS}[${videoId}_last_null]`);
|
|
331
344
|
ffmpegContext.filterComplex.push(`[${videoId}_last_src]reverse,select='eq(n,0)',reverse,scale=${canvasInfo.width}:${canvasInfo.height}[${videoId}_last_frame]`);
|
|
332
|
-
ffmpegContext.filterComplex.push(`[${videoId}_last_null][${videoId}_last_frame]overlay=format=auto,fps
|
|
345
|
+
ffmpegContext.filterComplex.push(`[${videoId}_last_null][${videoId}_last_frame]overlay=format=auto,fps=${VIDEO_FPS}[${videoId}_last]`);
|
|
333
346
|
}
|
|
334
347
|
else {
|
|
335
348
|
// Image beats: all frames are identical, so just select one
|
|
336
|
-
ffmpegContext.filterComplex.push(`nullsrc=size=${canvasInfo.width}x${canvasInfo.height}:duration=${lastDuration}:rate
|
|
349
|
+
ffmpegContext.filterComplex.push(`nullsrc=size=${canvasInfo.width}x${canvasInfo.height}:duration=${lastDuration}:rate=${VIDEO_FPS}[${videoId}_last_null]`);
|
|
337
350
|
ffmpegContext.filterComplex.push(`[${videoId}_last_src]select='eq(n,0)',scale=${canvasInfo.width}:${canvasInfo.height}[${videoId}_last_frame]`);
|
|
338
|
-
ffmpegContext.filterComplex.push(`[${videoId}_last_null][${videoId}_last_frame]overlay=format=auto,fps
|
|
351
|
+
ffmpegContext.filterComplex.push(`[${videoId}_last_null][${videoId}_last_frame]overlay=format=auto,fps=${VIDEO_FPS}[${videoId}_last]`);
|
|
339
352
|
}
|
|
340
353
|
}
|
|
341
354
|
};
|
|
@@ -367,6 +380,7 @@ export const createVideo = async (audioArtifactFilePath, outputVideoPath, contex
|
|
|
367
380
|
const needsFirstFrame = getNeedFirstFrame(context);
|
|
368
381
|
// Check which beats need _last (for any transition on next beat - they all need previous beat's last frame)
|
|
369
382
|
const needsLastFrame = getNeedLastFrame(context);
|
|
383
|
+
let cumulativeFrames = 0;
|
|
370
384
|
context.studio.beats.reduce((timestamp, studioBeat, index) => {
|
|
371
385
|
const beat = context.studio.script.beats[index];
|
|
372
386
|
if (beat.image?.type === "voice_over") {
|
|
@@ -377,13 +391,19 @@ export const createVideo = async (audioArtifactFilePath, outputVideoPath, contex
|
|
|
377
391
|
const sourceFile = isTest ? "/test/dummy.mp4" : validateBeatSource(studioBeat, index);
|
|
378
392
|
// The movie duration is bigger in case of voice-over.
|
|
379
393
|
const duration = Math.max(studioBeat.duration + getExtraPadding(context, index), studioBeat.movieDuration ?? 0);
|
|
394
|
+
// Use cumulative frame tracking to prevent audio-video drift from frame quantization.
|
|
395
|
+
// trim=duration=X rounds up to the next frame boundary (~0.03s per beat at 30fps),
|
|
396
|
+
// causing cumulative drift. Instead, compute exact frame counts per beat.
|
|
397
|
+
const targetEndFrame = Math.round((timestamp + duration) * VIDEO_FPS);
|
|
398
|
+
const frameCount = targetEndFrame - cumulativeFrames;
|
|
399
|
+
cumulativeFrames = targetEndFrame;
|
|
380
400
|
const inputIndex = FfmpegContextAddInput(ffmpegContext, sourceFile);
|
|
381
401
|
const isMovie = !!(studioBeat.lipSyncFile ||
|
|
382
402
|
studioBeat.movieFile ||
|
|
383
403
|
MulmoPresentationStyleMethods.getImageType(context.presentationStyle, beat) === "movie");
|
|
384
404
|
const speed = beat.movieParams?.speed ?? 1.0;
|
|
385
405
|
const filters = beat.movieParams?.filters;
|
|
386
|
-
const { videoId, videoPart } = getVideoPart(inputIndex, isMovie, duration, canvasInfo, getFillOption(context, beat), speed, filters);
|
|
406
|
+
const { videoId, videoPart } = getVideoPart(inputIndex, isMovie, duration, canvasInfo, getFillOption(context, beat), speed, filters, frameCount);
|
|
387
407
|
ffmpegContext.filterComplex.push(videoPart);
|
|
388
408
|
// for transition
|
|
389
409
|
const needFirst = needsFirstFrame[index]; // This beat has slidein
|
package/lib/types/type.d.ts
CHANGED
|
@@ -84,6 +84,7 @@ export type ImageProcessorParams = {
|
|
|
84
84
|
textSlideStyle: string;
|
|
85
85
|
canvasSize: MulmoCanvasDimension;
|
|
86
86
|
imageRefs?: Record<string, string>;
|
|
87
|
+
beatDuration?: number;
|
|
87
88
|
};
|
|
88
89
|
export type PDFMode = (typeof pdf_modes)[number];
|
|
89
90
|
export type PDFSize = (typeof pdf_sizes)[number];
|
|
@@ -34,9 +34,9 @@ const processHtmlTailwindAnimated = async (params) => {
|
|
|
34
34
|
const animConfig = getAnimationConfig(params);
|
|
35
35
|
if (!animConfig)
|
|
36
36
|
return;
|
|
37
|
-
const duration = beat.duration;
|
|
37
|
+
const duration = params.beatDuration ?? beat.duration;
|
|
38
38
|
if (duration === undefined) {
|
|
39
|
-
throw new Error("html_tailwind animation requires
|
|
39
|
+
throw new Error("html_tailwind animation requires beat.duration or audio-derived duration. Set duration in the beat or ensure audio is generated first.");
|
|
40
40
|
}
|
|
41
41
|
const fps = animConfig.fps;
|
|
42
42
|
const totalFrames = Math.floor(duration * fps);
|
package/package.json
CHANGED
|
@@ -546,6 +546,83 @@
|
|
|
546
546
|
"animation": { "fps": 24 }
|
|
547
547
|
}
|
|
548
548
|
},
|
|
549
|
+
{
|
|
550
|
+
"id": "demo_3d_card_flip",
|
|
551
|
+
"speaker": "Presenter",
|
|
552
|
+
"duration": 3,
|
|
553
|
+
"image": {
|
|
554
|
+
"type": "html_tailwind",
|
|
555
|
+
"html": [
|
|
556
|
+
"<div class='h-full flex items-center justify-center bg-gradient-to-br from-slate-900 to-indigo-950'>",
|
|
557
|
+
" <div style='perspective:1000px'>",
|
|
558
|
+
" <div id='card' class='relative' style='width:340px;height:200px;transform-style:preserve-3d'>",
|
|
559
|
+
" <div class='absolute inset-0 rounded-2xl flex flex-col items-center justify-center' style='backface-visibility:hidden;background:linear-gradient(135deg,#3b82f6,#06b6d4);box-shadow:0 20px 60px rgba(6,182,212,0.3)'>",
|
|
560
|
+
" <p class='text-white text-3xl font-bold tracking-wide'>MulmoCast</p>",
|
|
561
|
+
" <p class='text-blue-200 text-sm mt-2 tracking-wider'>FRONT SIDE</p>",
|
|
562
|
+
" </div>",
|
|
563
|
+
" <div class='absolute inset-0 rounded-2xl flex flex-col items-center justify-center' style='backface-visibility:hidden;transform:rotateY(180deg);background:linear-gradient(135deg,#8b5cf6,#ec4899);box-shadow:0 20px 60px rgba(139,92,246,0.3)'>",
|
|
564
|
+
" <p class='text-white text-3xl font-bold tracking-wide'>AI-Native</p>",
|
|
565
|
+
" <p class='text-purple-200 text-sm mt-2 tracking-wider'>BACK SIDE</p>",
|
|
566
|
+
" </div>",
|
|
567
|
+
" </div>",
|
|
568
|
+
" </div>",
|
|
569
|
+
"</div>"
|
|
570
|
+
],
|
|
571
|
+
"script": [
|
|
572
|
+
"const animation = new MulmoAnimation();",
|
|
573
|
+
"animation.animate('#card', { rotateY: [0, 180] }, { start: 0.5, end: 2.5, easing: 'easeInOut' });"
|
|
574
|
+
],
|
|
575
|
+
"animation": true
|
|
576
|
+
}
|
|
577
|
+
},
|
|
578
|
+
{
|
|
579
|
+
"id": "demo_3d_title_reveal",
|
|
580
|
+
"speaker": "Presenter",
|
|
581
|
+
"duration": 3,
|
|
582
|
+
"image": {
|
|
583
|
+
"type": "html_tailwind",
|
|
584
|
+
"html": [
|
|
585
|
+
"<div class='h-full flex flex-col items-center justify-center bg-black' style='perspective:800px'>",
|
|
586
|
+
" <h1 id='title' class='text-7xl font-bold tracking-wider' style='opacity:0;font-family:Impact,sans-serif;color:white;text-shadow:0 0 40px rgba(6,182,212,0.5)'>CINEMATIC</h1>",
|
|
587
|
+
" <div id='line' class='h-0.5 mt-6 rounded' style='width:0;background:linear-gradient(90deg,transparent,#06b6d4,transparent)'></div>",
|
|
588
|
+
" <p id='sub' class='text-lg mt-6 tracking-[0.4em]' style='opacity:0;color:#64748b;font-family:monospace'>3D PERSPECTIVE REVEAL</p>",
|
|
589
|
+
"</div>"
|
|
590
|
+
],
|
|
591
|
+
"script": [
|
|
592
|
+
"const animation = new MulmoAnimation();",
|
|
593
|
+
"animation.animate('#title', { opacity: [0, 1], rotateX: [90, 0] }, { start: 0.2, end: 1.2, easing: 'easeOut' });",
|
|
594
|
+
"animation.animate('#line', { width: [0, 400, 'px'] }, { start: 1.0, end: 1.8, easing: 'easeOut' });",
|
|
595
|
+
"animation.animate('#sub', { opacity: [0, 1] }, { start: 1.5, end: 2.2, easing: 'easeOut' });"
|
|
596
|
+
],
|
|
597
|
+
"animation": true
|
|
598
|
+
}
|
|
599
|
+
},
|
|
600
|
+
{
|
|
601
|
+
"id": "demo_split_reveal",
|
|
602
|
+
"speaker": "Presenter",
|
|
603
|
+
"duration": 3,
|
|
604
|
+
"image": {
|
|
605
|
+
"type": "html_tailwind",
|
|
606
|
+
"html": [
|
|
607
|
+
"<div class='h-full flex bg-black overflow-hidden'>",
|
|
608
|
+
" <div id='left' class='flex-1 flex items-center justify-center' style='background:linear-gradient(135deg,#1e3a5f,#0f172a);opacity:0'>",
|
|
609
|
+
" <p class='text-6xl font-bold text-white' style='font-family:Georgia,serif'>Create</p>",
|
|
610
|
+
" </div>",
|
|
611
|
+
" <div id='divider' class='w-1' style='background:linear-gradient(to bottom,transparent,#06b6d4,transparent);opacity:0'></div>",
|
|
612
|
+
" <div id='right' class='flex-1 flex items-center justify-center' style='background:linear-gradient(225deg,#4c1d95,#0f172a);opacity:0'>",
|
|
613
|
+
" <p class='text-6xl font-bold text-white' style='font-family:Georgia,serif'>Inspire</p>",
|
|
614
|
+
" </div>",
|
|
615
|
+
"</div>"
|
|
616
|
+
],
|
|
617
|
+
"script": [
|
|
618
|
+
"const animation = new MulmoAnimation();",
|
|
619
|
+
"animation.animate('#left', { translateX: [-640, 0], opacity: [0, 1] }, { start: 0, end: 1.0, easing: 'easeOut' });",
|
|
620
|
+
"animation.animate('#right', { translateX: [640, 0], opacity: [0, 1] }, { start: 0.3, end: 1.3, easing: 'easeOut' });",
|
|
621
|
+
"animation.animate('#divider', { opacity: [0, 1] }, { start: 1.2, end: 1.8 });"
|
|
622
|
+
],
|
|
623
|
+
"animation": true
|
|
624
|
+
}
|
|
625
|
+
},
|
|
549
626
|
{
|
|
550
627
|
"speaker": "Presenter",
|
|
551
628
|
"duration": 2,
|