@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
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * fade action
5
+ * add fade in, fade out, or both to a video
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { basename, dirname, extname, join } from "node:path";
10
+ import type { ActionMeta } from "../../cli/types";
11
+ import { fadeVideo } from "../../lib/ffmpeg";
12
+
13
+ export const meta: ActionMeta = {
14
+ name: "fade",
15
+ type: "action",
16
+ description: "add fade in, fade out, or both to a video",
17
+ inputType: "video",
18
+ outputType: "video",
19
+ schema: {
20
+ input: {
21
+ type: "object",
22
+ required: ["video", "type"],
23
+ properties: {
24
+ video: {
25
+ type: "string",
26
+ format: "file-path",
27
+ description: "input video file",
28
+ },
29
+ type: {
30
+ type: "string",
31
+ enum: ["in", "out", "both"],
32
+ description: "fade direction: in, out, or both",
33
+ },
34
+ duration: {
35
+ type: "number",
36
+ default: 1,
37
+ description: "fade duration in seconds",
38
+ },
39
+ output: {
40
+ type: "string",
41
+ format: "file-path",
42
+ description: "output video path (auto-generated if not provided)",
43
+ },
44
+ },
45
+ },
46
+ output: { type: "string", format: "file-path", description: "video path" },
47
+ },
48
+ async run(options) {
49
+ const { video, type, duration, output } = options as {
50
+ video: string;
51
+ type: "in" | "out" | "both";
52
+ duration?: number;
53
+ output?: string;
54
+ };
55
+ return fade({ video, type, duration, output });
56
+ },
57
+ };
58
+
59
+ export interface FadeOptions {
60
+ video: string;
61
+ type: "in" | "out" | "both";
62
+ duration?: number;
63
+ output?: string;
64
+ }
65
+
66
+ export interface FadeResult {
67
+ output: string;
68
+ fadeType: string;
69
+ fadeDuration: number;
70
+ }
71
+
72
+ /**
73
+ * add fade effects to video
74
+ */
75
+ export async function fade(options: FadeOptions): Promise<FadeResult> {
76
+ const { video, type, duration = 1, output } = options;
77
+
78
+ if (!video) {
79
+ throw new Error("video is required");
80
+ }
81
+ if (!type) {
82
+ throw new Error("type is required");
83
+ }
84
+ if (!existsSync(video)) {
85
+ throw new Error(`video file not found: ${video}`);
86
+ }
87
+
88
+ // Generate output path if not provided
89
+ const outputPath =
90
+ output ||
91
+ join(
92
+ dirname(video),
93
+ `${basename(video, extname(video))}_faded${extname(video)}`,
94
+ );
95
+
96
+ console.log(`[fade] applying fade ${type} (${duration}s)`);
97
+
98
+ await fadeVideo({
99
+ input: video,
100
+ output: outputPath,
101
+ type,
102
+ duration,
103
+ });
104
+
105
+ return {
106
+ output: outputPath,
107
+ fadeType: type,
108
+ fadeDuration: duration,
109
+ };
110
+ }
111
+
112
+ // cli
113
+ if (import.meta.main) {
114
+ const { runCli } = await import("../../cli/runner");
115
+ runCli(meta);
116
+ }
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * merge action
5
+ * join multiple videos into one, optionally with transitions
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import type { ActionMeta } from "../../cli/types";
10
+ import { concatWithFileList, xfadeVideos } from "../../lib/ffmpeg";
11
+
12
+ export const meta: ActionMeta = {
13
+ name: "merge",
14
+ type: "action",
15
+ description: "join multiple videos into one with optional transitions",
16
+ inputType: "video",
17
+ outputType: "video",
18
+ schema: {
19
+ input: {
20
+ type: "object",
21
+ required: ["videos", "output"],
22
+ properties: {
23
+ videos: {
24
+ type: "string",
25
+ description: "comma-separated video paths to merge",
26
+ },
27
+ output: {
28
+ type: "string",
29
+ format: "file-path",
30
+ description: "output video path",
31
+ },
32
+ transition: {
33
+ type: "string",
34
+ enum: ["cut", "crossfade", "dissolve"],
35
+ default: "cut",
36
+ description: "transition type between clips",
37
+ },
38
+ duration: {
39
+ type: "number",
40
+ default: 1,
41
+ description: "transition duration in seconds",
42
+ },
43
+ fit: {
44
+ type: "string",
45
+ enum: ["pad", "crop", "blur", "stretch"],
46
+ default: "pad",
47
+ description:
48
+ "how to handle different resolutions: pad (black bars), crop (center), blur (TikTok style), stretch",
49
+ },
50
+ },
51
+ },
52
+ output: { type: "string", format: "file-path", description: "video path" },
53
+ },
54
+ async run(options) {
55
+ const { videos, output, transition, duration, fit } = options as {
56
+ videos: string;
57
+ output: string;
58
+ transition?: "cut" | "crossfade" | "dissolve";
59
+ duration?: number;
60
+ fit?: "pad" | "crop" | "blur" | "stretch";
61
+ };
62
+ return merge({ videos, output, transition, duration, fit });
63
+ },
64
+ };
65
+
66
+ export interface MergeOptions {
67
+ videos: string;
68
+ output: string;
69
+ transition?: "cut" | "crossfade" | "dissolve";
70
+ duration?: number;
71
+ fit?: "pad" | "crop" | "blur" | "stretch";
72
+ }
73
+
74
+ export interface MergeResult {
75
+ output: string;
76
+ inputCount: number;
77
+ }
78
+
79
+ /**
80
+ * merge multiple videos with optional transitions
81
+ */
82
+ export async function merge(options: MergeOptions): Promise<MergeResult> {
83
+ const {
84
+ videos,
85
+ output,
86
+ transition = "cut",
87
+ duration = 1,
88
+ fit = "pad",
89
+ } = options;
90
+
91
+ if (!videos) {
92
+ throw new Error("videos are required");
93
+ }
94
+ if (!output) {
95
+ throw new Error("output is required");
96
+ }
97
+
98
+ // Parse video paths from comma-separated string
99
+ const videoPaths = videos.split(",").map((v) => v.trim());
100
+
101
+ if (videoPaths.length < 2) {
102
+ throw new Error("at least 2 videos are required");
103
+ }
104
+
105
+ // Validate all inputs exist
106
+ for (const video of videoPaths) {
107
+ if (!existsSync(video)) {
108
+ throw new Error(`video file not found: ${video}`);
109
+ }
110
+ }
111
+
112
+ console.log(
113
+ `[merge] joining ${videoPaths.length} videos with ${transition} transition`,
114
+ );
115
+
116
+ if (transition === "cut") {
117
+ // Simple concatenation without transitions
118
+ await concatWithFileList(videoPaths, output);
119
+ } else {
120
+ // Apply transitions between each pair of videos
121
+ const firstVideo = videoPaths[0] as string;
122
+ const secondVideo = videoPaths[1] as string;
123
+
124
+ if (videoPaths.length === 2) {
125
+ // Simple case: just two videos
126
+ await xfadeVideos({
127
+ input1: firstVideo,
128
+ input2: secondVideo,
129
+ output,
130
+ transition: transition as "crossfade" | "dissolve",
131
+ duration,
132
+ fit,
133
+ });
134
+ } else {
135
+ // Multiple videos: chain transitions
136
+ let currentInput: string = firstVideo;
137
+
138
+ for (let i = 1; i < videoPaths.length; i++) {
139
+ const nextVideo = videoPaths[i] as string;
140
+ const isLast = i === videoPaths.length - 1;
141
+ const tempOutput = isLast ? output : `/tmp/merge-temp-${i}.mp4`;
142
+
143
+ await xfadeVideos({
144
+ input1: currentInput,
145
+ input2: nextVideo,
146
+ output: tempOutput,
147
+ transition: transition as "crossfade" | "dissolve",
148
+ duration,
149
+ fit,
150
+ });
151
+
152
+ // Clean up previous temp file if not the original input
153
+ if (i > 1 && currentInput.startsWith("/tmp/merge-temp-")) {
154
+ try {
155
+ const { unlinkSync } = await import("node:fs");
156
+ unlinkSync(currentInput);
157
+ } catch {
158
+ // ignore cleanup errors
159
+ }
160
+ }
161
+
162
+ currentInput = tempOutput;
163
+ }
164
+ }
165
+ }
166
+
167
+ return {
168
+ output,
169
+ inputCount: videoPaths.length,
170
+ };
171
+ }
172
+
173
+ // cli
174
+ if (import.meta.main) {
175
+ const { runCli } = await import("../../cli/runner");
176
+ runCli(meta);
177
+ }
@@ -0,0 +1,184 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * remove action
5
+ * delete a segment from the middle of a video
6
+ */
7
+
8
+ import { existsSync, unlinkSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { basename, dirname, extname, join } from "node:path";
11
+ import type { ActionMeta } from "../../cli/types";
12
+ import {
13
+ concatWithFileList,
14
+ getVideoDuration,
15
+ trimVideo,
16
+ } from "../../lib/ffmpeg";
17
+
18
+ export const meta: ActionMeta = {
19
+ name: "remove",
20
+ type: "action",
21
+ description: "delete a segment from the middle of a video",
22
+ inputType: "video",
23
+ outputType: "video",
24
+ schema: {
25
+ input: {
26
+ type: "object",
27
+ required: ["video", "from", "to"],
28
+ properties: {
29
+ video: {
30
+ type: "string",
31
+ format: "file-path",
32
+ description: "input video file",
33
+ },
34
+ from: {
35
+ type: "number",
36
+ description: "start of segment to remove (seconds)",
37
+ },
38
+ to: {
39
+ type: "number",
40
+ description: "end of segment to remove (seconds)",
41
+ },
42
+ output: {
43
+ type: "string",
44
+ format: "file-path",
45
+ description: "output video path (auto-generated if not provided)",
46
+ },
47
+ },
48
+ },
49
+ output: { type: "string", format: "file-path", description: "video path" },
50
+ },
51
+ async run(options) {
52
+ const { video, from, to, output } = options as {
53
+ video: string;
54
+ from: number;
55
+ to: number;
56
+ output?: string;
57
+ };
58
+ return remove({ video, from, to, output });
59
+ },
60
+ };
61
+
62
+ export interface RemoveOptions {
63
+ video: string;
64
+ from: number;
65
+ to: number;
66
+ output?: string;
67
+ }
68
+
69
+ export interface RemoveResult {
70
+ output: string;
71
+ removedDuration: number;
72
+ }
73
+
74
+ /**
75
+ * remove a segment from video
76
+ */
77
+ export async function remove(options: RemoveOptions): Promise<RemoveResult> {
78
+ const { video, from, to, output } = options;
79
+
80
+ if (!video) {
81
+ throw new Error("video is required");
82
+ }
83
+ if (from === undefined || to === undefined) {
84
+ throw new Error("from and to are required");
85
+ }
86
+ if (from >= to) {
87
+ throw new Error("from must be less than to");
88
+ }
89
+ if (!existsSync(video)) {
90
+ throw new Error(`video file not found: ${video}`);
91
+ }
92
+
93
+ const videoDuration = await getVideoDuration(video);
94
+
95
+ if (from < 0 || to > videoDuration) {
96
+ throw new Error(
97
+ `segment ${from}s-${to}s is outside video duration (0-${videoDuration}s)`,
98
+ );
99
+ }
100
+
101
+ const removedDuration = to - from;
102
+
103
+ // Generate output path if not provided
104
+ const outputPath =
105
+ output ||
106
+ join(
107
+ dirname(video),
108
+ `${basename(video, extname(video))}_edited${extname(video)}`,
109
+ );
110
+
111
+ console.log(
112
+ `[remove] removing segment ${from}s - ${to}s (${removedDuration}s)`,
113
+ );
114
+
115
+ // Create temp files for the two parts
116
+ const timestamp = Date.now();
117
+ const part1Path = join(tmpdir(), `remove-part1-${timestamp}.mp4`);
118
+ const part2Path = join(tmpdir(), `remove-part2-${timestamp}.mp4`);
119
+
120
+ const tempFiles: string[] = [];
121
+
122
+ try {
123
+ // Extract part before the removed segment (0 to from)
124
+ if (from > 0) {
125
+ console.log(`[remove] extracting part 1: 0s - ${from}s`);
126
+ await trimVideo({
127
+ input: video,
128
+ output: part1Path,
129
+ start: 0,
130
+ duration: from,
131
+ });
132
+ tempFiles.push(part1Path);
133
+ }
134
+
135
+ // Extract part after the removed segment (to to end)
136
+ if (to < videoDuration) {
137
+ console.log(`[remove] extracting part 2: ${to}s - ${videoDuration}s`);
138
+ await trimVideo({
139
+ input: video,
140
+ output: part2Path,
141
+ start: to,
142
+ duration: videoDuration - to,
143
+ });
144
+ tempFiles.push(part2Path);
145
+ }
146
+
147
+ // Concatenate the two parts
148
+ if (tempFiles.length === 0) {
149
+ throw new Error("cannot remove entire video");
150
+ }
151
+
152
+ if (tempFiles.length === 1) {
153
+ // Only one part, just copy/rename it
154
+ const { copyFileSync } = await import("node:fs");
155
+ copyFileSync(tempFiles[0] as string, outputPath);
156
+ } else {
157
+ // Concatenate both parts
158
+ console.log(`[remove] joining parts...`);
159
+ await concatWithFileList(tempFiles, outputPath);
160
+ }
161
+
162
+ console.log(`[remove] saved to ${outputPath}`);
163
+ } finally {
164
+ // Cleanup temp files
165
+ for (const tempFile of tempFiles) {
166
+ try {
167
+ unlinkSync(tempFile);
168
+ } catch {
169
+ // ignore cleanup errors
170
+ }
171
+ }
172
+ }
173
+
174
+ return {
175
+ output: outputPath,
176
+ removedDuration,
177
+ };
178
+ }
179
+
180
+ // cli
181
+ if (import.meta.main) {
182
+ const { runCli } = await import("../../cli/runner");
183
+ runCli(meta);
184
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env bun
2
+
3
+ /**
4
+ * split action
5
+ * divide video into N equal-length parts
6
+ */
7
+
8
+ import { existsSync } from "node:fs";
9
+ import { basename, dirname, extname, join } from "node:path";
10
+ import type { ActionMeta } from "../../cli/types";
11
+ import { getVideoDuration, trimVideo } from "../../lib/ffmpeg";
12
+
13
+ export const meta: ActionMeta = {
14
+ name: "split",
15
+ type: "action",
16
+ description: "divide video into N equal-length parts",
17
+ inputType: "video",
18
+ outputType: "video",
19
+ schema: {
20
+ input: {
21
+ type: "object",
22
+ required: ["video", "parts"],
23
+ properties: {
24
+ video: {
25
+ type: "string",
26
+ format: "file-path",
27
+ description: "input video file",
28
+ },
29
+ parts: {
30
+ type: "integer",
31
+ description: "number of equal parts to split into",
32
+ },
33
+ "output-prefix": {
34
+ type: "string",
35
+ description: "prefix for output files (default: input filename)",
36
+ },
37
+ },
38
+ },
39
+ output: {
40
+ type: "string",
41
+ format: "file-path",
42
+ description: "comma-separated list of output paths",
43
+ },
44
+ },
45
+ async run(options) {
46
+ const {
47
+ video,
48
+ parts,
49
+ "output-prefix": outputPrefix,
50
+ } = options as {
51
+ video: string;
52
+ parts: number;
53
+ "output-prefix"?: string;
54
+ };
55
+ return split({ video, parts, outputPrefix });
56
+ },
57
+ };
58
+
59
+ export interface SplitOptions {
60
+ video: string;
61
+ parts: number;
62
+ outputPrefix?: string;
63
+ }
64
+
65
+ export interface SplitResult {
66
+ outputs: string[];
67
+ count: number;
68
+ partDuration: number;
69
+ }
70
+
71
+ /**
72
+ * divide video into N equal parts
73
+ */
74
+ export async function split(options: SplitOptions): Promise<SplitResult> {
75
+ const { video, parts, outputPrefix } = options;
76
+
77
+ if (!video) {
78
+ throw new Error("video is required");
79
+ }
80
+ if (!parts || parts < 2) {
81
+ throw new Error("parts must be at least 2");
82
+ }
83
+ if (!existsSync(video)) {
84
+ throw new Error(`video file not found: ${video}`);
85
+ }
86
+
87
+ // Get video duration
88
+ const videoDuration = await getVideoDuration(video);
89
+ const partDuration = videoDuration / parts;
90
+
91
+ // Generate output prefix if not provided
92
+ const prefix =
93
+ outputPrefix || join(dirname(video), basename(video, extname(video)));
94
+
95
+ console.log(
96
+ `[split] dividing ${videoDuration}s video into ${parts} parts of ${partDuration.toFixed(2)}s each`,
97
+ );
98
+
99
+ const outputs: string[] = [];
100
+
101
+ for (let i = 0; i < parts; i++) {
102
+ const start = i * partDuration;
103
+ const partNumber = String(i + 1).padStart(3, "0");
104
+ const outputPath = `${prefix}_part${partNumber}.mp4`;
105
+
106
+ console.log(
107
+ `[split] creating part ${i + 1}/${parts}: ${start.toFixed(2)}s - ${(start + partDuration).toFixed(2)}s`,
108
+ );
109
+
110
+ await trimVideo({
111
+ input: video,
112
+ output: outputPath,
113
+ start,
114
+ duration: partDuration,
115
+ });
116
+
117
+ outputs.push(outputPath);
118
+ }
119
+
120
+ console.log(`[split] created ${outputs.length} parts`);
121
+
122
+ return {
123
+ outputs,
124
+ count: outputs.length,
125
+ partDuration,
126
+ };
127
+ }
128
+
129
+ // cli
130
+ if (import.meta.main) {
131
+ const { runCli } = await import("../../cli/runner");
132
+ runCli(meta);
133
+ }