@lightcone-ai/daemon 0.15.32 → 0.15.34

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.
@@ -1097,110 +1097,6 @@ function resolveVoiceForPhase({ profile, role, index = 0 }) {
1097
1097
  };
1098
1098
  }
1099
1099
 
1100
- function resolveSummaryValue(strategy = {}, slotKey) {
1101
- const summary = toSafeString(strategy?.semantic_summary?.[slotKey]);
1102
- if (summary) return summary;
1103
- const slot = strategy?.semantic_slots?.[slotKey];
1104
- if (!slot) return '';
1105
- if (slotKey === 'recruitment_type') return formatRecruitmentType(slot) || slotValueToText(slot);
1106
- return slotValueToText(slot);
1107
- }
1108
-
1109
- function describeHighlightFocus(phase) {
1110
- const semanticLabel = slotLabel(toSafeString(phase?.semantic_slot));
1111
- const text = toSafeString(phase?.highlight?.text || phase?.highlight?.id || semanticLabel || phase?.visual_action?.target_hotspot);
1112
- if (!text) return '页面核心信息';
1113
- return text;
1114
- }
1115
-
1116
- function buildPhaseSentence({ phase, strategy, phaseIndex }) {
1117
- const role = toSafeString(phase?.role || '').toLowerCase() || (phase?.phase_id === 'cta' ? 'cta' : 'highlight');
1118
- const mode = normalizeModeHint(strategy?.narration_mode) || 'info_summary';
1119
- const slotKey = toSafeString(phase?.semantic_slot);
1120
- const slotText = resolveSummaryValue(strategy, slotKey);
1121
- const slotTextOrFallback = slotText || describeHighlightFocus(phase);
1122
- const company = resolveSummaryValue(strategy, 'company') || '该公司';
1123
- const publishedAt = resolveSummaryValue(strategy, 'published_at') || '近期';
1124
- const recruitmentType = resolveSummaryValue(strategy, 'recruitment_type') || '招聘信息';
1125
- const cohort = resolveSummaryValue(strategy, 'cohort');
1126
- const entry = resolveSummaryValue(strategy, 'entry_or_cta') || '官方岗位入口';
1127
- const locations = resolveSummaryValue(strategy, 'locations');
1128
- const jobDirections = resolveSummaryValue(strategy, 'job_directions');
1129
- const process = resolveSummaryValue(strategy, 'process');
1130
- const targetOrRequirements = resolveSummaryValue(strategy, 'target_or_requirements');
1131
-
1132
- if (role === 'hook') {
1133
- if (mode === 'job_intel_broadcast') {
1134
- const cohortPart = cohort ? `${cohort} ` : '';
1135
- return ensureSentenceLength(
1136
- `岗位情报先划重点:${company}在${publishedAt}发布了${cohortPart}${recruitmentType}。接下来我会按岗位方向、城市和流程三块快速过一遍,只保留能直接支持投递判断的信息。`
1137
- );
1138
- }
1139
-
1140
- if (mode === 'job_alert') {
1141
- return ensureSentenceLength(
1142
- `这是一条岗位提醒:${company}在${publishedAt}发布了${recruitmentType}。当前页面细节不完整,我只说已经能确认的事实,帮你先判断要不要继续花时间深挖。`
1143
- );
1144
- }
1145
-
1146
- if (mode === 'refuse_auto_broadcast') {
1147
- return ensureSentenceLength(
1148
- '当前页面缺少稳定的公司、岗位或发布时间线索,我不能直接生成岗位情报式结论。为了避免误导,下面仅给你保守处理建议。'
1149
- );
1150
- }
1151
-
1152
- return ensureSentenceLength(
1153
- '这条链接和招聘信息相关,但更像资讯或公告汇总。下面我只提可核验线索,帮助你快速决定是否跳转到官方岗位页继续确认。'
1154
- );
1155
- }
1156
-
1157
- if (role === 'cta') {
1158
- return null;
1159
- }
1160
-
1161
- if (mode === 'job_intel_broadcast') {
1162
- if (slotKey === 'job_directions') {
1163
- return ensureSentenceLength(
1164
- `岗位方向目前可确认为${slotTextOrFallback}。这直接决定你是否值得继续投入,建议先对照自己的专业或技能栈做第一轮筛选,再看后续细节。`
1165
- );
1166
- }
1167
- if (slotKey === 'locations') {
1168
- return ensureSentenceLength(
1169
- `工作城市线索是${slotTextOrFallback}。这一步主要用于判断通勤与生活成本是否可接受,先把城市匹配度过一遍,能明显减少后续无效投递。`
1170
- );
1171
- }
1172
- if (slotKey === 'process') {
1173
- return ensureSentenceLength(
1174
- `招聘流程可读到${slotTextOrFallback}。你可以按这个顺序预留时间准备简历、测评和面试节点,避免临近截止才发现准备节奏不够。`
1175
- );
1176
- }
1177
- if (slotKey === 'target_or_requirements') {
1178
- return ensureSentenceLength(
1179
- `任职对象与要求显示为${slotTextOrFallback}。这一段最适合做硬性条件核对,先看是否满足门槛,再决定要不要继续投入申请材料。`
1180
- );
1181
- }
1182
- return ensureSentenceLength(
1183
- `补充一个关键信息:${slotLabel(slotKey) || '重点线索'}是${slotTextOrFallback}。这条信息能帮助你更快判断岗位匹配度,避免被页面里次要内容分散注意力。`
1184
- );
1185
- }
1186
-
1187
- if (mode === 'job_alert') {
1188
- return ensureSentenceLength(
1189
- `目前页面能稳定识别的${slotLabel(slotKey) || '线索'}是${slotTextOrFallback}。其余细节暂不充足,建议你把它当作提醒信号,再回到原文逐条核验。`
1190
- );
1191
- }
1192
-
1193
- if (mode === 'refuse_auto_broadcast') {
1194
- return ensureSentenceLength(
1195
- `这一段的${slotLabel(slotKey) || '线索'}置信度仍然偏低,我不会把“${slotTextOrFallback}”当成可直接投递依据。先补充权威来源,再做下一步判断更安全。`
1196
- );
1197
- }
1198
-
1199
- const summaryPieces = [jobDirections, locations, process, targetOrRequirements].filter(Boolean).slice(0, 2);
1200
- return ensureSentenceLength(
1201
- `第${phaseIndex + 1}个可核验线索是${slotTextOrFallback}。目前它更适合作为招聘资讯摘要,${summaryPieces.length > 0 ? `可结合${summaryPieces.join('、')}交叉判断` : `建议与官方岗位页交叉核对`},不要直接当成完整岗位说明。`
1202
- );
1203
- }
1204
1100
 
