@lightcone-ai/daemon 0.15.40 → 0.15.42
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 +84 -2
- package/src/drivers/claude.js +1 -1
- package/src/tools/compose-video-v2.js +59 -0
- package/src/tools/get-library-file.js +77 -0
- package/src/tools/render-text-to-image.js +56 -0
- package/src/tools/synthesize-tts.js +69 -0
- package/src/tools/take-page-screenshot.js +74 -0
package/package.json
CHANGED
package/src/chat-bridge.js
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
} from './workspace-file-upload.js';
|
|
22
22
|
import { runRecordUrlNarrationTool } from './record-url-narration-tool.js';
|
|
23
23
|
import { runSubmitToLibraryTool } from './submit-to-library-tool.js';
|
|
24
|
+
import { runRenderTextToImageTool } from './tools/render-text-to-image.js';
|
|
25
|
+
import { runSynthesisTtsTool } from './tools/synthesize-tts.js';
|
|
26
|
+
import { runComposeVideoV2Tool } from './tools/compose-video-v2.js';
|
|
27
|
+
import { runTakePageScreenshotTool } from './tools/take-page-screenshot.js';
|
|
28
|
+
import { runGetLibraryFileTool } from './tools/get-library-file.js';
|
|
24
29
|
import { isLeaseInvalidated, clearInvalidatedLease } from './governance-state.js';
|
|
25
30
|
import { classifyLeaseWindow } from './lease-window.js';
|
|
26
31
|
import {
|
|
@@ -72,8 +77,11 @@ let currentWorkspaceId = WORKSPACE_ID;
|
|
|
72
77
|
const VOICEOVER_LOCAL_DIR = path.join(WORKSPACE_DIR, 'artifacts', 'audio');
|
|
73
78
|
const VIDEO_COMPOSE_LOCAL_DIR = path.join(WORKSPACE_DIR, 'artifacts', 'video');
|
|
74
79
|
const DEFAULT_OUTRO_PATH = path.join(homedir(), '.lightcone', 'assets', 'outros', 'default.mp4');
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
// Temporary: block legacy video pipeline tools for a specific editor_in_chief agent.
|
|
81
|
+
// Set via env so this doesn't need a code change when workspace/agent IDs rotate.
|
|
82
|
+
// Remove entirely once the new atomic tool framework is stable and the legacy pipeline retires.
|
|
83
|
+
const CVMAX_WORKSPACE_ID = process.env.BLOCKED_EDITOR_WORKSPACE_ID ?? '';
|
|
84
|
+
const CVMAX_EDITOR_IN_CHIEF_AGENT_ID = process.env.BLOCKED_EDITOR_AGENT_ID ?? '';
|
|
77
85
|
const CVMAX_EDITOR_BLOCKED_VIDEO_TOOLS = new Set([
|
|
78
86
|
'generate_voiceover',
|
|
79
87
|
'record_url_narration',
|
|
@@ -1399,6 +1407,80 @@ server.tool('request_credential_auth',
|
|
|
1399
1407
|
}
|
|
1400
1408
|
);
|
|
1401
1409
|
|
|
1410
|
+
// ── render_text_to_image ───────────────────────────────────────────────────────
|
|
1411
|
+
server.tool('render_text_to_image',
|
|
1412
|
+
'Render text content into image(s) for video synthesis. style=scroll produces a single tall image (for a scrolling video segment); style=carousel produces one image per card (for a slide-show segment). Returns local file paths.',
|
|
1413
|
+
{
|
|
1414
|
+
content: z.union([z.string(), z.array(z.string())]).describe('Text content. For carousel, pass an array of strings — one per card. For scroll, pass a single string (or array joined with line breaks).'),
|
|
1415
|
+
style: z.enum(['scroll', 'carousel']).describe('scroll: one tall image; carousel: one image per card.'),
|
|
1416
|
+
theme: z.enum(['dark', 'light']).optional().describe('Color theme. Default dark.'),
|
|
1417
|
+
width: z.number().optional().describe('Image width in pixels. Default 1080.'),
|
|
1418
|
+
card_height: z.number().optional().describe('Card height in pixels (carousel) or viewport height (scroll baseline). Default 1920.'),
|
|
1419
|
+
font_size: z.number().optional().describe('Base font size in pixels. Default 48.'),
|
|
1420
|
+
},
|
|
1421
|
+
async (args) => runRenderTextToImageTool(args)
|
|
1422
|
+
);
|
|
1423
|
+
|
|
1424
|
+
// ── synthesize_tts ─────────────────────────────────────────────────────────────
|
|
1425
|
+
server.tool('synthesize_tts',
|
|
1426
|
+
'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.',
|
|
1427
|
+
{
|
|
1428
|
+
text: z.string().describe('Text to synthesize. Keep under 500 characters per call for reliable results.'),
|
|
1429
|
+
voice_id: z.string().optional().describe('MiniMax voice ID. Omit to use the workspace default voice.'),
|
|
1430
|
+
workspace_id: z.string().optional().describe('Target workspace. Defaults to current workspace context.'),
|
|
1431
|
+
},
|
|
1432
|
+
async (args) => runSynthesisTtsTool({ ...args, currentWorkspaceId, api })
|
|
1433
|
+
);
|
|
1434
|
+
|
|
1435
|
+
// ── compose_video_v2 ───────────────────────────────────────────────────────────
|
|
1436
|
+
server.tool('compose_video_v2',
|
|
1437
|
+
'Compose a video from a list of segments using ffmpeg. Each segment has a visual source (image/scroll/carousel/video/gif) and optional audio. Segments are concatenated in order; an outro clip is appended at the end. Returns a local mp4 path.',
|
|
1438
|
+
{
|
|
1439
|
+
segments: z.array(z.object({
|
|
1440
|
+
visual_path: z.string().optional().describe('Absolute path to a single image, video, or gif file.'),
|
|
1441
|
+
visual_paths: z.array(z.string()).optional().describe('For carousel: array of image paths, one per card.'),
|
|
1442
|
+
visual_kind: z.enum(['image', 'video', 'gif', 'carousel']).describe('Type of visual. image: static image frame. video: video clip. gif: animated GIF. carousel: sequence of images (use visual_paths).'),
|
|
1443
|
+
presentation: z.object({
|
|
1444
|
+
style: z.enum(['static', 'scroll']).optional().describe('For image: static (default) or scroll (pan upward). Ignored for video/gif/carousel.'),
|
|
1445
|
+
duration: z.number().optional().describe('Segment duration in seconds. Required for image/scroll. For gif, omit to use natural GIF duration.'),
|
|
1446
|
+
per_card_duration: z.number().optional().describe('Seconds per card for carousel.'),
|
|
1447
|
+
}).optional(),
|
|
1448
|
+
audio_path: z.string().nullable().optional().describe('Absolute path to an audio file (mp3) for this segment. null or omit for silence.'),
|
|
1449
|
+
transition: z.enum(['cut', 'fade', 'crossfade']).optional().describe('Transition to next segment. Default cut.'),
|
|
1450
|
+
})).describe('Ordered list of video segments.'),
|
|
1451
|
+
outro_paths: z.array(z.string()).optional().describe('Absolute paths to outro video clips appended after all segments.'),
|
|
1452
|
+
format: z.string().optional().describe('Aspect ratio. Default "9:16".'),
|
|
1453
|
+
resolution: z.string().optional().describe('Output resolution WxH. Default "1080x1920".'),
|
|
1454
|
+
output_path: z.string().optional().describe('Absolute output path for the mp4. Auto-generated if omitted.'),
|
|
1455
|
+
},
|
|
1456
|
+
async (args) => runComposeVideoV2Tool({ ...args, workspaceDir: WORKSPACE_DIR })
|
|
1457
|
+
);
|
|
1458
|
+
|
|
1459
|
+
// ── take_page_screenshot ───────────────────────────────────────────────────────
|
|
1460
|
+
server.tool('take_page_screenshot',
|
|
1461
|
+
'Open a URL with a headless browser and capture a screenshot. crop=above_fold captures only the visible viewport (ideal for thumbnail-style frames); crop=full_page captures the entire page height.',
|
|
1462
|
+
{
|
|
1463
|
+
url: z.string().describe('Page URL to screenshot.'),
|
|
1464
|
+
crop: z.enum(['above_fold', 'full_page']).optional().describe('Capture mode. Default above_fold.'),
|
|
1465
|
+
viewport: z.object({
|
|
1466
|
+
width: z.number().optional(),
|
|
1467
|
+
height: z.number().optional(),
|
|
1468
|
+
}).optional().describe('Viewport size. Default 390×844 (mobile).'),
|
|
1469
|
+
wait_for: z.enum(['load', 'networkidle', 'domcontentloaded']).optional().describe('Page load event to wait for before screenshotting. Default networkidle.'),
|
|
1470
|
+
},
|
|
1471
|
+
async (args) => runTakePageScreenshotTool(args)
|
|
1472
|
+
);
|
|
1473
|
+
|
|
1474
|
+
// ── get_library_file ───────────────────────────────────────────────────────────
|
|
1475
|
+
server.tool('get_library_file',
|
|
1476
|
+
'Fetch a file (image, video, gif) from the content library by its ID and return a local absolute path. Use this to retrieve outro clips, emoji images, GIF animations, or any other asset stored in the workspace content library.',
|
|
1477
|
+
{
|
|
1478
|
+
library_id: z.string().describe('Content library item ID.'),
|
|
1479
|
+
workspace_id: z.string().optional().describe('Target workspace. Defaults to current workspace context.'),
|
|
1480
|
+
},
|
|
1481
|
+
async (args) => runGetLibraryFileTool({ ...args, currentWorkspaceId, api, SERVER_URL, MACHINE_API_KEY, workspaceDir: WORKSPACE_DIR })
|
|
1482
|
+
);
|
|
1483
|
+
|
|
1402
1484
|
// ── generate_voiceover ─────────────────────────────────────────────────────────
|
|
1403
1485
|
server.tool('generate_voiceover',
|
|
1404
1486
|
'Generate a TTS voiceover using an authorized tts_provider credential and return a local audio file path.',
|
package/src/drivers/claude.js
CHANGED
|
@@ -138,7 +138,7 @@ Only top-level workspace / DM messages can become tasks. Messages inside threads
|
|
|
138
138
|
**Primary-agent dispatch hard rule (fail-closed):**
|
|
139
139
|
- If your role is the workspace primary agent/owner and a user sends an execution request, you MUST call \`${t("create_tasks")}\` first and include an explicit \`scenario_type\`, then immediately send a visible acknowledgment/update via \`${t("send_message")}\`.
|
|
140
140
|
- Execution requests include requests like content writing, short-video scripting, research, design/asset production, implementation, or any request that requires downstream execution instead of a simple answer.
|
|
141
|
-
- Use \`scenario_type\` values declared by your scenario manifest/dispatch protocol (for example: \`trend_scan\`, \`
|
|
141
|
+
- Use \`scenario_type\` values declared by your scenario manifest/dispatch protocol (for example: \`trend_scan\`, \`topic_scan\`, \`research\`, \`text_writing\`, \`video_scripting\`, \`publish\`).
|
|
142
142
|
- Do not route execution work with only \`${t("send_message")}\`: skipping \`${t("create_tasks")}\` can cause downstream \`${t("claim_tasks")}\` failures and deadlock the workflow.
|
|
143
143
|
- If the request is a direct Q&A (no downstream execution dispatch needed), reply directly with \`${t("send_message")}\` and do not force \`${t("create_tasks")}\`.
|
|
144
144
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { randomUUID } from 'crypto';
|
|
4
|
+
import { composeVideoV2 } from '../../../src/video/composer-v2/index.js';
|
|
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
|
+
export async function runComposeVideoV2Tool({ segments, outro_paths, format, resolution, output_path, workspaceDir }) {
|
|
15
|
+
if (!Array.isArray(segments) || segments.length === 0) {
|
|
16
|
+
return toolError('segments must be a non-empty array.');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < segments.length; i++) {
|
|
20
|
+
const seg = segments[i];
|
|
21
|
+
const kind = seg.visual_kind;
|
|
22
|
+
if (!kind) return toolError(`segments[${i}]: visual_kind is required.`);
|
|
23
|
+
const validKinds = ['image', 'video', 'gif', 'carousel'];
|
|
24
|
+
if (!validKinds.includes(kind)) {
|
|
25
|
+
return toolError(`segments[${i}]: visual_kind must be one of ${validKinds.join(', ')}.`);
|
|
26
|
+
}
|
|
27
|
+
if (kind === 'carousel' && (!Array.isArray(seg.visual_paths) || seg.visual_paths.length === 0)) {
|
|
28
|
+
return toolError(`segments[${i}]: visual_paths (array) required for kind=carousel.`);
|
|
29
|
+
}
|
|
30
|
+
if (kind !== 'carousel' && !seg.visual_path) {
|
|
31
|
+
return toolError(`segments[${i}]: visual_path required for kind=${kind}.`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const outDir = workspaceDir
|
|
36
|
+
? path.join(workspaceDir, 'artifacts', 'video')
|
|
37
|
+
: path.join(os.tmpdir(), 'lightcone-video');
|
|
38
|
+
|
|
39
|
+
const outPath = output_path ?? path.join(outDir, `composed-${Date.now()}-${randomUUID().slice(0, 8)}.mp4`);
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
const result = await composeVideoV2({
|
|
43
|
+
segments,
|
|
44
|
+
outro_paths: outro_paths ?? [],
|
|
45
|
+
resolution: resolution ?? '1080x1920',
|
|
46
|
+
output_path: outPath,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
return toolText([
|
|
50
|
+
'compose_video_v2 completed.',
|
|
51
|
+
`path=${result.path}`,
|
|
52
|
+
`duration_ms=${result.duration_ms}`,
|
|
53
|
+
`segments=${segments.length}`,
|
|
54
|
+
`outro_clips=${(outro_paths ?? []).length}`,
|
|
55
|
+
].join('\n'));
|
|
56
|
+
} catch (error) {
|
|
57
|
+
return toolError(`compose_video_v2 failed: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { mkdirSync, writeFileSync, existsSync } from 'fs';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
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
|
+
function guessExtension(mediaType, url = '') {
|
|
15
|
+
const mt = String(mediaType ?? '').toLowerCase();
|
|
16
|
+
if (mt.includes('mp4') || mt.includes('video')) return '.mp4';
|
|
17
|
+
if (mt.includes('gif')) return '.gif';
|
|
18
|
+
if (mt.includes('png')) return '.png';
|
|
19
|
+
if (mt.includes('jpeg') || mt.includes('jpg')) return '.jpg';
|
|
20
|
+
if (mt.includes('webp')) return '.webp';
|
|
21
|
+
const fromUrl = path.extname(String(url ?? '').split('?')[0]).toLowerCase();
|
|
22
|
+
return fromUrl || '.bin';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runGetLibraryFileTool({ library_id, workspace_id, currentWorkspaceId, api, SERVER_URL, MACHINE_API_KEY }) {
|
|
26
|
+
const itemId = String(library_id ?? '').trim();
|
|
27
|
+
if (!itemId) return toolError('library_id is required for get_library_file.');
|
|
28
|
+
|
|
29
|
+
let data;
|
|
30
|
+
try {
|
|
31
|
+
data = await api('GET', `/content-library/file?id=${encodeURIComponent(itemId)}`);
|
|
32
|
+
} catch (error) {
|
|
33
|
+
return toolError(`get_library_file lookup failed: ${error.message}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Server already resolved to a local file path (video stored on same machine)
|
|
37
|
+
if (data.path && typeof data.path === 'string') {
|
|
38
|
+
if (!existsSync(data.path)) {
|
|
39
|
+
return toolError(`File not found at resolved path: ${data.path}`);
|
|
40
|
+
}
|
|
41
|
+
return toolText([
|
|
42
|
+
'get_library_file completed.',
|
|
43
|
+
`path=${data.path}`,
|
|
44
|
+
`media_type=${data.media_type ?? 'unknown'}`,
|
|
45
|
+
`item_id=${data.item_id ?? itemId}`,
|
|
46
|
+
].join('\n'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Server returned a download URL (image/GIF stored remotely or as URL)
|
|
50
|
+
if (data.download_url && typeof data.download_url === 'string') {
|
|
51
|
+
const downloadUrl = data.download_url.startsWith('http')
|
|
52
|
+
? data.download_url
|
|
53
|
+
: `${SERVER_URL}${data.download_url}`;
|
|
54
|
+
|
|
55
|
+
const res = await fetch(downloadUrl, {
|
|
56
|
+
headers: { 'Authorization': `Bearer ${MACHINE_API_KEY}` },
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
return toolError(`Failed to download library file (${res.status}): ${downloadUrl}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const ext = guessExtension(data.media_type, downloadUrl);
|
|
63
|
+
const outDir = path.join(os.tmpdir(), 'lightcone-library');
|
|
64
|
+
mkdirSync(outDir, { recursive: true });
|
|
65
|
+
const outPath = path.join(outDir, `lib-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`);
|
|
66
|
+
writeFileSync(outPath, Buffer.from(await res.arrayBuffer()));
|
|
67
|
+
|
|
68
|
+
return toolText([
|
|
69
|
+
'get_library_file completed.',
|
|
70
|
+
`path=${outPath}`,
|
|
71
|
+
`media_type=${data.media_type ?? 'unknown'}`,
|
|
72
|
+
`item_id=${data.item_id ?? itemId}`,
|
|
73
|
+
].join('\n'));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return toolError(`get_library_file: server returned unexpected response for item ${itemId}.`);
|
|
77
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { renderTextToImages } from '../../../src/video/text-renderer/index.js';
|
|
2
|
+
|
|
3
|
+
function toolText(text) {
|
|
4
|
+
return { content: [{ type: 'text', text }] };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function toolError(text) {
|
|
8
|
+
return { isError: true, content: [{ type: 'text', text }] };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function runRenderTextToImageTool({ content, style, theme, width, card_height, font_size }) {
|
|
12
|
+
const normalizedStyle = String(style ?? 'scroll');
|
|
13
|
+
if (normalizedStyle !== 'scroll' && normalizedStyle !== 'carousel') {
|
|
14
|
+
return toolError(`Invalid style "${normalizedStyle}". Must be "scroll" or "carousel".`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const normalizedTheme = String(theme ?? 'dark');
|
|
18
|
+
if (normalizedTheme !== 'dark' && normalizedTheme !== 'light') {
|
|
19
|
+
return toolError(`Invalid theme "${normalizedTheme}". Must be "dark" or "light".`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const normalizedContent = Array.isArray(content)
|
|
23
|
+
? content.map(item => String(item ?? '').trim()).filter(Boolean)
|
|
24
|
+
: [String(content ?? '').trim()].filter(Boolean);
|
|
25
|
+
|
|
26
|
+
if (normalizedContent.length === 0) {
|
|
27
|
+
return toolError('content is required and must not be empty.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (normalizedStyle === 'carousel' && !Array.isArray(content)) {
|
|
31
|
+
return toolError('carousel style requires content to be an array of strings (one per card).');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const input = normalizedStyle === 'scroll' ? normalizedContent.join('\n\n') : normalizedContent;
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const paths = await renderTextToImages({
|
|
38
|
+
content: input,
|
|
39
|
+
style: normalizedStyle,
|
|
40
|
+
theme: normalizedTheme,
|
|
41
|
+
width: Number(width ?? 1080),
|
|
42
|
+
cardHeight: Number(card_height ?? 1920),
|
|
43
|
+
fontSize: Number(font_size ?? 48),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const lines = [
|
|
47
|
+
`render_text_to_image completed.`,
|
|
48
|
+
`style=${normalizedStyle}`,
|
|
49
|
+
`paths=${JSON.stringify(paths)}`,
|
|
50
|
+
`count=${paths.length}`,
|
|
51
|
+
];
|
|
52
|
+
return toolText(lines.join('\n'));
|
|
53
|
+
} catch (error) {
|
|
54
|
+
return toolError(`render_text_to_image failed: ${error.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
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
|
+
function inferAudioExt(url) {
|
|
15
|
+
const clean = String(url ?? '').split('?')[0];
|
|
16
|
+
const ext = path.extname(clean).toLowerCase();
|
|
17
|
+
return ['.mp3', '.wav', '.flac', '.aac', '.ogg'].includes(ext) ? ext : '.mp3';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runSynthesisTtsTool({ text, voice_id, workspace_id, currentWorkspaceId, api }) {
|
|
21
|
+
const normalizedText = String(text ?? '').trim();
|
|
22
|
+
if (!normalizedText) {
|
|
23
|
+
return toolError('text is required for synthesize_tts.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const targetWorkspaceId = String(workspace_id ?? currentWorkspaceId ?? '').trim();
|
|
27
|
+
if (!targetWorkspaceId) {
|
|
28
|
+
return toolError('workspace_id is required (no current workspace context).');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const payload = {
|
|
32
|
+
workspace_id: targetWorkspaceId,
|
|
33
|
+
text: normalizedText,
|
|
34
|
+
speed: 1,
|
|
35
|
+
format: 'mp3',
|
|
36
|
+
};
|
|
37
|
+
if (voice_id) payload.voice_preset = String(voice_id).trim();
|
|
38
|
+
|
|
39
|
+
let data;
|
|
40
|
+
try {
|
|
41
|
+
data = await api('POST', '/tts/voiceover', payload);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return toolError(`synthesize_tts API error: ${error.message}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const remoteAudioUrl = String(data.audio_url ?? '').trim();
|
|
47
|
+
if (!remoteAudioUrl) {
|
|
48
|
+
return toolError('TTS API did not return audio_url.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const downloadRes = await fetch(remoteAudioUrl);
|
|
52
|
+
if (!downloadRes.ok) {
|
|
53
|
+
return toolError(`Failed to download synthesized audio (${downloadRes.status}).`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const fileBuffer = Buffer.from(await downloadRes.arrayBuffer());
|
|
57
|
+
const outDir = path.join(os.tmpdir(), 'lightcone-tts');
|
|
58
|
+
mkdirSync(outDir, { recursive: true });
|
|
59
|
+
const ext = inferAudioExt(remoteAudioUrl);
|
|
60
|
+
const outPath = path.join(outDir, `tts-${Date.now()}-${randomUUID().slice(0, 8)}${ext}`);
|
|
61
|
+
writeFileSync(outPath, fileBuffer);
|
|
62
|
+
|
|
63
|
+
return toolText([
|
|
64
|
+
'synthesize_tts completed.',
|
|
65
|
+
`path=${outPath}`,
|
|
66
|
+
`duration_ms=${data.duration_ms ?? 'unknown'}`,
|
|
67
|
+
`size_bytes=${fileBuffer.length}`,
|
|
68
|
+
].join('\n'));
|
|
69
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import { mkdirSync } from 'fs';
|
|
4
|
+
import { randomUUID } from 'crypto';
|
|
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
|
+
export async function runTakePageScreenshotTool({ url, crop, viewport, wait_for }) {
|
|
15
|
+
const normalizedUrl = String(url ?? '').trim();
|
|
16
|
+
if (!normalizedUrl) return toolError('url is required for take_page_screenshot.');
|
|
17
|
+
|
|
18
|
+
const cropMode = String(crop ?? 'above_fold');
|
|
19
|
+
if (cropMode !== 'above_fold' && cropMode !== 'full_page') {
|
|
20
|
+
return toolError('crop must be "above_fold" or "full_page".');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const vp = {
|
|
24
|
+
width: Number(viewport?.width ?? 390),
|
|
25
|
+
height: Number(viewport?.height ?? 844),
|
|
26
|
+
};
|
|
27
|
+
const waitFor = String(wait_for ?? 'networkidle');
|
|
28
|
+
|
|
29
|
+
let playwright;
|
|
30
|
+
try {
|
|
31
|
+
playwright = await import('playwright');
|
|
32
|
+
} catch {
|
|
33
|
+
return toolError('playwright_import_failed: playwright is not installed.');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const browser = await playwright.chromium.launch({ headless: true });
|
|
37
|
+
try {
|
|
38
|
+
const context = await browser.newContext({
|
|
39
|
+
viewport: vp,
|
|
40
|
+
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
|
|
41
|
+
deviceScaleFactor: 2,
|
|
42
|
+
isMobile: true,
|
|
43
|
+
hasTouch: true,
|
|
44
|
+
});
|
|
45
|
+
const page = await context.newPage();
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
await page.goto(normalizedUrl, { waitUntil: waitFor, timeout: 20000 });
|
|
49
|
+
} catch (navError) {
|
|
50
|
+
await browser.close();
|
|
51
|
+
return toolError(`Navigation failed for "${normalizedUrl}": ${navError.message}`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const outDir = path.join(os.tmpdir(), 'lightcone-screenshots');
|
|
55
|
+
mkdirSync(outDir, { recursive: true });
|
|
56
|
+
const outPath = path.join(outDir, `screenshot-${Date.now()}-${randomUUID().slice(0, 8)}.png`);
|
|
57
|
+
|
|
58
|
+
await page.screenshot({
|
|
59
|
+
path: outPath,
|
|
60
|
+
fullPage: cropMode === 'full_page',
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
await browser.close();
|
|
64
|
+
return toolText([
|
|
65
|
+
'take_page_screenshot completed.',
|
|
66
|
+
`path=${outPath}`,
|
|
67
|
+
`crop=${cropMode}`,
|
|
68
|
+
`viewport=${vp.width}x${vp.height}`,
|
|
69
|
+
].join('\n'));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
await browser.close().catch(() => {});
|
|
72
|
+
return toolError(`take_page_screenshot failed: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|