tuna-agent 0.1.1 → 0.1.2
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/dist/browser/actions/download.d.ts +16 -0
- package/dist/browser/actions/download.js +39 -0
- package/dist/browser/actions/emulation.d.ts +53 -0
- package/dist/browser/actions/emulation.js +103 -0
- package/dist/browser/actions/evaluate.d.ts +29 -0
- package/dist/browser/actions/evaluate.js +92 -0
- package/dist/browser/actions/interaction.d.ts +79 -0
- package/dist/browser/actions/interaction.js +210 -0
- package/dist/browser/actions/keyboard.d.ts +6 -0
- package/dist/browser/actions/keyboard.js +9 -0
- package/dist/browser/actions/navigation.d.ts +40 -0
- package/dist/browser/actions/navigation.js +92 -0
- package/dist/browser/actions/wait.d.ts +12 -0
- package/dist/browser/actions/wait.js +33 -0
- package/dist/browser/browser.d.ts +722 -0
- package/dist/browser/browser.js +1066 -0
- package/dist/browser/capture/activity.d.ts +22 -0
- package/dist/browser/capture/activity.js +39 -0
- package/dist/browser/capture/pdf.d.ts +6 -0
- package/dist/browser/capture/pdf.js +6 -0
- package/dist/browser/capture/response.d.ts +8 -0
- package/dist/browser/capture/response.js +28 -0
- package/dist/browser/capture/screenshot.d.ts +30 -0
- package/dist/browser/capture/screenshot.js +72 -0
- package/dist/browser/capture/trace.d.ts +13 -0
- package/dist/browser/capture/trace.js +19 -0
- package/dist/browser/chrome-launcher.d.ts +8 -0
- package/dist/browser/chrome-launcher.js +543 -0
- package/dist/browser/connection.d.ts +42 -0
- package/dist/browser/connection.js +359 -0
- package/dist/browser/index.d.ts +6 -0
- package/dist/browser/index.js +3 -0
- package/dist/browser/security.d.ts +51 -0
- package/dist/browser/security.js +357 -0
- package/dist/browser/snapshot/ai-snapshot.d.ts +12 -0
- package/dist/browser/snapshot/ai-snapshot.js +47 -0
- package/dist/browser/snapshot/aria-snapshot.d.ts +26 -0
- package/dist/browser/snapshot/aria-snapshot.js +121 -0
- package/dist/browser/snapshot/ref-map.d.ts +31 -0
- package/dist/browser/snapshot/ref-map.js +250 -0
- package/dist/browser/storage/index.d.ts +36 -0
- package/dist/browser/storage/index.js +65 -0
- package/dist/browser/types.d.ts +429 -0
- package/dist/browser/types.js +2 -0
- package/dist/daemon/extension-handlers.d.ts +63 -0
- package/dist/daemon/extension-handlers.js +630 -0
- package/dist/daemon/index.js +78 -19
- package/dist/daemon/ws-client.d.ts +16 -0
- package/dist/daemon/ws-client.js +45 -0
- package/dist/mcp/browser-server.d.ts +11 -0
- package/dist/mcp/browser-server.js +467 -0
- package/dist/mcp/knowledge-server.js +43 -18
- package/dist/mcp/setup.js +10 -0
- package/dist/utils/claude-cli.js +18 -9
- package/package.json +2 -1
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extension Task Handlers
|
|
3
|
+
* Handles specialized tasks from the Chrome extension that don't need Claude:
|
|
4
|
+
* - get_history — return local video history
|
|
5
|
+
* - retry_video — re-queue a video for rendering
|
|
6
|
+
* - generate_scene(s) — call Veo3 to create clips
|
|
7
|
+
* - render_video — compose final video via Remotion
|
|
8
|
+
* - list_characters — return character profiles with thumbnails
|
|
9
|
+
* - create_character — create new character profile directory
|
|
10
|
+
*/
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import os from 'os';
|
|
14
|
+
import { runClaude } from '../utils/claude-cli.js';
|
|
15
|
+
// ─── Storage ──────────────────────────────────────────────────────────────────
|
|
16
|
+
const VIDEOS_DIR = path.join(os.homedir(), '.tuna-content', 'videos');
|
|
17
|
+
const VIDEOS_DB = path.join(VIDEOS_DIR, 'history.json');
|
|
18
|
+
function ensureDir() {
|
|
19
|
+
fs.mkdirSync(VIDEOS_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
function loadHistory() {
|
|
22
|
+
try {
|
|
23
|
+
ensureDir();
|
|
24
|
+
if (!fs.existsSync(VIDEOS_DB))
|
|
25
|
+
return [];
|
|
26
|
+
return JSON.parse(fs.readFileSync(VIDEOS_DB, 'utf-8'));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return [];
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function saveHistory(videos) {
|
|
33
|
+
ensureDir();
|
|
34
|
+
fs.writeFileSync(VIDEOS_DB, JSON.stringify(videos, null, 2));
|
|
35
|
+
}
|
|
36
|
+
function upsertVideo(record) {
|
|
37
|
+
const all = loadHistory();
|
|
38
|
+
const idx = all.findIndex(v => v.id === record.id);
|
|
39
|
+
if (idx >= 0)
|
|
40
|
+
all[idx] = record;
|
|
41
|
+
else
|
|
42
|
+
all.unshift(record);
|
|
43
|
+
saveHistory(all);
|
|
44
|
+
}
|
|
45
|
+
// ─── Handler: get_history ─────────────────────────────────────────────────────
|
|
46
|
+
export function handleGetHistory(ws, code, taskId) {
|
|
47
|
+
const videos = loadHistory();
|
|
48
|
+
ws.sendExtensionEvent(code, { type: 'history_result', videos });
|
|
49
|
+
ws.sendExtensionDone(code, taskId, { ok: true });
|
|
50
|
+
}
|
|
51
|
+
// Fixed path to content-creator agent installed on this machine
|
|
52
|
+
const CONTENT_CREATOR_DIR = path.join(os.homedir(), 'agents/content-creator');
|
|
53
|
+
/** Returns true only when the content-creator agent is installed on this machine */
|
|
54
|
+
function hasContentCreator() {
|
|
55
|
+
try {
|
|
56
|
+
return fs.existsSync(path.join(CONTENT_CREATOR_DIR, '.claude', 'commands', 'script.md'));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// ─── Handler: generate_ideas ──────────────────────────────────────────────────
|
|
63
|
+
export async function handleGenerateIdeas(ws, code, taskId, topic) {
|
|
64
|
+
if (!hasContentCreator()) {
|
|
65
|
+
const error = 'content-creator agent not found on this machine';
|
|
66
|
+
console.error(`[generate_ideas] ${error}`);
|
|
67
|
+
ws.sendExtensionEvent(code, { type: 'ideas_result', ideas: [], error });
|
|
68
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const systemPrompt = [
|
|
72
|
+
`You are a top-tier content strategist and viral video expert specializing in YouTube Shorts and TikTok.`,
|
|
73
|
+
`You deeply understand viral hooks, storytelling patterns, and audience psychology.`,
|
|
74
|
+
`You know what makes people stop scrolling: curiosity gaps, emotional triggers, POV format, plot twists, listicles, and controversial takes.`,
|
|
75
|
+
`You always write titles in Vietnamese that feel native, not translated.`,
|
|
76
|
+
].join(' ');
|
|
77
|
+
const prompt = [
|
|
78
|
+
`Generate exactly 5 viral YouTube Shorts video ideas for the topic: "${topic}".`,
|
|
79
|
+
``,
|
|
80
|
+
`Requirements:`,
|
|
81
|
+
`- Each idea is a catchy, scroll-stopping video title`,
|
|
82
|
+
`- Use proven viral patterns: POV, "X điều...", plot twist, emotional hook, controversial take`,
|
|
83
|
+
`- Titles must be in Vietnamese, natural and engaging`,
|
|
84
|
+
`- Mix different angles/formats for variety`,
|
|
85
|
+
``,
|
|
86
|
+
`Respond with ONLY a JSON array of 5 strings. No explanation, no markdown, no wrapping.`,
|
|
87
|
+
`Example format: ["title 1","title 2","title 3","title 4","title 5"]`,
|
|
88
|
+
].join('\n');
|
|
89
|
+
try {
|
|
90
|
+
const result = await runClaude({
|
|
91
|
+
prompt,
|
|
92
|
+
systemPrompt,
|
|
93
|
+
cwd: CONTENT_CREATOR_DIR,
|
|
94
|
+
maxTurns: 1,
|
|
95
|
+
outputFormat: 'json',
|
|
96
|
+
timeoutMs: 60000,
|
|
97
|
+
});
|
|
98
|
+
let ideas = [];
|
|
99
|
+
try {
|
|
100
|
+
// Try all JSON arrays in the response and pick the one with 5 items
|
|
101
|
+
const arrays = [];
|
|
102
|
+
const re = /\[(?:[^\[\]]*(?:"(?:[^"\\]|\\.)*"[^\[\]]*)*)\]/g;
|
|
103
|
+
let m;
|
|
104
|
+
while ((m = re.exec(result.result)) !== null) {
|
|
105
|
+
try {
|
|
106
|
+
const parsed = JSON.parse(m[0]);
|
|
107
|
+
if (Array.isArray(parsed) && parsed.every(i => typeof i === 'string')) {
|
|
108
|
+
arrays.push(parsed);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch { /* skip non-JSON matches */ }
|
|
112
|
+
}
|
|
113
|
+
// Prefer the array closest to 5 items
|
|
114
|
+
if (arrays.length > 0) {
|
|
115
|
+
ideas = arrays.reduce((best, cur) => Math.abs(cur.length - 5) < Math.abs(best.length - 5) ? cur : best);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch { /* fall through to fallback */ }
|
|
119
|
+
// Fallback: split numbered/bulleted lines
|
|
120
|
+
if (ideas.length === 0) {
|
|
121
|
+
ideas = result.result
|
|
122
|
+
.split('\n')
|
|
123
|
+
.map(l => l.replace(/^[\d\-\*\.\)\s]+/, '').replace(/^[""]|[""]$/g, '').trim())
|
|
124
|
+
.filter(l => l.length > 10 && !l.startsWith('[') && !l.toLowerCase().includes('json'))
|
|
125
|
+
.slice(0, 5);
|
|
126
|
+
}
|
|
127
|
+
// Clean up ideas: strip U+FFFD replacement chars and stray emoji that may not render
|
|
128
|
+
ideas = ideas.map(idea => idea.replace(/\uFFFD/g, '').trim());
|
|
129
|
+
ws.sendExtensionEvent(code, { type: 'ideas_result', ideas });
|
|
130
|
+
ws.sendExtensionDone(code, taskId, { ok: true });
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
134
|
+
ws.sendExtensionEvent(code, { type: 'ideas_result', ideas: [], error });
|
|
135
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// ─── Handler: generate_script ─────────────────────────────────────────────────
|
|
139
|
+
//
|
|
140
|
+
// Reads the /script command template, expands $ARGUMENTS, and runs Claude in
|
|
141
|
+
// lightweight mode (no MCP servers, no tool loading). This avoids loading
|
|
142
|
+
// Pencil MCP and other IDE-specific servers that hang in headless mode.
|
|
143
|
+
//
|
|
144
|
+
// Flow:
|
|
145
|
+
// 1. Read .claude/commands/script.md, replace $ARGUMENTS with idea + style
|
|
146
|
+
// 2. Run `claude -p <prompt> --append-system-prompt <template>` in lightweight mode
|
|
147
|
+
// 3. Stream text deltas back to extension
|
|
148
|
+
// 4. Return full script on completion
|
|
149
|
+
export async function handleGenerateScript(ws, code, taskId, idea, topic, style) {
|
|
150
|
+
if (!hasContentCreator()) {
|
|
151
|
+
const error = 'content-creator agent not found on this machine';
|
|
152
|
+
console.error(`[generate_script] ${error}`);
|
|
153
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
// Read and expand the /script command template
|
|
157
|
+
const templatePath = path.join(CONTENT_CREATOR_DIR, '.claude', 'commands', 'script.md');
|
|
158
|
+
let template;
|
|
159
|
+
try {
|
|
160
|
+
template = fs.readFileSync(templatePath, 'utf-8');
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
const error = `Cannot read script template: ${err instanceof Error ? err.message : err}`;
|
|
164
|
+
console.error(`[generate_script] ${error}`);
|
|
165
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const styleFlag = style ? `--style ${style} ` : '';
|
|
169
|
+
const args = `${styleFlag}${idea}`.trim();
|
|
170
|
+
const expandedTemplate = template.replace(/\$ARGUMENTS/g, args);
|
|
171
|
+
// Also read character profiles so Claude can match characters
|
|
172
|
+
let characterContext = '';
|
|
173
|
+
try {
|
|
174
|
+
if (fs.existsSync(CHARACTERS_DIR)) {
|
|
175
|
+
const chars = fs.readdirSync(CHARACTERS_DIR).filter(d => fs.statSync(path.join(CHARACTERS_DIR, d)).isDirectory());
|
|
176
|
+
for (const charName of chars) {
|
|
177
|
+
const profilePath = path.join(CHARACTERS_DIR, charName, 'profile.md');
|
|
178
|
+
if (fs.existsSync(profilePath)) {
|
|
179
|
+
const profile = fs.readFileSync(profilePath, 'utf-8');
|
|
180
|
+
characterContext += `\n\n--- Character: ${charName} ---\n${profile}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Non-fatal — Claude will use N/A for characters
|
|
187
|
+
}
|
|
188
|
+
const systemPrompt = expandedTemplate + (characterContext
|
|
189
|
+
? `\n\n## AVAILABLE CHARACTERS\n${characterContext}`
|
|
190
|
+
: '');
|
|
191
|
+
try {
|
|
192
|
+
let streamChunks = 0;
|
|
193
|
+
let sentTextLen = 0; // track how much text we've already streamed
|
|
194
|
+
const result = await runClaude({
|
|
195
|
+
prompt: `Viết script video theo hướng dẫn trên. Arguments: ${args}`,
|
|
196
|
+
systemPrompt,
|
|
197
|
+
cwd: CONTENT_CREATOR_DIR,
|
|
198
|
+
maxTurns: 4,
|
|
199
|
+
outputFormat: 'stream-json',
|
|
200
|
+
includePartialMessages: true,
|
|
201
|
+
timeoutMs: 180000,
|
|
202
|
+
onStreamLine: (data) => {
|
|
203
|
+
if (streamChunks < 5) {
|
|
204
|
+
const sub = data.subtype || '';
|
|
205
|
+
console.log(`[generate_script] stream #${streamChunks}: type=${data.type} sub=${sub}`);
|
|
206
|
+
}
|
|
207
|
+
streamChunks++;
|
|
208
|
+
// stream_event: raw API events (token-level deltas) — fast, smooth streaming
|
|
209
|
+
if (data.type === 'stream_event') {
|
|
210
|
+
const evt = data.event;
|
|
211
|
+
if (evt?.type === 'content_block_delta') {
|
|
212
|
+
const delta = evt.delta;
|
|
213
|
+
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
|
|
214
|
+
sentTextLen += delta.text.length;
|
|
215
|
+
ws.sendExtensionStream(code, taskId, delta.text);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
// assistant: partial message snapshots (fallback for coarser updates)
|
|
221
|
+
if (data.type === 'assistant') {
|
|
222
|
+
const msg = data.message;
|
|
223
|
+
const content = (msg?.content ?? data.content);
|
|
224
|
+
if (Array.isArray(content)) {
|
|
225
|
+
let fullText = '';
|
|
226
|
+
for (const block of content) {
|
|
227
|
+
if (block.type === 'text' && typeof block.text === 'string') {
|
|
228
|
+
fullText += block.text;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (fullText.length > sentTextLen) {
|
|
232
|
+
const delta = fullText.slice(sentTextLen);
|
|
233
|
+
sentTextLen = fullText.length;
|
|
234
|
+
ws.sendExtensionStream(code, taskId, delta);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
console.log(`[generate_script] Done: ${streamChunks} chunks, streamed=${sentTextLen} chars, result=${result.result?.length || 0} chars`);
|
|
241
|
+
const scriptText = result.result;
|
|
242
|
+
ws.sendExtensionDone(code, taskId, { script: scriptText, text: scriptText });
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
246
|
+
console.error(`[generate_script] Error: ${error}`);
|
|
247
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
// ─── Handler: retry_video ─────────────────────────────────────────────────────
|
|
251
|
+
export function handleRetryVideo(ws, code, taskId, videoId) {
|
|
252
|
+
const videos = loadHistory();
|
|
253
|
+
const v = videos.find(x => x.id === videoId);
|
|
254
|
+
if (!v) {
|
|
255
|
+
ws.sendExtensionDone(code, taskId, { error: 'Video not found' });
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
// Reset status and re-queue
|
|
259
|
+
v.status = 'processing';
|
|
260
|
+
v.progress = 0;
|
|
261
|
+
v.error = undefined;
|
|
262
|
+
saveHistory(videos);
|
|
263
|
+
ws.sendExtensionEvent(code, { type: 'history_result', videos: loadHistory() });
|
|
264
|
+
ws.sendExtensionDone(code, taskId, { ok: true });
|
|
265
|
+
// Re-run the render in background
|
|
266
|
+
_doRender(ws, code, 'retry-' + taskId, v);
|
|
267
|
+
}
|
|
268
|
+
// ─── Handler: generate_scene ─────────────────────────────────────────────────
|
|
269
|
+
export async function handleGenerateScene(ws, code, taskId, sceneIdx, prompt, aspectRatio) {
|
|
270
|
+
ws.sendExtensionEvent(code, { type: 'scene_progress', idx: sceneIdx, progress: 0 });
|
|
271
|
+
try {
|
|
272
|
+
const videoUrl = await _callVeo3(prompt, aspectRatio, (p) => {
|
|
273
|
+
ws.sendExtensionEvent(code, { type: 'scene_progress', idx: sceneIdx, progress: p });
|
|
274
|
+
});
|
|
275
|
+
ws.sendExtensionEvent(code, { type: 'scene_done', idx: sceneIdx, videoUrl });
|
|
276
|
+
ws.sendExtensionDone(code, taskId, { ok: true, idx: sceneIdx, videoUrl });
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
280
|
+
ws.sendExtensionEvent(code, { type: 'scene_failed', idx: sceneIdx, error });
|
|
281
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// ─── Handler: generate_scenes (batch) ────────────────────────────────────────
|
|
285
|
+
export async function handleGenerateScenes(ws, code, taskId, scenes, aspectRatio) {
|
|
286
|
+
for (const scene of scenes) {
|
|
287
|
+
await handleGenerateScene(ws, code, taskId + '-' + scene.idx, scene.idx, scene.prompt, aspectRatio);
|
|
288
|
+
}
|
|
289
|
+
ws.sendExtensionDone(code, taskId, { ok: true });
|
|
290
|
+
}
|
|
291
|
+
// ─── Handler: render_video ────────────────────────────────────────────────────
|
|
292
|
+
export async function handleRenderVideo(ws, code, taskId, payload) {
|
|
293
|
+
const videoId = 'v-' + Date.now();
|
|
294
|
+
const record = {
|
|
295
|
+
id: videoId,
|
|
296
|
+
title: payload.title || 'Untitled',
|
|
297
|
+
niche: '',
|
|
298
|
+
status: 'processing',
|
|
299
|
+
progress: 0,
|
|
300
|
+
createdAt: new Date().toISOString(),
|
|
301
|
+
scenes: payload.scenes,
|
|
302
|
+
aspectRatio: payload.aspectRatio,
|
|
303
|
+
};
|
|
304
|
+
upsertVideo(record);
|
|
305
|
+
ws.sendExtensionEvent(code, { type: 'render_progress', progress: 0 });
|
|
306
|
+
_doRender(ws, code, taskId, record, payload);
|
|
307
|
+
}
|
|
308
|
+
// ─── Internal: Veo3 call ──────────────────────────────────────────────────────
|
|
309
|
+
async function _callVeo3(prompt, aspectRatio, onProgress) {
|
|
310
|
+
// TODO: integrate real Veo3 API
|
|
311
|
+
// Simulate progress for now
|
|
312
|
+
for (let p = 10; p <= 90; p += 20) {
|
|
313
|
+
await _sleep(800);
|
|
314
|
+
onProgress(p);
|
|
315
|
+
}
|
|
316
|
+
await _sleep(800);
|
|
317
|
+
onProgress(100);
|
|
318
|
+
// Placeholder: return a dummy local file path
|
|
319
|
+
// Real impl: call Google Veo3 API, download clip to VIDEOS_DIR, return file:// path
|
|
320
|
+
const outFile = path.join(VIDEOS_DIR, `scene-${Date.now()}.mp4`);
|
|
321
|
+
return 'file://' + outFile;
|
|
322
|
+
}
|
|
323
|
+
// ─── Internal: Render ─────────────────────────────────────────────────────────
|
|
324
|
+
async function _doRender(ws, code, taskId, record, payload) {
|
|
325
|
+
try {
|
|
326
|
+
// Progress: 0 → 100
|
|
327
|
+
for (let p = 10; p <= 90; p += 10) {
|
|
328
|
+
await _sleep(600);
|
|
329
|
+
record.progress = p;
|
|
330
|
+
upsertVideo(record);
|
|
331
|
+
ws.sendExtensionEvent(code, { type: 'render_progress', progress: p });
|
|
332
|
+
}
|
|
333
|
+
// TODO: integrate real Remotion render
|
|
334
|
+
// Real impl: call /render skill or Remotion CLI with scene clips + voiceover
|
|
335
|
+
const outFile = path.join(VIDEOS_DIR, `${record.id}.mp4`);
|
|
336
|
+
record.status = 'rendered';
|
|
337
|
+
record.progress = 100;
|
|
338
|
+
record.outputPath = outFile;
|
|
339
|
+
upsertVideo(record);
|
|
340
|
+
ws.sendExtensionEvent(code, {
|
|
341
|
+
type: 'render_done',
|
|
342
|
+
outputPath: outFile,
|
|
343
|
+
title: record.title,
|
|
344
|
+
video: record,
|
|
345
|
+
});
|
|
346
|
+
ws.sendExtensionDone(code, taskId, { ok: true, videoId: record.id });
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
350
|
+
record.status = 'failed';
|
|
351
|
+
record.error = error;
|
|
352
|
+
upsertVideo(record);
|
|
353
|
+
ws.sendExtensionEvent(code, { type: 'render_failed', error });
|
|
354
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
// ─── Character profile interface ─────────────────────────────────────────────
|
|
358
|
+
const CHARACTERS_DIR = path.join(os.homedir(), '.tuna', 'characters');
|
|
359
|
+
/** Parse profile.md key-value lines like `- **Key:** Value` */
|
|
360
|
+
function _parseProfileMd(text) {
|
|
361
|
+
const result = {};
|
|
362
|
+
// Extract title (# Name)
|
|
363
|
+
const titleMatch = text.match(/^#\s+(.+)/m);
|
|
364
|
+
if (titleMatch)
|
|
365
|
+
result.name = titleMatch[1].trim();
|
|
366
|
+
// Extract key-value pairs
|
|
367
|
+
const kvRe = /- \*\*(.+?):\*\*\s*(.+)/g;
|
|
368
|
+
let m;
|
|
369
|
+
while ((m = kvRe.exec(text)) !== null) {
|
|
370
|
+
result[m[1].toLowerCase().replace(/\s+/g, '_')] = m[2].trim();
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
// ─── Handler: list_characters ────────────────────────────────────────────────
|
|
375
|
+
export function handleListCharacters(ws, code, taskId) {
|
|
376
|
+
const charDir = CHARACTERS_DIR;
|
|
377
|
+
const characters = [];
|
|
378
|
+
try {
|
|
379
|
+
if (fs.existsSync(charDir)) {
|
|
380
|
+
const dirs = fs.readdirSync(charDir).filter(d => {
|
|
381
|
+
try {
|
|
382
|
+
return fs.statSync(path.join(charDir, d)).isDirectory();
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
for (const dirName of dirs) {
|
|
389
|
+
const profilePath = path.join(charDir, dirName, 'profile.md');
|
|
390
|
+
const profileText = fs.existsSync(profilePath)
|
|
391
|
+
? fs.readFileSync(profilePath, 'utf-8') : '';
|
|
392
|
+
const parsed = _parseProfileMd(profileText);
|
|
393
|
+
// Read main image as base64
|
|
394
|
+
let thumbnail = '';
|
|
395
|
+
const imgCandidates = ['main.jpg', 'main.jpeg', 'main.png'];
|
|
396
|
+
for (const fname of imgCandidates) {
|
|
397
|
+
const imgPath = path.join(charDir, dirName, fname);
|
|
398
|
+
if (fs.existsSync(imgPath)) {
|
|
399
|
+
const imgBuf = fs.readFileSync(imgPath);
|
|
400
|
+
const ext = path.extname(imgPath).slice(1);
|
|
401
|
+
const mime = ext === 'png' ? 'image/png' : 'image/jpeg';
|
|
402
|
+
thumbnail = `data:${mime};base64,${imgBuf.toString('base64')}`;
|
|
403
|
+
break;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
// Count selected poses
|
|
407
|
+
const selectedDir = path.join(charDir, dirName, 'selected');
|
|
408
|
+
let poseCount = 0;
|
|
409
|
+
if (fs.existsSync(selectedDir)) {
|
|
410
|
+
poseCount = fs.readdirSync(selectedDir).filter(f => /\.(jpe?g|png)$/i.test(f)).length;
|
|
411
|
+
}
|
|
412
|
+
characters.push({
|
|
413
|
+
id: dirName,
|
|
414
|
+
name: parsed.name || dirName,
|
|
415
|
+
thumbnail,
|
|
416
|
+
gender: parsed.gender || '',
|
|
417
|
+
age: parsed.age || '',
|
|
418
|
+
ethnicity: parsed.ethnicity || '',
|
|
419
|
+
vibe: parsed.vibe || '',
|
|
420
|
+
suitableFor: parsed.suitable_for || '',
|
|
421
|
+
poseCount,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
catch (err) {
|
|
427
|
+
console.error(`[list_characters] Error reading characters:`, err);
|
|
428
|
+
}
|
|
429
|
+
ws.sendExtensionEvent(code, { type: 'characters_result', characters });
|
|
430
|
+
ws.sendExtensionDone(code, taskId, { ok: true });
|
|
431
|
+
}
|
|
432
|
+
// ─── Helper: send a flow command and unwrap result ──────────────────────────
|
|
433
|
+
async function flowCmd(ws, code, command, timeout = 60000) {
|
|
434
|
+
const result = await ws.sendFlowCommand(code, command, timeout);
|
|
435
|
+
if (result && !result.success) {
|
|
436
|
+
throw new Error(`Flow command failed: ${result.error || JSON.stringify(result)}`);
|
|
437
|
+
}
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
// ─── Handler: create_character ───────────────────────────────────────────────
|
|
441
|
+
export async function handleCreateCharacter(ws, code, taskId, name, displayName, prompt, outputCount = 2, orientation = 'portrait') {
|
|
442
|
+
const charDir = path.join(CHARACTERS_DIR, name);
|
|
443
|
+
// Validate
|
|
444
|
+
if (!name || !/^[a-z0-9_]+$/.test(name)) {
|
|
445
|
+
ws.sendExtensionEvent(code, { type: 'char_create_failed', error: 'Invalid name — use lowercase letters, numbers, underscores only' });
|
|
446
|
+
ws.sendExtensionDone(code, taskId, { error: 'invalid name' });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
// Create directory structure (allow re-creation for regeneration)
|
|
451
|
+
fs.mkdirSync(path.join(charDir, 'generated'), { recursive: true });
|
|
452
|
+
fs.mkdirSync(path.join(charDir, 'selected'), { recursive: true });
|
|
453
|
+
// Write profile.md
|
|
454
|
+
fs.writeFileSync(path.join(charDir, 'profile.md'), `# ${displayName}\n\n- **Orientation:** ${orientation}\n- **Output Count:** ${outputCount}\n\n${prompt}\n`);
|
|
455
|
+
// ── Step 1: Open Google Flow tab ──────────────────────────────────────
|
|
456
|
+
ws.sendExtensionEvent(code, {
|
|
457
|
+
type: 'char_create_progress',
|
|
458
|
+
phase: 'setup',
|
|
459
|
+
progress: 5,
|
|
460
|
+
current: 'Opening Google Flow...',
|
|
461
|
+
});
|
|
462
|
+
await flowCmd(ws, code, { type: 'openFlowTab' }, 15000);
|
|
463
|
+
await _sleep(2000); // Extra time for page to fully initialize
|
|
464
|
+
// ── Step 2: Ping content script to verify it's ready ──────────────────
|
|
465
|
+
ws.sendExtensionEvent(code, {
|
|
466
|
+
type: 'char_create_progress',
|
|
467
|
+
phase: 'setup',
|
|
468
|
+
progress: 10,
|
|
469
|
+
current: 'Waiting for Google Flow to load...',
|
|
470
|
+
});
|
|
471
|
+
// Poll until content script is responsive
|
|
472
|
+
let pingOk = false;
|
|
473
|
+
for (let attempt = 0; attempt < 10; attempt++) {
|
|
474
|
+
try {
|
|
475
|
+
await flowCmd(ws, code, { type: 'ping' }, 5000);
|
|
476
|
+
pingOk = true;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
catch {
|
|
480
|
+
await _sleep(2000);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
if (!pingOk) {
|
|
484
|
+
throw new Error('Google Flow tab not responding — make sure Flow is open at labs.google/fx/tools/flow');
|
|
485
|
+
}
|
|
486
|
+
// ── Step 3: Configure settings (mode, orientation, count) ─────────────
|
|
487
|
+
ws.sendExtensionEvent(code, {
|
|
488
|
+
type: 'char_create_progress',
|
|
489
|
+
phase: 'configuring',
|
|
490
|
+
progress: 15,
|
|
491
|
+
current: 'Configuring image settings...',
|
|
492
|
+
});
|
|
493
|
+
// Map orientation to Flow aspect ratio names
|
|
494
|
+
const aspectRatio = orientation === 'landscape' ? 'landscape' : 'portrait';
|
|
495
|
+
await flowCmd(ws, code, {
|
|
496
|
+
type: 'configureSettings',
|
|
497
|
+
settings: {
|
|
498
|
+
mode: 'create image',
|
|
499
|
+
aspectRatio,
|
|
500
|
+
outputs: String(outputCount),
|
|
501
|
+
},
|
|
502
|
+
}, 15000);
|
|
503
|
+
await _sleep(1000);
|
|
504
|
+
// ── Step 4: Fill prompt and submit ────────────────────────────────────
|
|
505
|
+
ws.sendExtensionEvent(code, {
|
|
506
|
+
type: 'char_create_progress',
|
|
507
|
+
phase: 'submitting',
|
|
508
|
+
progress: 25,
|
|
509
|
+
current: 'Entering prompt...',
|
|
510
|
+
});
|
|
511
|
+
await flowCmd(ws, code, { type: 'fillTextbox', text: prompt }, 10000);
|
|
512
|
+
await _sleep(500);
|
|
513
|
+
await flowCmd(ws, code, { type: 'submitPrompt' }, 10000);
|
|
514
|
+
// ── Step 5: Wait for images to appear ────────────────────────────────
|
|
515
|
+
ws.sendExtensionEvent(code, {
|
|
516
|
+
type: 'char_create_progress',
|
|
517
|
+
phase: 'generating',
|
|
518
|
+
progress: 30,
|
|
519
|
+
current: 'Generating images (this may take 30-60s)...',
|
|
520
|
+
});
|
|
521
|
+
// Poll countGoogleImages until we have enough images
|
|
522
|
+
const maxWait = 120000; // 2 minutes max
|
|
523
|
+
const pollInterval = 3000;
|
|
524
|
+
const startTime = Date.now();
|
|
525
|
+
let imageCount = 0;
|
|
526
|
+
while (Date.now() - startTime < maxWait) {
|
|
527
|
+
await _sleep(pollInterval);
|
|
528
|
+
const countResult = await flowCmd(ws, code, { type: 'countImages' }, 10000);
|
|
529
|
+
imageCount = countResult.visible || 0;
|
|
530
|
+
const elapsed = Date.now() - startTime;
|
|
531
|
+
const progressPct = Math.min(30 + Math.round((elapsed / maxWait) * 60), 90);
|
|
532
|
+
ws.sendExtensionEvent(code, {
|
|
533
|
+
type: 'char_create_progress',
|
|
534
|
+
phase: 'generating',
|
|
535
|
+
progress: progressPct,
|
|
536
|
+
current: `Waiting for images... (${imageCount} found)`,
|
|
537
|
+
});
|
|
538
|
+
if (imageCount >= outputCount)
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
if (imageCount === 0) {
|
|
542
|
+
throw new Error('No images generated after waiting 2 minutes. Please check Google Flow.');
|
|
543
|
+
}
|
|
544
|
+
// ── Step 6: Extract images as base64 ──────────────────────────────────
|
|
545
|
+
ws.sendExtensionEvent(code, {
|
|
546
|
+
type: 'char_create_progress',
|
|
547
|
+
phase: 'downloading',
|
|
548
|
+
progress: 92,
|
|
549
|
+
current: 'Downloading generated images...',
|
|
550
|
+
});
|
|
551
|
+
const imgResult = await flowCmd(ws, code, { type: 'getImageBase64', minSize: 100 }, 30000);
|
|
552
|
+
const base64Images = (imgResult.images || []);
|
|
553
|
+
// Save generated images to disk
|
|
554
|
+
const genDir = path.join(charDir, 'generated');
|
|
555
|
+
const images = [];
|
|
556
|
+
for (let i = 0; i < base64Images.length; i++) {
|
|
557
|
+
const dataUrl = base64Images[i];
|
|
558
|
+
images.push(dataUrl);
|
|
559
|
+
// Also save to file
|
|
560
|
+
const match = dataUrl.match(/^data:image\/(\w+);base64,(.+)$/);
|
|
561
|
+
if (match) {
|
|
562
|
+
const ext = match[1] === 'png' ? 'png' : 'jpg';
|
|
563
|
+
const fname = `gen_${String(i + 1).padStart(2, '0')}.${ext}`;
|
|
564
|
+
fs.writeFileSync(path.join(genDir, fname), Buffer.from(match[2], 'base64'));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// ── Step 7: Done ─────────────────────────────────────────────────────
|
|
568
|
+
ws.sendExtensionEvent(code, {
|
|
569
|
+
type: 'char_create_progress',
|
|
570
|
+
phase: 'done',
|
|
571
|
+
progress: 100,
|
|
572
|
+
current: 'Generation complete',
|
|
573
|
+
});
|
|
574
|
+
// Return the new character profile with images
|
|
575
|
+
const character = {
|
|
576
|
+
id: name,
|
|
577
|
+
name: displayName,
|
|
578
|
+
thumbnail: images.length > 0 ? images[0] : '',
|
|
579
|
+
gender: '',
|
|
580
|
+
age: '',
|
|
581
|
+
ethnicity: '',
|
|
582
|
+
vibe: '',
|
|
583
|
+
suitableFor: '',
|
|
584
|
+
poseCount: 0,
|
|
585
|
+
images,
|
|
586
|
+
};
|
|
587
|
+
ws.sendExtensionEvent(code, { type: 'char_created', character });
|
|
588
|
+
ws.sendExtensionDone(code, taskId, { ok: true });
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
592
|
+
console.error(`[create_character] Error:`, error);
|
|
593
|
+
ws.sendExtensionEvent(code, { type: 'char_create_failed', error });
|
|
594
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
// ─── Handler: save_character_selection ────────────────────────────────────────
|
|
598
|
+
export function handleSaveCharacterSelection(ws, code, taskId, characterId, selectedImages) {
|
|
599
|
+
const charDir = path.join(CHARACTERS_DIR, characterId);
|
|
600
|
+
try {
|
|
601
|
+
if (!fs.existsSync(charDir)) {
|
|
602
|
+
ws.sendExtensionDone(code, taskId, { error: 'Character not found' });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
const selectedDir = path.join(charDir, 'selected');
|
|
606
|
+
fs.mkdirSync(selectedDir, { recursive: true });
|
|
607
|
+
// Save first selected image as main.jpg if no main exists
|
|
608
|
+
if (selectedImages.length > 0) {
|
|
609
|
+
const mainPath = path.join(charDir, 'main.jpg');
|
|
610
|
+
if (!fs.existsSync(mainPath)) {
|
|
611
|
+
const first = selectedImages[0];
|
|
612
|
+
const match = first.match(/^data:image\/\w+;base64,(.+)$/);
|
|
613
|
+
if (match) {
|
|
614
|
+
fs.writeFileSync(mainPath, Buffer.from(match[1], 'base64'));
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
// TODO: Copy selected generated images to selected/ directory
|
|
619
|
+
// For now, just acknowledge the selection
|
|
620
|
+
ws.sendExtensionDone(code, taskId, { ok: true, count: selectedImages.length });
|
|
621
|
+
}
|
|
622
|
+
catch (err) {
|
|
623
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
624
|
+
console.error(`[save_character_selection] Error:`, error);
|
|
625
|
+
ws.sendExtensionDone(code, taskId, { error });
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function _sleep(ms) {
|
|
629
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
630
|
+
}
|