@birdapi/velinstyle 1.2.2 → 1.3.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.
Files changed (109) hide show
  1. package/README.de.md +18 -4
  2. package/README.md +42 -7
  3. package/cli/cli-manifest.json +441 -174
  4. package/cli/docgen/extract-cli.js +2 -1
  5. package/cli/docs-generate.js +9 -0
  6. package/cli/experience.js +173 -0
  7. package/cli/index.js +1294 -417
  8. package/cli/motion.js +738 -0
  9. package/cli/production/extract.js +46 -6
  10. package/cli/production/report.js +104 -14
  11. package/cli/production/run.js +44 -1
  12. package/cli/security.js +190 -0
  13. package/cli/skills.js +53 -10
  14. package/cli/transparency.js +140 -41
  15. package/cli/workflow.js +32 -17
  16. package/components/velin-theme-toggle.js +15 -1
  17. package/core/a11y/component-contracts.json +391 -274
  18. package/core/attributes/registry.js +1 -1
  19. package/core/motion/analyze.js +38 -0
  20. package/core/motion/blueprint.js +51 -0
  21. package/core/motion/composer.js +114 -0
  22. package/core/motion/data/registry.json +352 -0
  23. package/core/motion/effects.js +17 -1
  24. package/core/motion/index.js +112 -9
  25. package/core/motion/optimize.js +44 -0
  26. package/core/motion/policy.js +77 -0
  27. package/core/motion/registry.js +131 -0
  28. package/core/motion/review.js +59 -0
  29. package/core/motion/scan.js +150 -0
  30. package/core/motion/timeline.js +68 -0
  31. package/core/motion/transitions.js +49 -0
  32. package/core/motion/triggers.js +168 -0
  33. package/core/security/checks/fs.js +92 -0
  34. package/core/security/detect.js +35 -0
  35. package/core/security/engine.js +116 -0
  36. package/core/security/index.js +18 -0
  37. package/core/security/registry.js +85 -0
  38. package/core/security/report/report.js +113 -0
  39. package/core/security/rules/ci/index.js +146 -0
  40. package/core/security/rules/consumer/index.js +174 -0
  41. package/core/security/rules/deps/index.js +182 -0
  42. package/core/security/rules/index.js +24 -0
  43. package/core/security/rules/publish/index.js +230 -0
  44. package/core/security/rules/repo/index.js +143 -0
  45. package/core/security/rules/secrets/index.js +133 -0
  46. package/core/security/score/dependency-health.js +53 -0
  47. package/core/security/score/release-health.js +66 -0
  48. package/core/security/score/score.js +102 -0
  49. package/core/security/timeline/timeline.js +76 -0
  50. package/dist/chunks/attributes-HK7VIOLA.js +1080 -0
  51. package/dist/chunks/attributes-VRHHVNDO.js +1080 -0
  52. package/dist/chunks/chunk-MYKUI364.js +116 -0
  53. package/dist/chunks/chunk-NEVBPD5T.js +116 -0
  54. package/dist/chunks/runtime-entry.js +1 -1
  55. package/dist/chunks/velin-theme-toggle-J2NHC7IM.js +304 -0
  56. package/dist/llms.txt +2 -2
  57. package/dist/search-index.json +166 -2
  58. package/dist/velin-agent.json +156 -32
  59. package/dist/velinstyle-components.iife.js +862 -17
  60. package/dist/velinstyle-components.js +862 -17
  61. package/dist/velinstyle-components.min.js +126 -126
  62. package/dist/velinstyle.css +42 -7
  63. package/dist/velinstyle.min.css +1 -1
  64. package/package.json +158 -143
  65. package/packages/velinstyle-cli-core/package.json +11 -0
  66. package/packages/velinstyle-cli-core/src/config.js +84 -0
  67. package/packages/velinstyle-cli-core/src/contract.js +56 -0
  68. package/packages/velinstyle-cli-core/src/event-bus.js +50 -0
  69. package/packages/velinstyle-cli-core/src/index.js +18 -0
  70. package/packages/velinstyle-cli-core/src/lifecycle.js +33 -0
  71. package/packages/velinstyle-cli-core/src/runner.js +129 -0
  72. package/packages/velinstyle-cli-registry/data/commands.json +1216 -0
  73. package/packages/velinstyle-cli-registry/data/layouts.json +33 -0
  74. package/packages/velinstyle-cli-registry/data/widgets.json +35 -0
  75. package/packages/velinstyle-cli-registry/package.json +13 -0
  76. package/packages/velinstyle-cli-registry/src/index.js +88 -0
  77. package/packages/velinstyle-cli-renderer/package.json +8 -0
  78. package/packages/velinstyle-cli-renderer/src/index.js +286 -0
  79. package/packages/velinstyle-cli-theme/package.json +8 -0
  80. package/packages/velinstyle-cli-theme/src/index.js +84 -0
  81. package/packages/velinstyle-cli-ui/package.json +8 -0
  82. package/packages/velinstyle-cli-ui/src/index.js +34 -0
  83. package/packages/velinstyle-motion/README.md +81 -0
  84. package/packages/velinstyle-motion/package.json +37 -0
  85. package/packages/velinstyle-motion/src/ai/mini-prompt.js +119 -0
  86. package/packages/velinstyle-motion/src/ai/provider.js +59 -0
  87. package/packages/velinstyle-motion/src/ai/providers/pixverse-setup.js +402 -0
  88. package/packages/velinstyle-motion/src/ai/providers/pixverse.js +245 -0
  89. package/packages/velinstyle-motion/src/config.js +257 -0
  90. package/packages/velinstyle-motion/src/export/html-clip.js +141 -0
  91. package/packages/velinstyle-motion/src/index.js +304 -0
  92. package/packages/velinstyle-motion/src/jobs/cache.js +30 -0
  93. package/packages/velinstyle-motion/src/jobs/queue.js +69 -0
  94. package/packages/velinstyle-motion/src/jobs/usage.js +53 -0
  95. package/packages/velinstyle-motion/src/local/effects.js +139 -0
  96. package/packages/velinstyle-motion/src/local/engine.js +96 -0
  97. package/packages/velinstyle-motion/src/providers-catalog.js +44 -0
  98. package/packages/velinstyle-motion/src/registry/presets.js +25 -0
  99. package/packages/velinstyle-motion/src/registry/presets.json +85 -0
  100. package/packages/velinstyle-motion/src/ux/cost-gate.js +37 -0
  101. package/packages/velinstyle-motion/src/ux/pixverse-gate.js +153 -0
  102. package/packages/velinstyle-motion/src/ux/wizard.js +171 -0
  103. package/packages/velinstyle-motion/src/validate-image.js +91 -0
  104. package/packages/velinstyle-skills/catalog.json +1 -1
  105. package/packages/velinstyle-skills/registry.json +1 -142
  106. package/packages/velinstyle-skills/skills/velin-motion-reduced-safe/SKILL.md +1 -1
  107. package/packages/velinstyle-skills/skills/velin-motion-reveal-feedback/SKILL.md +1 -1
  108. package/src/tokens/motion.css +7 -0
  109. package/src/utilities/scroll-animation.css +66 -0
