@vargai/sdk 0.1.1 → 0.1.2
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/.github/workflows/ci.yml +23 -0
- package/.husky/README.md +102 -0
- package/.husky/commit-msg +9 -0
- package/.husky/pre-commit +12 -0
- package/.husky/pre-push +9 -0
- package/.size-limit.json +8 -0
- package/.test-hooks.ts +5 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE.md +53 -0
- package/README.md +7 -0
- package/action/captions/index.ts +202 -12
- package/action/captions/tiktok.ts +538 -0
- package/action/cut/index.ts +119 -0
- package/action/fade/index.ts +116 -0
- package/action/merge/index.ts +177 -0
- package/action/remove/index.ts +184 -0
- package/action/split/index.ts +133 -0
- package/action/transition/index.ts +154 -0
- package/action/trim/index.ts +117 -0
- package/bun.lock +299 -8
- package/cli/index.ts +1 -1
- package/commitlint.config.js +22 -0
- package/index.ts +12 -0
- package/lib/ass.ts +547 -0
- package/lib/fal.ts +75 -1
- package/lib/ffmpeg.ts +400 -0
- package/lib/higgsfield/example.ts +22 -29
- package/lib/higgsfield/index.ts +3 -2
- package/lib/higgsfield/soul.ts +0 -5
- package/lib/remotion/SKILL.md +240 -21
- package/lib/remotion/cli.ts +34 -0
- package/package.json +20 -3
- package/pipeline/cookbooks/scripts/animate-frames-parallel.ts +83 -0
- package/pipeline/cookbooks/scripts/combine-scenes.sh +53 -0
- package/pipeline/cookbooks/scripts/generate-frames-parallel.ts +98 -0
- package/pipeline/cookbooks/scripts/still-to-video.sh +37 -0
- package/pipeline/cookbooks/text-to-tiktok.md +669 -0
- package/scripts/.gitkeep +0 -0
- package/service/music/index.ts +29 -14
- package/tsconfig.json +1 -1
- package/HIGGSFIELD_REWRITE_SUMMARY.md +0 -300
- package/TEST_RESULTS.md +0 -122
- package/output.txt +0 -1
- package/scripts/produce-menopause-campaign.sh +0 -202
- package/test-import.ts +0 -7
- package/test-services.ts +0 -97
package/cli/index.ts
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
extends: ["@commitlint/config-conventional"],
|
|
3
|
+
rules: {
|
|
4
|
+
"type-enum": [
|
|
5
|
+
2,
|
|
6
|
+
"always",
|
|
7
|
+
[
|
|
8
|
+
"feat", // New feature
|
|
9
|
+
"fix", // Bug fix
|
|
10
|
+
"docs", // Documentation changes
|
|
11
|
+
"style", // Code style changes (formatting, etc)
|
|
12
|
+
"refactor", // Code refactoring
|
|
13
|
+
"perf", // Performance improvements
|
|
14
|
+
"test", // Test changes
|
|
15
|
+
"build", // Build system changes
|
|
16
|
+
"ci", // CI/CD changes
|
|
17
|
+
"chore", // Other changes
|
|
18
|
+
"revert", // Revert previous commit
|
|
19
|
+
],
|
|
20
|
+
],
|
|
21
|
+
},
|
|
22
|
+
};
|
package/index.ts
CHANGED
|
@@ -14,6 +14,7 @@ export {
|
|
|
14
14
|
addCaptions,
|
|
15
15
|
type SubtitleStyle,
|
|
16
16
|
} from "./action/captions";
|
|
17
|
+
export { type CutOptions, type CutResult, cut } from "./action/cut";
|
|
17
18
|
export {
|
|
18
19
|
type CreateMontageOptions,
|
|
19
20
|
createMontage,
|
|
@@ -26,11 +27,15 @@ export {
|
|
|
26
27
|
quickResize,
|
|
27
28
|
quickTrim,
|
|
28
29
|
} from "./action/edit";
|
|
30
|
+
export { type FadeOptions, type FadeResult, fade } from "./action/fade";
|
|
29
31
|
export {
|
|
30
32
|
generateWithFal,
|
|
31
33
|
generateWithSoul,
|
|
32
34
|
type ImageGenerationResult,
|
|
33
35
|
} from "./action/image";
|
|
36
|
+
export { type MergeOptions, type MergeResult, merge } from "./action/merge";
|
|
37
|
+
export { type RemoveOptions, type RemoveResult, remove } from "./action/remove";
|
|
38
|
+
export { type SplitOptions, type SplitResult, split } from "./action/split";
|
|
34
39
|
export {
|
|
35
40
|
type LipsyncOptions,
|
|
36
41
|
lipsync,
|
|
@@ -43,6 +48,13 @@ export {
|
|
|
43
48
|
type TranscribeResult,
|
|
44
49
|
transcribe,
|
|
45
50
|
} from "./action/transcribe";
|
|
51
|
+
export {
|
|
52
|
+
type TransitionOptions,
|
|
53
|
+
type TransitionResult,
|
|
54
|
+
transition,
|
|
55
|
+
} from "./action/transition";
|
|
56
|
+
// new action exports - video editing
|
|
57
|
+
export { type TrimOptions, type TrimResult, trim } from "./action/trim";
|
|
46
58
|
export {
|
|
47
59
|
generateVideoFromImage,
|
|
48
60
|
generateVideoFromText,
|
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
|
+
}
|