@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,304 @@
1
+ /**
2
+ * @birdapi/velinstyle-motion — public API
3
+ */
4
+ export { assertMotionImageFile, MOTION_IMAGE_EXTENSIONS } from './validate-image.js';
5
+ export {
6
+ loadMotionConfig,
7
+ ensureMotionDirs,
8
+ motionDir,
9
+ MOTION_CONFIG_DEFAULTS,
10
+ hasPixVerseApiKey,
11
+ savePixVerseApiKey,
12
+ markPixVerseSetupCompleted,
13
+ isPixVerseSetupCompleted,
14
+ redactSecrets,
15
+ safeErrorMessage,
16
+ loadMotionDotEnv,
17
+ } from './config.js';
18
+ export { listClipPresets, getClipPreset, listWizardChoices } from './registry/presets.js';
19
+ export { createLocalClip, createLocalTransition, createEffectClip } from './local/engine.js';
20
+ export { listLocalEffectIds, profileToLocalEffects } from './local/effects.js';
21
+ export { exportHtmlClip } from './export/html-clip.js';
22
+ export {
23
+ registerAiProvider,
24
+ getAiProvider,
25
+ listAiProviders,
26
+ loadBuiltinAiProviders,
27
+ providerHas,
28
+ } from './ai/provider.js';
29
+ export { createPixVerseProvider } from './ai/providers/pixverse.js';
30
+ export {
31
+ runPixVerseSetup,
32
+ openPixVerseSignup,
33
+ pixVerseSignupUrl,
34
+ formatPixVerseSignupHelp,
35
+ formatPixVerseOpenAccountHelp,
36
+ formatPixVerseOpenFailedHelp,
37
+ formatPixVerseTrackerHint,
38
+ copyPixVersePartnerLink,
39
+ runPixVersePartnerOpenFlow,
40
+ PIXVERSE_AFFILIATE_URL,
41
+ PIXVERSE_DIRECT_URL,
42
+ PIXVERSE_DOCS_URL,
43
+ PIXVERSE_KEY_MISSING_HINT,
44
+ testPixVerseConnection,
45
+ } from './ai/providers/pixverse-setup.js';
46
+ export { suggestFromMiniPrompt, profileToAiPrompt } from './ai/mini-prompt.js';
47
+ export { runCreateWizard, runAtelierWizard } from './ux/wizard.js';
48
+ export { confirmAiCost } from './ux/cost-gate.js';
49
+ export { ensurePixVerseReady } from './ux/pixverse-gate.js';
50
+ export { createJob, finishJob, listJobs, loadJob } from './jobs/queue.js';
51
+ export { getUsageSummary } from './jobs/usage.js';
52
+ export { listMotionProviders, formatMotionProvidersText } from './providers-catalog.js';
53
+
54
+ import { loadMotionConfig, hasPixVerseApiKey, safeErrorMessage } from './config.js';
55
+ import { getClipPreset } from './registry/presets.js';
56
+ import { createLocalClip, createLocalTransition } from './local/engine.js';
57
+ import { loadBuiltinAiProviders, getAiProvider } from './ai/provider.js';
58
+ import { confirmAiCost } from './ux/cost-gate.js';
59
+ import { ensurePixVerseReady } from './ux/pixverse-gate.js';
60
+ import { createJob, finishJob } from './jobs/queue.js';
61
+ import { profileToAiPrompt } from './ai/mini-prompt.js';
62
+ import { assertMotionImageFile } from './validate-image.js';
63
+ import { readdirSync, existsSync, statSync } from 'fs';
64
+ import { join, resolve, extname } from 'path';
65
+
66
+ /**
67
+ * Resolve dry-run: only explicit flag / env — never auto on missing key.
68
+ * @param {object} opts
69
+ */
70
+ function resolveDryRun(opts) {
71
+ return Boolean(opts.dryRun || process.env.VELIN_MOTION_DRY_RUN === '1');
72
+ }
73
+
74
+ /**
75
+ * High-level create: local or AI based on options.
76
+ * @param {object} opts
77
+ */
78
+ export async function createMotionClip(opts = {}) {
79
+ const cwd = opts.cwd || process.cwd();
80
+ const cfg = loadMotionConfig(cwd);
81
+ const preset = getClipPreset(opts.preset || 'soft-zoom');
82
+ const engine = opts.engine || (preset?.engine === 'ai' ? 'ai' : 'local');
83
+
84
+ if (engine !== 'ai') {
85
+ return createLocalClip({
86
+ cwd,
87
+ image: opts.image,
88
+ preset: preset?.id || opts.preset || 'soft-zoom',
89
+ duration: opts.duration ?? cfg.default_duration,
90
+ outDir: opts.outDir || cfg.output_dir,
91
+ profile: opts.profile,
92
+ effects: opts.effects,
93
+ });
94
+ }
95
+
96
+ const imageAbs = resolve(cwd, opts.image);
97
+ assertMotionImageFile(imageAbs, 'Image');
98
+
99
+ const dryRun = resolveDryRun(opts);
100
+ await ensurePixVerseReady({
101
+ cwd,
102
+ yes: opts.yes,
103
+ quiet: opts.quiet,
104
+ json: opts.json,
105
+ dryRun,
106
+ openImpl: opts.openImpl,
107
+ });
108
+
109
+ await loadBuiltinAiProviders();
110
+ const providerId = opts.provider || cfg.provider || 'pixverse';
111
+ const provider = getAiProvider(providerId);
112
+ if (!provider) throw new Error(`Unknown AI provider: ${providerId}`);
113
+
114
+ const model = opts.model || cfg.model || provider.models[0];
115
+ const quality = opts.quality || cfg.quality || '720p';
116
+ const duration = Number(opts.duration ?? cfg.default_duration) || 5;
117
+ const prompt = opts.prompt || (opts.profile ? profileToAiPrompt(opts.profile) : undefined);
118
+
119
+ const ok = await confirmAiCost({
120
+ provider: provider.title,
121
+ model,
122
+ duration,
123
+ quality,
124
+ yes: opts.yes,
125
+ quiet: opts.quiet,
126
+ });
127
+ if (!ok) throw new Error('Abgebrochen');
128
+
129
+ const job = createJob({
130
+ cwd,
131
+ type: 'ai-clip',
132
+ engine: 'ai',
133
+ provider: providerId,
134
+ image: opts.image,
135
+ preset: preset?.id,
136
+ model,
137
+ quality,
138
+ duration,
139
+ creditsHint: 1,
140
+ apiKeyPresent: hasPixVerseApiKey(undefined, cwd),
141
+ });
142
+
143
+ try {
144
+ const fresh = loadMotionConfig(cwd);
145
+ const result = await provider.imageToVideo({
146
+ cwd,
147
+ apiKey: opts.apiKey || fresh.api_key || process.env.PIXVERSE_API_KEY,
148
+ image: imageAbs,
149
+ prompt,
150
+ model,
151
+ quality,
152
+ duration,
153
+ outDir: opts.outDir || cfg.output_dir,
154
+ dryRun,
155
+ });
156
+ finishJob(job, {
157
+ status: 'done',
158
+ result,
159
+ engine: 'ai',
160
+ provider: providerId,
161
+ creditsHint: result.creditsHint || 1,
162
+ apiKeyPresent: true,
163
+ });
164
+ return { job, ...result, preset };
165
+ } catch (err) {
166
+ finishJob(job, { status: 'failed', error: safeErrorMessage(err) });
167
+ throw new Error(safeErrorMessage(err));
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Batch atelier assets.
173
+ */
174
+ export async function createAtelierBatch(opts = {}) {
175
+ const cwd = opts.cwd || process.cwd();
176
+ const dir = resolve(cwd, opts.dir || '.');
177
+ const images = listImageFiles(dir).slice(0, opts.count || 10);
178
+ const results = [];
179
+ for (const image of images) {
180
+ const r = await createMotionClip({
181
+ ...opts,
182
+ cwd,
183
+ image,
184
+ preset: opts.preset || 'atelier',
185
+ engine: opts.engine || 'local',
186
+ yes: opts.yes !== false,
187
+ quiet: true,
188
+ });
189
+ results.push(r);
190
+ }
191
+ const totalSec = results.reduce((n, r) => n + ((r.durationMs || 0) / 1000), 0);
192
+ return {
193
+ count: results.length,
194
+ images: images.length,
195
+ totalSec,
196
+ outDir: opts.outDir || './motion-output',
197
+ results,
198
+ };
199
+ }
200
+
201
+ export function listImageFiles(dir) {
202
+ if (!existsSync(dir)) return [];
203
+ const st = statSync(dir);
204
+ if (st.isFile()) return [dir];
205
+ return readdirSync(dir)
206
+ .filter((f) => /\.(webp|png|jpe?g)$/i.test(f))
207
+ .sort()
208
+ .map((f) => join(dir, f));
209
+ }
210
+
211
+ /**
212
+ * AI or local transition between two images.
213
+ */
214
+ export async function createMotionTransition(opts = {}) {
215
+ const cwd = opts.cwd || process.cwd();
216
+ if ((opts.engine || 'local') !== 'ai') {
217
+ return createLocalTransition({
218
+ cwd,
219
+ imageA: opts.imageA,
220
+ imageB: opts.imageB,
221
+ duration: opts.duration,
222
+ outDir: opts.outDir,
223
+ });
224
+ }
225
+ const dryRun = resolveDryRun(opts);
226
+ const imageA = resolve(cwd, opts.imageA);
227
+ const imageB = resolve(cwd, opts.imageB);
228
+ assertMotionImageFile(imageA, 'Image A');
229
+ assertMotionImageFile(imageB, 'Image B');
230
+ await ensurePixVerseReady({
231
+ cwd,
232
+ yes: opts.yes,
233
+ quiet: opts.quiet,
234
+ json: opts.json,
235
+ dryRun,
236
+ openImpl: opts.openImpl,
237
+ });
238
+ await loadBuiltinAiProviders();
239
+ const cfg = loadMotionConfig(cwd);
240
+ const provider = getAiProvider(opts.provider || cfg.provider || 'pixverse');
241
+ if (!provider?.transition) throw new Error('Provider does not support transition');
242
+ const ok = await confirmAiCost({
243
+ provider: provider.title,
244
+ model: opts.model || cfg.model,
245
+ duration: opts.duration || cfg.default_duration,
246
+ quality: opts.quality || cfg.quality,
247
+ yes: opts.yes,
248
+ });
249
+ if (!ok) throw new Error('Abgebrochen');
250
+ return provider.transition({
251
+ cwd,
252
+ apiKey: cfg.api_key || process.env.PIXVERSE_API_KEY,
253
+ imageA,
254
+ imageB,
255
+ duration: opts.duration || cfg.default_duration,
256
+ model: opts.model || cfg.model,
257
+ quality: opts.quality || cfg.quality,
258
+ prompt: opts.prompt,
259
+ outDir: opts.outDir || cfg.output_dir,
260
+ dryRun,
261
+ });
262
+ }
263
+
264
+ /**
265
+ * Multi-frame sequence (AI multiTransition when possible).
266
+ */
267
+ export async function createMotionSequence(opts = {}) {
268
+ const cwd = opts.cwd || process.cwd();
269
+ const dryRun = resolveDryRun(opts);
270
+ const frames = (opts.frames || []).map((f) => resolve(cwd, f));
271
+ if (frames.length < 2) throw new Error('sequence needs at least 2 frames');
272
+ frames.forEach((f, i) => assertMotionImageFile(f, `Frame ${i + 1}`));
273
+ await ensurePixVerseReady({
274
+ cwd,
275
+ yes: opts.yes,
276
+ quiet: opts.quiet,
277
+ json: opts.json,
278
+ dryRun,
279
+ openImpl: opts.openImpl,
280
+ });
281
+ await loadBuiltinAiProviders();
282
+ const cfg = loadMotionConfig(cwd);
283
+ const provider = getAiProvider(opts.provider || cfg.provider || 'pixverse');
284
+ if (!provider?.multiTransition) throw new Error('Provider does not support multiTransition');
285
+ const ok = await confirmAiCost({
286
+ provider: provider.title,
287
+ model: opts.model || cfg.model,
288
+ duration: opts.duration || cfg.default_duration,
289
+ quality: opts.quality || cfg.quality,
290
+ yes: opts.yes,
291
+ });
292
+ if (!ok) throw new Error('Abgebrochen');
293
+ return provider.multiTransition({
294
+ cwd,
295
+ apiKey: cfg.api_key || process.env.PIXVERSE_API_KEY,
296
+ images: frames,
297
+ duration: opts.duration || cfg.default_duration,
298
+ model: opts.model || cfg.model,
299
+ quality: opts.quality || cfg.quality,
300
+ prompt: opts.prompt,
301
+ outDir: opts.outDir || cfg.output_dir,
302
+ dryRun,
303
+ });
304
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Simple cache helpers under `.velin/motion/cache/`.
3
+ */
4
+ import { createHash } from 'crypto';
5
+ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
6
+ import { join } from 'path';
7
+ import { motionDir, ensureMotionDirs } from '../config.js';
8
+
9
+ export function cacheKey(parts) {
10
+ return createHash('sha256').update(JSON.stringify(parts)).digest('hex').slice(0, 16);
11
+ }
12
+
13
+ export function cachePath(cwd, key, ext = '.bin') {
14
+ ensureMotionDirs(cwd);
15
+ const dir = join(motionDir(cwd), 'cache');
16
+ mkdirSync(dir, { recursive: true });
17
+ return join(dir, `${key}${ext}`);
18
+ }
19
+
20
+ export function readCache(cwd, key, ext) {
21
+ const p = cachePath(cwd, key, ext);
22
+ if (!existsSync(p)) return null;
23
+ return readFileSync(p);
24
+ }
25
+
26
+ export function writeCache(cwd, key, data, ext) {
27
+ const p = cachePath(cwd, key, ext);
28
+ writeFileSync(p, data);
29
+ return p;
30
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Job queue + manifests under `.velin/motion/jobs/`.
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { ensureMotionDirs, motionDir, redactSecrets } from '../config.js';
7
+ import { recordUsage } from './usage.js';
8
+
9
+ function nextId(cwd) {
10
+ const dir = join(motionDir(cwd), 'jobs');
11
+ mkdirSync(dir, { recursive: true });
12
+ const n = readdirSync(dir).filter((f) => f.endsWith('.json')).length + 1;
13
+ return `motion-${String(n).padStart(4, '0')}`;
14
+ }
15
+
16
+ /**
17
+ * @param {object} partial
18
+ */
19
+ export function createJob(partial = {}) {
20
+ const cwd = partial.cwd || process.cwd();
21
+ ensureMotionDirs(cwd);
22
+ const id = partial.id || nextId(cwd);
23
+ const job = {
24
+ id,
25
+ status: 'pending',
26
+ createdAt: new Date().toISOString(),
27
+ updatedAt: new Date().toISOString(),
28
+ ...partial,
29
+ cwd: undefined,
30
+ };
31
+ writeJob(cwd, job);
32
+ return { ...job, cwd };
33
+ }
34
+
35
+ export function writeJob(cwd, job) {
36
+ const dir = join(motionDir(cwd), 'jobs');
37
+ mkdirSync(dir, { recursive: true });
38
+ const path = join(dir, `${job.id}.json`);
39
+ const safe = redactSecrets({ ...job, updatedAt: new Date().toISOString() });
40
+ writeFileSync(path, `${JSON.stringify(safe, null, 2)}\n`);
41
+ return path;
42
+ }
43
+
44
+ export function finishJob(job, patch = {}) {
45
+ const cwd = job.cwd || process.cwd();
46
+ const next = {
47
+ ...job,
48
+ ...patch,
49
+ status: patch.status || 'done',
50
+ finishedAt: new Date().toISOString(),
51
+ };
52
+ writeJob(cwd, next);
53
+ recordUsage(cwd, next);
54
+ return next;
55
+ }
56
+
57
+ export function loadJob(cwd, id) {
58
+ const path = join(motionDir(cwd), 'jobs', `${id}.json`);
59
+ if (!existsSync(path)) return null;
60
+ return JSON.parse(readFileSync(path, 'utf8'));
61
+ }
62
+
63
+ export function listJobs(cwd = process.cwd()) {
64
+ const dir = join(motionDir(cwd), 'jobs');
65
+ if (!existsSync(dir)) return [];
66
+ return readdirSync(dir)
67
+ .filter((f) => f.endsWith('.json'))
68
+ .map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')));
69
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Aggregate motion usage stats from job manifests.
3
+ */
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
5
+ import { join } from 'path';
6
+ import { motionDir, ensureMotionDirs } from '../config.js';
7
+ import { listJobs } from './queue.js';
8
+
9
+ function usagePath(cwd) {
10
+ return join(motionDir(cwd), 'usage.json');
11
+ }
12
+
13
+ export function recordUsage(cwd, job) {
14
+ ensureMotionDirs(cwd);
15
+ const path = usagePath(cwd);
16
+ let data = { entries: [] };
17
+ if (existsSync(path)) {
18
+ try {
19
+ data = JSON.parse(readFileSync(path, 'utf8'));
20
+ } catch { /* reset */ }
21
+ }
22
+ data.entries.push({
23
+ id: job.id,
24
+ at: job.finishedAt || new Date().toISOString(),
25
+ engine: job.engine || job.result?.engine || 'local',
26
+ provider: job.provider || null,
27
+ durationSec: job.result?.durationMs ? job.result.durationMs / 1000 : job.duration || null,
28
+ preset: job.preset || null,
29
+ creditsHint: job.creditsHint || (job.engine === 'ai' ? 1 : 0),
30
+ });
31
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
32
+ }
33
+
34
+ /**
35
+ * @param {string} [cwd]
36
+ */
37
+ export function getUsageSummary(cwd = process.cwd()) {
38
+ const jobs = listJobs(cwd);
39
+ const now = Date.now();
40
+ const day = 86400000;
41
+ const today = jobs.filter((j) => j.finishedAt && now - Date.parse(j.finishedAt) < day);
42
+ const week = jobs.filter((j) => j.finishedAt && now - Date.parse(j.finishedAt) < 7 * day);
43
+ const aiToday = today.filter((j) => j.engine === 'ai' || j.provider);
44
+ const aiWeek = week.filter((j) => j.engine === 'ai' || j.provider);
45
+ const credits = aiWeek.reduce((n, j) => n + (j.creditsHint || 1), 0);
46
+
47
+ return {
48
+ today: { clips: today.length, ai: aiToday.length },
49
+ week: { clips: week.length, ai: aiWeek.length },
50
+ pixverse: { creditsUsedHint: credits },
51
+ totalJobs: jobs.length,
52
+ };
53
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Local clip effect definitions → CSS keyframes + animation meta.
3
+ */
4
+
5
+ /** @typedef {{ name: string, keyframes: string, animation: string, extraCss?: string, extraHtml?: string }} LocalEffect */
6
+
7
+ /** @type {Record<string, (opts: { durationMs: number }) => LocalEffect>} */
8
+ const EFFECTS = {
9
+ zoom({ durationMs }) {
10
+ return {
11
+ name: 'zoom',
12
+ keyframes: `@keyframes velin-clip-zoom {
13
+ from { transform: scale(1); }
14
+ to { transform: scale(1.12); }
15
+ }`,
16
+ animation: `velin-clip-zoom ${durationMs}ms ease-in-out both`,
17
+ };
18
+ },
19
+ pan({ durationMs }) {
20
+ return {
21
+ name: 'pan',
22
+ keyframes: `@keyframes velin-clip-pan {
23
+ from { transform: translateX(-4%) scale(1.08); }
24
+ to { transform: translateX(4%) scale(1.08); }
25
+ }`,
26
+ animation: `velin-clip-pan ${durationMs}ms ease-in-out both`,
27
+ };
28
+ },
29
+ rotate({ durationMs }) {
30
+ return {
31
+ name: 'rotate',
32
+ keyframes: `@keyframes velin-clip-rotate {
33
+ from { transform: rotate(-4deg) scale(1.05); }
34
+ to { transform: rotate(4deg) scale(1.05); }
35
+ }`,
36
+ animation: `velin-clip-rotate ${durationMs}ms ease-in-out both`,
37
+ };
38
+ },
39
+ fade({ durationMs }) {
40
+ return {
41
+ name: 'fade',
42
+ keyframes: `@keyframes velin-clip-fade {
43
+ from { opacity: 0; }
44
+ to { opacity: 1; }
45
+ }`,
46
+ animation: `velin-clip-fade ${Math.min(800, durationMs / 3)}ms ease-out both`,
47
+ };
48
+ },
49
+ intro({ durationMs }) {
50
+ return {
51
+ name: 'intro',
52
+ keyframes: `@keyframes velin-clip-intro {
53
+ from { opacity: 0; transform: translateY(12px) scale(0.98); }
54
+ to { opacity: 1; transform: translateY(0) scale(1); }
55
+ }`,
56
+ animation: `velin-clip-intro ${Math.min(900, durationMs / 3)}ms cubic-bezier(0.22, 1, 0.36, 1) both`,
57
+ };
58
+ },
59
+ outro({ durationMs }) {
60
+ return {
61
+ name: 'outro',
62
+ keyframes: `@keyframes velin-clip-outro {
63
+ from { opacity: 1; transform: scale(1); }
64
+ to { opacity: 0; transform: scale(1.04); }
65
+ }`,
66
+ animation: `velin-clip-outro ${Math.min(700, durationMs / 4)}ms ease-in ${Math.max(0, durationMs - 700)}ms both`,
67
+ };
68
+ },
69
+ particles({ durationMs }) {
70
+ return {
71
+ name: 'particles',
72
+ keyframes: `@keyframes velin-clip-particles {
73
+ from { opacity: 0.85; transform: scale(1.2); filter: blur(2px); }
74
+ to { opacity: 0; transform: scale(1); filter: blur(0); }
75
+ }`,
76
+ animation: `velin-clip-particles ${Math.min(1200, durationMs / 2)}ms ease-out both`,
77
+ extraCss: `.velin-clip__particles {
78
+ position: absolute; inset: 0; pointer-events: none;
79
+ background:
80
+ radial-gradient(circle at 20% 30%, rgba(255,255,255,.55) 0 2px, transparent 3px),
81
+ radial-gradient(circle at 70% 40%, rgba(255,255,255,.4) 0 1.5px, transparent 3px),
82
+ radial-gradient(circle at 40% 75%, rgba(255,255,255,.5) 0 2px, transparent 3px),
83
+ radial-gradient(circle at 85% 70%, rgba(255,255,255,.35) 0 1px, transparent 2px);
84
+ animation: velin-clip-particles ${Math.min(1200, durationMs / 2)}ms ease-out both;
85
+ }`,
86
+ extraHtml: '<div class="velin-clip__particles" aria-hidden="true"></div>',
87
+ };
88
+ },
89
+ 'soft-zoom': (opts) => EFFECTS.zoom(opts),
90
+ transition({ durationMs }) {
91
+ return {
92
+ name: 'transition',
93
+ keyframes: `@keyframes velin-clip-transition {
94
+ 0% { opacity: 0; transform: translateX(8%); }
95
+ 20% { opacity: 1; transform: translateX(0); }
96
+ 80% { opacity: 1; transform: translateX(0); }
97
+ 100% { opacity: 0; transform: translateX(-8%); }
98
+ }`,
99
+ animation: `velin-clip-transition ${durationMs}ms ease-in-out both`,
100
+ };
101
+ },
102
+ };
103
+
104
+ /**
105
+ * @param {string[]} effectIds
106
+ * @param {{ durationMs: number }} opts
107
+ */
108
+ export function buildLocalEffects(effectIds, opts) {
109
+ const effects = [];
110
+ for (const id of effectIds || []) {
111
+ const key = id === 'ai' || id === 'mini-prompt' ? null : id;
112
+ if (!key || !EFFECTS[key]) continue;
113
+ effects.push(EFFECTS[key](opts));
114
+ }
115
+ if (!effects.length) effects.push(EFFECTS.zoom(opts));
116
+ return effects;
117
+ }
118
+
119
+ export function listLocalEffectIds() {
120
+ return Object.keys(EFFECTS).filter((k) => k !== 'soft-zoom');
121
+ }
122
+
123
+ /**
124
+ * Map a motion profile (from mini-prompt) to local effect ids.
125
+ * @param {{ direction?: string, phases?: string[], intensity?: string }} profile
126
+ */
127
+ export function profileToLocalEffects(profile) {
128
+ const effects = ['fade'];
129
+ const phases = profile.phases || [];
130
+ if (phases.includes('zoom') || profile.direction === 'in') effects.push('zoom');
131
+ if (phases.includes('pan') || /left|right|rechts|links/i.test(profile.direction || '')) effects.push('pan');
132
+ if (phases.includes('rotate') || /rotat|dreh/i.test(JSON.stringify(profile))) effects.push('rotate');
133
+ if (phases.includes('particles') || phases.includes('explosion') || /explod|partikel/i.test(JSON.stringify(profile))) {
134
+ effects.push('particles');
135
+ }
136
+ if (phases.includes('outro') || phases.includes('exit')) effects.push('outro');
137
+ if (phases[0] === 'enter' || phases.includes('intro')) effects.unshift('intro');
138
+ return [...new Set(effects)];
139
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Local motion clip engine.
3
+ */
4
+ import { resolve, basename } from 'path';
5
+ import { getClipPreset, listClipPresets } from '../registry/presets.js';
6
+ import { exportHtmlClip, exportTransitionHtml } from '../export/html-clip.js';
7
+ import { profileToLocalEffects } from './effects.js';
8
+ import { createJob, finishJob } from '../jobs/queue.js';
9
+ import { assertMotionImageFile } from '../validate-image.js';
10
+
11
+ /**
12
+ * @param {object} opts
13
+ * @param {string} opts.image
14
+ * @param {string} [opts.preset]
15
+ * @param {string} [opts.outDir]
16
+ * @param {number} [opts.duration]
17
+ * @param {object} [opts.profile]
18
+ * @param {string} [opts.cwd]
19
+ * @param {string[]} [opts.effects]
20
+ */
21
+ export async function createLocalClip(opts) {
22
+ const image = resolve(opts.cwd || process.cwd(), opts.image);
23
+ assertMotionImageFile(image, 'Image');
24
+
25
+ const preset = getClipPreset(opts.preset || 'soft-zoom');
26
+ if (!preset) throw new Error(`Unknown clip preset: ${opts.preset}`);
27
+ if (preset.engine === 'ai') {
28
+ throw new Error(`Preset "${preset.id}" requires AI engine — use --ai / style ai`);
29
+ }
30
+
31
+ const durationMs = opts.duration
32
+ ? Math.round(Number(opts.duration) * 1000)
33
+ : preset.durationMs || 4000;
34
+
35
+ let effectIds = opts.effects || preset.effects || ['zoom'];
36
+ if (opts.profile) effectIds = profileToLocalEffects(opts.profile);
37
+
38
+ const job = createJob({
39
+ cwd: opts.cwd,
40
+ type: 'local-clip',
41
+ image,
42
+ preset: preset.id,
43
+ engine: 'local',
44
+ });
45
+
46
+ const outDir = resolve(opts.cwd || process.cwd(), opts.outDir || './motion-output');
47
+ const result = exportHtmlClip({
48
+ imagePath: image,
49
+ presetId: preset.id,
50
+ effectIds,
51
+ durationMs,
52
+ outDir,
53
+ jobId: job.id,
54
+ });
55
+
56
+ finishJob(job, { status: 'done', result });
57
+ return { job, ...result, preset };
58
+ }
59
+
60
+ /**
61
+ * @param {object} opts
62
+ */
63
+ export async function createLocalTransition(opts) {
64
+ const cwd = opts.cwd || process.cwd();
65
+ const a = resolve(cwd, opts.imageA);
66
+ const b = resolve(cwd, opts.imageB);
67
+ assertMotionImageFile(a, 'Image A');
68
+ assertMotionImageFile(b, 'Image B');
69
+ const durationMs = opts.duration ? Math.round(Number(opts.duration) * 1000) : 5000;
70
+ const job = createJob({ cwd, type: 'local-transition', engine: 'local', images: [a, b] });
71
+ const outDir = resolve(cwd, opts.outDir || './motion-output');
72
+ const result = exportTransitionHtml({
73
+ imageA: a,
74
+ imageB: b,
75
+ durationMs,
76
+ outDir,
77
+ jobId: job.id,
78
+ });
79
+ finishJob(job, { status: 'done', result });
80
+ return { job, ...result };
81
+ }
82
+
83
+ /**
84
+ * Shortcut: effect name as preset-ish local clip.
85
+ */
86
+ export async function createEffectClip(effect, image, opts = {}) {
87
+ return createLocalClip({
88
+ ...opts,
89
+ image,
90
+ preset: 'soft-zoom',
91
+ effects: [effect],
92
+ });
93
+ }
94
+
95
+ export { listClipPresets, getClipPreset };
96
+ export { assertMotionImageFile, MOTION_IMAGE_EXTENSIONS } from '../validate-image.js';