@lightcone-ai/daemon 0.13.0 → 0.14.1

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.
@@ -0,0 +1,377 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { constants as fsConstants } from 'node:fs';
5
+ import os from 'node:os';
6
+
7
+ const MAX_STDERR_LENGTH = 4000;
8
+
9
+ const TRANSCODE_TARGETS = Object.freeze({
10
+ short_video_cn: {
11
+ width: 1080,
12
+ height: 1920,
13
+ fps: 30,
14
+ videoCodec: 'libx264',
15
+ profile: 'baseline',
16
+ pixelFormat: 'yuv420p',
17
+ crf: 23,
18
+ preset: 'veryfast',
19
+ level: '4.0',
20
+ audioCodec: 'aac',
21
+ audioBitrate: '128k',
22
+ audioSampleRate: 48000,
23
+ audioChannels: 2,
24
+ },
25
+ });
26
+
27
+ function normalizeText(value) {
28
+ if (typeof value !== 'string') return '';
29
+ return value.trim();
30
+ }
31
+
32
+ function normalizePath(value, label) {
33
+ const raw = normalizeText(value);
34
+ if (!raw) throw new Error(`${label} required`);
35
+ return path.resolve(raw);
36
+ }
37
+
38
+ async function ensureReadableFile(filePath, label) {
39
+ try {
40
+ await access(filePath, fsConstants.R_OK);
41
+ } catch {
42
+ throw new Error(`${label} not found or unreadable: ${filePath}`);
43
+ }
44
+
45
+ const st = await stat(filePath);
46
+ if (!st.isFile()) throw new Error(`${label} is not a file: ${filePath}`);
47
+ }
48
+
49
+ async function ensureParentDir(filePath) {
50
+ await mkdir(path.dirname(filePath), { recursive: true });
51
+ }
52
+
53
+ function sanitizeStderr(stderr) {
54
+ const text = String(stderr ?? '').trim();
55
+ if (!text) return '';
56
+ if (text.length <= MAX_STDERR_LENGTH) return text;
57
+ return text.slice(text.length - MAX_STDERR_LENGTH);
58
+ }
59
+
60
+ function toolError(prefix, error, stderr = '') {
61
+ const details = [];
62
+ const message = normalizeText(error?.message);
63
+ if (message) details.push(message);
64
+ const cleanedStderr = sanitizeStderr(stderr);
65
+ if (cleanedStderr) details.push(cleanedStderr);
66
+ const suffix = details.length > 0 ? `: ${details.join(' | ')}` : '';
67
+ return new Error(`${prefix}${suffix}`);
68
+ }
69
+
70
+ function runProcess(binary, args, { name = binary } = {}) {
71
+ return new Promise((resolve, reject) => {
72
+ const child = spawn(binary, args, { stdio: ['ignore', 'pipe', 'pipe'] });
73
+ let stdout = '';
74
+ let stderr = '';
75
+
76
+ child.stdout.on('data', (chunk) => {
77
+ stdout += chunk.toString();
78
+ });
79
+ child.stderr.on('data', (chunk) => {
80
+ stderr += chunk.toString();
81
+ });
82
+
83
+ child.on('error', (error) => {
84
+ reject(toolError(`${name} failed`, error, stderr));
85
+ });
86
+
87
+ child.on('close', (code) => {
88
+ if (code === 0) {
89
+ resolve({ stdout, stderr });
90
+ return;
91
+ }
92
+ reject(toolError(`${name} exited with code ${code}`, null, stderr));
93
+ });
94
+ });
95
+ }
96
+
97
+ function normalizeStartMs(value) {
98
+ const parsed = Number(value);
99
+ if (!Number.isFinite(parsed) || parsed < 0) return null;
100
+ return Math.floor(parsed);
101
+ }
102
+
103
+ function resolveStartMsFromEvents(segment, eventsLog = []) {
104
+ const phase = normalizeText(segment?.phase ?? segment?.phase_id ?? segment?.phaseId);
105
+ if (!phase) return null;
106
+
107
+ let candidate = null;
108
+ for (const event of eventsLog) {
109
+ const eventPhase = normalizeText(event?.phase ?? event?.phase_id ?? event?.phaseId);
110
+ if (!eventPhase || eventPhase !== phase) continue;
111
+
112
+ const eventStart = normalizeStartMs(
113
+ event?.t_ms_start
114
+ ?? event?.tMsStart
115
+ ?? event?.t_ms
116
+ ?? event?.tMs
117
+ );
118
+ if (eventStart == null) continue;
119
+ if (candidate == null || eventStart < candidate) candidate = eventStart;
120
+ }
121
+
122
+ return candidate;
123
+ }
124
+
125
+ function normalizeAudioSegments(audioSegments = [], eventsLog = []) {
126
+ if (!Array.isArray(audioSegments)) {
127
+ throw new Error('audio_segments must be an array');
128
+ }
129
+
130
+ return audioSegments.map((segment, index) => {
131
+ if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
132
+ throw new Error(`audio_segments[${index}] must be an object`);
133
+ }
134
+
135
+ const audioPath = normalizePath(segment.audio_path ?? segment.audioPath, `audio_segments[${index}].audio_path`);
136
+ const startMs = normalizeStartMs(segment.start_ms ?? segment.startMs)
137
+ ?? resolveStartMsFromEvents(segment, eventsLog);
138
+ if (startMs == null) {
139
+ throw new Error(`audio_segments[${index}].start_ms missing (and no matching events_log entry found)`);
140
+ }
141
+
142
+ return {
143
+ audioPath,
144
+ startMs,
145
+ };
146
+ });
147
+ }
148
+
149
+ function defaultOutputPath(inputPath, suffix) {
150
+ const ext = path.extname(inputPath) || '.mp4';
151
+ const base = path.basename(inputPath, ext);
152
+ return path.join(path.dirname(inputPath), `${base}.${suffix}${ext}`);
153
+ }
154
+
155
+ function escapeConcatPath(filePath) {
156
+ return filePath.replace(/'/g, `'\\''`);
157
+ }
158
+
159
+ export async function muxAudioToVideo({
160
+ video_path,
161
+ audio_segments = [],
162
+ events_log = [],
163
+ output = null,
164
+ } = {}) {
165
+ const videoPath = normalizePath(video_path, 'video_path');
166
+ await ensureReadableFile(videoPath, 'video_path');
167
+
168
+ const segments = normalizeAudioSegments(audio_segments, events_log).sort((a, b) => a.startMs - b.startMs);
169
+ for (const segment of segments) {
170
+ await ensureReadableFile(segment.audioPath, `audio segment (${segment.audioPath})`);
171
+ }
172
+
173
+ const outputPath = output
174
+ ? normalizePath(output, 'output')
175
+ : defaultOutputPath(videoPath, 'muxed');
176
+ if (outputPath === videoPath) throw new Error('output must not equal video_path');
177
+ await ensureParentDir(outputPath);
178
+
179
+ if (segments.length === 0) {
180
+ await runProcess('ffmpeg', [
181
+ '-y',
182
+ '-i', videoPath,
183
+ '-map', '0:v:0',
184
+ '-c:v', 'copy',
185
+ '-an',
186
+ outputPath,
187
+ ], { name: 'ffmpeg mux(no-audio)' });
188
+ return outputPath;
189
+ }
190
+
191
+ const filterChunks = [];
192
+ const mixInputs = [];
193
+ for (let i = 0; i < segments.length; i += 1) {
194
+ const delay = segments[i].startMs;
195
+ const label = `a${i}`;
196
+ filterChunks.push(`[${i + 1}:a]adelay=${delay}|${delay},aresample=async=1:first_pts=0[${label}]`);
197
+ mixInputs.push(`[${label}]`);
198
+ }
199
+ filterChunks.push(`${mixInputs.join('')}amix=inputs=${segments.length}:duration=longest:dropout_transition=0,apad[a]`);
200
+
201
+ const args = [
202
+ '-y',
203
+ '-i', videoPath,
204
+ ];
205
+
206
+ for (const segment of segments) {
207
+ args.push('-i', segment.audioPath);
208
+ }
209
+
210
+ args.push(
211
+ '-filter_complex', filterChunks.join(';'),
212
+ '-map', '0:v:0',
213
+ '-map', '[a]',
214
+ '-c:v', 'copy',
215
+ '-c:a', 'aac',
216
+ '-shortest',
217
+ '-movflags', '+faststart',
218
+ outputPath
219
+ );
220
+
221
+ await runProcess('ffmpeg', args, { name: 'ffmpeg mux' });
222
+ return outputPath;
223
+ }
224
+
225
+ export async function concatVideos({
226
+ inputs = [],
227
+ output,
228
+ } = {}) {
229
+ if (!Array.isArray(inputs) || inputs.length === 0) {
230
+ throw new Error('inputs must be a non-empty array');
231
+ }
232
+
233
+ const normalizedInputs = inputs.map((input, index) => normalizePath(input, `inputs[${index}]`));
234
+ for (const inputPath of normalizedInputs) {
235
+ await ensureReadableFile(inputPath, `concat input (${inputPath})`);
236
+ }
237
+
238
+ const outputPath = output
239
+ ? normalizePath(output, 'output')
240
+ : defaultOutputPath(normalizedInputs[0], 'concat');
241
+ await ensureParentDir(outputPath);
242
+
243
+ if (normalizedInputs.length === 1) {
244
+ await runProcess('ffmpeg', [
245
+ '-y',
246
+ '-i', normalizedInputs[0],
247
+ '-c', 'copy',
248
+ outputPath,
249
+ ], { name: 'ffmpeg concat(single-input)' });
250
+ return outputPath;
251
+ }
252
+
253
+ const tempDir = await mkdtemp(path.join(os.tmpdir(), 'lightcone-concat-'));
254
+ const listPath = path.join(tempDir, 'inputs.txt');
255
+
256
+ try {
257
+ const content = normalizedInputs
258
+ .map(inputPath => `file '${escapeConcatPath(inputPath)}'`)
259
+ .join('\n');
260
+ await writeFile(listPath, `${content}\n`, 'utf8');
261
+
262
+ await runProcess('ffmpeg', [
263
+ '-y',
264
+ '-f', 'concat',
265
+ '-safe', '0',
266
+ '-i', listPath,
267
+ '-c', 'copy',
268
+ '-movflags', '+faststart',
269
+ outputPath,
270
+ ], { name: 'ffmpeg concat' });
271
+ } finally {
272
+ await rm(tempDir, { recursive: true, force: true }).catch(() => {});
273
+ }
274
+
275
+ return outputPath;
276
+ }
277
+
278
+ function resolveTranscodeTarget(target) {
279
+ const normalized = normalizeText(target).toLowerCase();
280
+ if (!normalized) return TRANSCODE_TARGETS.short_video_cn;
281
+
282
+ if (normalized === 'short_video_cn' || normalized === 'douyin' || normalized === 'xhs') {
283
+ return TRANSCODE_TARGETS.short_video_cn;
284
+ }
285
+
286
+ throw new Error(`unsupported transcode target: ${target}`);
287
+ }
288
+
289
+ export async function transcodeForPlatform({
290
+ input,
291
+ output,
292
+ target = 'short_video_cn',
293
+ } = {}) {
294
+ const inputPath = normalizePath(input, 'input');
295
+ await ensureReadableFile(inputPath, 'input');
296
+
297
+ const outputPath = output
298
+ ? normalizePath(output, 'output')
299
+ : defaultOutputPath(inputPath, 'platform');
300
+ if (outputPath === inputPath) throw new Error('output must not equal input');
301
+ await ensureParentDir(outputPath);
302
+
303
+ const preset = resolveTranscodeTarget(target);
304
+ const vf = [
305
+ `scale=${preset.width}:${preset.height}:force_original_aspect_ratio=decrease`,
306
+ `pad=${preset.width}:${preset.height}:(ow-iw)/2:(oh-ih)/2:black`,
307
+ 'setsar=1',
308
+ ].join(',');
309
+
310
+ await runProcess('ffmpeg', [
311
+ '-y',
312
+ '-i', inputPath,
313
+ '-vf', vf,
314
+ '-r', String(preset.fps),
315
+ '-c:v', preset.videoCodec,
316
+ '-profile:v', preset.profile,
317
+ '-level', preset.level,
318
+ '-pix_fmt', preset.pixelFormat,
319
+ '-preset', preset.preset,
320
+ '-crf', String(preset.crf),
321
+ '-c:a', preset.audioCodec,
322
+ '-b:a', preset.audioBitrate,
323
+ '-ar', String(preset.audioSampleRate),
324
+ '-ac', String(preset.audioChannels),
325
+ '-movflags', '+faststart',
326
+ outputPath,
327
+ ], { name: 'ffmpeg transcode' });
328
+
329
+ return outputPath;
330
+ }
331
+
332
+ export async function probeDurationMs(inputPath) {
333
+ const resolved = normalizePath(inputPath, 'input');
334
+ await ensureReadableFile(resolved, 'input');
335
+
336
+ const { stdout } = await runProcess('ffprobe', [
337
+ '-v', 'error',
338
+ '-show_entries', 'format=duration',
339
+ '-of', 'default=noprint_wrappers=1:nokey=1',
340
+ resolved,
341
+ ], { name: 'ffprobe duration' });
342
+
343
+ const seconds = Number.parseFloat(String(stdout ?? '').trim());
344
+ if (!Number.isFinite(seconds) || seconds <= 0) {
345
+ throw new Error(`ffprobe returned invalid duration for ${resolved}`);
346
+ }
347
+ return Math.floor(seconds * 1000);
348
+ }
349
+
350
+ export async function readMediaSpec(inputPath) {
351
+ const resolved = normalizePath(inputPath, 'input');
352
+ await ensureReadableFile(resolved, 'input');
353
+
354
+ const { stdout } = await runProcess('ffprobe', [
355
+ '-v', 'error',
356
+ '-select_streams', 'v:0',
357
+ '-show_entries', 'stream=width,height,r_frame_rate,pix_fmt,codec_name',
358
+ '-of', 'json',
359
+ resolved,
360
+ ], { name: 'ffprobe spec' });
361
+
362
+ let parsed;
363
+ try {
364
+ parsed = JSON.parse(stdout);
365
+ } catch {
366
+ throw new Error(`Failed to parse ffprobe spec output for ${resolved}`);
367
+ }
368
+
369
+ const stream = parsed?.streams?.[0] ?? {};
370
+ return {
371
+ width: Number(stream.width) || null,
372
+ height: Number(stream.height) || null,
373
+ frame_rate: String(stream.r_frame_rate ?? ''),
374
+ pixel_format: String(stream.pix_fmt ?? ''),
375
+ video_codec: String(stream.codec_name ?? ''),
376
+ };
377
+ }
@@ -462,7 +462,12 @@ export class AgentManager {
462
462
  chatBridgePath,
463
463
  config,
464
464
  triggerType = 'resume',
465
+ triggerContext = null,
465
466
  }) {
467
+ const mergedTriggerContext = {
468
+ source: 'daemon-agent-manager',
469
+ ...(triggerContext && typeof triggerContext === 'object' ? triggerContext : {}),
470
+ };
466
471
  const res = await fetch(`${this.serverUrl}/governance/spawn-directive`, {
467
472
  method: 'POST',
468
473
  headers: {
@@ -474,7 +479,7 @@ export class AgentManager {
474
479
  workspace_id: workspaceId ?? null,
475
480
  trigger: {
476
481
  type: triggerType,
477
- context: { source: 'daemon-agent-manager' },
482
+ context: mergedTriggerContext,
478
483
  },
479
484
  runtime_context: {
480
485
  cli_type: runtime,
@@ -535,6 +540,10 @@ export class AgentManager {
535
540
  const nextDirective = await this._fetchSpawnDirective({
536
541
  ...context,
537
542
  triggerType: 'lease_refresh',
543
+ triggerContext: {
544
+ spawn_bundle_id: directive?.spawn_bundle_id ?? null,
545
+ policy_version: directive?.policy_version ?? null,
546
+ },
538
547
  });
539
548
  const agent = this.agents.get(key);
540
549
  if (!agent) return;