hyper-animator-codex 0.2.0 → 0.4.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 +62 -0
- package/bin/hyper-animator-codex.mjs +16 -28
- package/lib/install-options.mjs +44 -0
- package/lib/install-skill.mjs +16 -0
- package/lib/minimax-config.mjs +162 -0
- package/package.json +1 -1
- package/skills/hyper-animator-codex/SKILL.md +20 -6
- package/skills/hyper-animator-codex/references/minimax-music-workflow.md +77 -0
- package/skills/hyper-animator-codex/references/preview-controls-workflow.md +54 -0
- package/skills/hyper-animator-codex/scripts/generate_minimax_music.mjs +346 -0
- package/skills/hyper-animator-codex/scripts/inject_preview_controls.mjs +338 -0
- package/skills/hyper-animator-codex/scripts/minimax_runtime_config.mjs +113 -0
- package/skills/hyper-animator-codex/scripts/validate_hyperframes_html.py +34 -2
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_MINIMAX_MODEL,
|
|
8
|
+
readMinimaxRuntimeConfig,
|
|
9
|
+
redactMinimaxConfig,
|
|
10
|
+
validateMinimaxConfig,
|
|
11
|
+
} from "./minimax_runtime_config.mjs";
|
|
12
|
+
|
|
13
|
+
const ENDPOINT = "https://api.minimaxi.com/v1/music_generation";
|
|
14
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const skillRoot = dirname(scriptDir);
|
|
16
|
+
|
|
17
|
+
function requireValue(args, index, flag) {
|
|
18
|
+
const value = args[index + 1];
|
|
19
|
+
|
|
20
|
+
if (!value || value.startsWith("--")) {
|
|
21
|
+
throw new Error(`${flag} requires a value`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseArgs(args) {
|
|
28
|
+
const parsed = {
|
|
29
|
+
prompt: undefined,
|
|
30
|
+
lyrics: undefined,
|
|
31
|
+
lyrics_optimizer: false,
|
|
32
|
+
is_instrumental: true,
|
|
33
|
+
output_dir: join(process.cwd(), "hyper-animator-output", "music"),
|
|
34
|
+
output_format: "hex",
|
|
35
|
+
audio_format: "mp3",
|
|
36
|
+
sample_rate: 44100,
|
|
37
|
+
bitrate: 256000,
|
|
38
|
+
model: undefined,
|
|
39
|
+
config_path: undefined,
|
|
40
|
+
dry_run: false,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
44
|
+
const arg = args[index];
|
|
45
|
+
|
|
46
|
+
if (arg === "--prompt") {
|
|
47
|
+
parsed.prompt = requireValue(args, index, arg);
|
|
48
|
+
index += 1;
|
|
49
|
+
} else if (arg === "--lyrics") {
|
|
50
|
+
parsed.lyrics = requireValue(args, index, arg);
|
|
51
|
+
index += 1;
|
|
52
|
+
} else if (arg === "--lyrics-optimizer") {
|
|
53
|
+
parsed.lyrics_optimizer = true;
|
|
54
|
+
} else if (arg === "--instrumental") {
|
|
55
|
+
parsed.is_instrumental = true;
|
|
56
|
+
} else if (arg === "--vocal") {
|
|
57
|
+
parsed.is_instrumental = false;
|
|
58
|
+
} else if (arg === "--output-dir") {
|
|
59
|
+
parsed.output_dir = requireValue(args, index, arg);
|
|
60
|
+
index += 1;
|
|
61
|
+
} else if (arg === "--output-format") {
|
|
62
|
+
parsed.output_format = requireValue(args, index, arg);
|
|
63
|
+
index += 1;
|
|
64
|
+
} else if (arg === "--format") {
|
|
65
|
+
parsed.audio_format = requireValue(args, index, arg);
|
|
66
|
+
index += 1;
|
|
67
|
+
} else if (arg === "--sample-rate") {
|
|
68
|
+
parsed.sample_rate = Number.parseInt(requireValue(args, index, arg), 10);
|
|
69
|
+
index += 1;
|
|
70
|
+
} else if (arg === "--bitrate") {
|
|
71
|
+
parsed.bitrate = Number.parseInt(requireValue(args, index, arg), 10);
|
|
72
|
+
index += 1;
|
|
73
|
+
} else if (arg === "--model") {
|
|
74
|
+
parsed.model = requireValue(args, index, arg);
|
|
75
|
+
index += 1;
|
|
76
|
+
} else if (arg === "--config") {
|
|
77
|
+
parsed.config_path = requireValue(args, index, arg);
|
|
78
|
+
index += 1;
|
|
79
|
+
} else if (arg === "--dry-run") {
|
|
80
|
+
parsed.dry_run = true;
|
|
81
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
82
|
+
parsed.help = true;
|
|
83
|
+
} else {
|
|
84
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function printHelp() {
|
|
92
|
+
console.log(`Usage:
|
|
93
|
+
node scripts/generate_minimax_music.mjs --prompt <text> [options]
|
|
94
|
+
|
|
95
|
+
Options:
|
|
96
|
+
--prompt <text> Music style, mood, and scene prompt
|
|
97
|
+
--lyrics <text> Lyrics for vocal generation
|
|
98
|
+
--lyrics-optimizer Let MiniMax generate lyrics from prompt for vocal generation
|
|
99
|
+
--instrumental Generate instrumental music, default
|
|
100
|
+
--vocal Generate vocal music; requires --lyrics or --lyrics-optimizer
|
|
101
|
+
--output-dir <dir> Directory for generated audio and metadata
|
|
102
|
+
--output-format <hex|url> MiniMax response format, default hex
|
|
103
|
+
--format <mp3|wav|pcm> Audio encoding format, default mp3
|
|
104
|
+
--sample-rate <rate> 16000, 24000, 32000, or 44100; default 44100
|
|
105
|
+
--bitrate <bits> 32000, 64000, 128000, or 256000; default 256000
|
|
106
|
+
--model <model> music-2.6 or music-2.6-free
|
|
107
|
+
--config <file> Explicit MiniMax config JSON
|
|
108
|
+
--dry-run Print redacted request without contacting MiniMax
|
|
109
|
+
`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateOptions(options) {
|
|
113
|
+
if (!options.prompt || options.prompt.trim().length === 0) {
|
|
114
|
+
throw new Error("--prompt is required");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (options.prompt.length > 2000) {
|
|
118
|
+
throw new Error("--prompt must be 2000 characters or fewer");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!["hex", "url"].includes(options.output_format)) {
|
|
122
|
+
throw new Error("--output-format must be hex or url");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!["mp3", "wav", "pcm"].includes(options.audio_format)) {
|
|
126
|
+
throw new Error("--format must be mp3, wav, or pcm");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (![16000, 24000, 32000, 44100].includes(options.sample_rate)) {
|
|
130
|
+
throw new Error("--sample-rate must be 16000, 24000, 32000, or 44100");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (![32000, 64000, 128000, 256000].includes(options.bitrate)) {
|
|
134
|
+
throw new Error("--bitrate must be 32000, 64000, 128000, or 256000");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (!options.is_instrumental && !options.lyrics && !options.lyrics_optimizer) {
|
|
138
|
+
throw new Error("Vocal MiniMax generation requires --lyrics or --lyrics-optimizer");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (options.lyrics && options.lyrics.length > 3500) {
|
|
142
|
+
throw new Error("--lyrics must be 3500 characters or fewer");
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function buildRequest(options, config) {
|
|
147
|
+
const model = options.model || config.model || DEFAULT_MINIMAX_MODEL;
|
|
148
|
+
const validatedConfig = validateMinimaxConfig({
|
|
149
|
+
...config,
|
|
150
|
+
model,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const request = {
|
|
154
|
+
model: validatedConfig.model,
|
|
155
|
+
prompt: options.prompt,
|
|
156
|
+
stream: false,
|
|
157
|
+
output_format: options.output_format,
|
|
158
|
+
audio_setting: {
|
|
159
|
+
sample_rate: options.sample_rate,
|
|
160
|
+
bitrate: options.bitrate,
|
|
161
|
+
format: options.audio_format,
|
|
162
|
+
},
|
|
163
|
+
is_instrumental: options.is_instrumental,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
if (options.lyrics) {
|
|
167
|
+
request.lyrics = options.lyrics;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (options.lyrics_optimizer) {
|
|
171
|
+
request.lyrics_optimizer = true;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return request;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function safeFileStem(prompt) {
|
|
178
|
+
const stem = prompt
|
|
179
|
+
.toLowerCase()
|
|
180
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
181
|
+
.replace(/^-+|-+$/g, "")
|
|
182
|
+
.slice(0, 48);
|
|
183
|
+
|
|
184
|
+
return stem || "minimax-music";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function downloadUrl(url) {
|
|
188
|
+
const response = await fetch(url);
|
|
189
|
+
|
|
190
|
+
if (!response.ok) {
|
|
191
|
+
throw new Error(`MiniMax audio URL download failed with HTTP ${response.status}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return Buffer.from(await response.arrayBuffer());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function decodeHexAudio(value) {
|
|
198
|
+
if (!/^[0-9a-fA-F]+$/.test(value) || value.length % 2 !== 0) {
|
|
199
|
+
throw new Error("MiniMax response audio is not valid hex data");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return Buffer.from(value, "hex");
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function callMiniMax(config, request) {
|
|
206
|
+
const response = await fetch(ENDPOINT, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
headers: {
|
|
209
|
+
"Content-Type": "application/json",
|
|
210
|
+
Authorization: `Bearer ${config.api_key}`,
|
|
211
|
+
},
|
|
212
|
+
body: JSON.stringify(request),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const text = await response.text();
|
|
216
|
+
let json;
|
|
217
|
+
|
|
218
|
+
try {
|
|
219
|
+
json = JSON.parse(text);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
throw new Error(`MiniMax returned non-JSON response with HTTP ${response.status}`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (!response.ok) {
|
|
225
|
+
const message = json.base_resp?.status_msg || `HTTP ${response.status}`;
|
|
226
|
+
throw new Error(`MiniMax request failed: ${message}`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const statusCode = json.base_resp?.status_code;
|
|
230
|
+
if (statusCode !== 0) {
|
|
231
|
+
const message = json.base_resp?.status_msg || "unknown MiniMax error";
|
|
232
|
+
throw new Error(`MiniMax request failed with status_code ${statusCode}: ${message}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (json.data?.status !== 2) {
|
|
236
|
+
throw new Error(`MiniMax generation is not complete; data.status is ${json.data?.status}`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (!json.data?.audio) {
|
|
240
|
+
throw new Error("MiniMax response did not include data.audio");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return json;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function writeAudioOutput({ responseJson, request, outputDir, prompt }) {
|
|
247
|
+
await mkdir(outputDir, { recursive: true });
|
|
248
|
+
|
|
249
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
250
|
+
const stem = `${safeFileStem(prompt)}-${timestamp}`;
|
|
251
|
+
const extension = request.audio_setting.format === "pcm" ? "pcm" : request.audio_setting.format;
|
|
252
|
+
const audioPath = join(outputDir, `${stem}.${extension}`);
|
|
253
|
+
const metadataPath = join(outputDir, `${stem}.minimax.json`);
|
|
254
|
+
const audioValue = responseJson.data.audio;
|
|
255
|
+
const audioBuffer = request.output_format === "url"
|
|
256
|
+
? await downloadUrl(audioValue)
|
|
257
|
+
: decodeHexAudio(audioValue);
|
|
258
|
+
|
|
259
|
+
await writeFile(audioPath, audioBuffer);
|
|
260
|
+
await writeFile(
|
|
261
|
+
metadataPath,
|
|
262
|
+
`${JSON.stringify({
|
|
263
|
+
provider: "minimax",
|
|
264
|
+
endpoint: ENDPOINT,
|
|
265
|
+
request,
|
|
266
|
+
response: {
|
|
267
|
+
base_resp: responseJson.base_resp,
|
|
268
|
+
data: {
|
|
269
|
+
status: responseJson.data.status,
|
|
270
|
+
},
|
|
271
|
+
extra_info: responseJson.extra_info,
|
|
272
|
+
trace_id: responseJson.trace_id,
|
|
273
|
+
},
|
|
274
|
+
audio_path: audioPath,
|
|
275
|
+
}, null, 2)}\n`,
|
|
276
|
+
"utf8",
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
audioPath,
|
|
281
|
+
metadataPath,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function main() {
|
|
286
|
+
const options = parseArgs(process.argv.slice(2));
|
|
287
|
+
|
|
288
|
+
if (options.help) {
|
|
289
|
+
printHelp();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
validateOptions(options);
|
|
294
|
+
|
|
295
|
+
const runtime = await readMinimaxRuntimeConfig({
|
|
296
|
+
skillRoot,
|
|
297
|
+
env: process.env,
|
|
298
|
+
configPath: options.config_path,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!runtime) {
|
|
302
|
+
throw 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.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const request = buildRequest(options, runtime.config);
|
|
306
|
+
const redactedConfig = redactMinimaxConfig(runtime.config);
|
|
307
|
+
|
|
308
|
+
if (options.dry_run) {
|
|
309
|
+
console.log(JSON.stringify({
|
|
310
|
+
ok: true,
|
|
311
|
+
dry_run: true,
|
|
312
|
+
provider: "minimax",
|
|
313
|
+
endpoint: ENDPOINT,
|
|
314
|
+
config: {
|
|
315
|
+
source: runtime.source,
|
|
316
|
+
path: runtime.configPath,
|
|
317
|
+
redacted: redactedConfig,
|
|
318
|
+
},
|
|
319
|
+
request,
|
|
320
|
+
output_dir: options.output_dir,
|
|
321
|
+
}, null, 2));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
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
|
+
}
|
|
342
|
+
|
|
343
|
+
main().catch((error) => {
|
|
344
|
+
console.error(`Error: ${error.message}`);
|
|
345
|
+
process.exitCode = 1;
|
|
346
|
+
});
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
|
|
4
|
+
const MARKER = "<!-- hyper-animator-preview-controls:start -->";
|
|
5
|
+
const PREVIEW_UI_MARKER = /<[A-Za-z][A-Za-z0-9:-]*(?:\s[^<>]*?)?\sdata-hyper-preview-ui(?:\s|=|\/?>)/i;
|
|
6
|
+
|
|
7
|
+
function parseArgs(args) {
|
|
8
|
+
const parsed = { input: undefined, output: undefined, force: false, componentId: undefined };
|
|
9
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
10
|
+
const arg = args[index];
|
|
11
|
+
if (arg === "-o" || arg === "--output") {
|
|
12
|
+
parsed.output = requireValue(args, index, arg);
|
|
13
|
+
index += 1;
|
|
14
|
+
} else if (arg === "--force") {
|
|
15
|
+
parsed.force = true;
|
|
16
|
+
} else if (arg === "--component-id") {
|
|
17
|
+
parsed.componentId = requireValue(args, index, arg);
|
|
18
|
+
index += 1;
|
|
19
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
20
|
+
parsed.help = true;
|
|
21
|
+
} else if (!parsed.input) {
|
|
22
|
+
parsed.input = arg;
|
|
23
|
+
} else {
|
|
24
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function requireValue(args, index, flag) {
|
|
31
|
+
const value = args[index + 1];
|
|
32
|
+
if (!value || value.startsWith("-")) {
|
|
33
|
+
throw new Error(`${flag} requires a value`);
|
|
34
|
+
}
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function printHelp() {
|
|
39
|
+
console.log(`Usage:
|
|
40
|
+
node scripts/inject_preview_controls.mjs input.html [-o preview.html] [--force] [--component-id <id>]
|
|
41
|
+
|
|
42
|
+
Options:
|
|
43
|
+
-o, --output <file> Write preview HTML to a separate file
|
|
44
|
+
--force Allow overwriting the output file
|
|
45
|
+
--component-id <id> Use a specific data-composition-id for timeline seeking
|
|
46
|
+
`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function pathExists(path) {
|
|
50
|
+
try {
|
|
51
|
+
await stat(path);
|
|
52
|
+
return true;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (error && error.code === "ENOENT") return false;
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function findCompositionId(html, explicitId) {
|
|
60
|
+
if (explicitId) return explicitId;
|
|
61
|
+
const match = html.match(/data-composition-id\s*=\s*["']([^"']+)["']/i);
|
|
62
|
+
if (!match) {
|
|
63
|
+
throw new Error("Cannot inject preview controls: missing data-composition-id. Pass --component-id to override.");
|
|
64
|
+
}
|
|
65
|
+
return match[1];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function serializeJsString(value) {
|
|
69
|
+
return JSON.stringify(value)
|
|
70
|
+
.replace(/</g, "\\u003C")
|
|
71
|
+
.replace(/\u2028/g, "\\u2028")
|
|
72
|
+
.replace(/\u2029/g, "\\u2029");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function findTagEnd(html, startIndex) {
|
|
76
|
+
let quote = null;
|
|
77
|
+
for (let index = startIndex + 1; index < html.length; index += 1) {
|
|
78
|
+
const char = html[index];
|
|
79
|
+
if (quote) {
|
|
80
|
+
if (char === quote) {
|
|
81
|
+
quote = null;
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (char === '"' || char === "'") {
|
|
86
|
+
quote = char;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (char === ">") {
|
|
90
|
+
return index;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return -1;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function scanLastClosingBodyTagIndex(html) {
|
|
97
|
+
const lower = html.toLowerCase();
|
|
98
|
+
let index = 0;
|
|
99
|
+
let lastClosingBodyTagIndex = -1;
|
|
100
|
+
let rawTextTag = "";
|
|
101
|
+
|
|
102
|
+
while (index < html.length) {
|
|
103
|
+
if (rawTextTag) {
|
|
104
|
+
const closingTag = `</${rawTextTag}`;
|
|
105
|
+
const closingTagIndex = lower.indexOf(closingTag, index);
|
|
106
|
+
if (closingTagIndex === -1) {
|
|
107
|
+
return lastClosingBodyTagIndex;
|
|
108
|
+
}
|
|
109
|
+
const closingTagEndIndex = findTagEnd(html, closingTagIndex);
|
|
110
|
+
if (closingTagEndIndex === -1) {
|
|
111
|
+
return lastClosingBodyTagIndex;
|
|
112
|
+
}
|
|
113
|
+
index = closingTagEndIndex + 1;
|
|
114
|
+
rawTextTag = "";
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const nextTagIndex = html.indexOf("<", index);
|
|
119
|
+
if (nextTagIndex === -1) {
|
|
120
|
+
return lastClosingBodyTagIndex;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (lower.startsWith("<!--", nextTagIndex)) {
|
|
124
|
+
const commentEndIndex = lower.indexOf("-->", nextTagIndex + 4);
|
|
125
|
+
if (commentEndIndex === -1) {
|
|
126
|
+
return lastClosingBodyTagIndex;
|
|
127
|
+
}
|
|
128
|
+
index = commentEndIndex + 3;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const tagEndIndex = findTagEnd(html, nextTagIndex);
|
|
133
|
+
if (tagEndIndex === -1) {
|
|
134
|
+
return lastClosingBodyTagIndex;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const tagContent = lower.slice(nextTagIndex + 1, tagEndIndex).trimStart();
|
|
138
|
+
if (/^\/\s*body\b/.test(tagContent)) {
|
|
139
|
+
lastClosingBodyTagIndex = nextTagIndex;
|
|
140
|
+
} else if (/^(script|style|textarea|title)\b/.test(tagContent)) {
|
|
141
|
+
rawTextTag = tagContent.match(/^(script|style|textarea|title)\b/)[1];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
index = tagEndIndex + 1;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return lastClosingBodyTagIndex;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function findLastClosingBodyTagIndex(html) {
|
|
151
|
+
return scanLastClosingBodyTagIndex(html);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function injectPreviewControls(html, componentId) {
|
|
155
|
+
if (PREVIEW_UI_MARKER.test(html)) {
|
|
156
|
+
return html;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const escapedId = serializeJsString(componentId);
|
|
160
|
+
const controls = `<!-- hyper-animator-preview-controls:start -->
|
|
161
|
+
<style data-hyper-preview-style>
|
|
162
|
+
.hyper-preview-ui {
|
|
163
|
+
position: fixed;
|
|
164
|
+
left: 0;
|
|
165
|
+
right: 0;
|
|
166
|
+
bottom: 0;
|
|
167
|
+
z-index: 2147483647;
|
|
168
|
+
display: grid;
|
|
169
|
+
grid-template-columns: auto minmax(0, 1fr);
|
|
170
|
+
align-items: center;
|
|
171
|
+
gap: 10px;
|
|
172
|
+
padding: 6px 10px 8px;
|
|
173
|
+
color: rgba(255, 255, 255, 0.86);
|
|
174
|
+
font: 12px/1.2 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
175
|
+
pointer-events: none;
|
|
176
|
+
background: linear-gradient(to top, rgba(0, 0, 0, 0.34), rgba(0, 0, 0, 0));
|
|
177
|
+
}
|
|
178
|
+
.hyper-preview-ui[hidden] {
|
|
179
|
+
display: none !important;
|
|
180
|
+
}
|
|
181
|
+
.hyper-preview-page {
|
|
182
|
+
min-width: 42px;
|
|
183
|
+
text-align: center;
|
|
184
|
+
font-variant-numeric: tabular-nums lining-nums;
|
|
185
|
+
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
|
186
|
+
}
|
|
187
|
+
.hyper-preview-progress {
|
|
188
|
+
width: 100%;
|
|
189
|
+
height: 3px;
|
|
190
|
+
accent-color: rgba(255, 255, 255, 0.92);
|
|
191
|
+
cursor: pointer;
|
|
192
|
+
pointer-events: auto;
|
|
193
|
+
}
|
|
194
|
+
</style>
|
|
195
|
+
<div class="hyper-preview-ui" data-hyper-preview-ui hidden aria-label="Animation preview controls">
|
|
196
|
+
<div class="hyper-preview-page" data-hyper-preview-page aria-live="polite">1 / 1</div>
|
|
197
|
+
<input class="hyper-preview-progress" data-hyper-preview-progress type="range" min="0" max="1000" step="1" value="0" aria-label="Preview progress" />
|
|
198
|
+
</div>
|
|
199
|
+
<script data-hyper-preview-script>
|
|
200
|
+
(function () {
|
|
201
|
+
const componentId = ${escapedId};
|
|
202
|
+
const params = new URLSearchParams(window.location.search);
|
|
203
|
+
const renderMode = params.get("render") === "1" || params.get("preview") === "0" || document.documentElement.dataset.renderMode === "video";
|
|
204
|
+
const root = document.querySelector("[data-hyper-preview-ui]");
|
|
205
|
+
if (!root) return;
|
|
206
|
+
if (renderMode) {
|
|
207
|
+
root.hidden = true;
|
|
208
|
+
root.style.display = "none";
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const pageLabel = root.querySelector("[data-hyper-preview-page]");
|
|
213
|
+
const progress = root.querySelector("[data-hyper-preview-progress]");
|
|
214
|
+
const composition = findComposition();
|
|
215
|
+
const duration = readDuration(composition);
|
|
216
|
+
const pages = readPages(composition, duration);
|
|
217
|
+
let pageIndex = 0;
|
|
218
|
+
|
|
219
|
+
root.hidden = false;
|
|
220
|
+
root.style.display = "";
|
|
221
|
+
updateForTime(0);
|
|
222
|
+
|
|
223
|
+
progress.addEventListener("input", function () {
|
|
224
|
+
const seconds = (Number(progress.value) / Number(progress.max)) * duration;
|
|
225
|
+
seek(seconds);
|
|
226
|
+
updateForTime(seconds);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
document.addEventListener("keydown", function (event) {
|
|
230
|
+
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey || renderMode) return;
|
|
231
|
+
if (event.key === "ArrowLeft") {
|
|
232
|
+
event.preventDefault();
|
|
233
|
+
goToPage(Math.max(0, pageIndex - 1));
|
|
234
|
+
} else if (event.key === "ArrowRight") {
|
|
235
|
+
event.preventDefault();
|
|
236
|
+
goToPage(Math.min(pages.length - 1, pageIndex + 1));
|
|
237
|
+
}
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
function readDuration(element) {
|
|
241
|
+
const raw = element ? Number(element.dataset.duration) : NaN;
|
|
242
|
+
const timelineDuration = getTimeline() && typeof getTimeline().duration === "function" ? Number(getTimeline().duration()) : NaN;
|
|
243
|
+
const value = Number.isFinite(raw) && raw > 0 ? raw : timelineDuration;
|
|
244
|
+
return Number.isFinite(value) && value > 0 ? value : 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function readPages(element, totalDuration) {
|
|
248
|
+
if (element && element.dataset.previewPages) {
|
|
249
|
+
const parsed = element.dataset.previewPages.split(",").map((value) => Number(value.trim())).filter((value) => Number.isFinite(value) && value >= 0 && value <= totalDuration);
|
|
250
|
+
if (parsed.length > 0) return parsed;
|
|
251
|
+
}
|
|
252
|
+
const count = element && Number.parseInt(element.dataset.previewPageCount || "", 10);
|
|
253
|
+
if (Number.isFinite(count) && count > 1) {
|
|
254
|
+
return Array.from({ length: count }, (_, index) => (totalDuration / count) * index);
|
|
255
|
+
}
|
|
256
|
+
return [0];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function getTimeline() {
|
|
260
|
+
return window.__timelines && window.__timelines[componentId];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function findComposition() {
|
|
264
|
+
const compositions = document.querySelectorAll("[data-composition-id]");
|
|
265
|
+
for (const element of compositions) {
|
|
266
|
+
if (element.dataset.compositionId === componentId) {
|
|
267
|
+
return element;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function seek(seconds) {
|
|
274
|
+
const timeline = getTimeline();
|
|
275
|
+
if (!timeline) return;
|
|
276
|
+
if (typeof timeline.time === "function") {
|
|
277
|
+
timeline.time(Math.max(0, Math.min(duration, seconds)));
|
|
278
|
+
} else if (typeof timeline.progress === "function") {
|
|
279
|
+
timeline.progress(Math.max(0, Math.min(1, seconds / duration)));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function goToPage(nextIndex) {
|
|
284
|
+
pageIndex = nextIndex;
|
|
285
|
+
const seconds = pages[pageIndex] || 0;
|
|
286
|
+
seek(seconds);
|
|
287
|
+
updateForTime(seconds);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function updateForTime(seconds) {
|
|
291
|
+
pageIndex = activePageIndex(seconds);
|
|
292
|
+
pageLabel.textContent = String(pageIndex + 1) + " / " + String(pages.length);
|
|
293
|
+
progress.value = String(Math.round((Math.max(0, Math.min(duration, seconds)) / duration) * Number(progress.max)));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function activePageIndex(seconds) {
|
|
297
|
+
let active = 0;
|
|
298
|
+
for (let index = 0; index < pages.length; index += 1) {
|
|
299
|
+
if (seconds + 0.0001 >= pages[index]) active = index;
|
|
300
|
+
}
|
|
301
|
+
return active;
|
|
302
|
+
}
|
|
303
|
+
})();
|
|
304
|
+
</script>
|
|
305
|
+
<!-- hyper-animator-preview-controls:end -->`;
|
|
306
|
+
|
|
307
|
+
const closingBodyTagIndex = findLastClosingBodyTagIndex(html);
|
|
308
|
+
if (closingBodyTagIndex !== -1) {
|
|
309
|
+
return `${html.slice(0, closingBodyTagIndex)}${controls}\n${html.slice(closingBodyTagIndex)}`;
|
|
310
|
+
}
|
|
311
|
+
return `${html}\n${controls}\n`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function main() {
|
|
315
|
+
const options = parseArgs(process.argv.slice(2));
|
|
316
|
+
if (options.help) {
|
|
317
|
+
printHelp();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (!options.input) {
|
|
321
|
+
throw new Error("input.html is required");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const output = options.output || options.input;
|
|
325
|
+
if (options.output && !options.force && await pathExists(output)) {
|
|
326
|
+
throw new Error(`Output already exists: ${output}. Re-run with --force to overwrite.`);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const html = await readFile(options.input, "utf8");
|
|
330
|
+
const componentId = findCompositionId(html, options.componentId);
|
|
331
|
+
await writeFile(output, injectPreviewControls(html, componentId), "utf8");
|
|
332
|
+
console.log(`Preview controls written to: ${output}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
main().catch((error) => {
|
|
336
|
+
console.error(`Error: ${error.message}`);
|
|
337
|
+
process.exitCode = 1;
|
|
338
|
+
});
|