@vargai/sdk 0.1.1 → 0.2.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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +23 -0
  2. package/.husky/README.md +102 -0
  3. package/.husky/commit-msg +9 -0
  4. package/.husky/pre-commit +12 -0
  5. package/.husky/pre-push +9 -0
  6. package/.size-limit.json +8 -0
  7. package/.test-hooks.ts +5 -0
  8. package/CONTRIBUTING.md +150 -0
  9. package/LICENSE.md +53 -0
  10. package/README.md +7 -0
  11. package/action/captions/index.ts +202 -12
  12. package/action/captions/tiktok.ts +538 -0
  13. package/action/cut/index.ts +119 -0
  14. package/action/fade/index.ts +116 -0
  15. package/action/merge/index.ts +177 -0
  16. package/action/remove/index.ts +184 -0
  17. package/action/split/index.ts +133 -0
  18. package/action/transition/index.ts +154 -0
  19. package/action/trim/index.ts +117 -0
  20. package/bun.lock +299 -8
  21. package/cli/commands/upload.ts +215 -0
  22. package/cli/index.ts +3 -1
  23. package/commitlint.config.js +22 -0
  24. package/index.ts +12 -0
  25. package/lib/ass.ts +547 -0
  26. package/lib/fal.ts +75 -1
  27. package/lib/ffmpeg.ts +400 -0
  28. package/lib/higgsfield/example.ts +22 -29
  29. package/lib/higgsfield/index.ts +3 -2
  30. package/lib/higgsfield/soul.ts +0 -5
  31. package/lib/remotion/SKILL.md +240 -21
  32. package/lib/remotion/cli.ts +34 -0
  33. package/package.json +20 -3
  34. package/pipeline/cookbooks/scripts/animate-frames-parallel.ts +83 -0
  35. package/pipeline/cookbooks/scripts/combine-scenes.sh +53 -0
  36. package/pipeline/cookbooks/scripts/generate-frames-parallel.ts +98 -0
  37. package/pipeline/cookbooks/scripts/still-to-video.sh +37 -0
  38. package/pipeline/cookbooks/text-to-tiktok.md +669 -0
  39. package/scripts/.gitkeep +0 -0
  40. package/service/music/index.ts +29 -14
  41. package/tsconfig.json +1 -1
  42. package/utilities/s3.ts +2 -2
  43. package/HIGGSFIELD_REWRITE_SUMMARY.md +0 -300
  44. package/TEST_RESULTS.md +0 -122
  45. package/output.txt +0 -1
  46. package/scripts/produce-menopause-campaign.sh +0 -202
  47. package/test-import.ts +0 -7
  48. package/test-services.ts +0 -97
