hyper-animator-codex 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -0
- package/package.json +1 -1
- package/skills/hyper-animator-codex/SKILL.md +18 -14
- package/skills/hyper-animator-codex/references/git-checkpoint-workflow.md +87 -0
- package/skills/hyper-animator-codex/references/minimax-music-workflow.md +11 -0
- package/skills/hyper-animator-codex/scripts/generate_minimax_music.mjs +279 -82
- package/skills/hyper-animator-codex/scripts/git_checkpoint.mjs +524 -0
package/README.md
CHANGED
|
@@ -37,6 +37,21 @@ Use a custom Codex skills directory:
|
|
|
37
37
|
npx hyper-animator-codex install --target /path/to/codex/skills
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
+
## Git Checkpoints
|
|
41
|
+
|
|
42
|
+
During `/hyper-animator` runs, the installed skill initializes a normal Git repository in a new output directory and creates rollback-friendly stage commits. It does not create branches or worktrees.
|
|
43
|
+
|
|
44
|
+
The bundled helper can be run from an installed skill directory or repository checkout:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
node skills/hyper-animator-codex/scripts/git_checkpoint.mjs \
|
|
48
|
+
--phase init \
|
|
49
|
+
--message "chore: initialize hyper animator workspace" \
|
|
50
|
+
--allow-empty
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Existing Git repositories with uncommitted changes are protected: the helper stops instead of mixing user edits into automatic commits. Large video and audio files above 10 MB are skipped by default and recorded in a manifest under `hyper-animator-output/git-checkpoints/`.
|
|
54
|
+
|
|
40
55
|
## MiniMax Music Configuration
|
|
41
56
|
|
|
42
57
|
MiniMax is the preferred generated-background-music provider. Configure it during install so the copied Codex skill can generate music without asking for credentials later:
|
|
@@ -93,6 +108,7 @@ When rendering video, hide preview controls with any one of:
|
|
|
93
108
|
|
|
94
109
|
- `skills/hyper-animator-codex/SKILL.md`: Codex skill instructions.
|
|
95
110
|
- `skills/hyper-animator-codex/references/`: HyperFrames catalog map, workflow guide, pseudocode, and request examples.
|
|
111
|
+
- `skills/hyper-animator-codex/scripts/git_checkpoint.mjs`: Git initialization and stage-commit helper for `/hyper-animator` runs.
|
|
96
112
|
- `skills/hyper-animator-codex/scripts/validate_hyperframes_html.py`: static pre-render HTML quality gate.
|
|
97
113
|
|
|
98
114
|
## Optional Beat Detection
|
package/package.json
CHANGED
|
@@ -11,22 +11,24 @@ Turn a natural-language animation or video brief into a validated HyperFrames HT
|
|
|
11
11
|
|
|
12
12
|
## Required Flow
|
|
13
13
|
|
|
14
|
-
1.
|
|
15
|
-
2.
|
|
16
|
-
3.
|
|
17
|
-
4.
|
|
18
|
-
5.
|
|
14
|
+
1. Read `references/git-checkpoint-workflow.md` and run `scripts/git_checkpoint.mjs --phase init --message "chore: initialize hyper animator workspace" --allow-empty` before requirement capture. If the helper reports an existing dirty repository or linked worktree, stop and ask the user to resolve it.
|
|
15
|
+
2. Capture the raw request and extract an initial intent profile: purpose, format, duration, content inputs, style tags, motion tags, and needed roles.
|
|
16
|
+
3. If purpose, format, or core content is unclear, ask the first clarification round before choosing catalog items.
|
|
17
|
+
4. Read `references/hyperframes-catalog-map.json` and score candidates by keyword, intent domain, format, role, style, motion, and constraints.
|
|
18
|
+
5. Pick candidate blocks/components for main scene, caption, effects, transitions, and outro.
|
|
19
|
+
6. Decide generation mode:
|
|
19
20
|
- `generate_new_hyperframes_html` when the user asks to write HTML, create a new effect, customize style, match a brand, use complex animation, or when component snippets are only paste placeholders.
|
|
20
21
|
- `assemble_existing_catalog_items` only when the user asks to use existing catalog items or quickly compose installed blocks/components.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
22
|
+
7. Clarify audio: ask whether to add animation/transition sound effects and whether to add background music.
|
|
23
|
+
8. If background music is requested or undecided, read `references/minimax-music-workflow.md` and use `scripts/generate_minimax_music.mjs` for every MiniMax request. Do not construct MiniMax HTTP calls directly. If MiniMax is unavailable or declined, ask for a local audio path or continue without BGM.
|
|
24
|
+
9. If background music is used, read `references/beat-sync-workflow.md`, generate or obtain the audio, and run `scripts/analyze_music_beats.py` when the file is available.
|
|
25
|
+
10. Ask the second clarification round with candidate context: visual direction, motion rhythm, generation mode, audio choices, music prompt/model when MiniMax is used, and beat-sync assumptions when background music is present.
|
|
26
|
+
11. Write or assemble HTML. When beat-sync is enabled, align major reveals, cuts, transitions, camera moves, and visual accents to the beat map instead of arbitrary timestamps.
|
|
27
|
+
12. After each file-writing stage, run `scripts/git_checkpoint.mjs` with the phase-specific allowlist from `references/git-checkpoint-workflow.md`. Do not create, switch, or recommend Git branches or worktrees.
|
|
28
|
+
13. Run pre-render quality gates on the base HTML.
|
|
29
|
+
14. Read `references/preview-controls-workflow.md`, run `scripts/inject_preview_controls.mjs` to create a preview HTML copy, and run `scripts/validate_hyperframes_html.py preview.html --preview-controls`.
|
|
30
|
+
15. Show a concise plan summary and preview path or HTML file to the user. Ask for confirmation before video render.
|
|
31
|
+
16. Render only after user confirmation. Prefer rendering the base HTML; if rendering the preview copy, use `?render=1`, `?preview=0`, or `<html data-render-mode="video">` so preview controls are hidden. Then report output path and any caveats.
|
|
30
32
|
|
|
31
33
|
## Interactive Questions
|
|
32
34
|
|
|
@@ -41,6 +43,7 @@ Do not ask everything upfront when the brief is already specific. Ask only for m
|
|
|
41
43
|
|
|
42
44
|
## References
|
|
43
45
|
|
|
46
|
+
- Read `references/git-checkpoint-workflow.md` before any `/hyper-animator` run writes files or initializes output.
|
|
44
47
|
- Read `references/hyperframes-intent-workflow.md` for the full AskUserQuestion workflow, generation-mode rules, scoring model, and render confirmation requirements.
|
|
45
48
|
- Read `references/hyperframes-catalog-map.json` whenever selecting catalog candidates or determining visual references.
|
|
46
49
|
- Read `references/hyperframes-agent-pseudocode.ts` when implementing the end-to-end loop or when the correct sequence is ambiguous.
|
|
@@ -102,5 +105,6 @@ Before rendering, summarize:
|
|
|
102
105
|
- content assumptions;
|
|
103
106
|
- preview location;
|
|
104
107
|
- validator result.
|
|
108
|
+
- Git checkpoint status, latest commit hash, skipped large media manifest path, and any dirty-worktree caveats;
|
|
105
109
|
|
|
106
110
|
Ask the user whether to render or revise. If rendering tools or project-specific commands are unavailable, stop at validated HTML and report the exact missing render command or dependency.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Git Checkpoint Workflow
|
|
2
|
+
|
|
3
|
+
Use this workflow whenever `/hyper-animator` writes files. The goal is rollback-friendly stage commits without creating branches or worktrees.
|
|
4
|
+
|
|
5
|
+
## Start Of Run
|
|
6
|
+
|
|
7
|
+
Run this before requirement capture or file generation:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
node scripts/git_checkpoint.mjs \
|
|
11
|
+
--phase init \
|
|
12
|
+
--message "chore: initialize hyper animator workspace" \
|
|
13
|
+
--allow-empty
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
If the helper reports an existing repository with uncommitted changes, stop and ask the user to resolve those files before continuing.
|
|
17
|
+
|
|
18
|
+
## Phase Commits
|
|
19
|
+
|
|
20
|
+
Use explicit allowlists for every checkpoint:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
node scripts/git_checkpoint.mjs \
|
|
24
|
+
--phase brief \
|
|
25
|
+
--message "feat: capture animation brief" \
|
|
26
|
+
--allow "hyper-animator-output/brief/**" \
|
|
27
|
+
--allow "hyper-animator-output/catalog/**"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
node scripts/git_checkpoint.mjs \
|
|
32
|
+
--phase base-html \
|
|
33
|
+
--message "feat: create base animation html" \
|
|
34
|
+
--allow "*.html" \
|
|
35
|
+
--allow "hyper-animator-output/html/**" \
|
|
36
|
+
--allow "hyper-animator-output/assets/**"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
node scripts/git_checkpoint.mjs \
|
|
41
|
+
--phase audio \
|
|
42
|
+
--message "feat: add audio assets" \
|
|
43
|
+
--allow "hyper-animator-output/music/**" \
|
|
44
|
+
--allow "hyper-animator-output/beat-maps/**"
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
node scripts/git_checkpoint.mjs \
|
|
49
|
+
--phase preview \
|
|
50
|
+
--message "feat: add preview controls" \
|
|
51
|
+
--allow "*.preview.html" \
|
|
52
|
+
--allow "hyper-animator-output/preview/**"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
node scripts/git_checkpoint.mjs \
|
|
57
|
+
--phase render \
|
|
58
|
+
--message "chore: render animation video" \
|
|
59
|
+
--allow "hyper-animator-output/render/**" \
|
|
60
|
+
--allow "*.mp4" \
|
|
61
|
+
--allow "*.webm"
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
For preview feedback revisions:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
node scripts/git_checkpoint.mjs \
|
|
68
|
+
--phase refine \
|
|
69
|
+
--message "refine: update animation from preview feedback" \
|
|
70
|
+
--allow "*.html" \
|
|
71
|
+
--allow "hyper-animator-output/**"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Media Policy
|
|
75
|
+
|
|
76
|
+
The default large media threshold is 10 MB. Large audio/video files are skipped unless `--include-large-media` is passed. When a file is skipped, the helper commits a manifest under:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
hyper-animator-output/git-checkpoints/<phase>-skipped-media.json
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Hard Rules
|
|
83
|
+
|
|
84
|
+
- Do not run `git switch`, `git checkout -b`, `git branch`, or `git worktree add`.
|
|
85
|
+
- Do not create or recommend branches or worktrees for `/hyper-animator` output.
|
|
86
|
+
- Do not commit MiniMax credentials, `.firecrawl/`, `node_modules/`, or render caches.
|
|
87
|
+
- If unrelated dirty files are reported, stop and ask the user before continuing.
|
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
Use this when the user wants generated background music or asks whether Hyper Animator Codex can create music for an animation.
|
|
4
4
|
|
|
5
|
+
## Script-Only Rule
|
|
6
|
+
|
|
7
|
+
Do not hand-assemble MiniMax HTTP requests in agent reasoning or shell commands. Always call `scripts/generate_minimax_music.mjs`. The script owns request validation, retry policy, error normalization, audio writing, metadata writing, and secret redaction.
|
|
8
|
+
|
|
5
9
|
## MiniMax Preference
|
|
6
10
|
|
|
7
11
|
Prefer MiniMax before other background-music sources when `config/minimax.json` exists in the installed skill or `MINIMAX_API_KEY` and `MINIMAX_GROUP_ID` are present in the environment.
|
|
@@ -60,6 +64,13 @@ node scripts/generate_minimax_music.mjs \
|
|
|
60
64
|
|
|
61
65
|
Use `--dry-run` before a real call when checking config, request shape, or model choice.
|
|
62
66
|
|
|
67
|
+
To generate a request JSON file for a scripted call:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
node scripts/generate_minimax_music.mjs \
|
|
71
|
+
--request-json hyper-animator-output/music/minimax-request.json
|
|
72
|
+
```
|
|
73
|
+
|
|
63
74
|
## Beat Sync
|
|
64
75
|
|
|
65
76
|
After music generation succeeds, analyze the generated audio:
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
validateMinimaxConfig,
|
|
11
11
|
} from "./minimax_runtime_config.mjs";
|
|
12
12
|
|
|
13
|
-
const
|
|
13
|
+
const DEFAULT_ENDPOINT = "https://api.minimaxi.com/v1/music_generation";
|
|
14
14
|
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const skillRoot = dirname(scriptDir);
|
|
16
16
|
|
|
@@ -28,15 +28,16 @@ function parseArgs(args) {
|
|
|
28
28
|
const parsed = {
|
|
29
29
|
prompt: undefined,
|
|
30
30
|
lyrics: undefined,
|
|
31
|
-
lyrics_optimizer:
|
|
32
|
-
is_instrumental:
|
|
33
|
-
output_dir:
|
|
34
|
-
output_format:
|
|
35
|
-
audio_format:
|
|
36
|
-
sample_rate:
|
|
37
|
-
bitrate:
|
|
31
|
+
lyrics_optimizer: undefined,
|
|
32
|
+
is_instrumental: undefined,
|
|
33
|
+
output_dir: undefined,
|
|
34
|
+
output_format: undefined,
|
|
35
|
+
audio_format: undefined,
|
|
36
|
+
sample_rate: undefined,
|
|
37
|
+
bitrate: undefined,
|
|
38
38
|
model: undefined,
|
|
39
39
|
config_path: undefined,
|
|
40
|
+
request_json: undefined,
|
|
40
41
|
dry_run: false,
|
|
41
42
|
};
|
|
42
43
|
|
|
@@ -76,6 +77,9 @@ function parseArgs(args) {
|
|
|
76
77
|
} else if (arg === "--config") {
|
|
77
78
|
parsed.config_path = requireValue(args, index, arg);
|
|
78
79
|
index += 1;
|
|
80
|
+
} else if (arg === "--request-json") {
|
|
81
|
+
parsed.request_json = requireValue(args, index, arg);
|
|
82
|
+
index += 1;
|
|
79
83
|
} else if (arg === "--dry-run") {
|
|
80
84
|
parsed.dry_run = true;
|
|
81
85
|
} else if (arg === "--help" || arg === "-h") {
|
|
@@ -105,41 +109,108 @@ Options:
|
|
|
105
109
|
--bitrate <bits> 32000, 64000, 128000, or 256000; default 256000
|
|
106
110
|
--model <model> music-2.6 or music-2.6-free
|
|
107
111
|
--config <file> Explicit MiniMax config JSON
|
|
112
|
+
--request-json <file> Read request options from a JSON file
|
|
108
113
|
--dry-run Print redacted request without contacting MiniMax
|
|
109
114
|
`);
|
|
110
115
|
}
|
|
111
116
|
|
|
117
|
+
async function readRequestJson(path) {
|
|
118
|
+
if (!path) {
|
|
119
|
+
return {};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
123
|
+
|
|
124
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
125
|
+
throw Object.assign(new Error(`--request-json must contain a JSON object: ${path}`), {
|
|
126
|
+
kind: "validation",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return parsed;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function mergeOptions(jsonOptions, cliOptions) {
|
|
134
|
+
return {
|
|
135
|
+
...cliOptions,
|
|
136
|
+
prompt: cliOptions.prompt ?? jsonOptions.prompt,
|
|
137
|
+
lyrics: cliOptions.lyrics ?? jsonOptions.lyrics,
|
|
138
|
+
lyrics_optimizer: cliOptions.lyrics_optimizer ?? Boolean(jsonOptions.lyrics_optimizer),
|
|
139
|
+
is_instrumental: cliOptions.is_instrumental ?? jsonOptions.is_instrumental ?? true,
|
|
140
|
+
output_dir: cliOptions.output_dir ?? jsonOptions.output_dir ?? join(process.cwd(), "hyper-animator-output", "music"),
|
|
141
|
+
output_format: cliOptions.output_format ?? jsonOptions.output_format ?? "hex",
|
|
142
|
+
audio_format: cliOptions.audio_format ?? jsonOptions.audio_format ?? "mp3",
|
|
143
|
+
sample_rate: cliOptions.sample_rate ?? jsonOptions.sample_rate ?? 44100,
|
|
144
|
+
bitrate: cliOptions.bitrate ?? jsonOptions.bitrate ?? 256000,
|
|
145
|
+
model: cliOptions.model ?? jsonOptions.model,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function endpointFromEnv(env = process.env) {
|
|
150
|
+
return env.MINIMAX_MUSIC_ENDPOINT || DEFAULT_ENDPOINT;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function timeoutFromEnv(env = process.env) {
|
|
154
|
+
const value = Number.parseInt(env.MINIMAX_REQUEST_TIMEOUT_MS || "60000", 10);
|
|
155
|
+
return Number.isFinite(value) && value > 0 ? value : 60000;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function makeErrorEnvelope(error, attempts = 0) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
provider: "minimax",
|
|
162
|
+
error: {
|
|
163
|
+
kind: error.kind || "validation",
|
|
164
|
+
message: error.message,
|
|
165
|
+
retryable: Boolean(error.retryable),
|
|
166
|
+
http_status: error.http_status ?? null,
|
|
167
|
+
status_code: error.status_code ?? null,
|
|
168
|
+
},
|
|
169
|
+
attempts,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function minimaxError(message, fields = {}) {
|
|
174
|
+
const error = new Error(message);
|
|
175
|
+
Object.assign(error, fields);
|
|
176
|
+
return error;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function printJson(payload) {
|
|
180
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
181
|
+
}
|
|
182
|
+
|
|
112
183
|
function validateOptions(options) {
|
|
113
184
|
if (!options.prompt || options.prompt.trim().length === 0) {
|
|
114
|
-
throw new Error("--prompt is required");
|
|
185
|
+
throw Object.assign(new Error("--prompt is required"), { kind: "validation" });
|
|
115
186
|
}
|
|
116
187
|
|
|
117
188
|
if (options.prompt.length > 2000) {
|
|
118
|
-
throw new Error("--prompt must be 2000 characters or fewer");
|
|
189
|
+
throw Object.assign(new Error("--prompt must be 2000 characters or fewer"), { kind: "validation" });
|
|
119
190
|
}
|
|
120
191
|
|
|
121
192
|
if (!["hex", "url"].includes(options.output_format)) {
|
|
122
|
-
throw new Error("--output-format must be hex or url");
|
|
193
|
+
throw Object.assign(new Error("--output-format must be hex or url"), { kind: "validation" });
|
|
123
194
|
}
|
|
124
195
|
|
|
125
196
|
if (!["mp3", "wav", "pcm"].includes(options.audio_format)) {
|
|
126
|
-
throw new Error("--format must be mp3, wav, or pcm");
|
|
197
|
+
throw Object.assign(new Error("--format must be mp3, wav, or pcm"), { kind: "validation" });
|
|
127
198
|
}
|
|
128
199
|
|
|
129
200
|
if (![16000, 24000, 32000, 44100].includes(options.sample_rate)) {
|
|
130
|
-
throw new Error("--sample-rate must be 16000, 24000, 32000, or 44100");
|
|
201
|
+
throw Object.assign(new Error("--sample-rate must be 16000, 24000, 32000, or 44100"), { kind: "validation" });
|
|
131
202
|
}
|
|
132
203
|
|
|
133
204
|
if (![32000, 64000, 128000, 256000].includes(options.bitrate)) {
|
|
134
|
-
throw new Error("--bitrate must be 32000, 64000, 128000, or 256000");
|
|
205
|
+
throw Object.assign(new Error("--bitrate must be 32000, 64000, 128000, or 256000"), { kind: "validation" });
|
|
135
206
|
}
|
|
136
207
|
|
|
137
208
|
if (!options.is_instrumental && !options.lyrics && !options.lyrics_optimizer) {
|
|
138
|
-
throw new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer");
|
|
209
|
+
throw Object.assign(new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer"), { kind: "validation" });
|
|
139
210
|
}
|
|
140
211
|
|
|
141
212
|
if (options.lyrics && options.lyrics.length > 3500) {
|
|
142
|
-
throw new Error("--lyrics must be 3500 characters or fewer");
|
|
213
|
+
throw Object.assign(new Error("--lyrics must be 3500 characters or fewer"), { kind: "validation" });
|
|
143
214
|
}
|
|
144
215
|
}
|
|
145
216
|
|
|
@@ -202,48 +273,148 @@ function decodeHexAudio(value) {
|
|
|
202
273
|
return Buffer.from(value, "hex");
|
|
203
274
|
}
|
|
204
275
|
|
|
205
|
-
|
|
206
|
-
|
|
276
|
+
function classifyHttpStatus(status) {
|
|
277
|
+
if (status === 401 || status === 403) {
|
|
278
|
+
return { kind: "auth", retryable: false };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (status === 429) {
|
|
282
|
+
return { kind: "rate_limit", retryable: true };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (status >= 500) {
|
|
286
|
+
return { kind: "server", retryable: true };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { kind: "response", retryable: false };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function fetchWithTimeout(url, options, timeoutMs) {
|
|
293
|
+
if (!URL.canParse(url)) {
|
|
294
|
+
throw minimaxError(`MiniMax endpoint is not a valid URL: ${url}`, {
|
|
295
|
+
kind: "validation",
|
|
296
|
+
retryable: false,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const controller = new AbortController();
|
|
301
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
return await fetch(url, { ...options, signal: controller.signal });
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error?.name === "AbortError") {
|
|
307
|
+
throw minimaxError("MiniMax request timed out", { kind: "timeout", retryable: true });
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (typeof error?.cause?.code === "string") {
|
|
311
|
+
throw minimaxError(`MiniMax network error: ${error.message}`, { kind: "network", retryable: true });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
throw minimaxError(`MiniMax request setup failed: ${error.message}`, {
|
|
315
|
+
kind: "response",
|
|
316
|
+
retryable: false,
|
|
317
|
+
});
|
|
318
|
+
} finally {
|
|
319
|
+
clearTimeout(timeout);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function callMiniMax(config, request, { endpoint, timeoutMs }) {
|
|
324
|
+
const response = await fetchWithTimeout(endpoint, {
|
|
207
325
|
method: "POST",
|
|
208
326
|
headers: {
|
|
209
327
|
"Content-Type": "application/json",
|
|
210
328
|
Authorization: `Bearer ${config.api_key}`,
|
|
211
329
|
},
|
|
212
330
|
body: JSON.stringify(request),
|
|
213
|
-
});
|
|
331
|
+
}, timeoutMs);
|
|
214
332
|
|
|
215
333
|
const text = await response.text();
|
|
216
|
-
let json;
|
|
334
|
+
let json = null;
|
|
335
|
+
|
|
336
|
+
if (!response.ok) {
|
|
337
|
+
if (text) {
|
|
338
|
+
try {
|
|
339
|
+
json = JSON.parse(text);
|
|
340
|
+
} catch {
|
|
341
|
+
json = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const message = json?.base_resp?.status_msg || `HTTP ${response.status}`;
|
|
346
|
+
throw minimaxError(`MiniMax request failed: ${message}`, {
|
|
347
|
+
...classifyHttpStatus(response.status),
|
|
348
|
+
http_status: response.status,
|
|
349
|
+
status_code: json?.base_resp?.status_code ?? null,
|
|
350
|
+
});
|
|
351
|
+
}
|
|
217
352
|
|
|
218
353
|
try {
|
|
219
354
|
json = JSON.parse(text);
|
|
220
355
|
} catch (error) {
|
|
221
|
-
throw
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
throw new Error(`MiniMax request failed: ${message}`);
|
|
356
|
+
throw minimaxError(`MiniMax returned non-JSON response with HTTP ${response.status}`, {
|
|
357
|
+
kind: "response",
|
|
358
|
+
retryable: false,
|
|
359
|
+
http_status: response.status,
|
|
360
|
+
});
|
|
227
361
|
}
|
|
228
362
|
|
|
229
363
|
const statusCode = json.base_resp?.status_code;
|
|
230
364
|
if (statusCode !== 0) {
|
|
231
365
|
const message = json.base_resp?.status_msg || "unknown MiniMax error";
|
|
232
|
-
throw
|
|
366
|
+
throw minimaxError(`MiniMax request failed with status_code ${statusCode}: ${message}`, {
|
|
367
|
+
kind: "provider",
|
|
368
|
+
retryable: false,
|
|
369
|
+
http_status: response.status,
|
|
370
|
+
status_code: statusCode,
|
|
371
|
+
});
|
|
233
372
|
}
|
|
234
373
|
|
|
235
374
|
if (json.data?.status !== 2) {
|
|
236
|
-
throw
|
|
375
|
+
throw minimaxError(`MiniMax generation is not complete; data.status is ${json.data?.status}`, {
|
|
376
|
+
kind: "provider",
|
|
377
|
+
retryable: false,
|
|
378
|
+
http_status: response.status,
|
|
379
|
+
status_code: statusCode,
|
|
380
|
+
});
|
|
237
381
|
}
|
|
238
382
|
|
|
239
383
|
if (!json.data?.audio) {
|
|
240
|
-
throw
|
|
384
|
+
throw minimaxError("MiniMax response did not include data.audio", {
|
|
385
|
+
kind: "provider",
|
|
386
|
+
retryable: false,
|
|
387
|
+
http_status: response.status,
|
|
388
|
+
status_code: statusCode,
|
|
389
|
+
});
|
|
241
390
|
}
|
|
242
391
|
|
|
243
392
|
return json;
|
|
244
393
|
}
|
|
245
394
|
|
|
246
|
-
async function
|
|
395
|
+
async function callMiniMaxWithRetries(config, request, { endpoint, timeoutMs, maxRetries = 2 }) {
|
|
396
|
+
let attempts = 0;
|
|
397
|
+
|
|
398
|
+
while (attempts <= maxRetries) {
|
|
399
|
+
attempts += 1;
|
|
400
|
+
|
|
401
|
+
try {
|
|
402
|
+
return {
|
|
403
|
+
responseJson: await callMiniMax(config, request, { endpoint, timeoutMs }),
|
|
404
|
+
attempts,
|
|
405
|
+
};
|
|
406
|
+
} catch (error) {
|
|
407
|
+
if (!error.retryable || attempts > maxRetries) {
|
|
408
|
+
error.attempts = attempts;
|
|
409
|
+
throw error;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
throw minimaxError("MiniMax request failed", { kind: "network", retryable: true, attempts });
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function writeAudioOutput({ responseJson, request, outputDir, prompt, endpoint }) {
|
|
247
418
|
await mkdir(outputDir, { recursive: true });
|
|
248
419
|
|
|
249
420
|
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
@@ -261,7 +432,7 @@ async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
|
|
|
261
432
|
metadataPath,
|
|
262
433
|
`${JSON.stringify({
|
|
263
434
|
provider: "minimax",
|
|
264
|
-
endpoint
|
|
435
|
+
endpoint,
|
|
265
436
|
request,
|
|
266
437
|
response: {
|
|
267
438
|
base_resp: responseJson.base_resp,
|
|
@@ -283,64 +454,90 @@ async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
|
|
|
283
454
|
}
|
|
284
455
|
|
|
285
456
|
async function main() {
|
|
286
|
-
|
|
457
|
+
try {
|
|
458
|
+
const cliOptions = parseArgs(process.argv.slice(2));
|
|
459
|
+
const endpoint = endpointFromEnv();
|
|
460
|
+
const timeoutMs = timeoutFromEnv();
|
|
287
461
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
462
|
+
if (cliOptions.help) {
|
|
463
|
+
printHelp();
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
292
466
|
|
|
293
|
-
|
|
467
|
+
const requestJson = await readRequestJson(cliOptions.request_json);
|
|
468
|
+
const options = mergeOptions(requestJson, cliOptions);
|
|
294
469
|
|
|
295
|
-
|
|
296
|
-
skillRoot,
|
|
297
|
-
env: process.env,
|
|
298
|
-
configPath: options.config_path,
|
|
299
|
-
});
|
|
470
|
+
validateOptions(options);
|
|
300
471
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
472
|
+
let runtime;
|
|
473
|
+
try {
|
|
474
|
+
runtime = await readMinimaxRuntimeConfig({
|
|
475
|
+
skillRoot,
|
|
476
|
+
env: process.env,
|
|
477
|
+
configPath: options.config_path,
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
throw Object.assign(error, { kind: "config", retryable: false });
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (!runtime) {
|
|
484
|
+
throw Object.assign(new Error("MiniMax config not found. Run hyper-animator-codex install with --minimax-api-key and --minimax-group-id, or set MINIMAX_API_KEY and MINIMAX_GROUP_ID."), {
|
|
485
|
+
kind: "config",
|
|
486
|
+
retryable: false,
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
let request;
|
|
491
|
+
try {
|
|
492
|
+
request = buildRequest(options, runtime.config);
|
|
493
|
+
} catch (error) {
|
|
494
|
+
throw Object.assign(error, { kind: "config", retryable: false });
|
|
495
|
+
}
|
|
496
|
+
const redactedConfig = redactMinimaxConfig(runtime.config);
|
|
497
|
+
|
|
498
|
+
if (options.dry_run) {
|
|
499
|
+
printJson({
|
|
500
|
+
ok: true,
|
|
501
|
+
dry_run: true,
|
|
502
|
+
provider: "minimax",
|
|
503
|
+
endpoint,
|
|
504
|
+
config: {
|
|
505
|
+
source: runtime.source,
|
|
506
|
+
path: runtime.configPath,
|
|
507
|
+
redacted: redactedConfig,
|
|
508
|
+
},
|
|
509
|
+
request_path: cliOptions.request_json,
|
|
510
|
+
request,
|
|
511
|
+
output_dir: options.output_dir,
|
|
512
|
+
});
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
304
515
|
|
|
305
|
-
|
|
306
|
-
|
|
516
|
+
const { responseJson, attempts } = await callMiniMaxWithRetries(runtime.config, request, {
|
|
517
|
+
endpoint,
|
|
518
|
+
timeoutMs,
|
|
519
|
+
});
|
|
520
|
+
const output = await writeAudioOutput({
|
|
521
|
+
responseJson,
|
|
522
|
+
request,
|
|
523
|
+
outputDir: options.output_dir,
|
|
524
|
+
prompt: options.prompt,
|
|
525
|
+
endpoint,
|
|
526
|
+
});
|
|
307
527
|
|
|
308
|
-
|
|
309
|
-
console.log(JSON.stringify({
|
|
528
|
+
printJson({
|
|
310
529
|
ok: true,
|
|
311
|
-
dry_run: true,
|
|
312
530
|
provider: "minimax",
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
return;
|
|
531
|
+
model: request.model,
|
|
532
|
+
attempts,
|
|
533
|
+
output_path: output.audioPath,
|
|
534
|
+
metadata_path: output.metadataPath,
|
|
535
|
+
beat_analysis_command: `python3 scripts/analyze_music_beats.py ${output.audioPath} -o ${join(options.output_dir, "beat-map.json")} --fps 60 --pretty`,
|
|
536
|
+
});
|
|
537
|
+
} catch (error) {
|
|
538
|
+
printJson(makeErrorEnvelope(error, error.attempts ?? 0));
|
|
539
|
+
process.exitCode = 1;
|
|
323
540
|
}
|
|
324
|
-
|
|
325
|
-
const responseJson = await callMiniMax(runtime.config, request);
|
|
326
|
-
const output = await writeAudioOutput({
|
|
327
|
-
responseJson,
|
|
328
|
-
request,
|
|
329
|
-
outputDir: options.output_dir,
|
|
330
|
-
prompt: options.prompt,
|
|
331
|
-
});
|
|
332
|
-
|
|
333
|
-
console.log(JSON.stringify({
|
|
334
|
-
ok: true,
|
|
335
|
-
provider: "minimax",
|
|
336
|
-
model: request.model,
|
|
337
|
-
output_path: output.audioPath,
|
|
338
|
-
metadata_path: output.metadataPath,
|
|
339
|
-
beat_analysis_command: `python3 scripts/analyze_music_beats.py ${output.audioPath} -o ${join(options.output_dir, "beat-map.json")} --fps 60 --pretty`,
|
|
340
|
-
}, null, 2));
|
|
341
541
|
}
|
|
342
542
|
|
|
343
|
-
main()
|
|
344
|
-
console.error(`Error: ${error.message}`);
|
|
345
|
-
process.exitCode = 1;
|
|
346
|
-
});
|
|
543
|
+
main();
|
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const FALLBACK_USER_NAME = "Hyper Animator Codex";
|
|
7
|
+
const FALLBACK_USER_EMAIL = "hyper-animator-codex@local";
|
|
8
|
+
const DEFAULT_MAX_BINARY_MB = 10;
|
|
9
|
+
const SKIPPED_MANIFEST_DIR = join("hyper-animator-output", "git-checkpoints");
|
|
10
|
+
const MEDIA_EXTENSIONS = new Set([
|
|
11
|
+
".mp4",
|
|
12
|
+
".mov",
|
|
13
|
+
".webm",
|
|
14
|
+
".mkv",
|
|
15
|
+
".avi",
|
|
16
|
+
".m4v",
|
|
17
|
+
".mp3",
|
|
18
|
+
".wav",
|
|
19
|
+
".m4a",
|
|
20
|
+
".aac",
|
|
21
|
+
".flac",
|
|
22
|
+
".ogg",
|
|
23
|
+
".wma",
|
|
24
|
+
]);
|
|
25
|
+
const DEFAULT_EXCLUDES = [
|
|
26
|
+
".firecrawl/",
|
|
27
|
+
".gitconfig-global",
|
|
28
|
+
".git/",
|
|
29
|
+
"node_modules/",
|
|
30
|
+
"config/minimax.json",
|
|
31
|
+
"skills/hyper-animator-codex/config/minimax.json",
|
|
32
|
+
"tmp/",
|
|
33
|
+
".tmp/",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
let repositoryRootCache = null;
|
|
37
|
+
let gitPrefixCache = null;
|
|
38
|
+
|
|
39
|
+
function requireValue(argv, index, flag) {
|
|
40
|
+
const value = argv[index + 1];
|
|
41
|
+
|
|
42
|
+
if (!value || value.startsWith("--")) {
|
|
43
|
+
throw new Error(`${flag} requires a value`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const options = {
|
|
51
|
+
phase: "",
|
|
52
|
+
message: "",
|
|
53
|
+
allow: [],
|
|
54
|
+
exclude: [],
|
|
55
|
+
maxBinaryMb: DEFAULT_MAX_BINARY_MB,
|
|
56
|
+
includeLargeMedia: false,
|
|
57
|
+
allowEmpty: false,
|
|
58
|
+
dryRun: false,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
62
|
+
const arg = argv[index];
|
|
63
|
+
|
|
64
|
+
if (arg === "--phase") {
|
|
65
|
+
options.phase = requireValue(argv, index, arg);
|
|
66
|
+
index += 1;
|
|
67
|
+
} else if (arg === "--message") {
|
|
68
|
+
options.message = requireValue(argv, index, arg);
|
|
69
|
+
index += 1;
|
|
70
|
+
} else if (arg === "--allow") {
|
|
71
|
+
options.allow.push(requireValue(argv, index, arg));
|
|
72
|
+
index += 1;
|
|
73
|
+
} else if (arg === "--exclude") {
|
|
74
|
+
options.exclude.push(requireValue(argv, index, arg));
|
|
75
|
+
index += 1;
|
|
76
|
+
} else if (arg === "--max-binary-mb") {
|
|
77
|
+
options.maxBinaryMb = Number(requireValue(argv, index, arg));
|
|
78
|
+
index += 1;
|
|
79
|
+
} else if (arg === "--include-large-media") {
|
|
80
|
+
options.includeLargeMedia = true;
|
|
81
|
+
} else if (arg === "--allow-empty") {
|
|
82
|
+
options.allowEmpty = true;
|
|
83
|
+
} else if (arg === "--dry-run") {
|
|
84
|
+
options.dryRun = true;
|
|
85
|
+
} else {
|
|
86
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!options.phase) {
|
|
91
|
+
throw new Error("--phase is required");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!options.message) {
|
|
95
|
+
throw new Error("--message is required");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!Number.isFinite(options.maxBinaryMb) || options.maxBinaryMb < 0) {
|
|
99
|
+
throw new Error("--max-binary-mb must be a non-negative number");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return options;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function git(args, { check = true } = {}) {
|
|
106
|
+
const result = spawnSync("git", args, {
|
|
107
|
+
cwd: process.cwd(),
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
if (check && result.status !== 0) {
|
|
112
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `git ${args.join(" ")}`;
|
|
113
|
+
throw new Error(detail);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeGitPath(value) {
|
|
120
|
+
return value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function repositoryRoot() {
|
|
124
|
+
if (repositoryRootCache !== null) {
|
|
125
|
+
return repositoryRootCache;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const result = git(["rev-parse", "--show-toplevel"], { check: false });
|
|
129
|
+
repositoryRootCache = result.status === 0 && result.stdout.trim() ? result.stdout.trim() : process.cwd();
|
|
130
|
+
|
|
131
|
+
return repositoryRootCache;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function gitPrefix() {
|
|
135
|
+
if (gitPrefixCache !== null) {
|
|
136
|
+
return gitPrefixCache;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const result = git(["rev-parse", "--show-prefix"], { check: false });
|
|
140
|
+
gitPrefixCache = result.status === 0 ? normalizeGitPath(result.stdout) : "";
|
|
141
|
+
|
|
142
|
+
return gitPrefixCache;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function cwdRelativeToGitPath(path) {
|
|
146
|
+
const prefix = gitPrefix();
|
|
147
|
+
|
|
148
|
+
return normalizeGitPath(prefix ? join(prefix, path) : path);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function resolveGitDirPath(value) {
|
|
152
|
+
const trimmed = value.trim();
|
|
153
|
+
const absolute = isAbsolute(trimmed) ? trimmed : resolve(process.cwd(), trimmed);
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
return normalizeGitPath(realpathSync(absolute));
|
|
157
|
+
} catch {
|
|
158
|
+
return normalizeGitPath(absolute);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isDefaultExcluded(path) {
|
|
163
|
+
const normalized = normalizeGitPath(path);
|
|
164
|
+
|
|
165
|
+
return DEFAULT_EXCLUDES.some((entry) => {
|
|
166
|
+
const cleaned = entry.endsWith("/") ? entry.slice(0, -1) : entry;
|
|
167
|
+
return normalized === cleaned || normalized.startsWith(`${cleaned}/`);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isInsideGitRepo() {
|
|
172
|
+
const result = git(["rev-parse", "--is-inside-work-tree"], { check: false });
|
|
173
|
+
|
|
174
|
+
return result.status === 0 && result.stdout.trim() === "true";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function isLinkedWorktree() {
|
|
178
|
+
const gitDir = resolveGitDirPath(git(["rev-parse", "--git-dir"]).stdout);
|
|
179
|
+
const commonDir = resolveGitDirPath(git(["rev-parse", "--git-common-dir"]).stdout);
|
|
180
|
+
|
|
181
|
+
return gitDir !== commonDir;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function hasAnyCommit() {
|
|
185
|
+
return git(["rev-parse", "--verify", "HEAD"], { check: false }).status === 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parsePorcelainLine(line) {
|
|
189
|
+
const path = line.slice(3).replace(/^"|"$/g, "");
|
|
190
|
+
const target = path.includes(" -> ") ? path.split(" -> ").pop() : path;
|
|
191
|
+
|
|
192
|
+
return normalizeGitPath(target);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function porcelainLines(pathspecs = []) {
|
|
196
|
+
const args = ["status", "--porcelain=v1", "--untracked-files=all"];
|
|
197
|
+
|
|
198
|
+
if (pathspecs.length > 0) {
|
|
199
|
+
args.push("--", ...pathspecs);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return git(args, { check: true }).stdout
|
|
203
|
+
.split("\n")
|
|
204
|
+
.map((line) => line.trimEnd())
|
|
205
|
+
.filter(Boolean);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function dirtyLines() {
|
|
209
|
+
return porcelainLines().filter((line) => !isDefaultExcluded(parsePorcelainLine(line)));
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function allDirtyFiles(pathspecs = []) {
|
|
213
|
+
const seen = new Set();
|
|
214
|
+
|
|
215
|
+
return porcelainLines(pathspecs)
|
|
216
|
+
.map(parsePorcelainLine)
|
|
217
|
+
.filter((path) => !isDefaultExcluded(path))
|
|
218
|
+
.filter((path) => {
|
|
219
|
+
if (seen.has(path)) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
seen.add(path);
|
|
224
|
+
return true;
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function trackedOrUntrackedMatching(pathspecs) {
|
|
229
|
+
return allDirtyFiles(pathspecs);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stagedFiles() {
|
|
233
|
+
return git(["diff", "--cached", "--name-only", "--diff-filter=ACDMR"], { check: true }).stdout
|
|
234
|
+
.split("\n")
|
|
235
|
+
.map((line) => line.trim())
|
|
236
|
+
.filter(Boolean)
|
|
237
|
+
.map(normalizeGitPath);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function patternToRegExp(pattern) {
|
|
241
|
+
let escaped = "";
|
|
242
|
+
|
|
243
|
+
for (let index = 0; index < pattern.length; index += 1) {
|
|
244
|
+
const char = pattern[index];
|
|
245
|
+
|
|
246
|
+
if (char === "*") {
|
|
247
|
+
if (pattern[index + 1] === "*") {
|
|
248
|
+
escaped += ".*";
|
|
249
|
+
index += 1;
|
|
250
|
+
} else {
|
|
251
|
+
escaped += "[^/]*";
|
|
252
|
+
}
|
|
253
|
+
} else if (/[.+^${}()|[\]\\]/.test(char)) {
|
|
254
|
+
escaped += `\\${char}`;
|
|
255
|
+
} else {
|
|
256
|
+
escaped += char;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return new RegExp(`^${escaped}$`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isExplicitlyExcluded(path, patterns) {
|
|
264
|
+
return patterns.some((pattern) => {
|
|
265
|
+
const normalized = normalizeGitPath(pattern);
|
|
266
|
+
|
|
267
|
+
if (normalized.endsWith("/")) {
|
|
268
|
+
return path.startsWith(normalized);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (normalized.includes("*")) {
|
|
272
|
+
return patternToRegExp(normalized).test(path);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return path === normalized;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function ensureIdentity() {
|
|
280
|
+
const name = git(["config", "--get", "user.name"], { check: false }).stdout.trim();
|
|
281
|
+
const email = git(["config", "--get", "user.email"], { check: false }).stdout.trim();
|
|
282
|
+
|
|
283
|
+
if (!name) {
|
|
284
|
+
git(["config", "--local", "user.name", FALLBACK_USER_NAME]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!email) {
|
|
288
|
+
git(["config", "--local", "user.email", FALLBACK_USER_EMAIL]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function extensionOf(path) {
|
|
293
|
+
const index = path.lastIndexOf(".");
|
|
294
|
+
|
|
295
|
+
return index === -1 ? "" : path.slice(index).toLowerCase();
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function fileSizeBytes(path) {
|
|
299
|
+
const fsPath = isAbsolute(path) ? path : join(repositoryRoot(), path);
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
return statSync(fsPath).size;
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error?.code === "ENOENT") {
|
|
305
|
+
return 0;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
throw error;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isLargeMedia(path, maxBinaryMb) {
|
|
313
|
+
return MEDIA_EXTENSIONS.has(extensionOf(path)) && fileSizeBytes(path) > maxBinaryMb * 1024 * 1024;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function writeSkippedMediaManifest(phase, skipped) {
|
|
317
|
+
if (skipped.length === 0) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
mkdirSync(SKIPPED_MANIFEST_DIR, { recursive: true });
|
|
322
|
+
const manifestPath = join(SKIPPED_MANIFEST_DIR, `${phase}-skipped-media.json`);
|
|
323
|
+
const manifest = {
|
|
324
|
+
phase,
|
|
325
|
+
skipped: skipped.map((path) => ({
|
|
326
|
+
path,
|
|
327
|
+
size_bytes: fileSizeBytes(path),
|
|
328
|
+
reason: "larger than max-binary-mb",
|
|
329
|
+
})),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
|
|
333
|
+
return cwdRelativeToGitPath(manifestPath);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function readKnownSkippedMedia() {
|
|
337
|
+
const known = new Set();
|
|
338
|
+
|
|
339
|
+
if (!existsSync(SKIPPED_MANIFEST_DIR)) {
|
|
340
|
+
return known;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
for (const entry of readdirSync(SKIPPED_MANIFEST_DIR, { withFileTypes: true })) {
|
|
344
|
+
if (!entry.isFile() || !entry.name.endsWith("-skipped-media.json")) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const manifestPath = join(SKIPPED_MANIFEST_DIR, entry.name);
|
|
349
|
+
let manifest;
|
|
350
|
+
|
|
351
|
+
try {
|
|
352
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
353
|
+
} catch {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (!Array.isArray(manifest?.skipped)) {
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const item of manifest.skipped) {
|
|
362
|
+
const path = typeof item === "string" ? item : item?.path;
|
|
363
|
+
|
|
364
|
+
if (typeof path === "string" && path.trim()) {
|
|
365
|
+
known.add(normalizeGitPath(path));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
return known;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function stageFiles(paths) {
|
|
374
|
+
if (paths.length > 0) {
|
|
375
|
+
git(["add", "--", ...paths.map((path) => `:(top)${normalizeGitPath(path)}`)]);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function planStageFiles(options) {
|
|
380
|
+
const dirty = allDirtyFiles();
|
|
381
|
+
const selected = trackedOrUntrackedMatching(options.allow);
|
|
382
|
+
const selectedSet = new Set(selected);
|
|
383
|
+
const knownSkippedMedia = readKnownSkippedMedia();
|
|
384
|
+
const explicitExcluded = new Set();
|
|
385
|
+
const stageable = [];
|
|
386
|
+
const skippedMedia = [];
|
|
387
|
+
|
|
388
|
+
for (const path of dirty) {
|
|
389
|
+
if (isExplicitlyExcluded(path, options.exclude)) {
|
|
390
|
+
explicitExcluded.add(path);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
for (const path of selected) {
|
|
395
|
+
if (isDefaultExcluded(path) || isExplicitlyExcluded(path, options.exclude)) {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (!options.includeLargeMedia && isLargeMedia(path, options.maxBinaryMb)) {
|
|
400
|
+
skippedMedia.push(path);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
stageable.push(path);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const skippedSet = new Set(skippedMedia);
|
|
408
|
+
const unrelated = dirty.filter((path) => {
|
|
409
|
+
if (selectedSet.has(path) || explicitExcluded.has(path) || skippedSet.has(path)) {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return options.includeLargeMedia || !knownSkippedMedia.has(path) || !isLargeMedia(path, options.maxBinaryMb);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
return { stageable, skippedMedia, unrelated };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function ensureOnlyPlannedFilesStaged(expectedPaths) {
|
|
420
|
+
const expected = new Set(expectedPaths.map(normalizeGitPath));
|
|
421
|
+
const unexpected = stagedFiles().filter((path) => !expected.has(path));
|
|
422
|
+
|
|
423
|
+
if (unexpected.length > 0) {
|
|
424
|
+
throw new Error(`Existing repository has staged files outside the planned set:\n${unexpected.join("\n")}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function createCommit(message, allowEmpty) {
|
|
429
|
+
const args = ["commit"];
|
|
430
|
+
|
|
431
|
+
if (allowEmpty) {
|
|
432
|
+
args.push("--allow-empty");
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
args.push("-m", message);
|
|
436
|
+
|
|
437
|
+
const result = git(args, { check: false });
|
|
438
|
+
const output = `${result.stdout}\n${result.stderr}`;
|
|
439
|
+
|
|
440
|
+
if (result.status !== 0 && /nothing to commit|no changes added/i.test(output)) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (result.status !== 0) {
|
|
445
|
+
throw new Error(output.trim() || `git ${args.join(" ")}`);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return true;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function main(argv = process.argv.slice(2)) {
|
|
452
|
+
const options = parseArgs(argv);
|
|
453
|
+
|
|
454
|
+
if (options.phase !== "init" && options.allow.length === 0) {
|
|
455
|
+
throw new Error("--allow is required for non-init phases");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (options.dryRun) {
|
|
459
|
+
console.log(`dry run: phase=${options.phase} message=${options.message}`);
|
|
460
|
+
console.log(isInsideGitRepo() ? "dry run: existing Git repository detected" : "dry run: would initialize Git repository");
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
const startedInsideRepo = isInsideGitRepo();
|
|
465
|
+
|
|
466
|
+
if (startedInsideRepo && isLinkedWorktree()) {
|
|
467
|
+
throw new Error("Refusing to run in a linked worktree. Use a normal repository directory for /hyper-animator.");
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
if (startedInsideRepo) {
|
|
471
|
+
if (options.phase === "init" && hasAnyCommit()) {
|
|
472
|
+
const dirty = dirtyLines();
|
|
473
|
+
|
|
474
|
+
if (dirty.length > 0) {
|
|
475
|
+
throw new Error(`Existing repository has uncommitted changes:\n${dirty.join("\n")}`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
ensureIdentity();
|
|
479
|
+
console.log(`checkpoint ${options.phase}: ${git(["rev-parse", "--short", "HEAD"]).stdout.trim()}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
} else {
|
|
483
|
+
git(["init"]);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
ensureIdentity();
|
|
487
|
+
|
|
488
|
+
if (!hasAnyCommit()) {
|
|
489
|
+
const plan = planStageFiles({ ...options, allow: [] });
|
|
490
|
+
ensureOnlyPlannedFilesStaged(plan.stageable);
|
|
491
|
+
const manifestPath = writeSkippedMediaManifest(options.phase, plan.skippedMedia);
|
|
492
|
+
const stageable = manifestPath ? [...plan.stageable, manifestPath] : plan.stageable;
|
|
493
|
+
stageFiles(stageable);
|
|
494
|
+
ensureOnlyPlannedFilesStaged(stageable);
|
|
495
|
+
if (!createCommit(options.message, options.allowEmpty)) {
|
|
496
|
+
throw new Error("nothing to commit");
|
|
497
|
+
}
|
|
498
|
+
} else {
|
|
499
|
+
const plan = planStageFiles(options);
|
|
500
|
+
|
|
501
|
+
if (plan.unrelated.length > 0) {
|
|
502
|
+
throw new Error(`Existing repository has unrelated dirty files outside allowlist:\n${plan.unrelated.join("\n")}`);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
ensureOnlyPlannedFilesStaged(plan.stageable);
|
|
506
|
+
const manifestPath = writeSkippedMediaManifest(options.phase, plan.skippedMedia);
|
|
507
|
+
const stageable = manifestPath ? [...plan.stageable, manifestPath] : plan.stageable;
|
|
508
|
+
stageFiles(stageable);
|
|
509
|
+
ensureOnlyPlannedFilesStaged(stageable);
|
|
510
|
+
|
|
511
|
+
if (!createCommit(options.message, options.allowEmpty)) {
|
|
512
|
+
throw new Error("nothing to commit");
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
console.log(`checkpoint ${options.phase}: ${git(["rev-parse", "--short", "HEAD"]).stdout.trim()}`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
try {
|
|
520
|
+
main();
|
|
521
|
+
} catch (error) {
|
|
522
|
+
console.error(`Error: ${error.message}`);
|
|
523
|
+
process.exitCode = 1;
|
|
524
|
+
}
|