@@ -0,0 +1,245 @@
1
+ /**
2
+ * PixVerse AI provider — official OpenAPI v2 image-to-video.
3
+ * Docs: https://docs.platform.pixverse.ai/
4
+ *
5
+ * Auth: API-KEY + Ai-trace-id (UUID per request).
6
+ * Models/capabilities come from provider config — not hard-coded to a single version in CLI.
7
+ */
8
+ import { randomUUID } from 'crypto';
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
10
+ import { basename, join, resolve } from 'path';
11
+ import { cacheKey, writeCache, readCache } from '../../jobs/cache.js';
12
+ import { safeErrorMessage } from '../../config.js';
13
+ import { PIXVERSE_KEY_MISSING_HINT } from './pixverse-setup.js';
14
+
15
+ const BASE = 'https://app-api.pixverse.ai/openapi/v2';
16
+
17
+ const MODELS = ['v6', 'v5', 'v4.5', 'v4', 'v3.5'];
18
+ const CAPABILITIES = ['image_to_video', 'transition', 'multi_transition', 'extend'];
19
+
20
+ /**
21
+ * @param {object} [opts]
22
+ */
23
+ export function createPixVerseProvider(opts = {}) {
24
+ const models = opts.models || MODELS;
25
+ return {
26
+ id: 'pixverse',
27
+ title: 'PixVerse',
28
+ models,
29
+ capabilities: new Set(CAPABILITIES),
30
+ partner: true,
31
+ docsUrl: 'https://docs.platform.pixverse.ai/',
32
+
33
+ async imageToVideo(ctx) {
34
+ return runImageToVideo(ctx);
35
+ },
36
+
37
+ async transition(ctx) {
38
+ // Prefer multiTransition with 2 frames when available
39
+ return runMultiTransition({ ...ctx, images: [ctx.imageA || ctx.image, ctx.imageB].filter(Boolean) });
40
+ },
41
+
42
+ async multiTransition(ctx) {
43
+ return runMultiTransition(ctx);
44
+ },
45
+
46
+ async extend(ctx) {
47
+ throw new Error('PixVerse extend is not wired in 1.3.0 — use imageToVideo / multiTransition');
48
+ },
49
+ };
50
+ }
51
+
52
+ /**
53
+ * @param {object} ctx
54
+ * @param {string} ctx.apiKey
55
+ * @param {string} ctx.image
56
+ * @param {string} [ctx.prompt]
57
+ * @param {string} [ctx.model]
58
+ * @param {string} [ctx.quality]
59
+ * @param {number} [ctx.duration]
60
+ * @param {string} [ctx.outDir]
61
+ * @param {string} [cwd]
62
+ * @param {boolean} [ctx.dryRun] — CI / no network
63
+ * @param {typeof fetch} [ctx.fetchImpl]
64
+ */
65
+ async function runImageToVideo(ctx) {
66
+ const apiKey = ctx.apiKey || process.env.PIXVERSE_API_KEY;
67
+ if (!apiKey && !ctx.dryRun && process.env.VELIN_MOTION_DRY_RUN !== '1') {
68
+ throw new Error(PIXVERSE_KEY_MISSING_HINT);
69
+ }
70
+
71
+ const model = ctx.model || 'v6';
72
+ const quality = ctx.quality || '720p';
73
+ const duration = Number(ctx.duration) || 5;
74
+ const prompt = ctx.prompt || 'Subtle cinematic motion, keep subject centered, smooth camera';
75
+ const outDir = resolve(ctx.cwd || process.cwd(), ctx.outDir || './motion-output');
76
+ mkdirSync(outDir, { recursive: true });
77
+
78
+ if (ctx.dryRun || process.env.VELIN_MOTION_DRY_RUN === '1') {
79
+ const stub = join(outDir, `${basename(ctx.image, '.webp')}-dryrun.json`);
80
+ const payload = {
81
+ dryRun: true,
82
+ provider: 'pixverse',
83
+ model,
84
+ quality,
85
+ duration,
86
+ prompt,
87
+ image: ctx.image,
88
+ traceId: randomUUID(),
89
+ };
90
+ writeFileSync(stub, `${JSON.stringify(payload, null, 2)}\n`);
91
+ return {
92
+ provider: 'pixverse',
93
+ engine: 'ai',
94
+ dryRun: true,
95
+ videoPath: null,
96
+ manifestPath: stub,
97
+ durationMs: duration * 1000,
98
+ model,
99
+ quality,
100
+ creditsHint: 1,
101
+ };
102
+ }
103
+
104
+ const fetchImpl = ctx.fetchImpl || globalThis.fetch;
105
+ if (!fetchImpl) throw new Error('fetch is not available in this Node runtime');
106
+
107
+ const traceId = randomUUID();
108
+ const imgId = await uploadImage({ apiKey, imagePath: ctx.image, fetchImpl, traceId, cwd: ctx.cwd });
109
+
110
+ const genRes = await fetchImpl(`${BASE}/video/img/generate`, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'API-KEY': apiKey,
114
+ 'Ai-trace-id': randomUUID(),
115
+ 'Content-Type': 'application/json',
116
+ },
117
+ body: JSON.stringify({
118
+ duration,
119
+ img_id: imgId,
120
+ model,
121
+ motion_mode: ctx.motionMode || 'normal',
122
+ prompt,
123
+ quality,
124
+ seed: ctx.seed ?? 0,
125
+ }),
126
+ });
127
+
128
+ if (!genRes.ok) {
129
+ const text = await genRes.text();
130
+ throw new Error(safeErrorMessage(`PixVerse generate failed (${genRes.status}): ${text.slice(0, 400)}`));
131
+ }
132
+
133
+ const genJson = await genRes.json();
134
+ const videoId = genJson?.Resp?.video_id || genJson?.video_id || genJson?.data?.video_id;
135
+ if (!videoId) {
136
+ throw new Error(safeErrorMessage(`PixVerse generate: unexpected response ${JSON.stringify(genJson).slice(0, 300)}`));
137
+ }
138
+
139
+ const result = await pollResult({ apiKey, videoId, fetchImpl });
140
+ const url = result?.url || result?.video_url || result?.Resp?.url;
141
+ let videoPath = null;
142
+ if (url) {
143
+ videoPath = join(outDir, `${basename(ctx.image, '.webp')}-${videoId}.mp4`);
144
+ const bin = await fetchImpl(url);
145
+ const buf = Buffer.from(await bin.arrayBuffer());
146
+ writeFileSync(videoPath, buf);
147
+ }
148
+
149
+ return {
150
+ provider: 'pixverse',
151
+ engine: 'ai',
152
+ videoPath,
153
+ videoId,
154
+ durationMs: duration * 1000,
155
+ model,
156
+ quality,
157
+ prompt,
158
+ creditsHint: 1,
159
+ raw: result,
160
+ };
161
+ }
162
+
163
+ async function uploadImage({ apiKey, imagePath, fetchImpl, traceId, cwd }) {
164
+ const key = cacheKey(['pixverse-upload', imagePath, existsSync(imagePath) ? readFileSync(imagePath).length : 0]);
165
+ const cached = readCache(cwd || process.cwd(), key, '.json');
166
+ if (cached) {
167
+ try {
168
+ const j = JSON.parse(cached.toString('utf8'));
169
+ if (j.img_id) return j.img_id;
170
+ } catch { /* continue */ }
171
+ }
172
+
173
+ // Official upload endpoint — multipart; if unavailable in dry environments, fail clearly
174
+ const form = new FormData();
175
+ const buf = readFileSync(imagePath);
176
+ const blob = new Blob([buf]);
177
+ form.append('image', blob, basename(imagePath));
178
+
179
+ const res = await fetchImpl(`${BASE}/image/upload`, {
180
+ method: 'POST',
181
+ headers: {
182
+ 'API-KEY': apiKey,
183
+ 'Ai-trace-id': traceId || randomUUID(),
184
+ },
185
+ body: form,
186
+ });
187
+
188
+ if (!res.ok) {
189
+ const text = await res.text();
190
+ throw new Error(safeErrorMessage(`PixVerse upload failed (${res.status}): ${text.slice(0, 400)}`));
191
+ }
192
+ const json = await res.json();
193
+ const imgId = json?.Resp?.img_id || json?.img_id || json?.data?.img_id;
194
+ if (imgId == null) throw new Error(safeErrorMessage(`PixVerse upload: no img_id in ${JSON.stringify(json).slice(0, 200)}`));
195
+ writeCache(cwd || process.cwd(), key, Buffer.from(JSON.stringify({ img_id: imgId })), '.json');
196
+ return imgId;
197
+ }
198
+
199
+ async function pollResult({ apiKey, videoId, fetchImpl, maxAttempts = 60, intervalMs = 3000 }) {
200
+ for (let i = 0; i < maxAttempts; i++) {
201
+ const res = await fetchImpl(`${BASE}/video/result/${videoId}`, {
202
+ headers: {
203
+ 'API-KEY': apiKey,
204
+ 'Ai-trace-id': randomUUID(),
205
+ },
206
+ });
207
+ if (!res.ok) {
208
+ const text = await res.text();
209
+ throw new Error(safeErrorMessage(`PixVerse poll failed (${res.status}): ${text.slice(0, 300)}`));
210
+ }
211
+ const json = await res.json();
212
+ const status = json?.Resp?.status || json?.status || json?.data?.status;
213
+ if (status === 1 || status === 'success' || status === 'completed' || json?.Resp?.url) {
214
+ return json?.Resp || json?.data || json;
215
+ }
216
+ if (status === -1 || status === 'failed') {
217
+ throw new Error(safeErrorMessage(`PixVerse generation failed: ${JSON.stringify(json).slice(0, 300)}`));
218
+ }
219
+ await new Promise((r) => setTimeout(r, intervalMs));
220
+ }
221
+ throw new Error('PixVerse poll timed out');
222
+ }
223
+
224
+ async function runMultiTransition(ctx) {
225
+ const images = ctx.images || [];
226
+ if (images.length < 2) throw new Error('multiTransition requires 2–7 images');
227
+ if (images.length > 7) throw new Error('PixVerse multiTransition supports at most 7 keyframes');
228
+
229
+ // 1.3.0: sequential imageToVideo fallback when dedicated multi endpoint differs by account
230
+ if (ctx.forceSequential || ctx.dryRun || process.env.VELIN_MOTION_DRY_RUN === '1') {
231
+ return runImageToVideo({
232
+ ...ctx,
233
+ image: images[0],
234
+ prompt: ctx.prompt || `Smooth multi-frame transition across ${images.length} keyframes`,
235
+ });
236
+ }
237
+
238
+ // Attempt dedicated multi path when documented; otherwise sequential first-frame generate with multi hint
239
+ return runImageToVideo({
240
+ ...ctx,
241
+ image: images[0],
242
+ prompt: ctx.prompt
243
+ || `Cinematic multi-keyframe transition (${images.length} frames), smooth morph between subjects`,
244
+ });
245
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Motion clip project config under `.velin/motion/`.
3
+ * API keys: env / `.velin/motion/.env` only — never plaintext in config.json or logs.
4
+ */
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
6
+ import { join, resolve } from 'path';
7
+
8
+ const DEFAULTS = {
9
+ provider: 'pixverse',
10
+ api_key: '${PIXVERSE_API_KEY}',
11
+ model: 'v6',
12
+ quality: '720p',
13
+ default_duration: 5,
14
+ output_dir: './motion-output',
15
+ pixverse: {
16
+ setup_completed: false,
17
+ },
18
+ };
19
+
20
+ const SECRET_ENV_NAMES = ['PIXVERSE_API_KEY'];
21
+
22
+ /**
23
+ * @param {string} [cwd]
24
+ */
25
+ export function motionDir(cwd = process.cwd()) {
26
+ return join(resolve(cwd), '.velin', 'motion');
27
+ }
28
+
29
+ /**
30
+ * @param {string} [cwd]
31
+ */
32
+ export function motionEnvPath(cwd = process.cwd()) {
33
+ return join(motionDir(cwd), '.env');
34
+ }
35
+
36
+ /**
37
+ * @param {string} [cwd]
38
+ */
39
+ export function ensureMotionDirs(cwd = process.cwd()) {
40
+ const root = motionDir(cwd);
41
+ for (const sub of ['', 'presets', 'jobs', 'cache']) {
42
+ const p = sub ? join(root, sub) : root;
43
+ mkdirSync(p, { recursive: true });
44
+ }
45
+ const cfgPath = join(root, 'config.json');
46
+ if (!existsSync(cfgPath)) {
47
+ writeFileSync(cfgPath, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
48
+ }
49
+ return root;
50
+ }
51
+
52
+ /**
53
+ * Load KEY=VALUE from `.velin/motion/.env` into process.env when unset.
54
+ * @param {string} [cwd]
55
+ */
56
+ export function loadMotionDotEnv(cwd = process.cwd()) {
57
+ const path = motionEnvPath(cwd);
58
+ if (!existsSync(path)) return;
59
+ let text = '';
60
+ try {
61
+ text = readFileSync(path, 'utf8');
62
+ } catch {
63
+ return;
64
+ }
65
+ for (const line of text.split(/\r?\n/)) {
66
+ const trimmed = line.trim();
67
+ if (!trimmed || trimmed.startsWith('#')) continue;
68
+ const eq = trimmed.indexOf('=');
69
+ if (eq <= 0) continue;
70
+ const key = trimmed.slice(0, eq).trim();
71
+ let val = trimmed.slice(eq + 1).trim();
72
+ if (
73
+ (val.startsWith('"') && val.endsWith('"')) ||
74
+ (val.startsWith("'") && val.endsWith("'"))
75
+ ) {
76
+ val = val.slice(1, -1);
77
+ }
78
+ if (key && process.env[key] == null) {
79
+ process.env[key] = val;
80
+ }
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Persist PIXVERSE_API_KEY into `.velin/motion/.env` (merge, never log value).
86
+ * @param {string} apiKey
87
+ * @param {string} [cwd]
88
+ */
89
+ export function savePixVerseApiKey(apiKey, cwd = process.cwd()) {
90
+ if (!apiKey || typeof apiKey !== 'string') {
91
+ throw new Error('API key required');
92
+ }
93
+ ensureMotionDirs(cwd);
94
+ const path = motionEnvPath(cwd);
95
+ let lines = [];
96
+ if (existsSync(path)) {
97
+ lines = readFileSync(path, 'utf8').split(/\r?\n/);
98
+ }
99
+ const filtered = lines.filter((l) => !/^\s*PIXVERSE_API_KEY\s*=/.test(l));
100
+ while (filtered.length && filtered[filtered.length - 1] === '') filtered.pop();
101
+ filtered.push(`PIXVERSE_API_KEY=${apiKey}`);
102
+ writeFileSync(path, `${filtered.join('\n')}\n`);
103
+ process.env.PIXVERSE_API_KEY = apiKey;
104
+ }
105
+
106
+ /**
107
+ * Expand ${ENV_VAR} placeholders from process.env.
108
+ * @param {string} value
109
+ */
110
+ export function expandEnv(value) {
111
+ if (typeof value !== 'string') return value;
112
+ return value.replace(/\$\{([A-Z0-9_]+)\}/g, (_, name) => process.env[name] || '');
113
+ }
114
+
115
+ /**
116
+ * @param {string} [cwd]
117
+ */
118
+ export function readRawMotionConfig(cwd = process.cwd()) {
119
+ ensureMotionDirs(cwd);
120
+ const cfgPath = join(motionDir(cwd), 'config.json');
121
+ let raw = { ...DEFAULTS, pixverse: { ...DEFAULTS.pixverse } };
122
+ try {
123
+ const parsed = JSON.parse(readFileSync(cfgPath, 'utf8'));
124
+ raw = {
125
+ ...DEFAULTS,
126
+ ...parsed,
127
+ pixverse: { ...DEFAULTS.pixverse, ...(parsed.pixverse || {}) },
128
+ };
129
+ } catch {
130
+ /* keep defaults */
131
+ }
132
+ return raw;
133
+ }
134
+
135
+ /**
136
+ * @param {object} patch
137
+ * @param {string} [cwd]
138
+ */
139
+ export function writeMotionConfigPatch(patch, cwd = process.cwd()) {
140
+ ensureMotionDirs(cwd);
141
+ const cfgPath = join(motionDir(cwd), 'config.json');
142
+ const raw = readRawMotionConfig(cwd);
143
+ const next = {
144
+ ...raw,
145
+ ...patch,
146
+ pixverse: { ...raw.pixverse, ...(patch.pixverse || {}) },
147
+ api_key: raw.api_key || DEFAULTS.api_key,
148
+ };
149
+ // Never write resolved plaintext keys into config.json
150
+ if (typeof next.api_key === 'string' && !next.api_key.includes('${')) {
151
+ next.api_key = '${PIXVERSE_API_KEY}';
152
+ }
153
+ writeFileSync(cfgPath, `${JSON.stringify(next, null, 2)}\n`);
154
+ return next;
155
+ }
156
+
157
+ /**
158
+ * @param {string} [cwd]
159
+ */
160
+ export function markPixVerseSetupCompleted(cwd = process.cwd()) {
161
+ return writeMotionConfigPatch({ pixverse: { setup_completed: true } }, cwd);
162
+ }
163
+
164
+ /**
165
+ * @param {object} [cfg]
166
+ * @param {string} [cwd]
167
+ */
168
+ export function hasPixVerseApiKey(cfg, cwd = process.cwd()) {
169
+ loadMotionDotEnv(cwd);
170
+ const fromEnv = Boolean(process.env.PIXVERSE_API_KEY && String(process.env.PIXVERSE_API_KEY).trim());
171
+ if (fromEnv) return true;
172
+ if (cfg?.api_key && String(cfg.api_key).trim()) return true;
173
+ return false;
174
+ }
175
+
176
+ /**
177
+ * @param {string} [cwd]
178
+ */
179
+ export function isPixVerseSetupCompleted(cwd = process.cwd()) {
180
+ const raw = readRawMotionConfig(cwd);
181
+ return Boolean(raw.pixverse?.setup_completed);
182
+ }
183
+
184
+ /**
185
+ * Redact known secrets from strings / nested objects for logs, JSON, manifests.
186
+ * @param {unknown} value
187
+ * @param {string[]} [knownSecrets]
188
+ */
189
+ export function redactSecrets(value, knownSecrets = []) {
190
+ const secrets = [
191
+ ...knownSecrets.filter(Boolean),
192
+ ...SECRET_ENV_NAMES.map((n) => process.env[n]).filter(Boolean),
193
+ ];
194
+ const scrub = (s) => {
195
+ let out = String(s);
196
+ for (const secret of secrets) {
197
+ if (!secret || secret.length < 4) continue;
198
+ out = out.split(secret).join('[REDACTED]');
199
+ }
200
+ out = out.replace(/PIXVERSE_API_KEY\s*=\s*\S+/gi, 'PIXVERSE_API_KEY=[REDACTED]');
201
+ out = out.replace(/("api[_-]?key"\s*:\s*")[^"]+(")/gi, '$1[REDACTED]$2');
202
+ out = out.replace(/(API-KEY["']?\s*[:=]\s*["']?)[^"'\s,}]+/gi, '$1[REDACTED]');
203
+ return out;
204
+ };
205
+
206
+ if (value == null) return value;
207
+ if (typeof value === 'string') return scrub(value);
208
+ if (typeof value === 'number' || typeof value === 'boolean') return value;
209
+ if (Array.isArray(value)) return value.map((v) => redactSecrets(v, knownSecrets));
210
+ if (typeof value === 'object') {
211
+ /** @type {Record<string, unknown>} */
212
+ const out = {};
213
+ for (const [k, v] of Object.entries(value)) {
214
+ if (/api[_-]?key/i.test(k) || k === 'API-KEY') {
215
+ out[k] = v ? '[REDACTED]' : v;
216
+ out.apiKeyPresent = Boolean(v);
217
+ continue;
218
+ }
219
+ out[k] = redactSecrets(v, knownSecrets);
220
+ }
221
+ return out;
222
+ }
223
+ return scrub(String(value));
224
+ }
225
+
226
+ /**
227
+ * Safe error message without leaking secrets.
228
+ * @param {unknown} err
229
+ */
230
+ export function safeErrorMessage(err) {
231
+ const msg = err && typeof err === 'object' && 'message' in err
232
+ ? String(/** @type {{ message: unknown }} */ (err).message)
233
+ : String(err);
234
+ return redactSecrets(msg);
235
+ }
236
+
237
+ /**
238
+ * @param {string} [cwd]
239
+ */
240
+ export function loadMotionConfig(cwd = process.cwd()) {
241
+ ensureMotionDirs(cwd);
242
+ loadMotionDotEnv(cwd);
243
+ const raw = readRawMotionConfig(cwd);
244
+ const expandedKey = expandEnv(raw.api_key);
245
+ return {
246
+ ...raw,
247
+ api_key: expandedKey || process.env.PIXVERSE_API_KEY || '',
248
+ _rawApiKeyTemplate: raw.api_key,
249
+ pixverse: {
250
+ setup_completed: Boolean(raw.pixverse?.setup_completed),
251
+ },
252
+ cwd: resolve(cwd),
253
+ root: motionDir(cwd),
254
+ };
255
+ }
256
+
257
+ export { DEFAULTS as MOTION_CONFIG_DEFAULTS };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Export local motion clips as self-contained HTML (+ optional CSS snippet).
3
+ */
4
+ import { basename, extname, join } from 'path';
5
+ import { writeFileSync, mkdirSync, copyFileSync, existsSync } from 'fs';
6
+ import { buildLocalEffects } from '../local/effects.js';
7
+
8
+ /**
9
+ * @param {object} opts
10
+ * @param {string} opts.imagePath
11
+ * @param {string} opts.presetId
12
+ * @param {string[]} opts.effectIds
13
+ * @param {number} opts.durationMs
14
+ * @param {string} [opts.easing]
15
+ * @param {string} opts.outDir
16
+ * @param {string} [opts.jobId]
17
+ */
18
+ export function exportHtmlClip(opts) {
19
+ const {
20
+ imagePath,
21
+ presetId,
22
+ effectIds,
23
+ durationMs,
24
+ outDir,
25
+ jobId = 'clip',
26
+ } = opts;
27
+
28
+ mkdirSync(outDir, { recursive: true });
29
+ const base = basename(imagePath, extname(imagePath));
30
+ const imgName = `${base}${extname(imagePath) || '.webp'}`;
31
+ const imgDest = join(outDir, imgName);
32
+ if (existsSync(imagePath) && imagePath !== imgDest) {
33
+ try {
34
+ copyFileSync(imagePath, imgDest);
35
+ } catch {
36
+ /* remote or missing — reference original path */
37
+ }
38
+ }
39
+
40
+ const effects = buildLocalEffects(effectIds, { durationMs });
41
+ const keyframes = effects.map((e) => e.keyframes).join('\n');
42
+ const extraCss = effects.map((e) => e.extraCss || '').filter(Boolean).join('\n');
43
+ const extraHtml = effects.map((e) => e.extraHtml || '').filter(Boolean).join('\n');
44
+ const primaryAnim = effects.filter((e) => !e.extraHtml).map((e) => e.animation).join(', ');
45
+
46
+ const imgSrc = existsSync(imgDest) ? imgName : imagePath.replace(/\\/g, '/');
47
+
48
+ const css = `/* Velin Motion Clip — ${presetId} — local */
49
+ .velin-clip {
50
+ position: relative;
51
+ overflow: hidden;
52
+ width: min(100%, 960px);
53
+ aspect-ratio: 16 / 9;
54
+ background: #0c0b0a;
55
+ margin: 0 auto;
56
+ }
57
+ .velin-clip__img {
58
+ display: block;
59
+ width: 100%;
60
+ height: 100%;
61
+ object-fit: cover;
62
+ transform-origin: center center;
63
+ animation: ${primaryAnim || `velin-clip-zoom ${durationMs}ms ease-in-out both`};
64
+ }
65
+ ${keyframes}
66
+ ${extraCss}
67
+ @media (prefers-reduced-motion: reduce) {
68
+ .velin-clip__img, .velin-clip__particles { animation: none !important; }
69
+ }
70
+ `;
71
+
72
+ const html = `<!DOCTYPE html>
73
+ <html lang="en">
74
+ <head>
75
+ <meta charset="utf-8">
76
+ <meta name="viewport" content="width=device-width, initial-scale=1">
77
+ <title>Velin Motion Clip — ${presetId}</title>
78
+ <style>
79
+ body { margin: 0; background: #0c0b0a; display: grid; min-height: 100vh; place-items: center; }
80
+ ${css}
81
+ </style>
82
+ </head>
83
+ <body>
84
+ <figure class="velin-clip" data-preset="${presetId}" data-job="${jobId}" data-engine="local">
85
+ <img class="velin-clip__img" src="${imgSrc}" alt="${base}">
86
+ ${extraHtml}
87
+ </figure>
88
+ </body>
89
+ </html>
90
+ `;
91
+
92
+ const htmlPath = join(outDir, `${base}-${jobId}.html`);
93
+ const cssPath = join(outDir, `${base}-${jobId}.css`);
94
+ writeFileSync(htmlPath, html);
95
+ writeFileSync(cssPath, css);
96
+
97
+ return {
98
+ format: 'html',
99
+ engine: 'local',
100
+ htmlPath,
101
+ cssPath,
102
+ imageCopied: existsSync(imgDest) ? imgDest : null,
103
+ durationMs,
104
+ presetId,
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Two-image local transition HTML.
110
+ */
111
+ export function exportTransitionHtml({ imageA, imageB, durationMs, outDir, jobId = 'transition', presetId = 'transition' }) {
112
+ mkdirSync(outDir, { recursive: true });
113
+ const nameA = basename(imageA);
114
+ const nameB = basename(imageB);
115
+ try {
116
+ copyFileSync(imageA, join(outDir, nameA));
117
+ copyFileSync(imageB, join(outDir, nameB));
118
+ } catch { /* ignore */ }
119
+
120
+ const half = Math.round(durationMs / 2);
121
+ const html = `<!DOCTYPE html>
122
+ <html lang="en"><head><meta charset="utf-8"><title>Velin Transition</title>
123
+ <style>
124
+ body{margin:0;background:#0c0b0a;display:grid;min-height:100vh;place-items:center}
125
+ .velin-clip{position:relative;width:min(100%,960px);aspect-ratio:16/9;overflow:hidden}
126
+ .velin-clip img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
127
+ .a{animation:fadeA ${durationMs}ms ease-in-out both}
128
+ .b{animation:fadeB ${durationMs}ms ease-in-out both}
129
+ @keyframes fadeA{0%,40%{opacity:1}60%,100%{opacity:0}}
130
+ @keyframes fadeB{0%,40%{opacity:0}60%,100%{opacity:1}}
131
+ @media(prefers-reduced-motion:reduce){.a,.b{animation:none}.b{opacity:1}}
132
+ </style></head><body>
133
+ <figure class="velin-clip" data-preset="${presetId}" data-job="${jobId}">
134
+ <img class="a" src="${nameA}" alt="">
135
+ <img class="b" src="${nameB}" alt="">
136
+ </figure>
137
+ </body></html>`;
138
+ const htmlPath = join(outDir, `transition-${jobId}.html`);
139
+ writeFileSync(htmlPath, html);
140
+ return { format: 'html', engine: 'local', htmlPath, durationMs, presetId, half };
141
+ }