package/lib/ass.ts ADDED
@@ -0,0 +1,547 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * ASS (Advanced SubStation Alpha) subtitle format generator
5
+ * Used for TikTok-style word-by-word captions with animations
6
+ *
7
+ * ASS Format Reference:
8
+ * - Colors are in BGR format: &HBBGGRR (e.g., white = &HFFFFFF, red = &H0000FF)
9
+ * - Alignment uses numpad layout (1-9): 2 = bottom center, 8 = top center, 5 = middle center
10
+ * - Animation tags: \t(start,end,\effect) for transitions
11
+ * - Fade tags: \fad(fadeIn,fadeOut) in milliseconds
12
+ */
13
+
14
+ import { writeFileSync } from "node:fs";
15
+
16
+ // ============ TYPES ============
17
+
18
+ export interface ASSStyle {
19
+ name: string;
20
+ fontname: string;
21
+ fontsize: number;
22
+ primarycolor: string; // BGR format: &HBBGGRR or &HAABBGGRR with alpha
23
+ secondarycolor: string; // Used for karaoke highlight
24
+ outlinecolor: string;
25
+ backcolor: string;
26
+ bold: boolean;
27
+ italic: boolean;
28
+ underline: boolean;
29
+ strikeout: boolean;
30
+ scaleX: number; // percentage, default 100
31
+ scaleY: number; // percentage, default 100
32
+ spacing: number; // letter spacing in pixels
33
+ angle: number; // rotation in degrees
34
+ borderStyle: number; // 1 = outline + shadow, 3 = opaque box
35
+ outline: number; // outline thickness in pixels
36
+ shadow: number; // shadow distance in pixels
37
+ alignment: number; // 1-9 numpad style
38
+ marginL: number;
39
+ marginR: number;
40
+ marginV: number;
41
+ encoding: number; // character encoding, 1 = default
42
+ }
43
+
44
+ export interface ASSEvent {
45
+ layer: number;
46
+ start: number; // in seconds
47
+ end: number; // in seconds
48
+ style: string;
49
+ name: string; // actor name (usually empty)
50
+ marginL: number;
51
+ marginR: number;
52
+ marginV: number;
53
+ effect: string;
54
+ text: string; // with ASS override tags
55
+ }
56
+
57
+ export interface ASSDocument {
58
+ title: string;
59
+ playResX: number;
60
+ playResY: number;
61
+ wrapStyle: number; // 0 = smart, 1 = end-of-line, 2 = no wrap, 3 = smart (lower)
62
+ scaledBorderAndShadow: boolean;
63
+ styles: ASSStyle[];
64
+ events: ASSEvent[];
65
+ }
66
+
67
+ // ============ COLOR UTILITIES ============
68
+
69
+ /**
70
+ * Named colors with RGB values
71
+ */
72
+ export const COLORS = {
73
+ white: [255, 255, 255],
74
+ black: [0, 0, 0],
75
+ yellow: [255, 229, 92],
76
+ tiktok_yellow: [254, 231, 21], // Bright TikTok-style yellow (#FEE715)
77
+ red: [255, 0, 0],
78
+ green: [0, 255, 0],
79
+ blue: [0, 0, 255],
80
+ cyan: [0, 255, 255],
81
+ magenta: [255, 0, 255],
82
+ } as const;
83
+
84
+ export type ColorName = keyof typeof COLORS;
85
+
86
+ /**
87
+ * Parse color to RGB tuple
88
+ * Supports: color names, hex (#RRGGBB), RGB array
89
+ */
90
+ export function parseColor(
91
+ color: string | [number, number, number],
92
+ ): [number, number, number] {
93
+ if (Array.isArray(color)) {
94
+ return color;
95
+ }
96
+
97
+ // Check named colors
98
+ const lowerColor = color.toLowerCase();
99
+ if (lowerColor in COLORS) {
100
+ return COLORS[lowerColor as ColorName] as [number, number, number];
101
+ }
102
+
103
+ // Parse hex color (#RRGGBB or RRGGBB)
104
+ const hex = color.replace("#", "");
105
+ if (hex.length === 6) {
106
+ const r = Number.parseInt(hex.slice(0, 2), 16);
107
+ const g = Number.parseInt(hex.slice(2, 4), 16);
108
+ const b = Number.parseInt(hex.slice(4, 6), 16);
109
+ return [r, g, b];
110
+ }
111
+
112
+ // Default to white
113
+ console.warn(`[ass] unknown color: ${color}, defaulting to white`);
114
+ return [255, 255, 255];
115
+ }
116
+
117
+ /**
118
+ * Convert RGB to ASS BGR format string
119
+ * ASS uses &HBBGGRR format (blue, green, red)
120
+ */
121
+ export function rgbToBGR(r: number, g: number, b: number): string {
122
+ const bHex = b.toString(16).padStart(2, "0").toUpperCase();
123
+ const gHex = g.toString(16).padStart(2, "0").toUpperCase();
124
+ const rHex = r.toString(16).padStart(2, "0").toUpperCase();
125
+ return `&H${bHex}${gHex}${rHex}`;
126
+ }
127
+
128
+ /**
129
+ * Convert RGB to ASS BGR format with alpha
130
+ * ASS uses &HAABBGGRR format (alpha, blue, green, red)
131
+ * Alpha: 00 = fully visible, FF = fully transparent
132
+ */
133
+ export function rgbToBGRWithAlpha(
134
+ r: number,
135
+ g: number,
136
+ b: number,
137
+ alpha = 0,
138
+ ): string {
139
+ const aHex = alpha.toString(16).padStart(2, "0").toUpperCase();
140
+ const bHex = b.toString(16).padStart(2, "0").toUpperCase();
141
+ const gHex = g.toString(16).padStart(2, "0").toUpperCase();
142
+ const rHex = r.toString(16).padStart(2, "0").toUpperCase();
143
+ return `&H${aHex}${bHex}${gHex}${rHex}`;
144
+ }
145
+
146
+ /**
147
+ * Convert color (name, hex, or RGB) to ASS BGR format
148
+ */
149
+ export function colorToBGR(color: string | [number, number, number]): string {
150
+ const [r, g, b] = parseColor(color);
151
+ return rgbToBGR(r, g, b);
152
+ }
153
+
154
+ // ============ TIME UTILITIES ============
155
+
156
+ /**
157
+ * Convert seconds to ASS time format: H:MM:SS.cc (centiseconds)
158
+ */
159
+ export function secondsToASSTime(seconds: number): string {
160
+ const totalCentiseconds = Math.round(seconds * 100);
161
+ const hours = Math.floor(totalCentiseconds / 360000);
162
+ const minutes = Math.floor((totalCentiseconds % 360000) / 6000);
163
+ const secs = Math.floor((totalCentiseconds % 6000) / 100);
164
+ const centisecs = totalCentiseconds % 100;
165
+
166
+ return `${hours}:${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}.${String(centisecs).padStart(2, "0")}`;
167
+ }
168
+
169
+ /**
170
+ * Convert milliseconds to ASS time format
171
+ */
172
+ export function msToASSTime(ms: number): string {
173
+ return secondsToASSTime(ms / 1000);
174
+ }
175
+
176
+ // ============ ASS OVERRIDE TAGS ============
177
+
178
+ /**
179
+ * Create color override tag
180
+ * @param color - Color in any supported format
181
+ * @returns ASS color tag like {\c&HFFFFFF&}
182
+ */
183
+ export function colorTag(color: string | [number, number, number]): string {
184
+ return `{\\c${colorToBGR(color)}&}`;
185
+ }
186
+
187
+ /**
188
+ * Create animation/transition tag
189
+ * @param startMs - Start time in milliseconds (relative to event start)
190
+ * @param endMs - End time in milliseconds
191
+ * @param effect - Effect to apply (e.g., \fscx120\fscy120)
192
+ * @returns ASS transition tag
193
+ */
194
+ export function transitionTag(
195
+ startMs: number,
196
+ endMs: number,
197
+ effect: string,
198
+ ): string {
199
+ return `{\\t(${startMs},${endMs},${effect})}`;
200
+ }
201
+
202
+ /**
203
+ * Create bounce animation tags (scale up then back to normal)
204
+ * @param durationMs - Total duration of the bounce
205
+ * @param scale - Scale percentage (e.g., 112 for 12% increase)
206
+ * @param animDurationMs - Duration of scale animation (default: 50ms)
207
+ * @returns ASS tags for bounce effect
208
+ */
209
+ export function bounceTag(
210
+ durationMs: number,
211
+ scale = 112,
212
+ animDurationMs = 50,
213
+ ): string {
214
+ const scaleUpEnd = Math.min(animDurationMs, durationMs / 2);
215
+ const scaleDownStart = Math.max(0, durationMs - animDurationMs);
216
+
217
+ return (
218
+ `{\\t(0,${scaleUpEnd},\\fscx${scale}\\fscy${scale})}` +
219
+ `{\\t(${scaleDownStart},${durationMs},\\fscx100\\fscy100)}`
220
+ );
221
+ }
222
+
223
+ /**
224
+ * Create fade tag
225
+ * @param fadeInMs - Fade in duration in milliseconds
226
+ * @param fadeOutMs - Fade out duration in milliseconds
227
+ * @returns ASS fade tag
228
+ */
229
+ export function fadeTag(fadeInMs: number, fadeOutMs: number): string {
230
+ return `{\\fad(${fadeInMs},${fadeOutMs})}`;
231
+ }
232
+
233
+ /**
234
+ * Create reset tag to clear all overrides
235
+ */
236
+ export function resetTag(): string {
237
+ return "{\\r}";
238
+ }
239
+
240
+ /**
241
+ * Create position override tag
242
+ * @param x - X position in pixels
243
+ * @param y - Y position in pixels
244
+ * @returns ASS position tag
245
+ */
246
+ export function positionTag(x: number, y: number): string {
247
+ return `{\\pos(${x},${y})}`;
248
+ }
249
+
250
+ /**
251
+ * Create alignment override tag
252
+ * @param alignment - Alignment value 1-9 (numpad style)
253
+ * @returns ASS alignment tag
254
+ */
255
+ export function alignmentTag(alignment: number): string {
256
+ return `{\\an${alignment}}`;
257
+ }
258
+
259
+ // ============ STYLE CREATION ============
260
+
261
+ /**
262
+ * Create default ASS style
263
+ */
264
+ export function createDefaultStyle(
265
+ name = "Default",
266
+ overrides: Partial<ASSStyle> = {},
267
+ ): ASSStyle {
268
+ return {
269
+ name,
270
+ fontname: "Arial",
271
+ fontsize: 48,
272
+ primarycolor: "&HFFFFFF", // white
273
+ secondarycolor: "&H00FFFF", // yellow (for karaoke)
274
+ outlinecolor: "&H000000", // black
275
+ backcolor: "&H00000000", // transparent
276
+ bold: true,
277
+ italic: false,
278
+ underline: false,
279
+ strikeout: false,
280
+ scaleX: 100,
281
+ scaleY: 100,
282
+ spacing: 0,
283
+ angle: 0,
284
+ borderStyle: 1,
285
+ outline: 2,
286
+ shadow: 0,
287
+ alignment: 2, // bottom center
288
+ marginL: 40,
289
+ marginR: 40,
290
+ marginV: 60,
291
+ encoding: 1,
292
+ ...overrides,
293
+ };
294
+ }
295
+
296
+ /**
297
+ * Create TikTok-optimized style
298
+ */
299
+ export function createTikTokStyle(
300
+ name = "TikTok",
301
+ overrides: Partial<ASSStyle> = {},
302
+ ): ASSStyle {
303
+ return createDefaultStyle(name, {
304
+ fontname: "Helvetica Bold",
305
+ fontsize: 80,
306
+ primarycolor: rgbToBGR(254, 231, 21), // TikTok yellow (inactive)
307
+ secondarycolor: rgbToBGR(255, 255, 255), // white (active)
308
+ outlinecolor: rgbToBGR(0, 0, 0), // black outline
309
+ backcolor: rgbToBGRWithAlpha(0, 0, 0, 255), // transparent
310
+ bold: true,
311
+ outline: 8, // thick outline for 4.5:1 contrast
312
+ shadow: 0,
313
+ spacing: 3,
314
+ alignment: 8, // top center (will be adjusted per position)
315
+ marginL: 60,
316
+ marginR: 120,
317
+ marginV: 300,
318
+ ...overrides,
319
+ });
320
+ }
321
+
322
+ // ============ DOCUMENT GENERATION ============
323
+
324
+ /**
325
+ * Generate ASS style line
326
+ */
327
+ function formatStyle(style: ASSStyle): string {
328
+ return (
329
+ `Style: ${style.name},${style.fontname},${style.fontsize},` +
330
+ `${style.primarycolor},${style.secondarycolor},${style.outlinecolor},${style.backcolor},` +
331
+ `${style.bold ? -1 : 0},${style.italic ? -1 : 0},${style.underline ? -1 : 0},${style.strikeout ? -1 : 0},` +
332
+ `${style.scaleX},${style.scaleY},${style.spacing},${style.angle},` +
333
+ `${style.borderStyle},${style.outline},${style.shadow},` +
334
+ `${style.alignment},${style.marginL},${style.marginR},${style.marginV},${style.encoding}`
335
+ );
336
+ }
337
+
338
+ /**
339
+ * Generate ASS event (dialogue) line
340
+ */
341
+ function formatEvent(event: ASSEvent): string {
342
+ const start = secondsToASSTime(event.start);
343
+ const end = secondsToASSTime(event.end);
344
+
345
+ return (
346
+ `Dialogue: ${event.layer},${start},${end},${event.style},` +
347
+ `${event.name},${event.marginL},${event.marginR},${event.marginV},` +
348
+ `${event.effect},${event.text}`
349
+ );
350
+ }
351
+
352
+ /**
353
+ * Generate complete ASS document string
354
+ */
355
+ export function generateASS(doc: ASSDocument): string {
356
+ const lines: string[] = [];
357
+
358
+ // Script Info section
359
+ lines.push("[Script Info]");
360
+ lines.push(`Title: ${doc.title}`);
361
+ lines.push("ScriptType: v4.00+");
362
+ lines.push(`PlayResX: ${doc.playResX}`);
363
+ lines.push(`PlayResY: ${doc.playResY}`);
364
+ lines.push(`WrapStyle: ${doc.wrapStyle}`);
365
+ lines.push(
366
+ `ScaledBorderAndShadow: ${doc.scaledBorderAndShadow ? "yes" : "no"}`,
367
+ );
368
+ lines.push("");
369
+
370
+ // Styles section
371
+ lines.push("[V4+ Styles]");
372
+ lines.push(
373
+ "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
374
+ );
375
+ for (const style of doc.styles) {
376
+ lines.push(formatStyle(style));
377
+ }
378
+ lines.push("");
379
+
380
+ // Events section
381
+ lines.push("[Events]");
382
+ lines.push(
383
+ "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
384
+ );
385
+ for (const event of doc.events) {
386
+ lines.push(formatEvent(event));
387
+ }
388
+
389
+ return lines.join("\n");
390
+ }
391
+
392
+ /**
393
+ * Create ASS event helper
394
+ */
395
+ export function createEvent(
396
+ start: number,
397
+ end: number,
398
+ text: string,
399
+ style = "Default",
400
+ overrides: Partial<ASSEvent> = {},
401
+ ): ASSEvent {
402
+ return {
403
+ layer: 0,
404
+ start,
405
+ end,
406
+ style,
407
+ name: "",
408
+ marginL: 0,
409
+ marginR: 0,
410
+ marginV: 0,
411
+ effect: "",
412
+ text,
413
+ ...overrides,
414
+ };
415
+ }
416
+
417
+ /**
418
+ * Create ASS document helper
419
+ */
420
+ export function createDocument(
421
+ width: number,
422
+ height: number,
423
+ styles: ASSStyle[],
424
+ events: ASSEvent[],
425
+ title = "Generated Subtitles",
426
+ ): ASSDocument {
427
+ return {
428
+ title,
429
+ playResX: width,
430
+ playResY: height,
431
+ wrapStyle: 2, // smart wrapping
432
+ scaledBorderAndShadow: true,
433
+ styles,
434
+ events,
435
+ };
436
+ }
437
+
438
+ /**
439
+ * Save ASS document to file
440
+ */
441
+ export function saveASS(doc: ASSDocument, outputPath: string): void {
442
+ const content = generateASS(doc);
443
+ writeFileSync(outputPath, content, "utf-8");
444
+ console.log(`[ass] saved to ${outputPath}`);
445
+ }
446
+
447
+ // ============ TEXT UTILITIES ============
448
+
449
+ /**
450
+ * Wrap text to multiple lines based on max characters per line
451
+ * Uses \\N for ASS line breaks
452
+ */
453
+ export function wrapText(text: string, maxCharsPerLine = 27): string {
454
+ const words = text.split(" ");
455
+ const lines: string[] = [];
456
+ let currentLine: string[] = [];
457
+ let currentLength = 0;
458
+
459
+ for (const word of words) {
460
+ const wordLength = word.length;
461
+ const spaceNeeded = wordLength + (currentLine.length > 0 ? 1 : 0);
462
+
463
+ if (currentLength + spaceNeeded <= maxCharsPerLine) {
464
+ currentLine.push(word);
465
+ currentLength += spaceNeeded;
466
+ } else {
467
+ if (currentLine.length > 0) {
468
+ lines.push(currentLine.join(" "));
469
+ }
470
+ currentLine = [word];
471
+ currentLength = wordLength;
472
+ }
473
+ }
474
+
475
+ if (currentLine.length > 0) {
476
+ lines.push(currentLine.join(" "));
477
+ }
478
+
479
+ return lines.join("\\N");
480
+ }
481
+
482
+ /**
483
+ * Split words into lines respecting max characters
484
+ */
485
+ export function splitIntoLines<T extends { word: string }>(
486
+ words: T[],
487
+ maxChars = 27,
488
+ ): T[][] {
489
+ const lines: T[][] = [];
490
+ let currentLine: T[] = [];
491
+ let currentLength = 0;
492
+
493
+ for (const wordData of words) {
494
+ const wordLen = wordData.word.length;
495
+
496
+ if (currentLength > 0 && currentLength + 1 + wordLen > maxChars) {
497
+ lines.push(currentLine);
498
+ currentLine = [wordData];
499
+ currentLength = wordLen;
500
+ } else {
501
+ currentLine.push(wordData);
502
+ currentLength += wordLen + (currentLength > 0 ? 1 : 0);
503
+ }
504
+ }
505
+
506
+ if (currentLine.length > 0) {
507
+ lines.push(currentLine);
508
+ }
509
+
510
+ return lines;
511
+ }
512
+
513
+ // ============ CLI ============
514
+
515
+ if (import.meta.main) {
516
+ console.log(`
517
+ ASS Subtitle Generator
518
+ ======================
519
+
520
+ This library generates ASS (Advanced SubStation Alpha) subtitle files
521
+ for use with ffmpeg's ass filter. It supports:
522
+
523
+ - Custom styles with colors, fonts, outlines
524
+ - Animation tags (bounce, fade, transitions)
525
+ - TikTok-style word-by-word highlighting
526
+ - Safe zone calculations for mobile video
527
+
528
+ Usage as library:
529
+
530
+ import { createDocument, createTikTokStyle, createEvent, generateASS } from './lib/ass'
531
+
532
+ const style = createTikTokStyle('TikTok')
533
+ const event = createEvent(0, 3, 'Hello World', 'TikTok')
534
+ const doc = createDocument(1080, 1920, [style], [event])
535
+ const assContent = generateASS(doc)
536
+
537
+ Color utilities:
538
+
539
+ import { colorToBGR, colorTag, bounceTag, fadeTag } from './lib/ass'
540
+
541
+ colorToBGR('white') // => '&HFFFFFF'
542
+ colorToBGR([255, 0, 0]) // => '&H0000FF' (red in BGR)
543
+ colorTag('tiktok_yellow') // => '{\\c&H15E7FE&}'
544
+ bounceTag(400, 112, 50) // => '{\\t(0,50,\\fscx112\\fscy112)}{\\t(350,400,\\fscx100\\fscy100)}'
545
+ fadeTag(150, 150) // => '{\\fad(150,150)}'
546
+ `);
547
+ }
package/lib/fal.ts CHANGED
@@ -15,6 +15,7 @@ interface FalImageToVideoArgs {
15
15
  imageUrl: string; // can be url or local file path
16
16
  modelVersion?: string;
17
17
  duration?: 5 | 10;
18
+ tailImageUrl?: string; // end frame for looping
18
19
  }
