@lightcone-ai/daemon 0.15.43 → 0.15.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.43",
3
+ "version": "0.15.45",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -0,0 +1,285 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { mkdir, rm, writeFile, access } from 'node:fs/promises';
4
+ import { constants as fsConstants } from 'node:fs';
5
+ import os from 'node:os';
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ const DEFAULT_WIDTH = 1080;
9
+ const DEFAULT_HEIGHT = 1920;
10
+ const DEFAULT_FPS = 30;
11
+ const TRANSITION_DURATION = 0.5;
12
+
13
+ async function fileExists(p) {
14
+ try { await access(p, fsConstants.R_OK); return true; } catch { return false; }
15
+ }
16
+
17
+ async function runFfmpeg(args, label = 'ffmpeg') {
18
+ return new Promise((resolve, reject) => {
19
+ const proc = spawn('ffmpeg', ['-y', ...args], { stdio: ['ignore', 'pipe', 'pipe'] });
20
+ const stderr = [];
21
+ proc.stderr.on('data', chunk => stderr.push(chunk));
22
+ proc.on('close', code => {
23
+ if (code === 0) return resolve();
24
+ const msg = Buffer.concat(stderr).toString().slice(-3000);
25
+ reject(new Error(`${label} exited ${code}:\n${msg}`));
26
+ });
27
+ proc.on('error', err => reject(new Error(`${label} spawn failed: ${err.message}`)));
28
+ });
29
+ }
30
+
31
+ async function probeDurationSec(inputPath) {
32
+ return new Promise((resolve, reject) => {
33
+ const proc = spawn('ffprobe', [
34
+ '-v', 'error',
35
+ '-select_streams', 'v:0',
36
+ '-show_entries', 'stream=duration',
37
+ '-of', 'csv=p=0',
38
+ inputPath,
39
+ ], { stdio: ['ignore', 'pipe', 'pipe'] });
40
+ const out = [];
41
+ proc.stdout.on('data', c => out.push(c));
42
+ proc.on('close', code => {
43
+ if (code !== 0) return reject(new Error(`ffprobe failed on ${inputPath}`));
44
+ const val = parseFloat(Buffer.concat(out).toString().trim());
45
+ resolve(Number.isFinite(val) ? val : 3);
46
+ });
47
+ proc.on('error', reject);
48
+ });
49
+ }
50
+
51
+ async function imageToClip({ imagePath, duration, style, tmpDir, width, height, fps }) {
52
+ const outPath = path.join(tmpDir, `clip-img-${randomUUID().slice(0, 8)}.mp4`);
53
+ if (style === 'scroll') {
54
+ const imgH = await probeDurationSec(imagePath).catch(() => height * 4);
55
+ const actualImgH = Math.round(Number.isFinite(imgH) ? imgH : height * 4);
56
+ const scrollFilter = `[0:v]scale=${width}:-1,crop=${width}:${height}:0:'if(gte(t*${actualImgH - height}/${duration},${actualImgH - height}),${actualImgH - height},t*${actualImgH - height}/${duration})'[v]`;
57
+ await runFfmpeg([
58
+ '-loop', '1', '-t', String(duration), '-i', imagePath,
59
+ '-filter_complex', scrollFilter,
60
+ '-map', '[v]',
61
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', String(fps),
62
+ '-movflags', '+faststart',
63
+ outPath,
64
+ ], 'ffmpeg scroll');
65
+ } else {
66
+ await runFfmpeg([
67
+ '-loop', '1', '-t', String(duration), '-i', imagePath,
68
+ '-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`,
69
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', String(fps),
70
+ '-movflags', '+faststart',
71
+ outPath,
72
+ ], 'ffmpeg image-to-clip');
73
+ }
74
+ return { path: outPath, duration };
75
+ }
76
+
77
+ async function gifToClip({ gifPath, duration, tmpDir, width, height, fps }) {
78
+ const naturalDuration = await probeDurationSec(gifPath).catch(() => 3);
79
+ const clipDuration = duration ?? naturalDuration;
80
+ const outPath = path.join(tmpDir, `clip-gif-${randomUUID().slice(0, 8)}.mp4`);
81
+ const loopCount = duration ? Math.ceil(duration / naturalDuration) : 1;
82
+ await runFfmpeg([
83
+ '-stream_loop', String(loopCount),
84
+ '-i', gifPath,
85
+ '-t', String(clipDuration),
86
+ '-vf', `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`,
87
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', String(fps),
88
+ '-movflags', '+faststart',
89
+ outPath,
90
+ ], 'ffmpeg gif-to-clip');
91
+ return { path: outPath, duration: clipDuration };
92
+ }
93
+
94
+ async function videoToClip({ videoPath, duration, tmpDir, width, height, fps }) {
95
+ const outPath = path.join(tmpDir, `clip-vid-${randomUUID().slice(0, 8)}.mp4`);
96
+ const vf = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1`;
97
+ const durationArgs = duration ? ['-t', String(duration)] : [];
98
+ await runFfmpeg([
99
+ '-i', videoPath, ...durationArgs,
100
+ '-vf', vf,
101
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-r', String(fps),
102
+ '-c:a', 'aac',
103
+ '-movflags', '+faststart',
104
+ outPath,
105
+ ], 'ffmpeg video-to-clip');
106
+ const actualDuration = duration ?? await probeDurationSec(outPath);
107
+ return { path: outPath, duration: actualDuration };
108
+ }
109
+
110
+ async function muxAudio({ videoPath, audioPath, duration, tmpDir }) {
111
+ const outPath = path.join(tmpDir, `muxed-${randomUUID().slice(0, 8)}.mp4`);
112
+ await runFfmpeg([
113
+ '-i', videoPath,
114
+ '-i', audioPath,
115
+ '-map', '0:v:0',
116
+ '-map', '1:a:0',
117
+ '-c:v', 'copy',
118
+ '-c:a', 'aac',
119
+ '-shortest',
120
+ '-t', String(duration),
121
+ outPath,
122
+ ], 'ffmpeg mux-audio');
123
+ return outPath;
124
+ }
125
+
126
+ async function silentClip({ videoPath, duration, tmpDir }) {
127
+ const outPath = path.join(tmpDir, `silent-${randomUUID().slice(0, 8)}.mp4`);
128
+ await runFfmpeg([
129
+ '-i', videoPath,
130
+ '-f', 'lavfi', '-i', `anullsrc=channel_layout=stereo:sample_rate=44100`,
131
+ '-map', '0:v:0',
132
+ '-map', '1:a:0',
133
+ '-c:v', 'copy',
134
+ '-c:a', 'aac',
135
+ '-shortest',
136
+ '-t', String(duration),
137
+ outPath,
138
+ ], 'ffmpeg silent-audio');
139
+ return outPath;
140
+ }
141
+
142
+ async function concatWithCuts({ clips, outputPath }) {
143
+ const listPath = outputPath + '.concat.txt';
144
+ const listContent = clips.map(c => `file '${c}'`).join('\n');
145
+ await writeFile(listPath, listContent);
146
+ try {
147
+ await runFfmpeg([
148
+ '-f', 'concat', '-safe', '0', '-i', listPath,
149
+ '-c', 'copy',
150
+ '-movflags', '+faststart',
151
+ outputPath,
152
+ ], 'ffmpeg concat');
153
+ } finally {
154
+ await rm(listPath, { force: true });
155
+ }
156
+ }
157
+
158
+ async function applyFadeTransition({ clipA, clipB, tmpDir, style = 'fade' }) {
159
+ const xfadeMode = style === 'crossfade' ? 'dissolve' : 'fade';
160
+ const durA = await probeDurationSec(clipA);
161
+ const offset = Math.max(0, durA - TRANSITION_DURATION);
162
+ const outPath = path.join(tmpDir, `xfade-${randomUUID().slice(0, 8)}.mp4`);
163
+ await runFfmpeg([
164
+ '-i', clipA,
165
+ '-i', clipB,
166
+ '-filter_complex',
167
+ `[0:v][1:v]xfade=transition=${xfadeMode}:duration=${TRANSITION_DURATION}:offset=${offset}[v];` +
168
+ `[0:a][1:a]acrossfade=d=${TRANSITION_DURATION}[a]`,
169
+ '-map', '[v]', '-map', '[a]',
170
+ '-c:v', 'libx264', '-pix_fmt', 'yuv420p',
171
+ '-c:a', 'aac',
172
+ '-movflags', '+faststart',
173
+ outPath,
174
+ ], 'ffmpeg xfade');
175
+ return outPath;
176
+ }
177
+
178
+ export async function composeVideoV2({
179
+ segments = [],
180
+ outro_paths = [],
181
+ resolution = '1080x1920',
182
+ output_path,
183
+ }) {
184
+ if (!Array.isArray(segments) || segments.length === 0) {
185
+ throw new Error('segments must be a non-empty array');
186
+ }
187
+
188
+ const [widthStr, heightStr] = String(resolution).split('x');
189
+ const width = parseInt(widthStr, 10) || DEFAULT_WIDTH;
190
+ const height = parseInt(heightStr, 10) || DEFAULT_HEIGHT;
191
+ const fps = DEFAULT_FPS;
192
+
193
+ const tmpDir = path.join(os.tmpdir(), `compose-v2-${randomUUID().slice(0, 8)}`);
194
+ await mkdir(tmpDir, { recursive: true });
195
+
196
+ const outPath = output_path ?? path.join(os.tmpdir(), `lightcone-video-${Date.now()}.mp4`);
197
+ await mkdir(path.dirname(outPath), { recursive: true });
198
+
199
+ try {
200
+ const readyClips = [];
201
+
202
+ for (let i = 0; i < segments.length; i++) {
203
+ const seg = segments[i];
204
+ const kind = String(seg.visual_kind ?? 'image');
205
+ const presentation = seg.presentation ?? {};
206
+ const style = String(presentation.style ?? 'static');
207
+ const duration = Number(presentation.duration ?? presentation.per_card_duration ?? 4);
208
+ const audioPath = seg.audio_path ?? null;
209
+ const transition = String(seg.transition ?? 'cut');
210
+
211
+ let visualClip;
212
+
213
+ if (kind === 'image') {
214
+ const imgPath = String(seg.visual_path ?? '');
215
+ if (!imgPath) throw new Error(`segments[${i}]: visual_path required for kind=image`);
216
+ visualClip = await imageToClip({ imagePath: imgPath, duration, style, tmpDir, width, height, fps });
217
+ } else if (kind === 'carousel') {
218
+ const paths = Array.isArray(seg.visual_paths) ? seg.visual_paths : [];
219
+ if (paths.length === 0) throw new Error(`segments[${i}]: visual_paths required for kind=carousel`);
220
+ const perCard = Number(presentation.per_card_duration ?? 3);
221
+ const cardClips = [];
222
+ for (const p of paths) {
223
+ const c = await imageToClip({ imagePath: p, duration: perCard, style: 'static', tmpDir, width, height, fps });
224
+ cardClips.push(c.path);
225
+ }
226
+ const carouselOut = path.join(tmpDir, `carousel-${randomUUID().slice(0, 8)}.mp4`);
227
+ await concatWithCuts({ clips: cardClips, outputPath: carouselOut });
228
+ visualClip = { path: carouselOut, duration: perCard * paths.length };
229
+ } else if (kind === 'gif') {
230
+ const gifPath = String(seg.visual_path ?? '');
231
+ if (!gifPath) throw new Error(`segments[${i}]: visual_path required for kind=gif`);
232
+ visualClip = await gifToClip({ gifPath, duration: presentation.duration ?? null, tmpDir, width, height, fps });
233
+ } else if (kind === 'video') {
234
+ const vidPath = String(seg.visual_path ?? '');
235
+ if (!vidPath) throw new Error(`segments[${i}]: visual_path required for kind=video`);
236
+ visualClip = await videoToClip({ videoPath: vidPath, duration: presentation.duration ?? null, tmpDir, width, height, fps });
237
+ } else {
238
+ throw new Error(`segments[${i}]: unknown visual_kind "${kind}"`);
239
+ }
240
+
241
+ let finalClip;
242
+ if (audioPath && await fileExists(audioPath)) {
243
+ finalClip = await muxAudio({ videoPath: visualClip.path, audioPath, duration: visualClip.duration, tmpDir });
244
+ } else {
245
+ finalClip = await silentClip({ videoPath: visualClip.path, duration: visualClip.duration, tmpDir });
246
+ }
247
+
248
+ readyClips.push({ path: finalClip, duration: visualClip.duration, transition });
249
+ }
250
+
251
+ const outroClips = [];
252
+ for (const outroPath of (outro_paths ?? [])) {
253
+ if (outroPath && await fileExists(outroPath)) {
254
+ const c = await videoToClip({ videoPath: outroPath, tmpDir, width, height, fps });
255
+ outroClips.push(c.path);
256
+ }
257
+ }
258
+
259
+ const allClips = [];
260
+ let accumulated = readyClips[0].path;
261
+ for (let i = 1; i < readyClips.length; i++) {
262
+ const { path: nextClip, transition } = readyClips[i];
263
+ if (transition === 'fade' || transition === 'crossfade') {
264
+ accumulated = await applyFadeTransition({ clipA: accumulated, clipB: nextClip, tmpDir, style: transition });
265
+ } else {
266
+ allClips.push(accumulated);
267
+ accumulated = nextClip;
268
+ }
269
+ }
270
+ allClips.push(accumulated);
271
+
272
+ const finalSequence = [...allClips, ...outroClips];
273
+
274
+ if (finalSequence.length === 1) {
275
+ await runFfmpeg(['-i', finalSequence[0], '-c', 'copy', '-movflags', '+faststart', outPath], 'ffmpeg copy');
276
+ } else {
277
+ await concatWithCuts({ clips: finalSequence, outputPath: outPath });
278
+ }
279
+
280
+ const totalDuration = await probeDurationSec(outPath);
281
+ return { path: outPath, duration_ms: Math.round(totalDuration * 1000) };
282
+ } finally {
283
+ await rm(tmpDir, { recursive: true, force: true });
284
+ }
285
+ }
@@ -0,0 +1,103 @@
1
+ import { readFileSync, mkdirSync, writeFileSync } from 'fs';
2
+ import { randomUUID } from 'crypto';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+ import os from 'os';
6
+
7
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
+ const TEMPLATES_DIR = path.join(__dirname, 'templates');
9
+
10
+ const THEMES = {
11
+ dark: { bg: '#1a1a1a', text: '#f0f0f0' },
12
+ light: { bg: '#ffffff', text: '#1a1a1a' },
13
+ };
14
+
15
+ function escapeHtml(str) {
16
+ return String(str)
17
+ .replace(/&/g, '&amp;')
18
+ .replace(/</g, '&lt;')
19
+ .replace(/>/g, '&gt;')
20
+ .replace(/"/g, '&quot;');
21
+ }
22
+
23
+ function renderTemplate(templateName, vars) {
24
+ let html = readFileSync(path.join(TEMPLATES_DIR, templateName), 'utf8');
25
+ for (const [key, value] of Object.entries(vars)) {
26
+ html = html.replaceAll(`{{${key}}}`, value);
27
+ }
28
+ return html;
29
+ }
30
+
31
+ async function launchBrowser() {
32
+ let playwright;
33
+ try {
34
+ playwright = await import('playwright');
35
+ } catch {
36
+ throw new Error('playwright_import_failed: run npm install playwright');
37
+ }
38
+ return playwright.chromium.launch({ headless: true });
39
+ }
40
+
41
+ export async function renderTextToImages({
42
+ content,
43
+ style = 'scroll',
44
+ theme = 'dark',
45
+ width = 1080,
46
+ cardHeight = 1920,
47
+ fontSize = 48,
48
+ outputDir,
49
+ }) {
50
+ const colors = THEMES[theme] ?? THEMES.dark;
51
+ const outDir = outputDir ?? path.join(os.tmpdir(), `text-render-${randomUUID().slice(0, 8)}`);
52
+ mkdirSync(outDir, { recursive: true });
53
+
54
+ const browser = await launchBrowser();
55
+ try {
56
+ if (style === 'scroll') {
57
+ const items = Array.isArray(content) ? content.join('\n\n') : String(content);
58
+ const html = renderTemplate('scroll.html', {
59
+ WIDTH: width,
60
+ BG_COLOR: colors.bg,
61
+ TEXT_COLOR: colors.text,
62
+ FONT_SIZE: fontSize,
63
+ CONTENT: escapeHtml(items),
64
+ });
65
+ const page = await browser.newPage();
66
+ await page.setViewportSize({ width, height: cardHeight });
67
+ await page.setContent(html, { waitUntil: 'load' });
68
+ const bodyHeight = await page.evaluate(() => document.body.scrollHeight);
69
+ await page.setViewportSize({ width, height: bodyHeight });
70
+ const outPath = path.join(outDir, 'scroll.png');
71
+ await page.screenshot({ path: outPath, fullPage: false });
72
+ await page.close();
73
+ return [outPath];
74
+ }
75
+
76
+ if (style === 'carousel') {
77
+ const items = Array.isArray(content) ? content : [String(content)];
78
+ const paths = [];
79
+ for (let i = 0; i < items.length; i++) {
80
+ const html = renderTemplate('carousel.html', {
81
+ WIDTH: width,
82
+ HEIGHT: cardHeight,
83
+ BG_COLOR: colors.bg,
84
+ TEXT_COLOR: colors.text,
85
+ FONT_SIZE: fontSize,
86
+ CONTENT: escapeHtml(items[i]),
87
+ });
88
+ const page = await browser.newPage();
89
+ await page.setViewportSize({ width, height: cardHeight });
90
+ await page.setContent(html, { waitUntil: 'load' });
91
+ const outPath = path.join(outDir, `card-${String(i).padStart(3, '0')}.png`);
92
+ await page.screenshot({ path: outPath });
93
+ await page.close();
94
+ paths.push(outPath);
95
+ }
96
+ return paths;
97
+ }
98
+
99
+ throw new Error(`unsupported_style: ${style}`);
100
+ } finally {
101
+ await browser.close();
102
+ }
103
+ }
@@ -0,0 +1,43 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ html, body {
9
+ width: {{WIDTH}}px;
10
+ height: {{HEIGHT}}px;
11
+ background: {{BG_COLOR}};
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ overflow: hidden;
16
+ }
17
+ .card {
18
+ width: 100%;
19
+ height: 100%;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ padding: 80px 70px;
24
+ }
25
+ .text {
26
+ font-family: -apple-system, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
27
+ color: {{TEXT_COLOR}};
28
+ font-size: {{FONT_SIZE}}px;
29
+ line-height: 1.85;
30
+ letter-spacing: 0.02em;
31
+ word-break: break-all;
32
+ overflow-wrap: break-word;
33
+ white-space: pre-wrap;
34
+ text-align: center;
35
+ }
36
+ </style>
37
+ </head>
38
+ <body>
39
+ <div class="card">
40
+ <div class="text">{{CONTENT}}</div>
41
+ </div>
42
+ </body>
43
+ </html>
@@ -0,0 +1,23 @@
1
+ <!DOCTYPE html>
2
+ <html lang="zh">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <style>
7
+ * { margin: 0; padding: 0; box-sizing: border-box; }
8
+ html, body { width: {{WIDTH}}px; background: {{BG_COLOR}}; }
9
+ body {
10
+ font-family: -apple-system, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
11
+ color: {{TEXT_COLOR}};
12
+ padding: 80px 60px 80px 60px;
13
+ font-size: {{FONT_SIZE}}px;
14
+ line-height: 1.85;
15
+ letter-spacing: 0.02em;
16
+ word-break: break-all;
17
+ overflow-wrap: break-word;
18
+ white-space: pre-wrap;
19
+ }
20
+ </style>
21
+ </head>
22
+ <body>{{CONTENT}}</body>
23
+ </html>
@@ -1,7 +1,7 @@
1
1
  import path from 'path';
2
2
  import os from 'os';
3
3
  import { randomUUID } from 'crypto';
4
- import { composeVideoV2 } from '../../../src/video/composer-v2/index.js';
4
+ import { composeVideoV2 } from '../_vendor/video/composer-v2/index.js';
5
5
 
6
6
  function toolText(text) {
7
7
  return { content: [{ type: 'text', text }] };
@@ -1,4 +1,4 @@
1
- import { renderTextToImages } from '../../../src/video/text-renderer/index.js';
1
+ import { renderTextToImages } from '../_vendor/video/text-renderer/index.js';
2
2
 
3
3
  function toolText(text) {
4
4
  return { content: [{ type: 'text', text }] };