@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightcone-ai/daemon",
3
- "version": "0.15.45",
3
+ "version": "0.15.47",
4
4
  "type": "module",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -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, rmSync, writeFileSync } from 'fs';
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 plus an events.json timestamp log that compose_video can use to align audio segments.\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.',
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
+ }