19
20
 
20
21
  /**
@@ -52,6 +53,7 @@ interface FalTextToVideoArgs {
52
53
  prompt: string;
53
54
  modelVersion?: string;
54
55
  duration?: 5 | 10;
56
+ aspectRatio?: "16:9" | "9:16" | "1:1";
55
57
  }
56
58
 
57
59
  export async function imageToVideo(args: FalImageToVideoArgs) {
@@ -60,9 +62,15 @@ export async function imageToVideo(args: FalImageToVideoArgs) {
60
62
  console.log(`[fal] starting image-to-video: ${modelId}`);
61
63
  console.log(`[fal] prompt: ${args.prompt}`);
62
64
  console.log(`[fal] image: ${args.imageUrl}`);
65
+ if (args.tailImageUrl) {
66
+ console.log(`[fal] tail image (loop): ${args.tailImageUrl}`);
67
+ }
63
68
 
64
69
  // upload local file if needed
65
70
  const imageUrl = await ensureImageUrl(args.imageUrl);
71
+ const tailImageUrl = args.tailImageUrl
72
+ ? await ensureImageUrl(args.tailImageUrl)
73
+ : undefined;
66
74
 
67
75
  try {
68
76
  const result = await fal.subscribe(modelId, {
@@ -70,6 +78,7 @@ export async function imageToVideo(args: FalImageToVideoArgs) {
70
78
  prompt: args.prompt,
71
79
  image_url: imageUrl,
72
80
  duration: args.duration || 5,
81
+ ...(tailImageUrl && { tail_image_url: tailImageUrl }),
73
82
  },
74
83
  logs: true,
75
84
  onQueueUpdate: (update: {
@@ -103,6 +112,7 @@ export async function textToVideo(args: FalTextToVideoArgs) {
103
112
  input: {
104
113
  prompt: args.prompt,
105
114
  duration: args.duration || 5,
115
+ aspect_ratio: args.aspectRatio || "16:9",
106
116
  },
107
117
  logs: true,
108
118
  onQueueUpdate: (update: {
@@ -302,6 +312,61 @@ export async function wan25(args: FalWan25Args) {
302
312
  }
303
313
  }
304
314
 
315
+ interface FalTextToMusicArgs {
316
+ prompt?: string;
317
+ tags?: string[];
318
+ lyricsPrompt?: string;
319
+ seed?: number;
320
+ promptStrength?: number;
321
+ balanceStrength?: number;
322
+ numSongs?: 1 | 2;
323
+ outputFormat?: "flac" | "mp3" | "wav" | "ogg" | "m4a";
324
+ outputBitRate?: 128 | 192 | 256 | 320;
325
+ bpm?: number | "auto";
326
+ }
327
+
328
+ export async function textToMusic(args: FalTextToMusicArgs) {
329
+ const modelId = "fal-ai/sonauto/bark";
330
+
331
+ console.log(`[fal] starting text-to-music: ${modelId}`);
332
+ if (args.prompt) console.log(`[fal] prompt: ${args.prompt}`);
333
+ if (args.tags) console.log(`[fal] tags: ${args.tags.join(", ")}`);
334
+
335
+ try {
336
+ const result = await fal.subscribe(modelId, {
337
+ input: {
338
+ prompt: args.prompt,
339
+ tags: args.tags,
340
+ lyrics_prompt: args.lyricsPrompt,
341
+ seed: args.seed,
342
+ prompt_strength: args.promptStrength,
343
+ balance_strength: args.balanceStrength,
344
+ num_songs: args.numSongs,
345
+ output_format: args.outputFormat,
346
+ output_bit_rate: args.outputBitRate,
347
+ bpm: args.bpm,
348
+ },
349
+ logs: true,
350
+ onQueueUpdate: (update: {
351
+ status: string;
352
+ logs?: Array<{ message: string }>;
353
+ }) => {
354
+ if (update.status === "IN_PROGRESS") {
355
+ console.log(
356
+ `[fal] ${update.logs?.map((l) => l.message).join(" ") || "processing..."}`,
357
+ );
358
+ }
359
+ },
360
+ });
361
+
362
+ console.log("[fal] completed!");
363
+ return result;
364
+ } catch (error) {
365
+ console.error("[fal] error:", error);
366
+ throw error;
367
+ }
368
+ }
369
+
305
370
  // cli runner
306
371
  if (import.meta.main) {
307
372
  const [command, ...args] = process.argv.slice(2);
@@ -336,10 +401,13 @@ examples:
336
401
  if (!args[0]) {
337
402
  console.log(`
338
403
  usage:
339
- bun run lib/fal.ts text_to_video <prompt> [duration]
404
+ bun run lib/fal.ts text_to_video <prompt> [duration] [aspect_ratio]
340
405
 
341
406
  examples:
342
407
  bun run lib/fal.ts text_to_video "ocean waves crashing" 5
408
+ bun run lib/fal.ts text_to_video "man walking in rain" 10 9:16
409
+
410
+ aspect_ratio: 16:9 (landscape), 9:16 (portrait/tiktok), 1:1 (square)
343
411
  `);
344
412
  process.exit(1);
345
413
  }
@@ -348,9 +416,15 @@ examples:
348
416
  console.error("duration must be 5 or 10");
349
417
  process.exit(1);
350
418
  }
419
+ const aspectRatio = args[2] as "16:9" | "9:16" | "1:1" | undefined;
420
+ if (aspectRatio && !["16:9", "9:16", "1:1"].includes(aspectRatio)) {
421
+ console.error("aspect_ratio must be 16:9, 9:16, or 1:1");
422
+ process.exit(1);
423
+ }
351
424
  const t2vResult = await textToVideo({
352
425
  prompt: args[0],
353
426
  duration: duration === "10" ? 10 : 5,
427
+ aspectRatio: aspectRatio || "16:9",
354
428
  });
355
429
  console.log(JSON.stringify(t2vResult, null, 2));
356
430
  break;