@flatkey-ai/cli 0.2.9 → 0.3.3

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/src/help.js CHANGED
@@ -14,8 +14,9 @@ JSON mode:
14
14
  - ASCII animation and human logs are disabled.
15
15
 
16
16
  Commands:
17
- - flatkey image generate --prompt "<prompt>" --json [--model <model>] [--output image.png]
18
- - flatkey video generate --prompt "<prompt>" --json [--model seedance2] [--ratio 16:9] [--resolution 720p] [--output video.mp4]
17
+ - flatkey image generate --prompt "<prompt>" --json [--model <model>] [--file <path>] [--image-url <url>] [--output image.png]
18
+ - flatkey video generate --prompt "<prompt>" --json [--model seedance2] [--file <path>] [--image-url <url>] [--video-url <url>] [--ratio 16:9] [--resolution 720p] [--output video.mp4]
19
+ - flatkey video copy --source input.mp4 --json [--model seedance2] [--frame-ratio 80] [--depth-resolution 480p] [--output video.mp4]
19
20
  - flatkey audio generate --prompt "<text>" --json [--voice-id <voice_id>] [--model eleven_multilingual_v2] [--output speech.mp3]
20
21
  - flatkey audio sfx --prompt "<sound>" --json [--duration <seconds>] [--output sfx.mp3]
21
22
  - flatkey audio music --prompt "<music prompt>" --json [--music-length-ms <ms>] [--output music.mp3]
@@ -54,6 +55,7 @@ Commands:
54
55
  image generate --prompt <txt> Generate image
55
56
  image upload --file <path> Upload local image and return a temporary URL
56
57
  video generate --prompt <txt> Generate video
58
+ video copy --source <mp4> Recreate a 5-15s video from Depth Anything V2 motion
57
59
  audio generate --prompt <txt> Generate speech with ElevenLabs voices
58
60
  audio sfx --prompt <txt> Generate sound effects
59
61
  audio music --prompt <txt> Generate music
@@ -66,6 +68,9 @@ Commands:
66
68
 
67
69
  Global options:
68
70
  --json Print machine-readable JSON
71
+ --file <path> Attach local media input
72
+ --image-url <url> Add remote image input, repeatable
73
+ --video-url <url> Add remote video input, repeatable
69
74
  --output, -o <file> Write generated output to a local file
70
75
  --verbose Print request/response logs to stderr
71
76
  --base-url <url> Override Flatkey router URL
@@ -76,7 +81,7 @@ Environment:
76
81
  --console-url <url> Override Flatkey console URL for login
77
82
 
78
83
  Video options:
79
- --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
84
+ --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, 1:1, or adaptive
80
85
  --resolution <value> 480p, 720p, or 1080p
81
86
  `;
82
87
  }
@@ -163,6 +168,8 @@ Options:
163
168
  --size <size> Image size for OpenAI-style image models
164
169
  --quality <quality> Image quality for supported models
165
170
  --n <count> Number of images for supported models
171
+ --file <path> Local image input, repeatable
172
+ --image-url <url> Remote image input, repeatable
166
173
  --output, -o <file> Write image file
167
174
  --json Print machine-readable JSON`,
168
175
  models: `Usage: flatkey models [options]
@@ -211,21 +218,34 @@ Aliases:
211
218
 
212
219
  Options:
213
220
  --json Print machine-readable JSON`,
214
- video: `Usage: flatkey video generate --prompt <txt> [options]
221
+ video: `Usage: flatkey video <command> [options]
222
+
223
+ Commands:
224
+ generate --prompt <txt> Generate video
225
+ copy --source <mp4> Recreate a 5-15s video from local depth motion
215
226
 
216
227
  Options:
217
228
  --model <model> Model id, default seedance-2.0-pro
218
229
  --duration <seconds> Video duration
230
+ --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, 1:1, or adaptive
231
+ --resolution <value> 480p, 720p, or 1080p
232
+ --generate-audio <true|false> Generate audio, default true
233
+ --output, -o <file> Write video file
234
+ --json Print machine-readable JSON`,
235
+ "video copy": `Usage: flatkey video copy --source <mp4> [options]
236
+
237
+ Options:
238
+ --source <mp4> Local source video, must be 5-15 seconds
239
+ --frame-ratio <20..100> Percent of frames kept for depth, default 100
240
+ --depth-resolution <value> original, 480p, or 320p; default 480p
241
+ --depth-location <dir> Directory for temporary depth video files
242
+ --keep-depth Keep generated depth video and reference frames
243
+ --model <model> Model id, default seedance-2.0-pro
244
+ --prompt <txt> Prompt override for copy generation
245
+ --duration <seconds> Must match source duration, 5-15 seconds
219
246
  --ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
