@lightcone-ai/daemon 0.15.26 → 0.15.28
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.
|
@@ -858,7 +858,7 @@ export function planVideo({
|
|
|
858
858
|
const coreMessage = toSafeString(understanding?.core_message || understanding?.coreMessage || understanding?.title) || '本页价值点概览';
|
|
859
859
|
|
|
860
860
|
const hotspots = collectHotspots(understanding);
|
|
861
|
-
const
|
|
861
|
+
const baseSkipZones = collectSkipZones(understanding);
|
|
862
862
|
const coreRange = resolveCoreRange(understanding, hotspots);
|
|
863
863
|
const semanticSlots = collectSemanticSlots(understanding);
|
|
864
864
|
const recruitmentFailClosedEnabled = shouldApplyRecruitmentFailClosed(understanding);
|
|
@@ -878,6 +878,16 @@ export function planVideo({
|
|
|
878
878
|
const modeInfo = resolveNarrationMode({ understanding, slots: semanticSlots });
|
|
879
879
|
const semanticSummary = buildSemanticSummary(semanticSlots);
|
|
880
880
|
|
|
881
|
+
const isRecruitmentMode = modeInfo.mode === 'job_intel_broadcast' || modeInfo.mode === 'job_alert';
|
|
882
|
+
// For recruitment pages, treat the entry/CTA region as a hard skip zone so the
|
|
883
|
+
// camera never scrolls into the QR-code / application-link area.
|
|
884
|
+
const entrySkipRange = isRecruitmentMode
|
|
885
|
+
? normalizeYRange(semanticSlots.entry_or_cta?.focus_region)
|
|
886
|
+
: null;
|
|
887
|
+
const skipZones = entrySkipRange
|
|
888
|
+
? [...baseSkipZones, entrySkipRange]
|
|
889
|
+
: baseSkipZones;
|
|
890
|
+
|
|
881
891
|
const hotspotHighlights = pickHighlightHotspots({
|
|
882
892
|
hotspots,
|
|
883
893
|
skipZones,
|
|
@@ -986,7 +996,11 @@ export function planVideo({
|
|
|
986
996
|
previousY = highlight.target_y;
|
|
987
997
|
}
|
|
988
998
|
|
|
989
|
-
|
|
999
|
+
// Recruitment pages: never navigate to entry/QR-code area for CTA.
|
|
1000
|
+
const ctaSlotCandidates = isRecruitmentMode
|
|
1001
|
+
? ['process', 'company']
|
|
1002
|
+
: ['entry_or_cta', 'process', 'company'];
|
|
1003
|
+
const ctaSlotKey = pickFirstAvailableSlot(semanticSlots, ctaSlotCandidates);
|
|
990
1004
|
const ctaSlot = ctaSlotKey ? semanticSlots[ctaSlotKey] : null;
|
|
991
1005
|
const ctaAnchor = focusRegionToHighlight({
|
|
992
1006
|
focusRegion: ctaSlot?.focus_region,
|
|
@@ -995,15 +1009,18 @@ export function planVideo({
|
|
|
995
1009
|
text: ctaSlotKey ? (slotValueToText(ctaSlot) || slotLabel(ctaSlotKey)) : '收尾行动',
|
|
996
1010
|
confidence: ctaSlot?.confidence ?? 0.38,
|
|
997
1011
|
});
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1012
|
+
// Recruitment mode: always scroll back to core start — never scroll to entry/QR area.
|
|
1013
|
+
const ctaAction = isRecruitmentMode
|
|
1014
|
+
? buildCtaAction({ coreRange })
|
|
1015
|
+
: (ctaAnchor && modeInfo.mode !== 'refuse_auto_broadcast'
|
|
1016
|
+
? {
|
|
1017
|
+
type: 'scroll_to_dwell',
|
|
1018
|
+
target_hotspot: ctaAnchor.id,
|
|
1019
|
+
target_y: ctaAnchor.target_y,
|
|
1020
|
+
transition_ms: 900,
|
|
1021
|
+
reason: '收尾聚焦行动提示',
|
|
1022
|
+
}
|
|
1023
|
+
: buildCtaAction({ coreRange }));
|
|
1007
1024
|
|
|
1008
1025
|
phasePlan.push({
|
|
1009
1026
|
phase_id: 'cta',
|
|
@@ -1013,7 +1030,7 @@ export function planVideo({
|
|
|
1013
1030
|
semantic_slot: ctaSlotKey,
|
|
1014
1031
|
focus_region: ctaAnchor?.y_range ?? null,
|
|
1015
1032
|
confidence: ctaSlot ? Number(clampNumber(ctaSlot.confidence, 0, 1, 0).toFixed(2)) : null,
|
|
1016
|
-
guidance: '
|
|
1033
|
+
guidance: '介绍完最后一个信息点直接结束,禁止加任何收口语(不说"感兴趣去原文查看""截止见正文"等),禁止提及 URL、网址、二维码或投递入口',
|
|
1017
1034
|
});
|
|
1018
1035
|
|
|
1019
1036
|
const cappedPlan = phasePlan.slice(0, 5);
|
|
@@ -1137,27 +1154,7 @@ function buildPhaseSentence({ phase, strategy, phaseIndex }) {
|
|
|
1137
1154
|
}
|
|
1138
1155
|
|
|
1139
1156
|
if (role === 'cta') {
|
|
1140
|
-
|
|
1141
|
-
return ensureSentenceLength(
|
|
1142
|
-
`感兴趣的同学去原文查看完整岗位要求和截止时间,对照自己情况再决定要不要投。`
|
|
1143
|
-
);
|
|
1144
|
-
}
|
|
1145
|
-
|
|
1146
|
-
if (mode === 'job_alert') {
|
|
1147
|
-
return ensureSentenceLength(
|
|
1148
|
-
`细节不完整,建议收藏原文,等正式公告发布后再核对岗位方向、城市和流程再决定。`
|
|
1149
|
-
);
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
if (mode === 'refuse_auto_broadcast') {
|
|
1153
|
-
return ensureSentenceLength(
|
|
1154
|
-
'页面信息不完整,暂不播报。建议直接去官方渠道确认公司、岗位和发布时间后再做判断。'
|
|
1155
|
-
);
|
|
1156
|
-
}
|
|
1157
|
-
|
|
1158
|
-
return ensureSentenceLength(
|
|
1159
|
-
'具体要求和截止时间见原文,建议直接查看官方公告,避免依赖摘要信息做投递判断。'
|
|
1160
|
-
);
|
|
1157
|
+
return null;
|
|
1161
1158
|
}
|
|
1162
1159
|
|
|
1163
1160
|
if (mode === 'job_intel_broadcast') {
|
|
@@ -1369,6 +1366,8 @@ export async function detailSections({
|
|
|
1369
1366
|
phaseIndex: i,
|
|
1370
1367
|
});
|
|
1371
1368
|
|
|
1369
|
+
if (sentence == null) continue;
|
|
1370
|
+
|
|
1372
1371
|
const voice = resolveVoiceForPhase({ profile, role, index: i });
|
|
1373
1372
|
const phaseBudgetMs = clampInt((toFiniteNumber(phase?.duration_s) ?? 6) * 1000, 1000, 180000, 6000);
|
|
1374
1373
|
|
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
]
|
|
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',
|
|
@@ -195,7 +195,7 @@ export async function recordUrlNarration({
|
|
|
195
195
|
xvfbStopTimeoutMs = 5000,
|
|
196
196
|
postPlanTailMs = 600,
|
|
197
197
|
} = {}) {
|
|
198
|
-
const zoom = Number.isFinite(Number(page_zoom)) && Number(page_zoom) > 0 ? Number(page_zoom) : 1.
|
|
198
|
+
const zoom = Number.isFinite(Number(page_zoom)) && Number(page_zoom) > 0 ? Number(page_zoom) : 1.1;
|
|
199
199
|
const rawPhases = normalizePlanPhases(plan);
|
|
200
200
|
const phases = zoom !== 1.0 ? rawPhases.map(p => scalePhaseY(p, zoom)) : rawPhases;
|
|
201
201
|
const executablePlan = {
|
package/src/chat-bridge.js
CHANGED
|
@@ -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,
|
|
@@ -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
|
|
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
|
};
|