shiveshnavin commited on
Commit
82367ec
·
1 Parent(s): b42c919
utils/bubble/Bubble.js CHANGED
@@ -83,6 +83,24 @@ class BubbleMaker {
83
  if (tpl) {
84
  // deep merge: template provides defaults, bubble overrides
85
  bubble = _.merge({}, tpl, bubble);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  // Scale pixel-based template values defined for a 1080x1920 reference to the actual video size
87
  const REF_W = 1080, REF_H = 1920;
88
  const scale = Math.min(vw / REF_W, vh / REF_H) || 1;
@@ -97,8 +115,6 @@ class BubbleMaker {
97
  }
98
 
99
  const extra = bubble.bubbleExtra || {};
100
- // animation config may now live on bubble.bubbleText.animExtra per model changes
101
- const anim = (bubble.bubbleText && bubble.bubbleText.animExtra) || bubble.animExtra || {};
102
 
103
 
104
  const from = typeof bubble.fromSec === 'number' ? bubble.fromSec : 0;
@@ -109,9 +125,15 @@ class BubbleMaker {
109
  let overlayPath = null;
110
  // accept either mediaAbsPaths (plural, template) or mediaAbsPath (singular) for compatibility
111
  const mediaField = bubble.mediaAbsPaths || bubble.mediaAbsPath || bubble.mediaAbs;
 
112
  if (mediaField) {
113
- const m = Array.isArray(mediaField) ? mediaField[0] : mediaField;
114
- overlayPath = m?.path;
 
 
 
 
 
115
  isMedia = !!overlayPath;
116
  }
117
 
@@ -119,9 +141,19 @@ class BubbleMaker {
119
  const padX = extra.paddingX || 5; // percent
120
  const padY = extra.paddingY || 5;
121
 
122
- let ow = Math.round(vw * (extra.size === 'half' ? 0.45 : 0.30));
 
 
123
  let oh = null; // keep aspect if image
124
 
 
 
 
 
 
 
 
 
125
 
126
  // build ffmpeg command
127
  let cmd;
@@ -139,18 +171,73 @@ class BubbleMaker {
139
  const iw = overlayMeta.video.width;
140
  const ih = overlayMeta.video.height;
141
  const ar = iw / ih;
142
- if (!oh) oh = Math.round(ow / ar);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  } else {
144
  // fallback to square-ish
145
- if (!oh) oh = Math.round(ow * 0.75);
 
 
 
 
 
 
 
146
  }
147
 
148
  const { x, y } = computeXY(ow, oh, extra, vw, vh);
149
 
150
- // fade durations
151
- const fadeDur = Math.min(0.5, (to - from) / 2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  const fadeInStart = from;
153
- const fadeOutStart = Math.max(from, to - fadeDur);
154
 
155
  // Build video part of filter_complex
156
  // If image and needs bg/rounded corners, pre-process the image to bake background and rounding into a PNG
@@ -164,7 +251,17 @@ class BubbleMaker {
164
  }
165
  }
166
 
167
- const videoFilter = `[1:v]scale=${ow}:${oh},format=rgba,fade=t=in:st=${fadeInStart}:d=${fadeDur}:alpha=1,fade=t=out:st=${fadeOutStart}:d=${fadeDur}:alpha=1[ov];[0:v][ov]overlay=${x}:${y}:enable='between(t,${from},${to})'[vout]`;
 
 
 
 
 
 
 
 
 
 
168
 
169
  // Inputs: 0 = main video, 1 = overlay (image/video) (looped for images)
170
  // Limit looped image input to main video duration to avoid encoding hanging
@@ -346,10 +443,24 @@ export async function test() {
346
  toSec: 5.0
347
  };
348
 
 
 
 
 
 
 
 
 
 
 
349
  try {
350
  const outPath = path.join(outDir, 'media_typing_template_bubble.mp4');
351
  await bubbleMaker.makeBubble(baseVideo, typingSample, outPath, console.log);
352
  console.log('Created:', outPath);
 
 
 
 
353
  } catch (e) {
354
  console.error('Test failed:', e);
355
  }
 
83
  if (tpl) {
84
  // deep merge: template provides defaults, bubble overrides
85
  bubble = _.merge({}, tpl, bubble);
86
+
87
+ // If user provided a simple string path for mediaAbsPath (common case) the
88
+ // deep merge will replace the object that contained animExtra, losing the
89
+ // animation defaults from the template. Copy any animExtra defined in the
90
+ // template up to the top level so later code can pick it up.
91
+ if (!bubble.animExtra) {
92
+ if (tpl.mediaAbsPath && tpl.mediaAbsPath.animExtra) {
93
+ bubble.animExtra = tpl.mediaAbsPath.animExtra;
94
+ } else if (
95
+ tpl.mediaAbsPaths &&
96
+ Array.isArray(tpl.mediaAbsPaths) &&
97
+ tpl.mediaAbsPaths[0] &&
98
+ tpl.mediaAbsPaths[0].animExtra
99
+ ) {
100
+ bubble.animExtra = tpl.mediaAbsPaths[0].animExtra;
101
+ }
102
+ }
103
+
104
  // Scale pixel-based template values defined for a 1080x1920 reference to the actual video size
105
  const REF_W = 1080, REF_H = 1920;
106
  const scale = Math.min(vw / REF_W, vh / REF_H) || 1;
 
115
  }
116
 
117
  const extra = bubble.bubbleExtra || {};
 
 
118
 
119
 
120
  const from = typeof bubble.fromSec === 'number' ? bubble.fromSec : 0;
 
125
  let overlayPath = null;
126
  // accept either mediaAbsPaths (plural, template) or mediaAbsPath (singular) for compatibility
127
  const mediaField = bubble.mediaAbsPaths || bubble.mediaAbsPath || bubble.mediaAbs;
128
+ let mediaItem = null;
129
  if (mediaField) {
130
+ // mediaField can be an array or single value, and items can be either SectionMedia objects or plain string paths
131
+ mediaItem = Array.isArray(mediaField) ? mediaField[0] : mediaField;
132
+ if (typeof mediaItem === 'string') {
133
+ overlayPath = mediaItem;
134
+ } else if (mediaItem && typeof mediaItem.path === 'string') {
135
+ overlayPath = mediaItem.path;
136
+ }
137
  isMedia = !!overlayPath;
138
  }
139
 
 
141
  const padX = extra.paddingX || 5; // percent
142
  const padY = extra.paddingY || 5;
143
 
144
+ // initial overlay dimensions; for full mode we'll adjust later once we know
145
+ // the media's aspect ratio.
146
+ let ow = null;
147
  let oh = null; // keep aspect if image
148
 
149
+ if (extra.size === 'half') {
150
+ ow = Math.round(vw * 0.45);
151
+ } else if (extra.size === 'full') {
152
+ // leave null for now, will be calculated after we inspect the media metadata
153
+ } else {
154
+ ow = Math.round(vw * 0.30);
155
+ }
156
+
157
 
158
  // build ffmpeg command
159
  let cmd;
 
171
  const iw = overlayMeta.video.width;
172
  const ih = overlayMeta.video.height;
173
  const ar = iw / ih;
174
+ if (extra.size === 'full') {
175
+ // compute maximum area available after applying padding percentages
176
+ const maxW = Math.round(vw * (1 - 2 * padX / 100));
177
+ const maxH = Math.round(vh * (1 - 2 * padY / 100));
178
+ // fit preserving aspect ratio
179
+ if (maxW / maxH > ar) {
180
+ // height is limiting factor
181
+ oh = maxH;
182
+ ow = Math.round(maxH * ar);
183
+ } else {
184
+ ow = maxW;
185
+ oh = Math.round(maxW / ar);
186
+ }
187
+ } else {
188
+ if (!ow) {
189
+ // ow may be null if we deferred calculation
190
+ ow = Math.round(vw * 0.30);
191
+ }
192
+ if (!oh) oh = Math.round(ow / ar);
193
+ }
194
  } else {
195
  // fallback to square-ish
196
+ if (!oh) {
197
+ if (extra.size === 'full') {
198
+ oh = Math.round(vh * (1 - 2 * padY / 100));
199
+ ow = Math.round(vw * (1 - 2 * padX / 100));
200
+ } else {
201
+ oh = Math.round(ow * 0.75);
202
+ }
203
+ }
204
  }
205
 
206
  const { x, y } = computeXY(ow, oh, extra, vw, vh);
207
 
208
+ // Determine animation config: prefer media item animExtra, then bubble.bubbleText.animExtra, then bubble.animExtra
209
+ const anim = (mediaItem && mediaItem.animExtra) || (bubble.bubbleText && bubble.bubbleText.animExtra) || bubble.animExtra || {};
210
+ const animTpl = (anim && typeof anim.template === 'string') ? anim.template : null;
211
+ const animDur = (typeof anim.durationSec === 'number') ? anim.durationSec : Math.min(0.5, (to - from) / 2);
212
+
213
+ // Build position expressions to support simple slide animations
214
+ let xExpr = x;
215
+ let yExpr = y;
216
+ if (animTpl) {
217
+ const tpl = animTpl;
218
+ const wrapVal = (v) => (typeof v === 'number' ? `(${v})` : v);
219
+ const quote = (expr) => `'${expr.replace(/'/g, "\\'")}'`;
220
+ if (tpl === 'slide_down') {
221
+ const startY = -oh;
222
+ yExpr = `if(lt(t,${from + animDur}), ${wrapVal(startY)} + (${wrapVal(y)}-${wrapVal(startY)})*(t-${from})/${animDur}, ${wrapVal(y)})`;
223
+ } else if (tpl === 'slide_up') {
224
+ const startY = vh;
225
+ yExpr = `if(lt(t,${from + animDur}), ${wrapVal(startY)} + (${wrapVal(y)}-${wrapVal(startY)})*(t-${from})/${animDur}, ${wrapVal(y)})`;
226
+ } else if (tpl === 'slide_left') {
227
+ const startX = -ow;
228
+ xExpr = `if(lt(t,${from + animDur}), ${wrapVal(startX)} + (${wrapVal(x)}-${wrapVal(startX)})*(t-${from})/${animDur}, ${wrapVal(x)})`;
229
+ } else if (tpl === 'slide_right') {
230
+ const startX = vw;
231
+ xExpr = `if(lt(t,${from + animDur}), ${wrapVal(startX)} + (${wrapVal(x)}-${wrapVal(startX)})*(t-${from})/${animDur}, ${wrapVal(x)})`;
232
+ }
233
+ // wrap the final expressions in quotes so commas are treated literally
234
+ if (typeof xExpr === 'string') xExpr = quote(xExpr);
235
+ if (typeof yExpr === 'string') yExpr = quote(yExpr);
236
+ }
237
+
238
+ // fade/animation timing
239
  const fadeInStart = from;
240
+ const fadeOutStart = Math.max(from, to - animDur);
241
 
242
  // Build video part of filter_complex
243
  // If image and needs bg/rounded corners, pre-process the image to bake background and rounding into a PNG
 
251
  }
252
  }
253
 
254
+ // Decide animation behavior based on anim.template: absent -> no animation, 'none' -> no animation,
255
+ // 'fade'|'popup' -> apply fade, 'slide_*' -> slide motion (no fade)
256
+ const isSlideAnim = anim && anim.template && typeof anim.template === 'string' && anim.template.startsWith('slide_');
257
+ const isFadeAnim = anim && anim.template && (anim.template === 'fade' || anim.template === 'popup');
258
+
259
+ let overlayFilter = `[1:v]scale=${ow}:${oh},format=rgba`;
260
+ if (isFadeAnim) {
261
+ overlayFilter += `,fade=t=in:st=${fadeInStart}:d=${animDur}:alpha=1,fade=t=out:st=${fadeOutStart}:d=${animDur}:alpha=1`;
262
+ }
263
+ overlayFilter += `[ov];[0:v][ov]overlay=${xExpr}:${yExpr}:enable='between(t,${from},${to})'[vout]`;
264
+ const videoFilter = overlayFilter;
265
 
266
  // Inputs: 0 = main video, 1 = overlay (image/video) (looped for images)
267
  // Limit looped image input to main video duration to avoid encoding hanging
 
443
  toSec: 5.0
444
  };
