@flatkey-ai/cli 0.3.2 → 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/README.md +39 -1
- package/package.json +4 -2
- package/scripts/install-video-runtime.js +14 -0
- package/src/api.js +45 -11
- package/src/cli.js +160 -32
- package/src/help.js +25 -11
- package/src/video-copy.js +251 -0
- package/src/video-runtime.js +119 -0
package/README.md
CHANGED
|
@@ -140,7 +140,29 @@ flatkey video generate \
|
|
|
140
140
|
-o launch.mp4
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
-
|
|
143
|
+
### Copy Video
|
|
144
|
+
|
|
145
|
+
Recreate a local 5-15 second video using a generated Depth Anything V2 video
|
|
146
|
+
reference:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
flatkey video copy \
|
|
150
|
+
--source input.mp4 \
|
|
151
|
+
--frame-ratio 80 \
|
|
152
|
+
--depth-resolution 480p \
|
|
153
|
+
-o result.mp4
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
`video copy` preserves the source duration by default. The source and requested
|
|
157
|
+
duration must be between 5 and 15 seconds. Depth processing uses a platform
|
|
158
|
+
runtime downloaded by the CLI; runtime distribution is configured through
|
|
159
|
+
`FLATKEY_VIDEO_RUNTIME_BASE_URL` until the Flatkey runtime release channel is
|
|
160
|
+
published. The generated depth video is uploaded as the primary motion
|
|
161
|
+
reference. If the selected upstream model cannot fetch video references, the
|
|
162
|
+
CLI explicitly falls back to three depth keyframes and reports that mode in
|
|
163
|
+
JSON output as `depth-keyframes-fallback`.
|
|
164
|
+
|
|
165
|
+
Video ratios: `16:9`, `9:16`, `4:3`, `3:4`, `21:9`, `1:1`, `adaptive`.
|
|
144
166
|
Video resolutions: `480p`, `720p`, `1080p`.
|
|
145
167
|
|
|
146
168
|
Local reference images are uploaded through Flatkey temporary media first:
|
|
@@ -153,6 +175,22 @@ flatkey video generate \
|
|
|
153
175
|
-o kitten.mp4
|
|
154
176
|
```
|
|
155
177
|
|
|
178
|
+
Seedance 2.5 accepts reference images and videos as ordered content inputs:
|
|
179
|
+
|
|
180
|
+
```bash
|
|
181
|
+
flatkey video generate \
|
|
182
|
+
--model seedance-2-5-pro \
|
|
183
|
+
--prompt "保持人物、服装和镜头运动一致" \
|
|
184
|
+
--image-url https://example.com/character.png \
|
|
185
|
+
--video-url https://example.com/motion.mp4 \
|
|
186
|
+
--ratio adaptive \
|
|
187
|
+
-o result.mp4
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
For Seedance 2.5, the CLI allows up to 30 reference images and 10 reference
|
|
191
|
+
videos in one request. Local files are uploaded before generation and all
|
|
192
|
+
inputs keep their command-line order. Reference videos can be `.mp4` or `.mov`.
|
|
193
|
+
|
|
156
194
|
First/last frame:
|
|
157
195
|
|
|
158
196
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flatkey-ai/cli",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Flatkey media generation CLI for image, video, audio, credits, status, and models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,9 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin",
|
|
11
|
-
"src"
|
|
11
|
+
"src",
|
|
12
|
+
"scripts"
|
|
12
13
|
],
|
|
13
14
|
"scripts": {
|
|
15
|
+
"postinstall": "node scripts/install-video-runtime.js",
|
|
14
16
|
"test": "node --test"
|
|
15
17
|
},
|
|
16
18
|
"engines": {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ensureVideoRuntime } from "../src/video-runtime.js";
|
|
3
|
+
|
|
4
|
+
try {
|
|
5
|
+
await ensureVideoRuntime();
|
|
6
|
+
} catch (error) {
|
|
7
|
+
const message = error?.message ?? String(error);
|
|
8
|
+
if (process.env.FLATKEY_VIDEO_RUNTIME_REQUIRED === "1") {
|
|
9
|
+
process.stderr.write(`${message}\n`);
|
|
10
|
+
process.exitCode = 1;
|
|
11
|
+
} else {
|
|
12
|
+
process.stderr.write(`Flatkey video runtime not installed during postinstall: ${message}\n`);
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/api.js
CHANGED
|
@@ -5,10 +5,11 @@ export const DEFAULT_MODELS_BASE_URL = "https://console.flatkey.ai";
|
|
|
5
5
|
export const DEFAULT_CONSOLE_URL = "https://console.flatkey.ai";
|
|
6
6
|
|
|
7
7
|
export class FlatkeyError extends Error {
|
|
8
|
-
constructor(message, { status } = {}) {
|
|
8
|
+
constructor(message, { status, code } = {}) {
|
|
9
9
|
super(message);
|
|
10
10
|
this.name = "FlatkeyError";
|
|
11
11
|
this.status = status;
|
|
12
|
+
this.code = code;
|
|
12
13
|
}
|
|
13
14
|
}
|
|
14
15
|
|
|
@@ -39,7 +40,7 @@ async function uploadTempMediaByKind(options, kind) {
|
|
|
39
40
|
export function planImageRequest(options) {
|
|
40
41
|
const model = options.model ?? "nano-banana-pro-preview";
|
|
41
42
|
const mediaInputs = orderedMediaInputs(options);
|
|
42
|
-
|
|
43
|
+
assertReferenceLimits(mediaInputs, "image", model);
|
|
43
44
|
if (mediaInputs.some((entry) => entry.kind === "video")) {
|
|
44
45
|
throw new Error("Image models only accept image inputs.");
|
|
45
46
|
}
|
|
@@ -90,7 +91,7 @@ export function getVideo(options, taskId) {
|
|
|
90
91
|
export function planVideoRequest(options) {
|
|
91
92
|
const model = options.model ?? "seedance-2.0-pro";
|
|
92
93
|
const mediaInputs = orderedMediaInputs(options);
|
|
93
|
-
|
|
94
|
+
assertReferenceLimits(mediaInputs, "video", model);
|
|
94
95
|
if (mediaInputs.some((entry) => entry.role === "first_frame" && entry.kind === "video")
|
|
95
96
|
|| mediaInputs.some((entry) => entry.role === "last_frame" && entry.kind === "video")) {
|
|
96
97
|
throw new Error("Video first-frame and last-frame inputs must be images.");
|
|
@@ -98,9 +99,13 @@ export function planVideoRequest(options) {
|
|
|
98
99
|
const imageUrls = mediaInputs
|
|
99
100
|
.filter((entry) => entry.kind !== "video")
|
|
100
101
|
.map((entry) => entry.url);
|
|
102
|
+
const videoUrls = mediaInputs
|
|
103
|
+
.filter((entry) => entry.kind === "video")
|
|
104
|
+
.map((entry) => entry.url);
|
|
101
105
|
const ratio = validateOptionalValue(
|
|
102
|
-
optionValue(options, "ratio", "aspect")
|
|
103
|
-
|
|
106
|
+
optionValue(options, "ratio", "aspect")
|
|
107
|
+
?? (isMiniMaxModel(model) ? "16:9" : isSeedance25Model(model) ? "adaptive" : undefined),
|
|
108
|
+
["16:9", "9:16", "4:3", "3:4", "21:9", "1:1", "adaptive"],
|
|
104
109
|
"ratio",
|
|
105
110
|
);
|
|
106
111
|
const resolution = validateOptionalValue(
|
|
@@ -113,19 +118,24 @@ export function planVideoRequest(options) {
|
|
|
113
118
|
prompt: options.prompt,
|
|
114
119
|
duration: options.duration === undefined && isMiniMaxModel(model)
|
|
115
120
|
? 5
|
|
116
|
-
:
|
|
121
|
+
: options.duration === undefined && isSeedance25Model(model)
|
|
122
|
+
? -1
|
|
123
|
+
: parseOptionalFloat(options.duration),
|
|
117
124
|
aspect: ratio,
|
|
118
125
|
ratio,
|
|
119
126
|
resolution,
|
|
120
127
|
quality: resolution,
|
|
121
128
|
fps: parseOptionalInteger(options.fps),
|
|
122
129
|
generate_audio: parseOptionalBoolean(options.generate_audio) ?? true,
|
|
130
|
+
omni_reference_task_type: isSeedance25Model(model) && mediaInputs.some((entry) => entry.kind === "video" || entry.role === "reference_image")
|
|
131
|
+
? "auto"
|
|
132
|
+
: undefined,
|
|
123
133
|
temp_url: true,
|
|
124
134
|
images: !isSeedanceModel(model) && imageUrls.length > 0 ? imageUrls : undefined,
|
|
125
135
|
});
|
|
126
136
|
const seedanceContent = buildSeedanceContent(options, mediaInputs);
|
|
127
137
|
if ((isSeedanceModel(model) || isMiniMaxModel(model)) && seedanceContent.length > 0) {
|
|
128
|
-
return planJsonPost(options,
|
|
138
|
+
return planJsonPost(options, videoGenerationPath(model, videoUrls), cleanObject({
|
|
129
139
|
...basePayload,
|
|
130
140
|
content: seedanceContent,
|
|
131
141
|
}));
|
|
@@ -282,6 +292,7 @@ async function requestJsonFromPlan(options, plan) {
|
|
|
282
292
|
if (!response.ok || body?.success === false) {
|
|
283
293
|
throw new FlatkeyError(extractErrorMessage(body, response.status), {
|
|
284
294
|
status: response.status,
|
|
295
|
+
code: body?.code ?? body?.error?.code,
|
|
285
296
|
});
|
|
286
297
|
}
|
|
287
298
|
return body;
|
|
@@ -306,6 +317,7 @@ async function requestBinaryArtifactFromPlan(options, plan) {
|
|
|
306
317
|
logResponseBody(options, body);
|
|
307
318
|
throw new FlatkeyError(extractErrorMessage(body, response.status), {
|
|
308
319
|
status: response.status,
|
|
320
|
+
code: body?.code ?? body?.error?.code,
|
|
309
321
|
});
|
|
310
322
|
}
|
|
311
323
|
return {
|
|
@@ -414,6 +426,13 @@ function isMiniMaxModel(model) {
|
|
|
414
426
|
return /^minimax-h3$/i.test(model);
|
|
415
427
|
}
|
|
416
428
|
|
|
429
|
+
function videoGenerationPath(model, videoUrls = []) {
|
|
430
|
+
// Seedance keeps text, image, and video-reference content on /v1/videos.
|
|
431
|
+
// The legacy route does not reliably fetch video references upstream.
|
|
432
|
+
if (isSeedanceModel(model)) return "/v1/videos";
|
|
433
|
+
return "/v1/video/generations";
|
|
434
|
+
}
|
|
435
|
+
|
|
417
436
|
function buildSeedanceContent(options, mediaInputs = orderedMediaInputs(options)) {
|
|
418
437
|
const content = [];
|
|
419
438
|
if (options.prompt !== undefined) {
|
|
@@ -440,10 +459,21 @@ function cleanObject(value) {
|
|
|
440
459
|
);
|
|
441
460
|
}
|
|
442
461
|
|
|
443
|
-
function
|
|
462
|
+
function assertReferenceLimits(mediaInputs, kind, model) {
|
|
444
463
|
const imageInputs = mediaInputs.filter((entry) => entry.kind !== "video");
|
|
445
|
-
|
|
446
|
-
|
|
464
|
+
const videoInputs = mediaInputs.filter((entry) => entry.kind === "video");
|
|
465
|
+
if (isSeedance25Model(model)) {
|
|
466
|
+
if (imageInputs.length > 30) {
|
|
467
|
+
throw new Error(`Too many reference images for flatkey ${kind} generate with Seedance 2.5: maximum 30.`);
|
|
468
|
+
}
|
|
469
|
+
if (videoInputs.length > 10) {
|
|
470
|
+
throw new Error(`Too many reference videos for flatkey ${kind} generate with Seedance 2.5: maximum 10.`);
|
|
471
|
+
}
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (imageInputs.length > 5) {
|
|
475
|
+
throw new Error(`Too many reference images for flatkey ${kind} generate: maximum 5.`);
|
|
476
|
+
}
|
|
447
477
|
}
|
|
448
478
|
|
|
449
479
|
function orderedMediaInputs(options) {
|
|
@@ -479,10 +509,14 @@ function orderedMediaInputs(options) {
|
|
|
479
509
|
function inferMediaKindFromName(name, value) {
|
|
480
510
|
const lowerName = String(name ?? "").toLowerCase();
|
|
481
511
|
if (lowerName.includes("video")) return "video";
|
|
482
|
-
if (typeof value === "string" &&
|
|
512
|
+
if (typeof value === "string" && /\.(mp4|mov)$/i.test(value)) return "video";
|
|
483
513
|
return "image";
|
|
484
514
|
}
|
|
485
515
|
|
|
516
|
+
function isSeedance25Model(model) {
|
|
517
|
+
return String(model ?? "").toLowerCase().replaceAll(/[-_.]/g, "").includes("seedance25");
|
|
518
|
+
}
|
|
519
|
+
|
|
486
520
|
async function readJson(response) {
|
|
487
521
|
try {
|
|
488
522
|
return await response.json();
|
package/src/cli.js
CHANGED
|
@@ -44,6 +44,7 @@ const COMMAND_OPTIONS = {
|
|
|
44
44
|
version: new Set([]),
|
|
45
45
|
video: new Set([]),
|
|
46
46
|
"video generate": new Set(["aspect", "duration", "file", "first_frame", "first_frame_url", "fps", "generate_audio", "image", "image_url", "last_frame", "last_frame_url", "model", "prompt", "ratio", "resolution", "video_url"]),
|
|
47
|
+
"video copy": new Set(["aspect", "depth_location", "depth_model", "depth_resolution", "duration", "fps", "frame_ratio", "generate_audio", "keep_depth", "model", "prompt", "ratio", "resolution", "source"]),
|
|
47
48
|
};
|
|
48
49
|
|
|
49
50
|
export function parseArgv(argv) {
|
|
@@ -151,6 +152,9 @@ function repeatableOptionsFor(group, action) {
|
|
|
151
152
|
if (group === "video" && action === "generate") {
|
|
152
153
|
return new Set([...REPEATABLE_OPTIONS, "file"]);
|
|
153
154
|
}
|
|
155
|
+
if (group === "video" && action === "copy") {
|
|
156
|
+
return REPEATABLE_OPTIONS;
|
|
157
|
+
}
|
|
154
158
|
return REPEATABLE_OPTIONS;
|
|
155
159
|
}
|
|
156
160
|
|
|
@@ -249,7 +253,9 @@ export async function runCommand(command, deps = {}) {
|
|
|
249
253
|
? ["generate", "sfx", "music"].includes(command.action)
|
|
250
254
|
: command.group === "image"
|
|
251
255
|
? ["generate", "upload"].includes(command.action)
|
|
252
|
-
: command.
|
|
256
|
+
: command.group === "video"
|
|
257
|
+
? ["generate", "copy"].includes(command.action)
|
|
258
|
+
: command.action === "generate";
|
|
253
259
|
if (!validAction) {
|
|
254
260
|
throw new Error(`Unknown action for ${command.group}: ${command.action}`);
|
|
255
261
|
}
|
|
@@ -515,6 +521,7 @@ async function handleGenerate(command, deps) {
|
|
|
515
521
|
uploadTempMediaVideo,
|
|
516
522
|
} = await import("./api.js");
|
|
517
523
|
const { createAnimation } = await import("./animation.js");
|
|
524
|
+
const { prepareVideoCopy } = await import("./video-copy.js");
|
|
518
525
|
const apiKey = command.options.dry_run
|
|
519
526
|
? (command.options.api_key ?? "FLATKEY_API_KEY")
|
|
520
527
|
: await resolveApiKey({
|
|
@@ -545,17 +552,43 @@ async function handleGenerate(command, deps) {
|
|
|
545
552
|
if (Array.isArray(command.options.__media_inputs)) {
|
|
546
553
|
options.__media_inputs = command.options.__media_inputs;
|
|
547
554
|
}
|
|
555
|
+
let videoCopy;
|
|
556
|
+
if (command.group === "video" && command.action === "copy") {
|
|
557
|
+
if (!command.options.source) {
|
|
558
|
+
throw new Error("Missing --source value. Run `flatkey video copy --help` to see supported options.");
|
|
559
|
+
}
|
|
560
|
+
videoCopy = await (deps.prepareVideoCopy ?? prepareVideoCopy)(command.options, deps);
|
|
561
|
+
options.prompt ??= "Recreate the reference video as faithfully as possible while following the supplied depth motion.";
|
|
562
|
+
options.duration = videoCopy.duration;
|
|
563
|
+
options.__media_inputs = [{
|
|
564
|
+
name: "depth_video",
|
|
565
|
+
value: videoCopy.depthPath,
|
|
566
|
+
kind: "local",
|
|
567
|
+
optionName: "file",
|
|
568
|
+
role: "reference_video",
|
|
569
|
+
}];
|
|
570
|
+
options.__copy_reference_mode = "depth-video";
|
|
571
|
+
}
|
|
548
572
|
if (command.group === "image" || command.group === "video") {
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
573
|
+
try {
|
|
574
|
+
await prepareMediaInputs(command, options, {
|
|
575
|
+
apiKey,
|
|
576
|
+
baseUrl: options.baseUrl,
|
|
577
|
+
tempMediaBaseUrl: tempMediaBaseUrl ?? DEFAULT_CONSOLE_URL,
|
|
578
|
+
fetch: deps.fetch,
|
|
579
|
+
verbose: command.options.verbose,
|
|
580
|
+
verboseLog: verboseLogger(deps.stderr, command.options.verbose),
|
|
581
|
+
uploadTempMediaImage,
|
|
582
|
+
uploadTempMediaVideo,
|
|
583
|
+
});
|
|
584
|
+
} catch (error) {
|
|
585
|
+
await videoCopy?.cleanup?.();
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (command.group === "video" && command.action === "copy"
|
|
590
|
+
&& !options.__media_inputs?.some((entry) => entry.kind === "video")) {
|
|
591
|
+
throw new Error("Video copy failed to prepare its depth-video reference.");
|
|
559
592
|
}
|
|
560
593
|
if (command.options.dry_run) {
|
|
561
594
|
const request = command.group === "image"
|
|
@@ -569,7 +602,14 @@ async function handleGenerate(command, deps) {
|
|
|
569
602
|
? planAudioMusicRequest(options)
|
|
570
603
|
: planAudioRequest(options)
|
|
571
604
|
: planTextRequest(options);
|
|
572
|
-
|
|
605
|
+
await videoCopy?.cleanup?.();
|
|
606
|
+
return {
|
|
607
|
+
dryRun: true,
|
|
608
|
+
kind: command.group,
|
|
609
|
+
request: redactRequest(request),
|
|
610
|
+
videoCopy: scrubVideoCopy(videoCopy),
|
|
611
|
+
copyReferenceMode: options.__copy_reference_mode,
|
|
612
|
+
};
|
|
573
613
|
}
|
|
574
614
|
const animation = createAnimation({
|
|
575
615
|
json: Boolean(command.options.json),
|
|
@@ -577,20 +617,52 @@ async function handleGenerate(command, deps) {
|
|
|
577
617
|
});
|
|
578
618
|
animation.start(command.group);
|
|
579
619
|
try {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
? await
|
|
584
|
-
: command.group === "
|
|
585
|
-
?
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
? await
|
|
589
|
-
:
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
620
|
+
let response;
|
|
621
|
+
try {
|
|
622
|
+
response = command.group === "image"
|
|
623
|
+
? await generateImage(options)
|
|
624
|
+
: command.group === "video"
|
|
625
|
+
? await generateVideo(options)
|
|
626
|
+
: command.group === "audio"
|
|
627
|
+
? command.action === "sfx"
|
|
628
|
+
? await generateAudioSfx(options)
|
|
629
|
+
: command.action === "music"
|
|
630
|
+
? await generateAudioMusic(options)
|
|
631
|
+
: await generateAudio(options)
|
|
632
|
+
: await generateText(options);
|
|
633
|
+
response = command.group === "video"
|
|
634
|
+
? await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep })
|
|
635
|
+
: response;
|
|
636
|
+
} catch (error) {
|
|
637
|
+
if (!videoCopy || options.__copy_reference_mode !== "depth-video" || !isDepthVideoFetchFailure(error)) {
|
|
638
|
+
throw error;
|
|
639
|
+
}
|
|
640
|
+
options.__media_inputs = (videoCopy.referencePaths ?? []).map((value, index) => ({
|
|
641
|
+
name: `depth_frame_${String(index + 1).padStart(2, "0")}`,
|
|
642
|
+
value,
|
|
643
|
+
kind: "local",
|
|
644
|
+
optionName: "image",
|
|
645
|
+
role: index === 0
|
|
646
|
+
? "first_frame"
|
|
647
|
+
: index === (videoCopy.referencePaths.length - 1)
|
|
648
|
+
? "last_frame"
|
|
649
|
+
: "reference_image",
|
|
650
|
+
}));
|
|
651
|
+
await prepareMediaInputs(command, options, {
|
|
652
|
+
apiKey,
|
|
653
|
+
baseUrl: options.baseUrl,
|
|
654
|
+
tempMediaBaseUrl: tempMediaBaseUrl ?? DEFAULT_CONSOLE_URL,
|
|
655
|
+
fetch: deps.fetch,
|
|
656
|
+
verbose: command.options.verbose,
|
|
657
|
+
verboseLog: verboseLogger(deps.stderr, command.options.verbose),
|
|
658
|
+
uploadTempMediaImage,
|
|
659
|
+
uploadTempMediaVideo,
|
|
660
|
+
});
|
|
661
|
+
options.__copy_reference_mode = "depth-keyframes-fallback";
|
|
662
|
+
response = await generateVideo(options);
|
|
663
|
+
response = await waitForVideoResult(response, { ...options, getVideo, sleep: deps.sleep });
|
|
664
|
+
}
|
|
665
|
+
const artifactResponse = response;
|
|
594
666
|
if (command.group === "text") {
|
|
595
667
|
const text = extractText(response);
|
|
596
668
|
const output = await writeTextOutput(text, command.options.output);
|
|
@@ -606,21 +678,27 @@ async function handleGenerate(command, deps) {
|
|
|
606
678
|
output,
|
|
607
679
|
fetch: deps.fetch,
|
|
608
680
|
});
|
|
609
|
-
return {
|
|
681
|
+
return {
|
|
682
|
+
kind: command.group,
|
|
683
|
+
artifacts,
|
|
684
|
+
response: scrubArtifactResponse(artifactResponse),
|
|
685
|
+
...(videoCopy ? { copyReferenceMode: options.__copy_reference_mode } : {}),
|
|
686
|
+
};
|
|
610
687
|
} finally {
|
|
611
688
|
animation.stop();
|
|
689
|
+
await videoCopy?.cleanup?.();
|
|
612
690
|
}
|
|
613
691
|
}
|
|
614
692
|
|
|
615
693
|
async function prepareMediaInputs(command, options, deps) {
|
|
616
694
|
const mediaInputs = orderedMediaInputs(command, options);
|
|
617
695
|
if (mediaInputs.length === 0) return;
|
|
618
|
-
assertMediaReferenceLimit(mediaInputs, command.group);
|
|
696
|
+
assertMediaReferenceLimit(mediaInputs, command.group, options.model);
|
|
619
697
|
|
|
620
698
|
const resolved = [];
|
|
621
699
|
for (const input of mediaInputs) {
|
|
622
700
|
if (input.kind === "local") {
|
|
623
|
-
resolved.push(await uploadLocalMedia(input.value, command.group, deps, input.name));
|
|
701
|
+
resolved.push(await uploadLocalMedia(input.value, command.group, deps, input.optionName ?? input.name));
|
|
624
702
|
} else {
|
|
625
703
|
resolved.push(await confirmRemoteMediaUrl(input.value, command.group, deps, input.name));
|
|
626
704
|
}
|
|
@@ -628,10 +706,25 @@ async function prepareMediaInputs(command, options, deps) {
|
|
|
628
706
|
options.__media_inputs = resolved;
|
|
629
707
|
}
|
|
630
708
|
|
|
631
|
-
function assertMediaReferenceLimit(mediaInputs, commandGroup) {
|
|
709
|
+
function assertMediaReferenceLimit(mediaInputs, commandGroup, model) {
|
|
632
710
|
const imageInputs = mediaInputs.filter((entry) => entry.kind !== "video");
|
|
633
|
-
|
|
634
|
-
|
|
711
|
+
const videoInputs = mediaInputs.filter((entry) => entry.kind === "video");
|
|
712
|
+
if (isSeedance25Model(model)) {
|
|
713
|
+
if (imageInputs.length > 30) {
|
|
714
|
+
throw new Error(`Too many reference images for flatkey ${commandGroup} generate with Seedance 2.5: maximum 30.`);
|
|
715
|
+
}
|
|
716
|
+
if (videoInputs.length > 10) {
|
|
717
|
+
throw new Error(`Too many reference videos for flatkey ${commandGroup} generate with Seedance 2.5: maximum 10.`);
|
|
718
|
+
}
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
if (imageInputs.length > MAX_REFERENCE_IMAGES) {
|
|
722
|
+
throw new Error(`Too many reference images for flatkey ${commandGroup} generate: maximum ${MAX_REFERENCE_IMAGES}.`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function isSeedance25Model(model) {
|
|
727
|
+
return String(model ?? "").toLowerCase().replaceAll(/[-_.]/g, "").includes("seedance25");
|
|
635
728
|
}
|
|
636
729
|
|
|
637
730
|
function orderedMediaInputs(command, options) {
|
|
@@ -730,6 +823,12 @@ function mediaInfoFromPath(file) {
|
|
|
730
823
|
if (lower.endsWith(".png")) return { kind: "image", contentType: "image/png" };
|
|
731
824
|
if (lower.endsWith(".webp")) return { kind: "image", contentType: "image/webp" };
|
|
732
825
|
if (lower.endsWith(".mp4")) return { kind: "video", contentType: "video/mp4" };
|
|
826
|
+
if (lower.endsWith(".mov")) return { kind: "video", contentType: "video/quicktime" };
|
|
827
|
+
if (lower.endsWith(".bmp")) return { kind: "image", contentType: "image/bmp" };
|
|
828
|
+
if (lower.endsWith(".tif") || lower.endsWith(".tiff")) return { kind: "image", contentType: "image/tiff" };
|
|
829
|
+
if (lower.endsWith(".gif")) return { kind: "image", contentType: "image/gif" };
|
|
830
|
+
if (lower.endsWith(".heic")) return { kind: "image", contentType: "image/heic" };
|
|
831
|
+
if (lower.endsWith(".heif")) return { kind: "image", contentType: "image/heif" };
|
|
733
832
|
return undefined;
|
|
734
833
|
}
|
|
735
834
|
|
|
@@ -740,6 +839,12 @@ function mediaInfoFromMime(contentType) {
|
|
|
740
839
|
if (lower.startsWith("image/png")) return { kind: "image", contentType: "image/png" };
|
|
741
840
|
if (lower.startsWith("image/webp")) return { kind: "image", contentType: "image/webp" };
|
|
742
841
|
if (lower.startsWith("video/mp4")) return { kind: "video", contentType: "video/mp4" };
|
|
842
|
+
if (lower.startsWith("video/quicktime")) return { kind: "video", contentType: "video/quicktime" };
|
|
843
|
+
if (lower.startsWith("image/bmp")) return { kind: "image", contentType: "image/bmp" };
|
|
844
|
+
if (lower.startsWith("image/tiff")) return { kind: "image", contentType: "image/tiff" };
|
|
845
|
+
if (lower.startsWith("image/gif")) return { kind: "image", contentType: "image/gif" };
|
|
846
|
+
if (lower.startsWith("image/heic")) return { kind: "image", contentType: "image/heic" };
|
|
847
|
+
if (lower.startsWith("image/heif")) return { kind: "image", contentType: "image/heif" };
|
|
743
848
|
return undefined;
|
|
744
849
|
}
|
|
745
850
|
|
|
@@ -864,6 +969,29 @@ function scrubArtifactResponse(value) {
|
|
|
864
969
|
);
|
|
865
970
|
}
|
|
866
971
|
|
|
972
|
+
function scrubVideoCopy(value) {
|
|
973
|
+
if (!value) return undefined;
|
|
974
|
+
return {
|
|
975
|
+
source: value.source,
|
|
976
|
+
depthPath: value.depthPath,
|
|
977
|
+
referencePaths: value.referencePaths,
|
|
978
|
+
duration: value.duration,
|
|
979
|
+
sourceInfo: value.sourceInfo,
|
|
980
|
+
helper: value.helper,
|
|
981
|
+
};
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
function isDepthVideoFetchFailure(error) {
|
|
985
|
+
const code = String(error?.code ?? "").toLowerCase();
|
|
986
|
+
const message = String(error?.message ?? error).toLowerCase();
|
|
987
|
+
return code.includes("fail_to_fetch")
|
|
988
|
+
|| code.includes("video_fetch")
|
|
989
|
+
|| message.includes("fail_to_fetch")
|
|
990
|
+
|| message.includes("failed to fetch")
|
|
991
|
+
|| message.includes("unable to fetch")
|
|
992
|
+
|| message.includes("video reference");
|
|
993
|
+
}
|
|
994
|
+
|
|
867
995
|
async function handleVoices(command, deps) {
|
|
868
996
|
const { resolveApiKey, resolveOrigins } = await import("./config.js");
|
|
869
997
|
const { getVoices } = await import("./api.js");
|
package/src/help.js
CHANGED
|
@@ -16,6 +16,7 @@ JSON mode:
|
|
|
16
16
|
Commands:
|
|
17
17
|
- flatkey image generate --prompt "<prompt>" --json [--model <model>] [--file <path>] [--image-url <url>] [--output image.png]
|
|
18
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
|
|
@@ -79,7 +81,7 @@ Environment:
|
|
|
79
81
|
--console-url <url> Override Flatkey console URL for login
|
|
80
82
|
|
|
81
83
|
Video options:
|
|
82
|
-
--ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9,
|
|
84
|
+
--ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, 1:1, or adaptive
|
|
83
85
|
--resolution <value> 480p, 720p, or 1080p
|
|
84
86
|
`;
|
|
85
87
|
}
|
|
@@ -216,22 +218,34 @@ Aliases:
|
|
|
216
218
|
|
|
217
219
|
Options:
|
|
218
220
|
--json Print machine-readable JSON`,
|
|
219
|
-
video: `Usage: flatkey video
|
|
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
|
|
220
226
|
|
|
221
227
|
Options:
|
|
222
228
|
--model <model> Model id, default seedance-2.0-pro
|
|
223
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
|
|
224
246
|
--ratio <value> 16:9, 9:16, 4:3, 3:4, 21:9, or 1:1
|
|
225
247
|
--resolution <value> 480p, 720p, or 1080p
|
|
226
248
|
--generate-audio <true|false> Generate audio, default true
|
|
227
|
-
--image <file> Upload local reference image, repeatable
|
|
228
|
-
--image-url <url> Add reference image URL, repeatable
|
|
229
|
-
--file <path> Local image or video input, repeatable
|
|
230
|
-
--first-frame <file> Upload local first frame image
|
|
231
|
-
--video-url <url> Add reference video URL, repeatable
|
|
232
|
-
--first-frame-url <url> Add first frame image URL
|
|
233
|
-
--last-frame <file> Upload local last frame image
|
|
234
|
-
--last-frame-url <url> Add last frame image URL
|
|
235
249
|
--fps <fps> Frames per second
|
|
236
250
|
--output, -o <file> Write video file
|
|
237
251
|
--json Print machine-readable JSON`,
|
|
@@ -247,7 +261,7 @@ Options:
|
|
|
247
261
|
--image-url <url> Add reference image URL, repeatable
|
|
248
262
|
--file <path> Local image or video input, repeatable
|
|
249
263
|
--first-frame <file> Upload local first frame image
|
|
250
|
-
--video-url <url> Add reference video URL, repeatable
|
|
264
|
+
--video-url <url> Add reference video URL, repeatable; Seedance 2.5 supports up to 10
|
|
251
265
|
--first-frame-url <url> Add first frame image URL
|
|
252
266
|
--last-frame <file> Upload local last frame image
|
|
253
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
|
+
}
|