@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.
- 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/commands/upload.ts +215 -0
- package/cli/index.ts +3 -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/utilities/s3.ts +2 -2
- 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
|
@@ -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
|
+
}
|