@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
|
|
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 =
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
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
|
-
|
|
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:
|
|
1321
|
+
ttsErrors.push({ phase_id: phaseId, error: message });
|
|
1402
1322
|
if (strict_tts) {
|
|
1403
|
-
throw new Error(`detail_sections_tts_failed:${
|
|
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-${
|
|
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:
|
|
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:
|
|
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.'),
|
package/package.json
CHANGED
|
@@ -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:
|
|
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
|
|
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
|
|