1205
1101
  function estimateDurationMs(text, speed = 1) {
1206
1102
  const chars = Math.max(1, toCharLength(text));
@@ -1342,6 +1238,7 @@ function shouldTryTts({ env, workspaceId }) {
1342
1238
 
1343
1239
  export async function detailSections({
1344
1240
  strategy = {},
1241
+ sentences = null,
1345
1242
  workspace_id = null,
1346
1243
  credential_id = null,
1347
1244
  format = 'mp3',
@@ -1358,6 +1255,21 @@ export async function detailSections({
1358
1255
  );
1359
1256
  }
1360
1257
 
1258
+ if (!Array.isArray(sentences) || sentences.length === 0) {
1259
+ throw new Error(
1260
+ 'sentences are required. Write one narration sentence per phase '
1261
+ + '(hook, highlight_1…N, cta) based on the semantic_slots and persona from plan_video. '
1262
+ + 'Use natural peer-voice tone for the target platform — not a news broadcast.'
1263
+ );
1264
+ }
1265
+
1266
+ const sentenceMap = {};
1267
+ for (const item of sentences) {
1268
+ const phaseId = toSafeString(item?.phase_id);
1269
+ const text = toSafeString(item?.text);
1270
+ if (phaseId && text) sentenceMap[phaseId] = text;
1271
+ }
1272
+
1361
1273
  const profile = getPlatformProfile(strategy?.target_platform);
1362
1274
  const phases = normalizePhaseList(strategy);
1363
1275
 
@@ -1367,15 +1279,23 @@ export async function detailSections({
1367
1279
 
1368
1280
  for (let i = 0; i < phases.length; i += 1) {
1369
1281
  const phase = phases[i];
1370
- const role = toSafeString(phase?.role).toLowerCase() || (phase?.phase_id === 'cta' ? 'cta' : (phase?.phase_id === 'hook' ? 'hook' : 'highlight'));
1282
+ const phaseId = toSafeString(phase.phase_id) || `phase_${i + 1}`;
1283
+ const role = toSafeString(phase?.role).toLowerCase() || (phaseId === 'cta' ? 'cta' : (phaseId === 'hook' ? 'hook' : 'highlight'));
1371
1284
 
1372
- const sentence = buildPhaseSentence({
1373
- phase,
1374
- strategy,
1375
- phaseIndex: i,
1376
- });
1285
+ const sentence = sentenceMap[phaseId];
1286
+ if (!sentence) {
1287
+ throw new Error(
1288
+ `missing sentence for phase "${phaseId}". Provide a text sentence for every phase in the plan.`
1289
+ );
1290
+ }
1377
1291
 
1378
- if (sentence == null) continue;
1292
+ const charLen = toCharLength(sentence);
1293
+ if (charLen < SENTENCE_MIN_CHARS) {
1294
+ throw new Error(`sentence for "${phaseId}" too short (${charLen} chars, min ${SENTENCE_MIN_CHARS}).`);
1295
+ }
1296
+ if (charLen > SENTENCE_MAX_CHARS) {
1297
+ throw new Error(`sentence for "${phaseId}" too long (${charLen} chars, max ${SENTENCE_MAX_CHARS}). Shorten to fit one TTS segment.`);
1298
+ }
1379
1299
 
1380
1300
  const voice = resolveVoiceForPhase({ profile, role, index: i });
1381
1301
  const phaseBudgetMs = clampInt((toFiniteNumber(phase?.duration_s) ?? 6) * 1000, 1000, 180000, 6000);
@@ -1398,9 +1318,9 @@ export async function detailSections({
1398
1318
  usedLiveTts = true;
1399
1319
  } catch (error) {
1400
1320
  const message = toSafeString(error?.message) || 'tts_call_failed';
1401
- ttsErrors.push({ phase_id: phase.phase_id, error: message });
1321
+ ttsErrors.push({ phase_id: phaseId, error: message });
1402
1322
  if (strict_tts) {
1403
- throw new Error(`detail_sections_tts_failed:${phase.phase_id}:${message}`);
1323
+ throw new Error(`detail_sections_tts_failed:${phaseId}:${message}`);
1404
1324
  }
1405
1325
  }
1406
1326
  }
@@ -1408,7 +1328,7 @@ export async function detailSections({
1408
1328
  if (!audio) {
1409
1329
  const estimatedDuration = estimateDurationMs(sentence, voice.speed);
1410
1330
  audio = {
1411
- audio_id: `estimated-${phase.phase_id || `p${i + 1}`}`,
1331
+ audio_id: `estimated-${phaseId}`,
1412
1332
  duration_ms: estimatedDuration,
1413
1333
  audio_url: null,
1414
1334
  audio_path: null,
@@ -1430,7 +1350,7 @@ export async function detailSections({
1430
1350
  );
1431
1351
 
1432
1352
  sections.push({
1433
- phase_id: toSafeString(phase.phase_id) || `phase_${i + 1}`,
1353
+ phase_id: phaseId,
1434
1354
  visual_action: visualAction,
1435
1355
  sentence,
1436
1356
  voice_preset: voice.voice_preset,
@@ -84,9 +84,19 @@ export function createVideoNarrationPlannerServer({
84
84
 
85
85
  server.tool(
86
86
  'detail_sections',
87
- 'Stage 3: expand each phase into sentence+voice settings, call TTS voiceover, and fill duration/dwell.',
87
+ 'Stage 3: take agent-written sentences, call TTS voiceover for each phase, and fill duration/dwell. You MUST write the sentences yourself before calling this tool.',
88
88
  {
89
89
  strategy: z.record(z.any()).describe('Output of plan_video (video strategy with phase_plan).'),
90
+ sentences: z.array(z.object({
91
+ phase_id: z.string().describe('Phase ID from plan_video phase_plan (e.g. hook, highlight_1, cta).'),
92
+ text: z.string().describe('Narration sentence you wrote for this phase.'),
93
+ })).describe(
94
+ 'Narration sentences YOU write, one per phase in the plan. '
95
+ + 'Use semantic_slots, persona, and platform tone from plan_video output. '
96
+ + 'Sound like a peer sharing useful info — not a news anchor. '
97
+ + 'Each sentence: 8–60 chars, spoken Chinese, ends with punctuation. '
98
+ + 'Do NOT use "信息来源为", "发布时间为", "凭截图判断" or similar metadata/warning phrasing.'
99
+ ),
90
100
  workspace_id: z.string().optional().describe('Workspace id for TTS generation. Defaults to WORKSPACE_ID env if provided.'),
91
101
  credential_id: z.string().optional().describe('Optional explicit tts_provider credential id.'),
92
102
  format: z.enum(['mp3', 'wav', 'flac']).optional().describe('Desired audio format for generated voiceover.'),
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "default_outro_video_id": "outro-default-zh",
3
3
  "sentence_char_range": {
4
- "min": 100,
5
- "max": 150
4
+ "min": 14,
5
+ "max": 60
6
6
  },
7
7
  "platform_profiles": {
8
8
  "xiaohongshu": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.32",
3
+ "version": "0.15.34",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -301,13 +301,30 @@ function msToAssTimestamp(ms) {
301
301
  return `${hr}:${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
302
302
  }
303
303
 
304
+ // Hard-wrap CJK subtitle text so it never overflows the video frame.
305
+ // libass WrapStyle:0 doesn't handle Chinese text reliably (no word boundaries),
306
+ // so we insert explicit \N breaks every maxChars characters.
307
+ function wrapSubtitleText(text, maxChars = 14) {
308
+ const chars = Array.from(String(text ?? ''));
309
+ if (chars.length <= maxChars) return chars.join('');
310
+ const lines = [];
311
+ for (let i = 0; i < chars.length; i += maxChars) {
312
+ lines.push(chars.slice(i, i + maxChars).join(''));
313
+ }
314
+ return lines.join('\\N');
315
+ }
316
+
304
317
  export function buildAssContent(subtitles = [], { playResX = 1080, playResY = 1920 } = {}) {
318
+ // Max chars per line: (playResX - marginL - marginR) / fontSizePx
319
+ // 1080 - 30 - 30 = 1020px, fontsize 72 ≈ 72px/char → 14 chars
320
+ const maxCharsPerLine = Math.floor((playResX - 60) / SUBTITLE_FONT_SIZE);
321
+
305
322
  const header = [
306
323
  '[Script Info]',
307
324
  'ScriptType: v4.00+',
308
325
  `PlayResX: ${playResX}`,
309
326
  `PlayResY: ${playResY}`,
310
- 'WrapStyle: 0',
327
+ 'WrapStyle: 2',
311
328
  '',
312
329
  '[V4+ Styles]',
313
330
  'Format: Name, Fontname, Fontsize, PrimaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding',
@@ -318,7 +335,8 @@ export function buildAssContent(subtitles = [], { playResX = 1080, playResY = 19
318
335
  ].join('\n');
319
336
 
320
337
  const events = subtitles.map(({ text, start_ms, end_ms }) => {
321
- const safeText = String(text ?? '').replace(/\r?\n/g, '\\N').replace(/,/g, '{\\,}');
338
+ const wrapped = wrapSubtitleText(text, maxCharsPerLine);
339
+ const safeText = wrapped.replace(/\r?\n/g, '\\N').replace(/,/g, '{\\,}');
322
340
  return `Dialogue: 0,${msToAssTimestamp(start_ms)},${msToAssTimestamp(end_ms)},Default,,0,0,0,,${safeText}`;
323
341
  });
324
342