445
 
446
+ // Additional sample: simple-top-center-image uses an image or video asset centered at top with text
447
+ const imageSample = {
448
+ templateName: 'simple-top-center-image',
449
+ // provide mediaAbsPath (what makeBubble expects) pointing to an image
450
+ mediaAbsPath: path.join(cwd, 'public', 'media2.png'),
451
+ bubbleText: { text: 'IMAGE ABOVE, TEXT CENTERED' },
452
+ fromSec: 0.5,
453
+ toSec: 5.0
454
+ };
455
+
456
  try {
457
  const outPath = path.join(outDir, 'media_typing_template_bubble.mp4');
458
  await bubbleMaker.makeBubble(baseVideo, typingSample, outPath, console.log);
459
  console.log('Created:', outPath);
460
+
461
+ const outPath2 = path.join(outDir, 'media_image_template_bubble.mp4');
462
+ await bubbleMaker.makeBubble(baseVideo, imageSample, outPath2, console.log);
463
+ console.log('Created:', outPath2);
464
  } catch (e) {
465
  console.error('Test failed:', e);
466
  }
utils/bubble/bubble-templates.js CHANGED
@@ -3,7 +3,7 @@ export const BaseBubbleTemplates = {
3
  // Simple text box: black text on white rounded box at top-center
4
  'simple-top-center': {
5
  // top-level bubble background controls whether a box is rendered
6
- backgroundColor: '#ffffff22',
7
  borderRadius: 20,
8
  audioEffectFile: 'woosh',
9
  bubbleText: {
@@ -13,8 +13,27 @@ export const BaseBubbleTemplates = {
13
  fontWeight: '700',
14
  },
15
  bubbleExtra: {
16
- positionX: 'right',
17
  positionY: 'center'
18
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  }
20
  };
 
3
  // Simple text box: black text on white rounded box at top-center
4
  'simple-top-center': {
5
  // top-level bubble background controls whether a box is rendered
6
+ backgroundColor: '#ffffff',
7
  borderRadius: 20,
8
  audioEffectFile: 'woosh',
9
  bubbleText: {
 
13
  fontWeight: '700',
14
  },
15
  bubbleExtra: {
16
+ positionX: 'top',
17
  positionY: 'center'
18
  }
19
+ },
20
+ // image-only variant: the caller must still provide the path when using this
21
+ // template. animation defaults are pulled up to the top level so they survive
22
+ // the case where the user specifies `mediaAbsPath` as a simple string.
23
+ 'simple-top-center-image': {
24
+ borderRadius: 20,
25
+ audioEffectFile: 'woosh',
26
+ // default animation for the overlay itself. callers can override
27
+ animExtra: {
28
+ template: 'slide_down',
29
+ durationSec: 0.1
30
+ },
31
+ bubbleExtra: {
32
+ positionX: 'center',
33
+ positionY: 'top',
34
+ size: 'full', // | 'half' // useful for images and videos
35
+ paddingX: 0, // number in percentage
36
+ paddingY: 0 // number in percentage
37
+ }
38
  }
39
  };