grok-studio 0.1.0 → 0.1.2
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 +29 -96
- package/dist/server/cli.js +65 -243
- package/package.json +3 -1
package/README.md
CHANGED
|
@@ -1,117 +1,50 @@
|
|
|
1
1
|
# Grok Studio
|
|
2
2
|
|
|
3
|
-
Self-hosted Grok
|
|
3
|
+
Self-hosted Grok image-to-video studio. Your xAI credentials stay on the server; turn an image into video from a web UI or the CLI, with outputs stored on disk. Built for local / LAN use.
|
|
4
4
|
|
|
5
|
-
##
|
|
6
|
-
|
|
7
|
-
The UI is organized around generated outputs, active work, and source-scoped lineage.
|
|
8
|
-
|
|
9
|
-
- **Left rail**: `+` creates a new source image. Active Jobs appears above the output gallery when queued or running work exists; it is global and not tied to the currently selected source. Outputs are typed as `Frame` or `Video`; status text is only for exceptional or in-progress states.
|
|
10
|
-
- **Result Graph**: the center canvas shows lineage for the current source. Nodes are concrete artifacts/runs:
|
|
11
|
-
- `Source` is the root image.
|
|
12
|
-
- each Prep run creates a separate `Frame` node.
|
|
13
|
-
- each Video run creates a separate `Video` node.
|
|
14
|
-
- **Inspector**: the right panel is the only place for details and actions. Select a node to inspect it, download video output, rerun, prepare another frame, or animate the selected frame.
|
|
15
|
-
|
|
16
|
-
Lineage is data-driven:
|
|
17
|
-
|
|
18
|
-
- Source-linked video jobs attach only when `inputFrame.source === "source"` and the job's `clientSourceId` matches the current source.
|
|
19
|
-
- Frame-linked video jobs attach only when `inputFrame.source === "prep"` and `preparedImageId` matches a frame in the current graph.
|
|
20
|
-
- UI cache must not decide graph edges. If lineage fields are missing, do not guess by recent history.
|
|
21
|
-
|
|
22
|
-
## Run
|
|
5
|
+
## Install & run
|
|
23
6
|
|
|
24
7
|
```bash
|
|
25
|
-
|
|
26
|
-
cp .env.example .env
|
|
27
|
-
vpr build
|
|
28
|
-
vpr start
|
|
8
|
+
npx grok-studio
|
|
29
9
|
```
|
|
30
10
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
```bash
|
|
34
|
-
vpr launch
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
`launch` builds the app, checks xAI auth, opens the browser login flow when no token exists, then starts the web app. Open <http://127.0.0.1:8787>.
|
|
38
|
-
|
|
39
|
-
## LAN Access
|
|
40
|
-
|
|
41
|
-
For local-network access, set:
|
|
42
|
-
|
|
43
|
-
```dotenv
|
|
44
|
-
HOST=0.0.0.0
|
|
45
|
-
ACCESS_TOKEN=
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
Then restart the service and open `http://<mac-lan-ip>:8787` or `http://<mac-local-hostname>.local:8787` from another device on the same LAN.
|
|
49
|
-
|
|
50
|
-
`ACCESS_TOKEN` can be left empty for trusted local/LAN use. Set it before exposing the app outside a trusted network.
|
|
11
|
+
That's it. First run opens the xAI browser login if needed, then serves on <http://127.0.0.1:8787>. (`npm i -g grok-studio` also works.)
|
|
51
12
|
|
|
52
|
-
##
|
|
53
|
-
|
|
54
|
-
- `HOST` / `PORT` control where the server binds.
|
|
55
|
-
- `ACCESS_TOKEN` optionally protects browser/API access outside loopback.
|
|
56
|
-
- `WORKSPACE_DIR` stores `images/`, `videos/`, `jobs/`, and `prepared-images/`.
|
|
57
|
-
- `XAI_AUTH_MODE=oauth` reads `XAI_OAUTH_TOKEN_FILE`, refreshes it when needed, and `vpr launch` can bootstrap it interactively.
|
|
58
|
-
- `XAI_AUTH_MODE=api_key` uses `XAI_API_KEY`.
|
|
59
|
-
- `XAI_VIDEO_MODEL` controls image-to-video generation.
|
|
60
|
-
- `XAI_IMAGE_MODEL` controls first-frame image editing.
|
|
61
|
-
- `DEFAULT_DURATION_SECONDS` supports up to 15 seconds.
|
|
62
|
-
- `MAX_VARIATIONS` limits sequential variations per video job.
|
|
63
|
-
|
|
64
|
-
The app binds to `127.0.0.1` by default. Use `HOST=0.0.0.0` only for LAN/tunnel exposure.
|
|
65
|
-
|
|
66
|
-
## Grok Login Test
|
|
67
|
-
|
|
68
|
-
To test the xAI/Grok browser login without touching the token file used by the running service:
|
|
69
|
-
|
|
70
|
-
```bash
|
|
71
|
-
vpr login
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
The command opens the xAI authorization page, waits for the local callback, saves the token state to a test path, and verifies it with the API. If xAI shows a fallback code instead of redirecting, paste that code into the terminal prompt.
|
|
75
|
-
|
|
76
|
-
Useful variants:
|
|
13
|
+
## CLI
|
|
77
14
|
|
|
78
15
|
```bash
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
16
|
+
grok-studio serve [--open] # start the web app (default command)
|
|
17
|
+
grok-studio gen --image a.png --prompt "slow head turn" \
|
|
18
|
+
[--prep --duration 6 --resolution 720p --aspect 9:16 --count 2 --out clip.mp4]
|
|
19
|
+
grok-studio login # xAI OAuth login (browser)
|
|
20
|
+
grok-studio status # config + xAI auth + server health
|
|
82
21
|
```
|
|
83
22
|
|
|
84
|
-
`
|
|
23
|
+
`gen` is headless: image →(optional `--prep` first frame)→ video, no UI; result paths print to stdout.
|
|
85
24
|
|
|
86
|
-
|
|
25
|
+
## Web UI
|
|
87
26
|
|
|
88
|
-
|
|
27
|
+
- **Left rail** — `+` adds a source image; **Active Jobs** lists running work globally; **Outputs** is a gallery of every `Frame` / `Video`.
|
|
28
|
+
- **Result Graph** (center) — every node is one concrete run: `Source` → `Frame` (each prep) → `Video` (each run). Forks are sibling nodes. Lineage is derived purely from data (`inputFrame.preparedImageId` / `clientSourceId`), never guessed from history.
|
|
29
|
+
- **Inspector** (right) — the only place to view a result, download, rerun, prep, or animate the selected node.
|
|
89
30
|
|
|
90
|
-
|
|
31
|
+
## Configure (`.env`)
|
|
91
32
|
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
vpr smoke
|
|
33
|
+
```dotenv
|
|
34
|
+
HOST=127.0.0.1 # 0.0.0.0 for LAN access
|
|
35
|
+
PORT=8787
|
|
36
|
+
ACCESS_TOKEN= # set to gate non-loopback access (empty = open on loopback/LAN)
|
|
37
|
+
WORKSPACE_DIR=./workspace # holds images/ videos/ jobs/ prepared-images/
|
|
38
|
+
XAI_AUTH_MODE=oauth # or: api_key (+ XAI_API_KEY)
|
|
39
|
+
XAI_VIDEO_MODEL=grok-imagine-video
|
|
40
|
+
XAI_IMAGE_MODEL=grok-imagine-image-quality
|
|
101
41
|
```
|
|
102
42
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
- React-specific lint rules for hooks, effect dependencies, JSX keys, nested components, unsafe JSX, and button types.
|
|
106
|
-
- Type-aware linting and TypeScript checks.
|
|
107
|
-
- Architecture guardrails in `tests/architecture.test.ts` to keep the main app shell thin, limit local React state in `App.tsx`, split stylesheet ownership, and keep server route files focused.
|
|
108
|
-
- Browser smoke coverage in `scripts/smoke.mjs`, including paste input, image lightbox, video-card scroll behavior, and legacy history not restoring unrelated Prep outputs.
|
|
109
|
-
|
|
110
|
-
## Local Service
|
|
111
|
-
|
|
112
|
-
If installed as the local launchd service, restart it after building:
|
|
43
|
+
## Develop
|
|
113
44
|
|
|
114
45
|
```bash
|
|
115
|
-
|
|
116
|
-
|
|
46
|
+
vp install
|
|
47
|
+
vpr check && vp test && vpr build && vpr smoke
|
|
117
48
|
```
|
|
49
|
+
|
|
50
|
+
Quality gates: React/a11y lint, type checks, an architecture-size test (`tests/architecture.test.ts`), and a Playwright smoke (`scripts/smoke.mjs`).
|
package/dist/server/cli.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
|
+
import { Command } from "commander";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import process$1 from "node:process";
|
|
5
6
|
import { config } from "dotenv";
|
|
6
7
|
import { z } from "zod";
|
|
7
8
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
8
9
|
import crypto from "node:crypto";
|
|
9
|
-
import http from "node:http";
|
|
10
|
-
import readline from "node:readline/promises";
|
|
11
10
|
import { execFile } from "node:child_process";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import * as clack from "@clack/prompts";
|
|
12
13
|
import { fileURLToPath } from "node:url";
|
|
13
14
|
import express from "express";
|
|
14
15
|
import multer from "multer";
|
|
@@ -1000,21 +1001,16 @@ function formatError(error) {
|
|
|
1000
1001
|
}
|
|
1001
1002
|
//#endregion
|
|
1002
1003
|
//#region src/server/gen.ts
|
|
1003
|
-
async function runGenCli(
|
|
1004
|
-
|
|
1005
|
-
if (!args.image) {
|
|
1006
|
-
printHelp$2();
|
|
1007
|
-
throw new Error("--image <path> is required.");
|
|
1008
|
-
}
|
|
1009
|
-
if (!fs.existsSync(args.image)) throw new Error(`Image not found: ${args.image}`);
|
|
1004
|
+
async function runGenCli(opts) {
|
|
1005
|
+
if (!fs.existsSync(opts.image)) throw new Error(`Image not found: ${opts.image}`);
|
|
1010
1006
|
const config = loadConfig();
|
|
1011
1007
|
try {
|
|
1012
1008
|
await resolveXaiAuth(config);
|
|
1013
1009
|
} catch (error) {
|
|
1014
1010
|
throw new Error(`xAI auth not ready (${error instanceof Error ? error.message : String(error)}). Run: grok-studio login`);
|
|
1015
1011
|
}
|
|
1016
|
-
let imagePath = path.resolve(
|
|
1017
|
-
if (
|
|
1012
|
+
let imagePath = path.resolve(opts.image);
|
|
1013
|
+
if (opts.prep) {
|
|
1018
1014
|
process.stderr.write("preparing first frame…\n");
|
|
1019
1015
|
imagePath = (await generateGrokImageEdit({
|
|
1020
1016
|
config,
|
|
@@ -1024,11 +1020,11 @@ async function runGenCli(argv) {
|
|
|
1024
1020
|
})).localPath;
|
|
1025
1021
|
process.stderr.write(`prepared frame: ${imagePath}\n`);
|
|
1026
1022
|
}
|
|
1027
|
-
const overrides = { prompt:
|
|
1028
|
-
if (
|
|
1029
|
-
if (
|
|
1030
|
-
if (
|
|
1031
|
-
if (
|
|
1023
|
+
const overrides = { prompt: opts.prompt ?? "" };
|
|
1024
|
+
if (opts.duration !== void 0) overrides.durationSeconds = opts.duration;
|
|
1025
|
+
if (opts.resolution) overrides.resolution = opts.resolution;
|
|
1026
|
+
if (opts.aspect) overrides.aspectRatio = opts.aspect;
|
|
1027
|
+
if (opts.count !== void 0) overrides.count = opts.count;
|
|
1032
1028
|
const options = normalizeGenerationOptions(overrides, defaultGenerationOptions(config.defaults), config.defaults.maxVariations);
|
|
1033
1029
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
|
1034
1030
|
const outputs = [];
|
|
@@ -1042,7 +1038,7 @@ async function runGenCli(argv) {
|
|
|
1042
1038
|
jobId: `gen_${stamp}_${take}`,
|
|
1043
1039
|
onStatus: (status) => process.stderr.write(` take ${take}: ${status}\n`)
|
|
1044
1040
|
});
|
|
1045
|
-
outputs.push(resolveOutput(result.localPath,
|
|
1041
|
+
outputs.push(resolveOutput(result.localPath, opts.out, take, options.count));
|
|
1046
1042
|
}
|
|
1047
1043
|
for (const file of outputs) console.log(file);
|
|
1048
1044
|
}
|
|
@@ -1052,59 +1048,6 @@ function resolveOutput(localPath, out, take, count) {
|
|
|
1052
1048
|
fs.copyFileSync(localPath, path.resolve(target));
|
|
1053
1049
|
return path.resolve(target);
|
|
1054
1050
|
}
|
|
1055
|
-
function parseArgs$1(values) {
|
|
1056
|
-
const parsed = { prep: false };
|
|
1057
|
-
for (let index = 0; index < values.length; index += 1) {
|
|
1058
|
-
const value = values[index];
|
|
1059
|
-
if (value === "--help" || value === "-h") {
|
|
1060
|
-
printHelp$2();
|
|
1061
|
-
process.exit(0);
|
|
1062
|
-
}
|
|
1063
|
-
if (value === "--prep") {
|
|
1064
|
-
parsed.prep = true;
|
|
1065
|
-
continue;
|
|
1066
|
-
}
|
|
1067
|
-
const next = values[index + 1];
|
|
1068
|
-
const requireNext = () => {
|
|
1069
|
-
if (!next || next.startsWith("--")) throw new Error(`${value} requires a value.`);
|
|
1070
|
-
index += 1;
|
|
1071
|
-
return next;
|
|
1072
|
-
};
|
|
1073
|
-
if (value === "--image") parsed.image = requireNext();
|
|
1074
|
-
else if (value === "--prompt") parsed.prompt = requireNext();
|
|
1075
|
-
else if (value === "--out") parsed.out = requireNext();
|
|
1076
|
-
else if (value === "--resolution") parsed.resolution = requireNext();
|
|
1077
|
-
else if (value === "--aspect") parsed.aspect = requireNext();
|
|
1078
|
-
else if (value === "--duration") parsed.duration = parsePositiveInt(requireNext(), value);
|
|
1079
|
-
else if (value === "--count") parsed.count = parsePositiveInt(requireNext(), value);
|
|
1080
|
-
else throw new Error(`Unknown argument: ${value}`);
|
|
1081
|
-
}
|
|
1082
|
-
return parsed;
|
|
1083
|
-
}
|
|
1084
|
-
function parsePositiveInt(value, flag) {
|
|
1085
|
-
const parsed = Number(value);
|
|
1086
|
-
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} requires a positive integer.`);
|
|
1087
|
-
return parsed;
|
|
1088
|
-
}
|
|
1089
|
-
function printHelp$2() {
|
|
1090
|
-
console.log(`Usage: grok-studio gen --image <path> [options]
|
|
1091
|
-
|
|
1092
|
-
Headlessly turn an image into a video (no UI).
|
|
1093
|
-
|
|
1094
|
-
Options:
|
|
1095
|
-
--image <path> Source image (required).
|
|
1096
|
-
--prompt <text> Motion prompt.
|
|
1097
|
-
--prep Run a first-frame prep pass before the video.
|
|
1098
|
-
--duration <seconds> Clip length. Default from config.
|
|
1099
|
-
--resolution <res> e.g. 720p / 1080p.
|
|
1100
|
-
--aspect <ratio> e.g. source / 9:16 / 16:9 / 1:1.
|
|
1101
|
-
--count <n> Number of takes. Default 1.
|
|
1102
|
-
--out <path> Write the result here (count>1 appends -N). Default: workspace path.
|
|
1103
|
-
|
|
1104
|
-
Example:
|
|
1105
|
-
grok-studio gen --image portrait.png --prompt "slow head turn" --duration 6 --out clip.mp4
|
|
1106
|
-
`);
|
|
1107
|
-
}
|
|
1108
1051
|
//#endregion
|
|
1109
1052
|
//#region src/server/oauth-login.ts
|
|
1110
1053
|
var DISCOVERY_URL = `https://auth.x.ai/.well-known/openid-configuration`;
|
|
@@ -1128,18 +1071,15 @@ async function runXaiOauthLogin(options) {
|
|
|
1128
1071
|
nonce
|
|
1129
1072
|
});
|
|
1130
1073
|
try {
|
|
1131
|
-
|
|
1132
|
-
console.log(authorizeUrl);
|
|
1133
|
-
console.log();
|
|
1074
|
+
clack.note(authorizeUrl, "Authorize Grok Studio with xAI");
|
|
1134
1075
|
if (options.printUrlOnly) return;
|
|
1135
|
-
console.log(`Waiting for callback on ${redirectUri}`);
|
|
1136
|
-
console.log("If xAI shows a fallback code, paste that code here and press Return.");
|
|
1137
1076
|
if (!options.noBrowser) openBrowser$1(authorizeUrl);
|
|
1138
|
-
const callback = await
|
|
1077
|
+
const callback = await awaitAuthorization({
|
|
1139
1078
|
server,
|
|
1140
1079
|
timeoutMs: (options.timeoutSeconds ?? 600) * 1e3,
|
|
1141
1080
|
redirectUri,
|
|
1142
|
-
expectedState: state
|
|
1081
|
+
expectedState: state,
|
|
1082
|
+
noBrowser: Boolean(options.noBrowser)
|
|
1143
1083
|
});
|
|
1144
1084
|
if (callback.error) throw new Error(`xAI authorization failed: ${callback.error_description ?? callback.error}`);
|
|
1145
1085
|
if (callback.state !== state) throw new Error("xAI authorization failed: state mismatch.");
|
|
@@ -1170,7 +1110,7 @@ async function runXaiOauthLogin(options) {
|
|
|
1170
1110
|
last_refresh: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1171
1111
|
source: "grok-video-web-oauth-login"
|
|
1172
1112
|
});
|
|
1173
|
-
|
|
1113
|
+
clack.log.success(`xAI OAuth login successful. Token saved to ${options.outputPath}`);
|
|
1174
1114
|
} finally {
|
|
1175
1115
|
await closeServer(server.server);
|
|
1176
1116
|
}
|
|
@@ -1266,24 +1206,25 @@ async function waitForCallback(serverState, timeoutMs) {
|
|
|
1266
1206
|
}
|
|
1267
1207
|
throw new Error("Timed out waiting for xAI OAuth callback.");
|
|
1268
1208
|
}
|
|
1269
|
-
async function
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
});
|
|
1209
|
+
async function awaitAuthorization(input) {
|
|
1210
|
+
if (input.noBrowser) return pasteFallback(input.redirectUri, input.expectedState);
|
|
1211
|
+
const spinner = clack.spinner();
|
|
1212
|
+
spinner.start("Waiting for browser login…");
|
|
1274
1213
|
try {
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1214
|
+
const result = await waitForCallback(input.server, input.timeoutMs);
|
|
1215
|
+
spinner.stop("Authorized in browser.");
|
|
1216
|
+
return result;
|
|
1217
|
+
} catch {
|
|
1218
|
+
spinner.stop("No browser callback received.");
|
|
1219
|
+
return pasteFallback(input.redirectUri, input.expectedState);
|
|
1281
1220
|
}
|
|
1282
1221
|
}
|
|
1283
|
-
async function
|
|
1222
|
+
async function pasteFallback(redirectUri, expectedState) {
|
|
1284
1223
|
while (true) {
|
|
1285
|
-
const
|
|
1286
|
-
if (
|
|
1224
|
+
const value = await clack.text({ message: `Paste the ${redirectUri} callback URL or the xAI fallback code` });
|
|
1225
|
+
if (clack.isCancel(value)) throw new Error("Login cancelled.");
|
|
1226
|
+
const parsed = parseCallbackInput(String(value), expectedState);
|
|
1227
|
+
if (parsed?.code || parsed?.error) return parsed;
|
|
1287
1228
|
}
|
|
1288
1229
|
}
|
|
1289
1230
|
function parseCallbackInput(value, expectedState) {
|
|
@@ -1337,11 +1278,10 @@ function writeJson0600(filePath, value) {
|
|
|
1337
1278
|
//#endregion
|
|
1338
1279
|
//#region src/server/login.ts
|
|
1339
1280
|
var DEFAULT_TEST_TOKEN_FILE = "~/.grok-video-web/login-test/xai-oauth.json";
|
|
1340
|
-
async function runLoginCli(
|
|
1341
|
-
const args = parseArgs(argv);
|
|
1281
|
+
async function runLoginCli(opts) {
|
|
1342
1282
|
const config = loadConfig();
|
|
1343
1283
|
if (config.xai.authMode !== "oauth") throw new Error("Grok login flow only applies when XAI_AUTH_MODE=oauth.");
|
|
1344
|
-
const outputPath = path.resolve(expandHome(
|
|
1284
|
+
const outputPath = path.resolve(expandHome(opts.output ?? (opts.live ? config.xai.oauthTokenFile : DEFAULT_TEST_TOKEN_FILE)));
|
|
1345
1285
|
const authConfig = {
|
|
1346
1286
|
...config,
|
|
1347
1287
|
xai: {
|
|
@@ -1349,115 +1289,26 @@ async function runLoginCli(argv) {
|
|
|
1349
1289
|
oauthTokenFile: outputPath
|
|
1350
1290
|
}
|
|
1351
1291
|
};
|
|
1352
|
-
if (
|
|
1292
|
+
if (opts.check) {
|
|
1353
1293
|
const auth = await resolveXaiAuth(authConfig);
|
|
1354
1294
|
console.log(`xAI OAuth ready: ${outputPath}`);
|
|
1355
1295
|
console.log(`Auth mode: ${auth.label}`);
|
|
1356
1296
|
console.log(`Base URL: ${auth.baseUrl}`);
|
|
1357
1297
|
return;
|
|
1358
1298
|
}
|
|
1359
|
-
if (fs.existsSync(outputPath) && !
|
|
1360
|
-
console.error(`Refusing to overwrite existing token state: ${outputPath}`);
|
|
1361
|
-
console.error("Pass --force to refresh this token file. The default test token path can be overwritten without --force.");
|
|
1362
|
-
process.exit(1);
|
|
1363
|
-
}
|
|
1299
|
+
if (fs.existsSync(outputPath) && !opts.force && !opts.printUrlOnly && (opts.live || opts.output)) throw new Error(`Refusing to overwrite existing token state: ${outputPath}. Pass --force to refresh it.`);
|
|
1364
1300
|
await runXaiOauthLogin({
|
|
1365
1301
|
outputPath,
|
|
1366
|
-
noBrowser:
|
|
1367
|
-
port:
|
|
1368
|
-
timeoutSeconds:
|
|
1369
|
-
printUrlOnly:
|
|
1302
|
+
noBrowser: opts.browser === false,
|
|
1303
|
+
port: opts.port,
|
|
1304
|
+
timeoutSeconds: opts.timeout,
|
|
1305
|
+
printUrlOnly: opts.printUrlOnly
|
|
1370
1306
|
});
|
|
1371
|
-
if (!
|
|
1307
|
+
if (!opts.printUrlOnly) {
|
|
1372
1308
|
await resolveXaiAuth(authConfig);
|
|
1373
1309
|
console.log("xAI OAuth token verified with the API.");
|
|
1374
1310
|
}
|
|
1375
1311
|
}
|
|
1376
|
-
function parseArgs(values) {
|
|
1377
|
-
const parsed = {
|
|
1378
|
-
live: false,
|
|
1379
|
-
force: false,
|
|
1380
|
-
noBrowser: false,
|
|
1381
|
-
check: false,
|
|
1382
|
-
printUrlOnly: false
|
|
1383
|
-
};
|
|
1384
|
-
for (let index = 0; index < values.length; index += 1) {
|
|
1385
|
-
const value = values[index];
|
|
1386
|
-
if (value === "--") continue;
|
|
1387
|
-
if (value === "--help" || value === "-h") {
|
|
1388
|
-
printHelp$1();
|
|
1389
|
-
process.exit(0);
|
|
1390
|
-
}
|
|
1391
|
-
if (value === "--force") {
|
|
1392
|
-
parsed.force = true;
|
|
1393
|
-
continue;
|
|
1394
|
-
}
|
|
1395
|
-
if (value === "--live") {
|
|
1396
|
-
parsed.live = true;
|
|
1397
|
-
continue;
|
|
1398
|
-
}
|
|
1399
|
-
if (value === "--no-browser") {
|
|
1400
|
-
parsed.noBrowser = true;
|
|
1401
|
-
continue;
|
|
1402
|
-
}
|
|
1403
|
-
if (value === "--check") {
|
|
1404
|
-
parsed.check = true;
|
|
1405
|
-
continue;
|
|
1406
|
-
}
|
|
1407
|
-
if (value === "--print-url-only") {
|
|
1408
|
-
parsed.printUrlOnly = true;
|
|
1409
|
-
parsed.noBrowser = true;
|
|
1410
|
-
continue;
|
|
1411
|
-
}
|
|
1412
|
-
if (value === "--output") {
|
|
1413
|
-
parsed.output = requireValue(values, index, value);
|
|
1414
|
-
index += 1;
|
|
1415
|
-
continue;
|
|
1416
|
-
}
|
|
1417
|
-
if (value === "--port") {
|
|
1418
|
-
parsed.port = parseInteger(requireValue(values, index, value), value);
|
|
1419
|
-
index += 1;
|
|
1420
|
-
continue;
|
|
1421
|
-
}
|
|
1422
|
-
if (value === "--timeout") {
|
|
1423
|
-
parsed.timeoutSeconds = parseInteger(requireValue(values, index, value), value);
|
|
1424
|
-
index += 1;
|
|
1425
|
-
continue;
|
|
1426
|
-
}
|
|
1427
|
-
throw new Error(`Unknown argument: ${value}`);
|
|
1428
|
-
}
|
|
1429
|
-
return parsed;
|
|
1430
|
-
}
|
|
1431
|
-
function printHelp$1() {
|
|
1432
|
-
console.log(`Usage: grok-studio login [options]
|
|
1433
|
-
|
|
1434
|
-
Options:
|
|
1435
|
-
--live Write to XAI_OAUTH_TOKEN_FILE instead of the safe test token file.
|
|
1436
|
-
--output <path> Token state file to write. Defaults to ~/.grok-video-web/login-test/xai-oauth.json.
|
|
1437
|
-
--force Allow replacing an existing explicit or live token state file.
|
|
1438
|
-
--check Verify the selected token file without opening login.
|
|
1439
|
-
--no-browser Print the auth URL and wait instead of opening a browser.
|
|
1440
|
-
--print-url-only Print a disposable auth URL for smoke testing, then exit.
|
|
1441
|
-
--port <number> Local callback port. Default: 56121.
|
|
1442
|
-
--timeout <seconds> Login wait timeout. Default: 600.
|
|
1443
|
-
|
|
1444
|
-
Safe test example:
|
|
1445
|
-
grok-studio login
|
|
1446
|
-
|
|
1447
|
-
Live token repair:
|
|
1448
|
-
grok-studio login --live --force
|
|
1449
|
-
`);
|
|
1450
|
-
}
|
|
1451
|
-
function requireValue(values, index, flag) {
|
|
1452
|
-
const next = values[index + 1];
|
|
1453
|
-
if (!next || next.startsWith("--")) throw new Error(`${flag} requires a value.`);
|
|
1454
|
-
return next;
|
|
1455
|
-
}
|
|
1456
|
-
function parseInteger(value, flag) {
|
|
1457
|
-
const parsed = Number(value);
|
|
1458
|
-
if (!Number.isInteger(parsed) || parsed <= 0) throw new Error(`${flag} requires a positive integer.`);
|
|
1459
|
-
return parsed;
|
|
1460
|
-
}
|
|
1461
1312
|
function expandHome(value) {
|
|
1462
1313
|
if (value === "~") return process.env.HOME ?? process.cwd();
|
|
1463
1314
|
if (value.startsWith("~/")) return path.join(process.env.HOME ?? process.cwd(), value.slice(2));
|
|
@@ -1832,14 +1683,15 @@ function cleanOptionalString(value) {
|
|
|
1832
1683
|
//#endregion
|
|
1833
1684
|
//#region src/server/serve.ts
|
|
1834
1685
|
async function startServer(options = {}) {
|
|
1686
|
+
clack.intro("Grok Studio");
|
|
1835
1687
|
const config = loadConfig();
|
|
1836
1688
|
await ensureAuthReady(config);
|
|
1837
1689
|
const { app } = createServer(config);
|
|
1838
1690
|
app.listen(config.port, config.host, () => {
|
|
1839
|
-
const url = `http://${config.host}:${config.port}`;
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1691
|
+
const url = `http://${config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host}:${config.port}`;
|
|
1692
|
+
const access = config.accessToken ? "Access: token required (set in .env)" : "Access: open — no ACCESS_TOKEN set";
|
|
1693
|
+
clack.note(`${url}\n${access}`, "Running");
|
|
1694
|
+
clack.outro("Press Ctrl+C to stop.");
|
|
1843
1695
|
if (options.open) openBrowser(url);
|
|
1844
1696
|
});
|
|
1845
1697
|
}
|
|
@@ -1850,13 +1702,11 @@ async function ensureAuthReady(config) {
|
|
|
1850
1702
|
}
|
|
1851
1703
|
try {
|
|
1852
1704
|
await resolveXaiAuth(config);
|
|
1853
|
-
|
|
1705
|
+
clack.log.success(`xAI OAuth ready (${config.xai.oauthTokenFile})`);
|
|
1854
1706
|
return;
|
|
1855
1707
|
} catch (error) {
|
|
1856
|
-
if (fs.existsSync(config.xai.oauthTokenFile)) {
|
|
1857
|
-
|
|
1858
|
-
console.warn("Starting OAuth login to repair the token state.");
|
|
1859
|
-
} else console.log(`No xAI OAuth token found at ${config.xai.oauthTokenFile}. Starting login.`);
|
|
1708
|
+
if (fs.existsSync(config.xai.oauthTokenFile)) clack.log.warn(`xAI token check failed: ${error instanceof Error ? error.message : String(error)} — re-running login.`);
|
|
1709
|
+
else clack.log.info("No xAI token found — starting login.");
|
|
1860
1710
|
}
|
|
1861
1711
|
await runXaiOauthLogin({ outputPath: config.xai.oauthTokenFile });
|
|
1862
1712
|
await resolveXaiAuth(config);
|
|
@@ -1868,35 +1718,22 @@ function openBrowser(url) {
|
|
|
1868
1718
|
}
|
|
1869
1719
|
//#endregion
|
|
1870
1720
|
//#region src/server/cli.ts
|
|
1871
|
-
var
|
|
1721
|
+
var program = new Command();
|
|
1722
|
+
program.name("grok-studio").description("Self-hosted Grok image-to-video studio (web app + CLI).").version(readVersion(), "-v, --version");
|
|
1723
|
+
program.command("serve", { isDefault: true }).description("Start the web app (ensures xAI auth, then serves HTTP).").option("--open", "open the browser after starting").action(async (opts) => {
|
|
1724
|
+
await startServer({ open: Boolean(opts.open) });
|
|
1725
|
+
});
|
|
1726
|
+
program.command("login").description("Run the xAI OAuth login.").option("--live", "write to the live token file instead of the safe test path").option("--output <path>", "token state file to write").option("--force", "allow replacing an existing token file").option("--check", "verify the selected token file without logging in").option("--no-browser", "print the auth URL and wait instead of opening a browser").option("--print-url-only", "print a disposable auth URL, then exit").option("--port <number>", "local callback port", Number).option("--timeout <seconds>", "login wait timeout", Number).action(async (opts) => {
|
|
1727
|
+
await runLoginCli(opts);
|
|
1728
|
+
});
|
|
1729
|
+
program.command("gen").description("Headlessly turn an image into a video (no UI).").requiredOption("--image <path>", "source image").option("--prompt <text>", "motion prompt").option("--prep", "run a first-frame prep pass before the video").option("--duration <seconds>", "clip length", Number).option("--resolution <res>", "e.g. 720p / 1080p").option("--aspect <ratio>", "e.g. source / 9:16 / 16:9 / 1:1").option("--count <n>", "number of takes", Number).option("--out <path>", "write the result here (count>1 appends -N)").action(async (opts) => {
|
|
1730
|
+
await runGenCli(opts);
|
|
1731
|
+
});
|
|
1732
|
+
program.command("status").description("Print config, xAI auth, and server health.").action(async () => {
|
|
1733
|
+
await runStatus();
|
|
1734
|
+
});
|
|
1872
1735
|
try {
|
|
1873
|
-
|
|
1874
|
-
case void 0:
|
|
1875
|
-
case "serve":
|
|
1876
|
-
await startServer({ open: rest.includes("--open") });
|
|
1877
|
-
break;
|
|
1878
|
-
case "login":
|
|
1879
|
-
await runLoginCli(rest);
|
|
1880
|
-
break;
|
|
1881
|
-
case "gen":
|
|
1882
|
-
await runGenCli(rest);
|
|
1883
|
-
break;
|
|
1884
|
-
case "status":
|
|
1885
|
-
await runStatus();
|
|
1886
|
-
break;
|
|
1887
|
-
case "-v":
|
|
1888
|
-
case "--version":
|
|
1889
|
-
console.log(readVersion());
|
|
1890
|
-
break;
|
|
1891
|
-
case "-h":
|
|
1892
|
-
case "--help":
|
|
1893
|
-
printHelp();
|
|
1894
|
-
break;
|
|
1895
|
-
default:
|
|
1896
|
-
console.error(`Unknown command: ${command}\n`);
|
|
1897
|
-
printHelp();
|
|
1898
|
-
process.exit(1);
|
|
1899
|
-
}
|
|
1736
|
+
await program.parseAsync(process.argv);
|
|
1900
1737
|
} catch (error) {
|
|
1901
1738
|
console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
1902
1739
|
process.exit(1);
|
|
@@ -1930,20 +1767,5 @@ function readVersion() {
|
|
|
1930
1767
|
return "0.0.0";
|
|
1931
1768
|
}
|
|
1932
1769
|
}
|
|
1933
|
-
function printHelp() {
|
|
1934
|
-
console.log(`Grok Studio — self-hosted image-to-video studio
|
|
1935
|
-
|
|
1936
|
-
Usage: grok-studio <command> [options]
|
|
1937
|
-
|
|
1938
|
-
Commands:
|
|
1939
|
-
serve Start the web app (default). Ensures xAI auth, then serves HTTP.
|
|
1940
|
-
--open open the browser after starting
|
|
1941
|
-
login [options] Run the xAI OAuth login (see: grok-studio login --help)
|
|
1942
|
-
gen [options] Headlessly turn an image into a video (see: grok-studio gen --help)
|
|
1943
|
-
status Print config, xAI auth, and server health
|
|
1944
|
-
--version, -v Print the version
|
|
1945
|
-
--help, -h Print this help
|
|
1946
|
-
`);
|
|
1947
|
-
}
|
|
1948
1770
|
//#endregion
|
|
1949
1771
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "grok-studio",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Self-hosted Grok image-to-video studio (web app + CLI).",
|
|
6
6
|
"bin": {
|
|
@@ -35,7 +35,9 @@
|
|
|
35
35
|
"prepack": "vp run build"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
+
"@clack/prompts": "^1.5.0",
|
|
38
39
|
"@xyflow/react": "^12.11.0",
|
|
40
|
+
"commander": "^15.0.0",
|
|
39
41
|
"dotenv": "^17.2.3",
|
|
40
42
|
"express": "^5.2.1",
|
|
41
43
|
"lucide-react": "^0.561.0",
|