@lightcone-ai/daemon 0.15.45 → 0.15.47
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.
- package/package.json +1 -1
- package/src/chat-bridge.js +16 -291
- package/src/tools/render-html-to-image.js +58 -0
package/package.json
CHANGED
package/src/chat-bridge.js
CHANGED
|
@@ -2,17 +2,9 @@
|
|
|
2
2
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
3
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { createReadStream, existsSync, mkdirSync, readFileSync,
|
|
5
|
+
import { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
6
6
|
import { createHash, randomUUID } from 'crypto';
|
|
7
7
|
import path, { extname } from 'path';
|
|
8
|
-
import { homedir } from 'os';
|
|
9
|
-
import {
|
|
10
|
-
buildAssContent,
|
|
11
|
-
concatVideos,
|
|
12
|
-
muxAudioToVideo,
|
|
13
|
-
probeDurationMs,
|
|
14
|
-
transcodeForPlatform,
|
|
15
|
-
} from './_vendor/video/composer/index.js';
|
|
16
8
|
import { recordUrlNarration } from './_vendor/video/recorder/index.js';
|
|
17
9
|
import {
|
|
18
10
|
VIDEO_EXT,
|
|
@@ -22,6 +14,7 @@ import {
|
|
|
22
14
|
import { runRecordUrlNarrationTool } from './record-url-narration-tool.js';
|
|
23
15
|
import { runSubmitToLibraryTool } from './submit-to-library-tool.js';
|
|
24
16
|
import { runRenderTextToImageTool } from './tools/render-text-to-image.js';
|
|
17
|
+
import { runRenderHtmlToImageTool } from './tools/render-html-to-image.js';
|
|
25
18
|
import { runSynthesisTtsTool } from './tools/synthesize-tts.js';
|
|
26
19
|
import { runComposeVideoV2Tool } from './tools/compose-video-v2.js';
|
|
27
20
|
import { runTakePageScreenshotTool } from './tools/take-page-screenshot.js';
|
|
@@ -74,18 +67,13 @@ function redactTokenPrefix(token) {
|
|
|
74
67
|
// Current active workspaceId for memory isolation (defaults to spawn-time WORKSPACE_ID)
|
|
75
68
|
let currentWorkspaceId = WORKSPACE_ID;
|
|
76
69
|
|
|
77
|
-
const VOICEOVER_LOCAL_DIR = path.join(WORKSPACE_DIR, 'artifacts', 'audio');
|
|
78
|
-
const VIDEO_COMPOSE_LOCAL_DIR = path.join(WORKSPACE_DIR, 'artifacts', 'video');
|
|
79
|
-
const DEFAULT_OUTRO_PATH = path.join(homedir(), '.lightcone', 'assets', 'outros', 'default.mp4');
|
|
80
70
|
// Temporary: block legacy video pipeline tools for a specific editor_in_chief agent.
|
|
81
71
|
// Set via env so this doesn't need a code change when workspace/agent IDs rotate.
|
|
82
72
|
// Remove entirely once the new atomic tool framework is stable and the legacy pipeline retires.
|
|
83
73
|
const CVMAX_WORKSPACE_ID = process.env.BLOCKED_EDITOR_WORKSPACE_ID ?? '';
|
|
84
74
|
const CVMAX_EDITOR_IN_CHIEF_AGENT_ID = process.env.BLOCKED_EDITOR_AGENT_ID ?? '';
|
|
85
75
|
const CVMAX_EDITOR_BLOCKED_VIDEO_TOOLS = new Set([
|
|
86
|
-
'generate_voiceover',
|
|
87
76
|
'record_url_narration',
|
|
88
|
-
'compose_video',
|
|
89
77
|
'submit_to_library',
|
|
90
78
|
]);
|
|
91
79
|
|
|
@@ -131,27 +119,6 @@ function cvmaxEditorVideoToolError(toolName) {
|
|
|
131
119
|
};
|
|
132
120
|
}
|
|
133
121
|
|
|
134
|
-
function normalizeVoiceFormat(value) {
|
|
135
|
-
const normalized = String(value ?? '').trim().toLowerCase();
|
|
136
|
-
if (!normalized) return 'mp3';
|
|
137
|
-
if (['mp3', 'wav', 'flac'].includes(normalized)) return normalized;
|
|
138
|
-
return 'mp3';
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function inferAudioExtension(url, format = 'mp3') {
|
|
142
|
-
const normalizedFormat = normalizeVoiceFormat(format);
|
|
143
|
-
if (typeof url === 'string' && url.trim()) {
|
|
144
|
-
try {
|
|
145
|
-
const pathname = new URL(url).pathname;
|
|
146
|
-
const ext = extname(pathname).toLowerCase();
|
|
147
|
-
if (ext && ['.mp3', '.wav', '.flac'].includes(ext)) return ext;
|
|
148
|
-
} catch {
|
|
149
|
-
// noop
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
return `.${normalizedFormat}`;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
122
|
function isInsideDir(filePath, dir) {
|
|
156
123
|
const rel = path.relative(dir, filePath);
|
|
157
124
|
return rel === '' || (!!rel && !rel.startsWith('..') && !path.isAbsolute(rel));
|
|
@@ -167,58 +134,6 @@ function resolveLocalWorkspaceFile(filePath) {
|
|
|
167
134
|
throw new Error(`Local file must be inside the agent workspace or workspace shared artifacts/notes/tmp directories. Got: ${filePath}`);
|
|
168
135
|
}
|
|
169
136
|
|
|
170
|
-
function normalizeComposePath(filePath, label) {
|
|
171
|
-
const normalized = String(filePath ?? '').trim();
|
|
172
|
-
if (!normalized) throw new Error(`${label} is required.`);
|
|
173
|
-
return path.resolve(WORKSPACE_DIR, normalized);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function normalizeComposeTarget(value) {
|
|
177
|
-
const normalized = String(value ?? '').trim().toLowerCase();
|
|
178
|
-
if (!normalized) return 'short_video_cn';
|
|
179
|
-
if (['short_video_cn', 'douyin', 'xhs'].includes(normalized)) return normalized;
|
|
180
|
-
throw new Error(`Unsupported compose target: ${value}`);
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function normalizeComposeAudioSegments(audioSegments) {
|
|
184
|
-
if (!Array.isArray(audioSegments) || audioSegments.length === 0) {
|
|
185
|
-
throw new Error('audio_segments must be a non-empty array.');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
return audioSegments.map((segment, index) => {
|
|
189
|
-
if (!segment || typeof segment !== 'object' || Array.isArray(segment)) {
|
|
190
|
-
throw new Error(`audio_segments[${index}] must be an object.`);
|
|
191
|
-
}
|
|
192
|
-
const audioPath = normalizeComposePath(
|
|
193
|
-
segment.audio_path ?? segment.audioPath,
|
|
194
|
-
`audio_segments[${index}].audio_path`
|
|
195
|
-
);
|
|
196
|
-
const startMsRaw = segment.start_ms ?? segment.startMs;
|
|
197
|
-
const startMs = startMsRaw == null ? null : Number(startMsRaw);
|
|
198
|
-
if (startMsRaw != null && (!Number.isFinite(startMs) || startMs < 0)) {
|
|
199
|
-
throw new Error(`audio_segments[${index}].start_ms must be a non-negative number.`);
|
|
200
|
-
}
|
|
201
|
-
const phase = String(segment.phase ?? segment.phase_id ?? segment.phaseId ?? '').trim();
|
|
202
|
-
if (startMs == null && !phase) {
|
|
203
|
-
throw new Error(`audio_segments[${index}] requires start_ms, or provide phase with events_log.`);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const normalized = { audio_path: audioPath };
|
|
207
|
-
if (startMs != null) normalized.start_ms = Math.floor(startMs);
|
|
208
|
-
if (phase) normalized.phase = phase;
|
|
209
|
-
return {
|
|
210
|
-
...normalized,
|
|
211
|
-
};
|
|
212
|
-
});
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function cleanupLocalFiles(paths = []) {
|
|
216
|
-
for (const filePath of paths) {
|
|
217
|
-
if (!filePath) continue;
|
|
218
|
-
try { rmSync(filePath, { force: true }); } catch { /* noop */ }
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
|
|
222
137
|
const DEFAULT_TOOL_CLASSIFICATION = {
|
|
223
138
|
check_messages: 'local',
|
|
224
139
|
list_memory: 'local',
|
|
@@ -246,9 +161,7 @@ const DEFAULT_TOOL_CLASSIFICATION = {
|
|
|
246
161
|
update_goal_field: 'mandatory',
|
|
247
162
|
supersede_goal_field: 'mandatory',
|
|
248
163
|
request_credential_auth: 'mandatory',
|
|
249
|
-
generate_voiceover: 'mandatory',
|
|
250
164
|
record_url_narration: 'mandatory',
|
|
251
|
-
compose_video: 'mandatory',
|
|
252
165
|
submit_to_library: 'mandatory',
|
|
253
166
|
register_data_source: 'mandatory',
|
|
254
167
|
bind_workspace_scenario: 'mandatory',
|
|
@@ -384,7 +297,6 @@ function inferToolForApi(method, apiPath, body) {
|
|
|
384
297
|
if (method === 'POST' && cleanPath === '/goal-fields/update') return 'update_goal_field';
|
|
385
298
|
if (method === 'POST' && cleanPath === '/goal-fields/supersede') return 'supersede_goal_field';
|
|
386
299
|
if (method === 'POST' && cleanPath === '/credential-auth/request') return 'request_credential_auth';
|
|
387
|
-
if (method === 'POST' && cleanPath === '/tts/voiceover') return 'generate_voiceover';
|
|
388
300
|
if (method === 'POST' && cleanPath === '/content-library/submit') return 'submit_to_library';
|
|
389
301
|
if (method === 'POST' && cleanPath === '/api/data-sources') return 'register_data_source';
|
|
390
302
|
if (method === 'POST' && cleanPath === '/orchestrate/decision') return 'write_governance_decision';
|
|
@@ -1421,6 +1333,19 @@ server.tool('render_text_to_image',
|
|
|
1421
1333
|
async (args) => runRenderTextToImageTool(args)
|
|
1422
1334
|
);
|
|
1423
1335
|
|
|
1336
|
+
// ── render_html_to_image ───────────────────────────────────────────────────────
|
|
1337
|
+
server.tool('render_html_to_image',
|
|
1338
|
+
'Render a raw HTML string to a PNG image by navigating to it as a local file:// page. Unlike evaluate_script+document.write on about:blank, this preserves file:// origin so <img src="file:///..."> references load correctly. Returns the output image path.',
|
|
1339
|
+
{
|
|
1340
|
+
html: z.string().describe('Full HTML document to render (including <!doctype>, <html>, <head>, <body>).'),
|
|
1341
|
+
output_path: z.string().optional().describe('Absolute path to save the PNG. Auto-generated in /tmp if omitted.'),
|
|
1342
|
+
viewport_width: z.number().optional().describe('Viewport width in pixels. Default 1080.'),
|
|
1343
|
+
viewport_height: z.number().optional().describe('Viewport height in pixels. Default 1920.'),
|
|
1344
|
+
wait_until: z.enum(['load', 'networkidle', 'domcontentloaded']).optional().describe('Navigation wait condition. Default load.'),
|
|
1345
|
+
},
|
|
1346
|
+
async (args) => runRenderHtmlToImageTool(args)
|
|
1347
|
+
);
|
|
1348
|
+
|
|
1424
1349
|
// ── synthesize_tts ─────────────────────────────────────────────────────────────
|
|
1425
1350
|
server.tool('synthesize_tts',
|
|
1426
1351
|
'Convert text to speech using the workspace MiniMax TTS credential. Returns a local mp3 file path and duration. Use this to generate narration audio for individual video segments.',
|
|
@@ -1481,89 +1406,9 @@ server.tool('get_library_file',
|
|
|
1481
1406
|
async (args) => runGetLibraryFileTool({ ...args, currentWorkspaceId, api, SERVER_URL, MACHINE_API_KEY, workspaceDir: WORKSPACE_DIR })
|
|
1482
1407
|
);
|
|
1483
1408
|
|
|
1484
|
-
// ── generate_voiceover ─────────────────────────────────────────────────────────
|
|
1485
|
-
server.tool('generate_voiceover',
|
|
1486
|
-
'Generate a TTS voiceover using an authorized tts_provider credential and return a local audio file path.',
|
|
1487
|
-
{
|
|
1488
|
-
workspace_id: z.string().optional().describe('Target workspace id. Defaults to current workspace context.'),
|
|
1489
|
-
text: z.string().describe('Text content to synthesize.'),
|
|
1490
|
-
voice_preset: z.string().optional().describe('Platform-neutral voice preset id, e.g. "warm_female_zh_01".'),
|
|
1491
|
-
speed: z.number().optional().describe('Speech speed. Typical range is 0.5 to 2.0.'),
|
|
1492
|
-
format: z.enum(['mp3', 'wav', 'flac']).optional().describe('Audio format. Defaults to mp3.'),
|
|
1493
|
-
credential_id: z.string().optional().describe('Optional explicit credential id. If omitted, uses latest granted tts_provider credential.'),
|
|
1494
|
-
},
|
|
1495
|
-
async ({ workspace_id, text, voice_preset, speed, format, credential_id }) => {
|
|
1496
|
-
if (isBlockedCvmaxEditorVideoTool('generate_voiceover')) {
|
|
1497
|
-
return cvmaxEditorVideoToolError('generate_voiceover');
|
|
1498
|
-
}
|
|
1499
|
-
const targetWorkspaceId = (workspace_id ?? currentWorkspaceId ?? WORKSPACE_ID ?? '').trim();
|
|
1500
|
-
if (!targetWorkspaceId) {
|
|
1501
|
-
return { isError: true, content: [{ type: 'text', text: 'workspace_id is required (no current workspace context).' }] };
|
|
1502
|
-
}
|
|
1503
|
-
|
|
1504
|
-
const normalizedText = String(text ?? '').trim();
|
|
1505
|
-
if (!normalizedText) {
|
|
1506
|
-
return { isError: true, content: [{ type: 'text', text: 'text is required for generate_voiceover.' }] };
|
|
1507
|
-
}
|
|
1508
|
-
|
|
1509
|
-
const normalizedSpeed = speed == null ? 1 : Number(speed);
|
|
1510
|
-
if (!Number.isFinite(normalizedSpeed)) {
|
|
1511
|
-
return { isError: true, content: [{ type: 'text', text: 'speed must be numeric.' }] };
|
|
1512
|
-
}
|
|
1513
|
-
|
|
1514
|
-
const normalizedFormat = normalizeVoiceFormat(format);
|
|
1515
|
-
const payload = {
|
|
1516
|
-
workspace_id: targetWorkspaceId,
|
|
1517
|
-
text: normalizedText,
|
|
1518
|
-
speed: normalizedSpeed,
|
|
1519
|
-
format: normalizedFormat,
|
|
1520
|
-
};
|
|
1521
|
-
if (voice_preset) payload.voice_preset = String(voice_preset).trim();
|
|
1522
|
-
if (credential_id) payload.credential_id = String(credential_id).trim();
|
|
1523
|
-
|
|
1524
|
-
const data = await api('POST', '/tts/voiceover', payload);
|
|
1525
|
-
const remoteAudioUrl = String(data.audio_url ?? '').trim();
|
|
1526
|
-
if (!remoteAudioUrl) {
|
|
1527
|
-
return { isError: true, content: [{ type: 'text', text: 'Voiceover API did not return audio_url.' }] };
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
const downloadRes = await fetch(remoteAudioUrl, {
|
|
1531
|
-
method: 'GET',
|
|
1532
|
-
headers: { 'Authorization': `Bearer ${MACHINE_API_KEY}` },
|
|
1533
|
-
});
|
|
1534
|
-
if (!downloadRes.ok) {
|
|
1535
|
-
return {
|
|
1536
|
-
isError: true,
|
|
1537
|
-
content: [{ type: 'text', text: `Failed to download synthesized audio (${downloadRes.status})` }],
|
|
1538
|
-
};
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
const fileBuffer = Buffer.from(await downloadRes.arrayBuffer());
|
|
1542
|
-
mkdirSync(VOICEOVER_LOCAL_DIR, { recursive: true });
|
|
1543
|
-
const audioExt = inferAudioExtension(remoteAudioUrl, data.format ?? normalizedFormat);
|
|
1544
|
-
const localFileName = `voiceover-${Date.now()}-${randomUUID().slice(0, 8)}${audioExt}`;
|
|
1545
|
-
const localAudioPath = path.join(VOICEOVER_LOCAL_DIR, localFileName);
|
|
1546
|
-
writeFileSync(localAudioPath, fileBuffer);
|
|
1547
|
-
|
|
1548
|
-
return {
|
|
1549
|
-
content: [{
|
|
1550
|
-
type: 'text',
|
|
1551
|
-
text:
|
|
1552
|
-
`Voiceover generated.\n` +
|
|
1553
|
-
`workspace_id=${data.workspace_id ?? targetWorkspaceId}\n` +
|
|
1554
|
-
`local_audio_path=${localAudioPath}\n` +
|
|
1555
|
-
`duration_ms=${data.duration_ms ?? 'unknown'}\n` +
|
|
1556
|
-
`sample_rate=${data.sample_rate ?? 'unknown'}\n` +
|
|
1557
|
-
`format=${data.format ?? normalizedFormat}\n` +
|
|
1558
|
-
`size=${formatBytes(fileBuffer.length)}`,
|
|
1559
|
-
}],
|
|
1560
|
-
};
|
|
1561
|
-
}
|
|
1562
|
-
);
|
|
1563
|
-
|
|
1564
1409
|
// ── record_url_narration ────────────────────────────────────────────────────────
|
|
1565
1410
|
server.tool('record_url_narration',
|
|
1566
|
-
'Record a silent video of a URL by orchestrating Xvfb + Chromium + ffmpeg, driven by a video plan. Outputs a silent mp4
|
|
1411
|
+
'Record a silent video of a URL by orchestrating Xvfb + Chromium + ffmpeg, driven by a video plan. Outputs a silent mp4 that can be passed to compose_video_v2 as a video-kind segment with an audio_path for narration.\n\nUse this as the canonical recording step for URL-narration videos. Falls back: if the page needs interactions outside the visual_action vocabulary (clicks, waits, OCR loops), use Monitor (Bash) with custom Playwright instead.\n\nRuntime requirements: this tool only works on a Linux daemon machine with Xvfb + ffmpeg (x11grab) + Chromium installed. macOS / Windows daemons will fail at startup.',
|
|
1567
1412
|
{
|
|
1568
1413
|
url: z.string().describe('Page URL to record'),
|
|
1569
1414
|
plan: z.record(z.any()).describe('Must be the full output from detail_sections (not plan_video). detail_sections output includes detail_sections_version, sections[], audio metadata, and dwell_ms per phase.'),
|
|
@@ -1590,126 +1435,6 @@ server.tool('record_url_narration',
|
|
|
1590
1435
|
}
|
|
1591
1436
|
);
|
|
1592
1437
|
|
|
1593
|
-
// ── compose_video ───────────────────────────────────────────────────────────────
|
|
1594
|
-
server.tool('compose_video',
|
|
1595
|
-
'Compose a final short video by muxing audio onto a base video, optionally burning subtitles, concatenating an outro, and transcoding to platform spec.',
|
|
1596
|
-
{
|
|
1597
|
-
video_path: z.string().describe('Base silent video path. Relative paths resolve from the current workspace.'),
|
|
1598
|
-
audio_segments: z.array(z.object({
|
|
1599
|
-
audio_path: z.string().describe('Audio file path for one narration segment.'),
|
|
1600
|
-
start_ms: z.union([z.number(), z.string()]).optional().describe('Segment start offset in milliseconds.'),
|
|
1601
|
-
phase: z.string().optional().describe('Optional phase id. Used with events_log to derive start time.'),
|
|
1602
|
-
})).describe('Ordered or unordered narration audio segments.'),
|
|
1603
|
-
events_log: z.array(z.any()).optional().describe('Optional recorder event log. Used to resolve segment start time by phase.'),
|
|
1604
|
-
subtitles: z.array(z.object({
|
|
1605
|
-
text: z.string().describe('Subtitle text for this segment (the narration sentence).'),
|
|
1606
|
-
start_ms: z.number().describe('Subtitle start time in milliseconds.'),
|
|
1607
|
-
end_ms: z.number().describe('Subtitle end time in milliseconds.'),
|
|
1608
|
-
})).optional().describe('Subtitle segments to burn into the video. Pass each phase sentence text (from detail_sections sections[].sentence) with cumulative start/end time derived from dwell_ms. Omit to produce no subtitles.'),
|
|
1609
|
-
outro_path: z.string().optional().describe('Optional outro mp4 path. If omitted, uses ~/.lightcone/assets/outros/default.mp4 when present.'),
|
|
1610
|
-
target: z.enum(['short_video_cn', 'douyin', 'xhs']).optional().describe('Transcode target profile. Defaults to short_video_cn.'),
|
|
1611
|
-
},
|
|
1612
|
-
async ({ video_path, audio_segments, events_log, subtitles, outro_path, target }) => {
|
|
1613
|
-
if (isBlockedCvmaxEditorVideoTool('compose_video')) {
|
|
1614
|
-
return cvmaxEditorVideoToolError('compose_video');
|
|
1615
|
-
}
|
|
1616
|
-
const composeInput = { video_path, audio_segments, events_log, subtitles, outro_path, target };
|
|
1617
|
-
try {
|
|
1618
|
-
const result = await runMandatoryLocalTool({
|
|
1619
|
-
toolName: 'compose_video',
|
|
1620
|
-
toolInput: composeInput,
|
|
1621
|
-
executor: async (checkedInput) => {
|
|
1622
|
-
const videoPath = normalizeComposePath(checkedInput.video_path, 'video_path');
|
|
1623
|
-
const audioSegments = normalizeComposeAudioSegments(checkedInput.audio_segments);
|
|
1624
|
-
const eventsLog = Array.isArray(checkedInput.events_log) ? checkedInput.events_log : [];
|
|
1625
|
-
const targetProfile = normalizeComposeTarget(checkedInput.target);
|
|
1626
|
-
const requestedOutroPath = String(checkedInput.outro_path ?? '').trim();
|
|
1627
|
-
|
|
1628
|
-
let resolvedOutroPath = null;
|
|
1629
|
-
if (requestedOutroPath) {
|
|
1630
|
-
resolvedOutroPath = path.resolve(WORKSPACE_DIR, requestedOutroPath);
|
|
1631
|
-
if (!existsSync(resolvedOutroPath)) {
|
|
1632
|
-
throw new Error(`outro_path not found: ${resolvedOutroPath}`);
|
|
1633
|
-
}
|
|
1634
|
-
} else if (existsSync(DEFAULT_OUTRO_PATH)) {
|
|
1635
|
-
resolvedOutroPath = DEFAULT_OUTRO_PATH;
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
mkdirSync(VIDEO_COMPOSE_LOCAL_DIR, { recursive: true });
|
|
1639
|
-
const runId = `${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
1640
|
-
const muxedPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.muxed.mp4`);
|
|
1641
|
-
const concatPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.concat.mp4`);
|
|
1642
|
-
const finalPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.final.mp4`);
|
|
1643
|
-
const assPath = path.join(VIDEO_COMPOSE_LOCAL_DIR, `compose-${runId}.ass`);
|
|
1644
|
-
const intermediates = [muxedPath, concatPath, assPath];
|
|
1645
|
-
|
|
1646
|
-
const subtitleSegments = Array.isArray(checkedInput.subtitles) ? checkedInput.subtitles : [];
|
|
1647
|
-
let subtitlesAssPath = null;
|
|
1648
|
-
if (subtitleSegments.length > 0) {
|
|
1649
|
-
const assContent = buildAssContent(subtitleSegments);
|
|
1650
|
-
writeFileSync(assPath, assContent, 'utf8');
|
|
1651
|
-
subtitlesAssPath = assPath;
|
|
1652
|
-
}
|
|
1653
|
-
|
|
1654
|
-
try {
|
|
1655
|
-
await muxAudioToVideo({
|
|
1656
|
-
video_path: videoPath,
|
|
1657
|
-
audio_segments: audioSegments,
|
|
1658
|
-
events_log: eventsLog,
|
|
1659
|
-
output: muxedPath,
|
|
1660
|
-
});
|
|
1661
|
-
|
|
1662
|
-
let composedPath = muxedPath;
|
|
1663
|
-
if (resolvedOutroPath) {
|
|
1664
|
-
await concatVideos({
|
|
1665
|
-
inputs: [muxedPath, resolvedOutroPath],
|
|
1666
|
-
output: concatPath,
|
|
1667
|
-
});
|
|
1668
|
-
composedPath = concatPath;
|
|
1669
|
-
}
|
|
1670
|
-
|
|
1671
|
-
await transcodeForPlatform({
|
|
1672
|
-
input: composedPath,
|
|
1673
|
-
output: finalPath,
|
|
1674
|
-
target: targetProfile,
|
|
1675
|
-
subtitlesAssPath,
|
|
1676
|
-
});
|
|
1677
|
-
|
|
1678
|
-
const durationMs = await probeDurationMs(finalPath);
|
|
1679
|
-
cleanupLocalFiles(intermediates);
|
|
1680
|
-
return {
|
|
1681
|
-
finalVideoPath: finalPath,
|
|
1682
|
-
durationMs,
|
|
1683
|
-
outroPath: resolvedOutroPath,
|
|
1684
|
-
target: targetProfile,
|
|
1685
|
-
subtitles: subtitleSegments.length > 0,
|
|
1686
|
-
};
|
|
1687
|
-
} catch (error) {
|
|
1688
|
-
cleanupLocalFiles([...intermediates, finalPath]);
|
|
1689
|
-
throw error;
|
|
1690
|
-
}
|
|
1691
|
-
},
|
|
1692
|
-
});
|
|
1693
|
-
|
|
1694
|
-
const outroText = result.outroPath ? result.outroPath : 'skipped';
|
|
1695
|
-
return {
|
|
1696
|
-
content: [{
|
|
1697
|
-
type: 'text',
|
|
1698
|
-
text:
|
|
1699
|
-
`Video composed.\n` +
|
|
1700
|
-
`final_video_path=${result.finalVideoPath}\n` +
|
|
1701
|
-
`duration_ms=${result.durationMs}\n` +
|
|
1702
|
-
`target=${result.target}\n` +
|
|
1703
|
-
`subtitles=${result.subtitles ? 'burned' : 'none'}\n` +
|
|
1704
|
-
`outro=${outroText}`,
|
|
1705
|
-
}],
|
|
1706
|
-
};
|
|
1707
|
-
} catch (error) {
|
|
1708
|
-
return { isError: true, content: [{ type: 'text', text: `Error: ${error.message}` }] };
|
|
1709
|
-
}
|
|
1710
|
-
}
|
|
1711
|
-
);
|
|
1712
|
-
|
|
1713
1438
|
// ── submit_to_library ──────────────────────────────────────────────────────────
|
|
1714
1439
|
server.tool('submit_to_library',
|
|
1715
1440
|
'把已生成的视频成片归档进内容库(content_video_draft entry)。调用前 mp4 必须已经通过 write_workspace_file 落到 workspace 的 artifacts/ 路径。归档后内容库会出现一张新卡片,含视频预览 + 元数据 + 后续支持发布/回采链路。',
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import os from 'os';
|
|
5
|
+
|
|
6
|
+
function toolText(text) {
|
|
7
|
+
return { content: [{ type: 'text', text }] };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function toolError(text) {
|
|
11
|
+
return { isError: true, content: [{ type: 'text', text }] };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async function launchBrowser() {
|
|
15
|
+
let playwright;
|
|
16
|
+
try {
|
|
17
|
+
playwright = await import('playwright');
|
|
18
|
+
} catch {
|
|
19
|
+
throw new Error('playwright_import_failed: run npm install playwright');
|
|
20
|
+
}
|
|
21
|
+
return playwright.chromium.launch({ headless: true });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runRenderHtmlToImageTool({ html, output_path, viewport_width, viewport_height, wait_until }) {
|
|
25
|
+
const normalizedHtml = String(html ?? '').trim();
|
|
26
|
+
if (!normalizedHtml) return toolError('html is required and must not be empty.');
|
|
27
|
+
|
|
28
|
+
const vw = Number(viewport_width ?? 1080);
|
|
29
|
+
const vh = Number(viewport_height ?? 1920);
|
|
30
|
+
const waitUntil = String(wait_until ?? 'load');
|
|
31
|
+
|
|
32
|
+
const tmpDir = path.join(os.tmpdir(), 'lightcone-html-render');
|
|
33
|
+
mkdirSync(tmpDir, { recursive: true });
|
|
34
|
+
|
|
35
|
+
const htmlFile = path.join(tmpDir, `render-${randomUUID().slice(0, 8)}.html`);
|
|
36
|
+
writeFileSync(htmlFile, normalizedHtml, 'utf-8');
|
|
37
|
+
|
|
38
|
+
const outPath = output_path
|
|
39
|
+
? String(output_path)
|
|
40
|
+
: path.join(tmpDir, `render-${randomUUID().slice(0, 8)}.png`);
|
|
41
|
+
|
|
42
|
+
const browser = await launchBrowser();
|
|
43
|
+
try {
|
|
44
|
+
const page = await browser.newPage();
|
|
45
|
+
await page.setViewportSize({ width: vw, height: vh });
|
|
46
|
+
// Navigate to file:// so local file:// image src attributes load correctly
|
|
47
|
+
await page.goto(`file://${htmlFile}`, { waitUntil, timeout: 30000 });
|
|
48
|
+
await page.screenshot({ path: outPath });
|
|
49
|
+
await page.close();
|
|
50
|
+
return toolText([
|
|
51
|
+
'render_html_to_image completed.',
|
|
52
|
+
`path=${outPath}`,
|
|
53
|
+
`viewport=${vw}x${vh}`,
|
|
54
|
+
].join('\n'));
|
|
55
|
+
} finally {
|
|
56
|
+
await browser.close();
|
|
57
|
+
}
|
|
58
|
+
}
|