220
247
  --resolution <value> 480p, 720p, or 1080p
221
248
  --generate-audio <true|false> Generate audio, default true
222
- --image <file> Upload local reference image, repeatable
223
- --image-url <url> Add reference image URL, repeatable
224
- --first-frame <file> Upload local first frame image
225
- --video-url <url> Add reference video URL, repeatable
226
- --first-frame-url <url> Add first frame image URL
227
- --last-frame <file> Upload local last frame image
228
- --last-frame-url <url> Add last frame image URL
229
249
  --fps <fps> Frames per second
230
250
  --output, -o <file> Write video file
231
251
  --json Print machine-readable JSON`,
@@ -239,8 +259,9 @@ Options:
239
259
  --generate-audio <true|false> Generate audio, default true
240
260
  --image <file> Upload local reference image, repeatable
241
261
  --image-url <url> Add reference image URL, repeatable
262
+ --file <path> Local image or video input, repeatable
242
263
  --first-frame <file> Upload local first frame image
243
- --video-url <url> Add reference video URL, repeatable
264
+ --video-url <url> Add reference video URL, repeatable; Seedance 2.5 supports up to 10
244
265
  --first-frame-url <url> Add first frame image URL
245
266
  --last-frame <file> Upload local last frame image
246
267
  --last-frame-url <url> Add last frame image URL
@@ -0,0 +1,251 @@
1
+ import { access, mkdir, rm } from "node:fs/promises";
2
+ import { homedir, tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { spawn } from "node:child_process";
5
+
6
+ export const MIN_COPY_DURATION_SECONDS = 5;
7
+ export const MAX_COPY_DURATION_SECONDS = 15;
8
+ export const DEFAULT_FRAME_RATIO = 100;
9
+ export const DEFAULT_DEPTH_RESOLUTION = "480p";
10
+
11
+ export async function prepareVideoCopy(options, deps = {}) {
12
+ const source = await expandHomePath(options.source);
13
+ const sourceInfo = await inspectVideo(source, deps);
14
+ const duration = sourceInfo.duration;
15
+ validateCopyDuration(duration, "source video");
16
+
17
+ const requestedDuration = options.duration === undefined
18
+ ? duration
19
+ : parseDuration(options.duration, "--duration");
20
+ validateCopyDuration(requestedDuration, "--duration");
21
+ if (Math.abs(requestedDuration - duration) > 0.01) {
22
+ throw new Error(
23
+ `--duration must match the source video duration (${formatSeconds(duration)}s); time stretching is not supported by video copy.`,
24
+ );
25
+ }
26
+
27
+ const depthRoot = await resolveDepthLocation(options.depth_location, deps);
28
+ const workDir = join(depthRoot, `copy-${Date.now()}-${Math.random().toString(16).slice(2)}`);
29
+ await mkdir(workDir, { recursive: true });
30
+ const depthPath = join(workDir, "depth.mp4");
31
+ let helperResult;
32
+ try {
33
+ helperResult = await runDepthHelper({
34
+ source,
35
+ output: depthPath,
36
+ frameRatio: parseFrameRatio(options.frame_ratio),
37
+ resolution: parseDepthResolution(options.depth_resolution),
38
+ model: options.depth_model,
39
+ workDir,
40
+ }, deps);
41
+ } catch (error) {
42
+ if (!options.keep_depth) await rm(workDir, { recursive: true, force: true });
43
+ throw error;
44
+ }
45
+ let referencePaths;
46
+ try {
47
+ referencePaths = await (deps.extractReferenceFrames ?? extractReferenceFrames)({
48
+ source: helperResult.output ?? depthPath,
49
+ outputDir: workDir,
50
+ duration,
51
+ ffmpegPath: deps.ffmpegPath,
52
+ env: deps.env,
53
+ }, deps);
54
+ } catch (error) {
55
+ if (!options.keep_depth) await rm(workDir, { recursive: true, force: true });
56
+ throw error;
57
+ }
58
+
59
+ return {
60
+ source,
61
+ depthPath: helperResult.output ?? depthPath,
62
+ referencePaths,
63
+ workDir,
64
+ duration: requestedDuration,
65
+ sourceInfo,
66
+ helper: helperResult,
67
+ cleanup: async () => {
68
+ if (!options.keep_depth) {
69
+ await rm(workDir, { recursive: true, force: true });
70
+ }
71
+ },
72
+ };
73
+ }
74
+
75
+ export function validateCopyDuration(value, label = "duration") {
76
+ const duration = parseDuration(value, label);
77
+ if (duration < MIN_COPY_DURATION_SECONDS || duration > MAX_COPY_DURATION_SECONDS) {
78
+ throw new Error(
79
+ `${label} must be between ${MIN_COPY_DURATION_SECONDS} and ${MAX_COPY_DURATION_SECONDS} seconds; received ${formatSeconds(duration)}s.`,
80
+ );
81
+ }
82
+ return duration;
83
+ }
84
+
85
+ export function parseDuration(value, label = "duration") {
86
+ const duration = Number(value);
87
+ if (!Number.isFinite(duration) || duration <= 0) {
88
+ throw new Error(`${label} must be a positive number of seconds.`);
89
+ }
90
+ return duration;
91
+ }
92
+
93
+ export function parseFrameRatio(value = DEFAULT_FRAME_RATIO) {
94
+ const ratio = Number(value);
95
+ if (!Number.isInteger(ratio) || ratio < 20 || ratio > 100) {
96
+ throw new Error("--frame-ratio must be an integer between 20 and 100.");
97
+ }
98
+ return ratio;
99
+ }
100
+
101
+ export function parseDepthResolution(value = DEFAULT_DEPTH_RESOLUTION) {
102
+ const normalized = String(value).toLowerCase();
103
+ if (normalized === "original" || normalized === "480" || normalized === "480p") return normalized === "original" ? "original" : "480p";
104
+ if (normalized === "320" || normalized === "320p") return "320p";
105
+ throw new Error("--depth-resolution must be original, 480p, or 320p.");
106
+ }
107
+
108
+ export async function resolveDepthLocation(value, deps = {}) {
109
+ const candidates = [
110
+ value,
111
+ deps.tmpdir,
112
+ tmpdir(),
113
+ join(homedir(), ".tmp"),
114
+ join(process.cwd(), ".flatkey-tmp"),
115
+ ].filter(Boolean);
116
+
117
+ let lastError;
118
+ for (const candidate of candidates) {
119
+ const expanded = await expandHomePath(candidate);
120
+ try {
121
+ await mkdir(expanded, { recursive: true });
122
+ await access(expanded);
123
+ return join(expanded, "flatkey-video-copy");
124
+ } catch (error) {
125
+ lastError = error;
126
+ }
127
+ }
128
+ throw new Error(`Unable to create a writable depth-video directory${lastError ? `: ${lastError.message}` : "."}`);
129
+ }
130
+
131
+ export async function inspectVideo(source, deps = {}) {
132
+ const ffprobe = deps.ffprobePath ?? "ffprobe";
133
+ const result = await runProcess(ffprobe, [
134
+ "-v", "error",
135
+ "-show_entries", "format=duration:stream=width,height,avg_frame_rate",
136
+ "-of", "json",
137
+ source,
138
+ ], deps);
139
+ let metadata;
140
+ try {
141
+ metadata = JSON.parse(result.stdout);
142
+ } catch {
143
+ throw new Error(`ffprobe returned invalid metadata for ${source}.`);
144
+ }
145
+ const duration = Number(metadata?.format?.duration);
146
+ if (!Number.isFinite(duration) || duration <= 0) {
147
+ throw new Error(`Unable to read source video duration: ${source}`);
148
+ }
149
+ return {
150
+ duration,
151
+ width: Number(metadata?.streams?.[0]?.width) || undefined,
152
+ height: Number(metadata?.streams?.[0]?.height) || undefined,
153
+ frameRate: metadata?.streams?.[0]?.avg_frame_rate,
154
+ };
155
+ }
156
+
157
+ export async function runDepthHelper(options, deps = {}) {
158
+ let helper = deps.depthHelperPath ?? deps.env?.FLATKEY_DEPTH_HELPER ?? process.env.FLATKEY_DEPTH_HELPER;
159
+ if (!helper) {
160
+ const { ensureVideoRuntime } = await import("./video-runtime.js");
161
+ helper = (await ensureVideoRuntime({
162
+ env: deps.env,
163
+ fetch: deps.fetch,
164
+ platform: deps.platform,
165
+ arch: deps.arch,
166
+ })).helperPath;
167
+ }
168
+
169
+ const args = [
170
+ "--source", options.source,
171
+ "--output", options.output,
172
+ "--frame-ratio", String(options.frameRatio),
173
+ "--resolution", options.resolution,
174
+ "--work-dir", options.workDir,
175
+ ];
176
+ if (options.model) args.push("--model", options.model);
177
+ const result = await runProcess(helper, args, deps);
178
+ try {
179
+ return JSON.parse(result.stdout);
180
+ } catch {
181
+ throw new Error("The Flatkey Rust depth helper returned invalid JSON.");
182
+ }
183
+ }
184
+
185
+ export async function extractReferenceFrames(options, deps = {}) {
186
+ const ffmpeg = options.ffmpegPath
187
+ ?? deps.env?.FFMPEG_PATH
188
+ ?? process.env.FFMPEG_PATH
189
+ ?? "ffmpeg";
190
+ const duration = Number(options.duration);
191
+ if (!Number.isFinite(duration) || duration <= 0) {
192
+ throw new Error("Cannot extract depth reference frames without a valid duration.");
193
+ }
194
+
195
+ await mkdir(options.outputDir, { recursive: true });
196
+ const timestamps = [
197
+ 0,
198
+ duration / 2,
199
+ Math.max(0, duration - 1 / 30),
200
+ ];
201
+ const paths = [];
202
+ for (let index = 0; index < timestamps.length; index += 1) {
203
+ const output = join(options.outputDir, `depth-frame-${String(index + 1).padStart(2, "0")}.png`);
204
+ await runProcess(ffmpeg, [
205
+ "-v", "error",
206
+ "-y",
207
+ "-ss", timestamps[index].toFixed(6),
208
+ "-i", options.source,
209
+ "-frames:v", "1",
210
+ output,
211
+ ], deps);
212
+ paths.push(output);
213
+ }
214
+ return paths;
215
+ }
216
+
217
+ export function runProcess(command, args, deps = {}) {
218
+ if (deps.runProcess) return deps.runProcess(command, args);
219
+ return new Promise((resolve, reject) => {
220
+ const child = spawn(command, args, {
221
+ cwd: deps.cwd,
222
+ env: deps.env ?? process.env,
223
+ windowsHide: true,
224
+ });
225
+ let stdout = "";
226
+ let stderr = "";
227
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
228
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
229
+ child.once("error", (error) => {
230
+ reject(new Error(`Failed to start ${command}: ${error.message}`));
231
+ });
232
+ child.once("close", (code) => {
233
+ if (code === 0) {
234
+ resolve({ stdout, stderr, code });
235
+ return;
236
+ }
237
+ reject(new Error(`${command} failed with exit code ${code}${stderr.trim() ? `: ${stderr.trim()}` : "."}`));
238
+ });
239
+ });
240
+ }
241
+
242
+ async function expandHomePath(value) {
243
+ if (typeof value !== "string") return value;
244
+ if (value === "~") return homedir();
245
+ if (value.startsWith("~/")) return join(homedir(), value.slice(2));
246
+ return value;
247
+ }
248
+
249
+ function formatSeconds(value) {
250
+ return Number(value).toFixed(3).replace(/\.?0+$/, "");
251
+ }
@@ -0,0 +1,119 @@
1
+ import { createHash } from "node:crypto";
2
+ import { chmod, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export const VIDEO_RUNTIME_VERSION = "0.1.0";
7
+
8
+ export function runtimePlatform(platform = process.platform, arch = process.arch) {
9
+ const supported = {
10
+ darwin: { arm64: "darwin-arm64", x64: "darwin-x64" },
11
+ linux: { arm64: "linux-arm64", x64: "linux-x64" },
12
+ win32: { arm64: "win32-arm64", x64: "win32-x64" },
13
+ };
14
+ const target = supported[platform]?.[arch];
15
+ if (!target) throw new Error(`Unsupported Flatkey video runtime platform: ${platform}-${arch}`);
16
+ return target;
17
+ }
18
+
19
+ export async function runtimeDirectory({ platform = process.platform, arch = process.arch, env = process.env } = {}) {
20
+ const target = runtimePlatform(platform, arch);
21
+ const base = env.FLATKEY_RUNTIME_HOME
22
+ ?? (platform === "win32"
23
+ ? join(env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"), "flatkey")
24
+ : platform === "darwin"
25
+ ? join(homedir(), "Library", "Caches", "flatkey")
26
+ : join(env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "flatkey"));
27
+ return join(base, "runtime", VIDEO_RUNTIME_VERSION, target);
28
+ }
29
+
30
+ export async function ensureVideoRuntime(options = {}) {
31
+ const directory = await runtimeDirectory(options);
32
+ const helperName = options.platform === "win32" || (options.platform ?? process.platform) === "win32"
33
+ ? "flatkey-depth.exe"
34
+ : "flatkey-depth";
35
+ const helperPath = join(directory, helperName);
36
+ try {
37
+ await stat(helperPath);
38
+ return { directory, helperPath, downloaded: false };
39
+ } catch {
40
+ // Continue to download below.
41
+ }
42
+
43
+ const baseUrl = options.baseUrl
44
+ ?? options.env?.FLATKEY_VIDEO_RUNTIME_BASE_URL
45
+ ?? process.env.FLATKEY_VIDEO_RUNTIME_BASE_URL;
46
+ if (!baseUrl) {
47
+ throw new Error(
48
+ `Flatkey video runtime is missing at ${helperPath}. Set FLATKEY_VIDEO_RUNTIME_BASE_URL to enable automatic download.`,
49
+ );
50
+ }
51
+ const target = runtimePlatform(options.platform, options.arch);
52
+ const fetchImpl = options.fetch ?? fetch;
53
+ const manifestUrl = `${baseUrl.replace(/\/$/, "")}/${VIDEO_RUNTIME_VERSION}/${target}/manifest.json`;
54
+ const manifestResponse = await fetchImpl(manifestUrl);
55
+ if (!manifestResponse.ok) throw new Error(`Failed to download Flatkey video runtime manifest: HTTP ${manifestResponse.status}`);
56
+ const manifest = await manifestResponse.json();
57
+ const archiveUrl = manifest.archive_url ?? manifest.url;
58
+ if (Array.isArray(manifest.files)) {
59
+ await mkdir(directory, { recursive: true });
60
+ for (const file of manifest.files) {
61
+ await downloadRuntimeFile(file, directory, fetchImpl);
62
+ }
63
+ await stat(helperPath);
64
+ return { directory, helperPath, downloaded: true };
65
+ }
66
+
67
+ const sha256 = manifest.sha256;
68
+ if (!archiveUrl || !sha256) throw new Error("Flatkey video runtime manifest is missing files or archive_url/sha256.");
69
+
70
+ const archivePath = join(tmpdir(), `flatkey-video-runtime-${Date.now()}.archive`);
71
+ try {
72
+ const response = await fetchImpl(archiveUrl);
73
+ if (!response.ok) throw new Error(`Failed to download Flatkey video runtime: HTTP ${response.status}`);
74
+ const bytes = Buffer.from(await response.arrayBuffer());
75
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
76
+ if (actualHash !== sha256.toLowerCase()) throw new Error("Flatkey video runtime checksum verification failed.");
77
+ await mkdir(directory, { recursive: true });
78
+ await writeFile(archivePath, bytes);
79
+ await extractArchive(archivePath, directory, options);
80
+ await stat(helperPath);
81
+ return { directory, helperPath, downloaded: true };
82
+ } finally {
83
+ await rm(archivePath, { force: true });
84
+ }
85
+ }
86
+
87
+ async function downloadRuntimeFile(file, directory, fetchImpl) {
88
+ if (!file?.path || !file?.url || !file?.sha256) {
89
+ throw new Error("Flatkey video runtime manifest contains an invalid file entry.");
90
+ }
91
+ if (file.path.includes("..") || file.path.startsWith("/") || /^[a-z]:/i.test(file.path)) {
92
+ throw new Error(`Flatkey video runtime manifest contains an unsafe path: ${file.path}`);
93
+ }
94
+ const response = await fetchImpl(file.url);
95
+ if (!response.ok) throw new Error(`Failed to download Flatkey video runtime file ${file.path}: HTTP ${response.status}`);
96
+ const bytes = Buffer.from(await response.arrayBuffer());
97
+ const actualHash = createHash("sha256").update(bytes).digest("hex");
98
+ if (actualHash !== file.sha256.toLowerCase()) {
99
+ throw new Error(`Flatkey video runtime checksum verification failed for ${file.path}.`);
100
+ }
101
+ const destination = join(directory, file.path);
102
+ await mkdir(join(destination, ".."), { recursive: true });
103
+ await writeFile(destination, bytes);
104
+ if (file.executable || file.mode === "755") {
105
+ await chmod(destination, 0o755);
106
+ }
107
+ }
108
+
109
+ async function extractArchive(archivePath, directory, options) {
110
+ if (options.extractArchive) {
111
+ await options.extractArchive(archivePath, directory);
112
+ return;
113
+ }
114
+ throw new Error("Flatkey video runtime archive extraction is not configured for this build.");
115
+ }
116
+
117
+ export async function sha256File(path) {
118
+ return createHash("sha256").update(await readFile(path)).digest("hex");
119
+ }