@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/README.md +39 -1
- package/package.json +4 -2
- package/scripts/install-video-runtime.js +14 -0
- package/src/api.js +123 -41
- package/src/artifacts.js +30 -2
- package/src/cli.js +340 -63
- package/src/help.js +33 -12
- package/src/video-copy.js +251 -0
- package/src/video-runtime.js +119 -0
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,
|
|
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
|
|
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
|
+
}
|