@lightcone-ai/daemon 0.15.25 → 0.15.27

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.
@@ -781,7 +781,7 @@ function pickSlotKeysForMode({ mode, slots }) {
781
781
  return ordered.filter(key => slotHasValue(slots[key])).slice(0, 3);
782
782
  }
783
783
  if (mode === 'job_alert') {
784
- const ordered = ['job_directions', 'locations', 'cohort', 'entry_or_cta'];
784
+ const ordered = ['job_directions', 'locations', 'cohort'];
785
785
  return ordered.filter(key => slotHasValue(slots[key])).slice(0, 1);
786
786
  }
787
787
  if (mode === 'info_summary') {
@@ -1013,7 +1013,7 @@ export function planVideo({
1013
1013
  semantic_slot: ctaSlotKey,
1014
1014
  focus_region: ctaAnchor?.y_range ?? null,
1015
1015
  confidence: ctaSlot ? Number(clampNumber(ctaSlot.confidence, 0, 1, 0).toFixed(2)) : null,
1016
- guidance: '收尾给出下一步动作,避免空泛口号',
1016
+ guidance: '收尾自然收口(如"感兴趣去原文查看"),禁止提及任何 URL、网址、二维码或投递入口',
1017
1017
  });
1018
1018
 
1019
1019
  const cappedPlan = phasePlan.slice(0, 5);
@@ -1139,24 +1139,24 @@ function buildPhaseSentence({ phase, strategy, phaseIndex }) {
1139
1139
  if (role === 'cta') {
1140
1140
  if (mode === 'job_intel_broadcast') {
1141
1141
  return ensureSentenceLength(
1142
- `最后给行动建议:如果你对这次机会感兴趣,优先去${entry}核对截止时间、岗位要求和投递方式,再决定是否马上投递;如果与你目标不匹配,就按同一口径快速跳过。`
1142
+ `感兴趣的同学去原文查看完整岗位要求和截止时间,对照自己情况再决定要不要投。`
1143
1143
  );
1144
1144
  }
1145
1145
 
1146
1146
  if (mode === 'job_alert') {
1147
1147
  return ensureSentenceLength(
1148
- `建议先把这条提醒收藏,然后去${entry}逐项核对岗位方向、城市和流程细节。确认信息完整后再投递,会比凭单条资讯直接判断更稳妥。`
1148
+ `细节不完整,建议收藏原文,等正式公告发布后再核对岗位方向、城市和流程再决定。`
1149
1149
  );
1150
1150
  }
1151
1151
 
1152
1152
  if (mode === 'refuse_auto_broadcast') {
1153
1153
  return ensureSentenceLength(
1154
- '建议补充官方招聘链接,或提供包含公司、岗位、发布时间的清晰页面截图后再自动播报。信息完整度上来后,系统才能给出可执行的投递判断。'
1154
+ '页面信息不完整,暂不播报。建议直接去官方渠道确认公司、岗位和发布时间后再做判断。'
1155
1155
  );
1156
1156
  }
1157
1157
 
1158
1158
  return ensureSentenceLength(
1159
- `把它当作招聘资讯线索更合适:最终请以${entry}的原始岗位说明为准,尤其是城市、要求和截止时间,避免被二次转述或摘要信息带偏判断。`
1159
+ '具体要求和截止时间见原文,建议直接查看官方公告,避免依赖摘要信息做投递判断。'
1160
1160
  );
1161
1161
  }
1162
1162
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.25",
3
+ "version": "0.15.27",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -4,6 +4,10 @@ import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
4
4
  import { constants as fsConstants } from 'node:fs';
5
5
  import os from 'node:os';
6
6
 
7
+ const SUBTITLE_FONT = 'WenQuanYi Micro Hei';
8
+ const SUBTITLE_FONT_SIZE = 72;
9
+ const SUBTITLE_MARGIN_V = 80;
10
+
7
11
  const MAX_STDERR_LENGTH = 4000;
8
12
 
9
13
  const TRANSCODE_TARGETS = Object.freeze({
@@ -286,10 +290,46 @@ function resolveTranscodeTarget(target) {
286
290
  throw new Error(`unsupported transcode target: ${target}`);
287
291
  }
288
292
 
293
+ function msToAssTimestamp(ms) {
294
+ const totalCs = Math.round(Math.max(0, ms) / 10);
295
+ const cs = totalCs % 100;
296
+ const totalSec = Math.floor(totalCs / 100);
297
+ const sec = totalSec % 60;
298
+ const totalMin = Math.floor(totalSec / 60);
299
+ const min = totalMin % 60;
300
+ const hr = Math.floor(totalMin / 60);
301
+ return `${hr}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
302
+ }
303
+
304
+ export function buildAssContent(subtitles = [], { playResX = 1080, playResY = 1920 } = {}) {
305
+ const header = [
306
+ '[Script Info]',
307
+ 'ScriptType: v4.00+',
308
+ `PlayResX: ${playResX}`,
309
+ `PlayResY: ${playResY}`,
310
+ 'WrapStyle: 0',
311
+ '',
312
+ '[V4+ Styles]',
313
+ 'Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
314
+ `Style: Default,${SUBTITLE_FONT},${SUBTITLE_FONT_SIZE},&H00FFFFFF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,4,0,2,30,30,${SUBTITLE_MARGIN_V},1`,
315
+ '',
316
+ '[Events]',
317
+ 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
318
+ ].join('\n');
319
+
320
+ const events = subtitles.map(({ text, start_ms, end_ms }) => {
321
+ const safeText = String(text ?? '').replace(/\r?\n/g, '\\N').replace(/,/g, '{\\,}');
322
+ return `Dialogue: 0,${msToAssTimestamp(start_ms)},${msToAssTimestamp(end_ms)},Default,,0,0,0,,${safeText}`;
323
+ });
324
+
325
+ return `${header}\n${events.join('\n')}\n`;
326
+ }
327
+
289
328
  export async function transcodeForPlatform({
290
329
  input,
291
330
  output,
292
331
  target = 'short_video_cn',
332
+ subtitlesAssPath = null,
293
333
  } = {}) {
294
334
  const inputPath = normalizePath(input, 'input');
295
335
  await ensureReadableFile(inputPath, 'input');
@@ -301,11 +341,16 @@ export async function transcodeForPlatform({
301
341
  await ensureParentDir(outputPath);
302
342
 
303
343
  const preset = resolveTranscodeTarget(target);
304
- const vf = [
344
+ const vfParts = [
305
345
  `scale=${preset.width}:${preset.height}:force_original_aspect_ratio=decrease`,
306
346
  `pad=${preset.width}:${preset.height}:(ow-iw)/2:(oh-ih)/2:black`,
307
347
  'setsar=1',
308
- ].join(',');
348
+ ];
349
+ if (subtitlesAssPath) {
350
+ const escapedPath = subtitlesAssPath.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
351
+ vfParts.push(`subtitles='${escapedPath}'`);
352
+ }
353
+ const vf = vfParts.join(',');
309
354
 
310
355
  await runProcess('ffmpeg', [
311
356
  '-y',
@@ -187,7 +187,7 @@ export async function recordUrlNarration({
187
187
  viewport = DEFAULT_VIEWPORT,
188
188
  fps = DEFAULT_FPS,
189
189
  settle_ms = 4000,
190
- page_zoom = 1.0,
190
+ page_zoom = 1.1,
191
191
  displayPool = defaultDisplayPool,
192
192
  ffmpegDurationBufferSec = 8,
193
193
  startupProbeMs = 1200,
@@ -7,6 +7,7 @@ import { createHash, randomUUID } from 'crypto';
7
7
  import path, { extname } from 'path';
8
8
  import { homedir } from 'os';
9
9
  import {
10
+ buildAssContent,
10
11
  concatVideos,
11
12
  muxAudioToVideo,
12
13
  probeDurationMs,
@@ -1480,7 +1481,7 @@ server.tool('record_url_narration',
1480
1481
  }).optional().describe('Default 1080x1920 (mobile portrait). Override only if the plan requires a different shape.'),
1481
1482
  fps: z.number().optional().describe('Default 30. Do not change unless needed.'),
1482
1483
  settle_ms: z.number().optional().describe('Default 4000. Settle wait after navigation before recording starts.'),
1483
- page_zoom: z.number().optional().describe('Browser zoom factor applied before recording. Default 1.0 (no zoom). Use e.g. 1.1 to zoom in 10% so text appears larger. Plan Y coordinates are automatically scaled.'),
1484
+ page_zoom: z.number().optional().describe('Browser zoom factor applied before recording. Default 1.1 (10% zoom in). Set to 1.0 to disable. Plan Y coordinates are automatically scaled by this factor.'),
1484
1485
  },
1485
1486
  async (args) => {
1486
1487
  if (isBlockedCvmaxEditorVideoTool('record_url_narration')) {
@@ -1498,7 +1499,7 @@ server.tool('record_url_narration',
1498
1499
 
1499
1500
  // ── compose_video ───────────────────────────────────────────────────────────────
1500
1501
  server.tool('compose_video',
1501
- 'Compose a final short video by muxing audio onto a base video, optionally concatenating an outro, and transcoding to platform spec.',
1502
+ 'Compose a final short video by muxing audio onto a base video, optionally burning subtitles, concatenating an outro, and transcoding to platform spec.',
1502
1503
  {
1503
1504
  video_path: z.string().describe('Base silent video path. Relative paths resolve from the current workspace.'),
1504
1505
  audio_segments: z.array(z.object({
@@ -1507,14 +1508,19 @@ server.tool('compose_video',
1507
1508
  phase: z.string().optional().describe('Optional phase id. Used with events_log to derive start time.'),
1508
1509
  })).describe('Ordered or unordered narration audio segments.'),
1509
1510
  events_log: z.array(z.any()).optional().describe('Optional recorder event log. Used to resolve segment start time by phase.'),
1511
+ subtitles: z.array(z.object({
1512
+ text: z.string().describe('Subtitle text for this segment (the narration sentence).'),
1513
+ start_ms: z.number().describe('Subtitle start time in milliseconds.'),
1514
+ end_ms: z.number().describe('Subtitle end time in milliseconds.'),
1515
+ })).optional().describe('Subtitle segments to burn into the video. Pass each phase narration text with its start/end time (derived from detail_sections dwell_ms). Omit to produce no subtitles.'),
1510
1516
  outro_path: z.string().optional().describe('Optional outro mp4 path. If omitted, uses ~/.lightcone/assets/outros/default.mp4 when present.'),
1511
1517
  target: z.enum(['short_video_cn', 'douyin', 'xhs']).optional().describe('Transcode target profile. Defaults to short_video_cn.'),
1512
1518
  },
1513
- async ({ video_path, audio_segments, events_log, outro_path, target }) => {
1519
+ async ({ video_path, audio_segments, events_log, subtitles, outro_path, target }) => {
1514
1520
  if (isBlockedCvmaxEditorVideoTool('compose_video')) {
1515
1521
  return cvmaxEditorVideoToolError('compose_video');
1516
1522
  }
1517
- const composeInput = { video_path, audio_segments, events_log, outro_path, target };
1523
+ const composeInput = { video_path, audio_segments, events_log, subtitles, outro_path, target };
1518
1524
  try {
1519
1525
  const result = await runMandatoryLocalTool({
1520
1526
  toolName: 'compose_video',
@@ -1541,7 +1547,16 @@ server.tool('compose_video',
1541
1547
  const muxedPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.muxed.mp4`);
1542
1548
  const concatPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.concat.mp4`);
1543
1549
  const finalPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.final.mp4`);
1544
- const intermediates = [muxedPath, concatPath];
1550
+ const assPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.ass`);
1551
+ const intermediates = [muxedPath, concatPath, assPath];
1552
+
1553
+ const subtitleSegments = Array.isArray(checkedInput.subtitles) ? checkedInput.subtitles : [];
1554
+ let subtitlesAssPath = null;
1555
+ if (subtitleSegments.length > 0) {
1556
+ const assContent = buildAssContent(subtitleSegments);
1557
+ writeFileSync(assPath, assContent, 'utf8');
1558
+ subtitlesAssPath = assPath;
1559
+ }
1545
1560
 
1546
1561
  try {
1547
1562
  await muxAudioToVideo({
@@ -1564,6 +1579,7 @@ server.tool('compose_video',
1564
1579
  input: composedPath,
1565
1580
  output: finalPath,
1566
1581
  target: targetProfile,
1582
+ subtitlesAssPath,
1567
1583
  });
1568
1584
 
1569
1585
  const durationMs = await probeDurationMs(finalPath);
@@ -1573,6 +1589,7 @@ server.tool('compose_video',
1573
1589
  durationMs,
1574
1590
  outroPath: resolvedOutroPath,
1575
1591
  target: targetProfile,
1592
+ subtitles: subtitleSegments.length > 0,
1576
1593
  };
1577
1594
  } catch (error) {
1578
1595
  cleanupLocalFiles([...intermediates, finalPath]);
@@ -1590,6 +1607,7 @@ server.tool('compose_video',
1590
1607
  `final_video_path=${result.finalVideoPath}\n` +
1591
1608
  `duration_ms=${result.durationMs}\n` +
1592
1609
  `target=${result.target}\n` +
1610
+ `subtitles=${result.subtitles ? 'burned' : 'none'}\n` +
1593
1611
  `outro=${outroText}`,
1594
1612
  }],
1595
1613
  };