tuna-agent 0.1.146 → 0.1.148

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.
@@ -280,7 +280,7 @@ Return ONLY a JSON object, no markdown fences:
280
280
 
281
281
  Rules:
282
282
  - characters.name: a stable short uppercase label reused for this subject (e.g. "THE BISHOP", "U-94 SUBMARINE"). Max 4 words.
283
- - Only RECURRING subjects worth a reference sheet. Skip one-off extras. Max 6.
283
+ - RECALL (CRITICAL): list EVERY distinct recurring subject SEPARATELY. If a family or group recurs, include EACH member as its own entry (e.g. adult man, adult woman, older boy, younger girl) — never merge them into one. Skip only true one-off background extras. Be COMPLETE: missing a recurring character is worse than one extra. Up to 8.
284
284
  - characters.description: ENGLISH only, factual, no camera/action words.
285
285
  - DISTINCT FACES (CRITICAL): every character MUST have a HIGHLY UNIQUE facial structure, a distinct hairstyle, a specific body type and a clearly different age. NEVER reuse the same or a similar facial description for two characters — they must look completely different from one another.`,
286
286
  },
@@ -308,26 +308,107 @@ Rules:
308
308
  }
309
309
  const characters = (parsed.characters || [])
310
310
  .filter(c => c && c.name && c.description)
311
- .slice(0, 6)
311
+ .slice(0, 8)
312
312
  .map(c => ({ name: String(c.name).trim(), description: String(c.description).trim() }));
313
313
  const video_summary = (parsed.video_summary || '').trim();
314
314
  const video_style = (parsed.video_style || '').trim();
315
- let master_cast_prompt = '';
316
- if (characters.length > 0) {
317
- const styleLine = video_style || 'Keep the original video’s visual style, color grading, and lighting.';
318
- const castList = characters.map(c => `- ${c.name}: ${c.description}`).join('\n');
319
- master_cast_prompt =
320
- `[AESTHETIC & STYLE]\n${styleLine}\n` +
321
- `[COMPOSITION & LAYOUT]\nCharacter Reference Sheet. Full-body side-by-side.\n` +
322
- `[CHARACTER CAST LIST]\n${castList}\n` +
323
- `[TECHNICAL SPECIFICATIONS]\nHigh detail, 8k resolution, consistent facial structures across all frames. Each character has a completely distinct face, hairstyle, body type and age — no two characters look alike.`;
324
- }
315
+ const master_cast_prompt = buildMasterCastPrompt(video_style, characters);
325
316
  return { video_summary, video_style, master_cast_prompt, characters };
326
317
  }
327
318
  catch {
328
319
  return empty;
329
320
  }
330
321
  }
322
+ // Single source of truth for the master-cast prompt block (used by Phase-1
323
+ // and the post-Phase-2 cast reconciliation so the format never drifts).
324
+ function buildMasterCastPrompt(videoStyle, characters) {
325
+ if (!characters.length)
326
+ return '';
327
+ const styleLine = videoStyle || 'Keep the original video’s visual style, color grading, and lighting.';
328
+ const castList = characters.map(c => `- ${c.name}: ${c.description}`).join('\n');
329
+ return (`[AESTHETIC & STYLE]\n${styleLine}\n` +
330
+ `[COMPOSITION & LAYOUT]\nCharacter Reference Sheet. Full-body side-by-side.\n` +
331
+ `[CHARACTER CAST LIST]\n${castList}\n` +
332
+ `[TECHNICAL SPECIFICATIONS]\nHigh detail, 8k resolution, consistent facial structures across all frames. Each character has a completely distinct face, hairstyle, body type and age — no two characters look alike.`);
333
+ }
334
+ // Post-Phase-2 cast RECONCILE. Phase-1 only sees a 30-frame sample so it can
335
+ // miss a recurring character; the per-scene visionDescribe pass, however,
336
+ // looked at EVERY scene. Feed all per-scene descriptions (+ Phase-1 cast as a
337
+ // trusted seed) to one gpt-4o text call to produce the definitive cast: keep
338
+ // seed entries, merge duplicates, ADD any recurring subject Phase-1 missed.
339
+ // Returns null on any failure → caller keeps the Phase-1 cast.
340
+ async function reconcileCast(seed, sceneDescriptions, transcript, videoStyle, cost) {
341
+ if (!OPENAI_KEY)
342
+ return null;
343
+ const descs = sceneDescriptions.map(s => (s || '').trim()).filter(Boolean);
344
+ if (descs.length < 3)
345
+ return null;
346
+ // Bound tokens: cap each scene line + overall.
347
+ const joined = descs
348
+ .map((d, i) => `S${i + 1}: ${d.slice(0, 240)}`)
349
+ .join('\n')
350
+ .slice(0, 60000);
351
+ const seedBlock = seed.length
352
+ ? seed.map(c => `- ${c.name}: ${c.description}`).join('\n')
353
+ : '(none detected yet)';
354
+ try {
355
+ const res = await fetch('https://api.openai.com/v1/chat/completions', {
356
+ method: 'POST',
357
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${OPENAI_KEY}` },
358
+ body: JSON.stringify({
359
+ model: 'gpt-4o',
360
+ max_tokens: 1200,
361
+ messages: [{
362
+ role: 'user',
363
+ content: `You are reconciling the definitive recurring CHARACTER CAST of a video from per-scene visual descriptions that cover EVERY scene (the seed cast below was extracted from only a few sampled frames and may be INCOMPLETE).
364
+
365
+ SEED CAST (trusted — keep these, refine wording if scenes add detail):
366
+ ${seedBlock}
367
+
368
+ PER-SCENE DESCRIPTIONS (every scene, in order):
369
+ ${joined}
370
+
371
+ Transcript (may name characters): "${(transcript || '').slice(0, 1500)}"
372
+
373
+ Return ONLY a JSON object, no markdown:
374
+ { "characters": [ { "name": "SHORT_UPPERCASE_LABEL", "description": "age/build, face, hair, outfit, colors, distinguishing features (English, factual, no camera/action words)" } ] }
375
+
376
+ Rules:
377
+ - Start from the SEED CAST; KEEP every seed character (don't drop them).
378
+ - ADD any recurring subject that appears in multiple scenes but is missing from the seed (e.g. a family member, a recurring animal/mascot/object). Missing a recurring character is the main failure to avoid.
379
+ - Merge entries that clearly refer to the SAME subject under one name.
380
+ - If a group/family recurs, list EACH member SEPARATELY (e.g. adult man, adult woman, older boy, younger girl) — never merge them.
381
+ - Skip true one-off background extras.
382
+ - DISTINCT FACES: every character must have a UNIQUE facial structure, hairstyle, body type and a clearly different age — never reuse a similar facial description for two characters.
383
+ - Stable short UPPERCASE name, max 4 words. Up to 8 characters total.`,
384
+ }],
385
+ }),
386
+ });
387
+ if (!res.ok)
388
+ return null;
389
+ const data = await res.json();
390
+ cost?.chat('cast', 'gpt-4o', data.usage);
391
+ const raw = (data.choices?.[0]?.message?.content || '').trim();
392
+ let parsed = {};
393
+ try {
394
+ const m = raw.match(/\{[\s\S]*\}/);
395
+ parsed = JSON.parse(m ? m[0] : raw);
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ const characters = (parsed.characters || [])
401
+ .filter(c => c && c.name && c.description)
402
+ .slice(0, 8)
403
+ .map(c => ({ name: String(c.name).trim(), description: String(c.description).trim() }));
404
+ if (!characters.length)
405
+ return null;
406
+ return { characters, master_cast_prompt: buildMasterCastPrompt(videoStyle, characters) };
407
+ }
408
+ catch {
409
+ return null;
410
+ }
411
+ }
331
412
  export async function analyzeVideo(url, onProgress) {
332
413
  const progress = onProgress || (() => { });
333
414
  const cost = new CostTracker();
@@ -518,14 +599,17 @@ export async function analyzeVideo(url, onProgress) {
518
599
  // master cast + characters. Runs before per-scene describe so the cast
519
600
  // context keeps naming consistent across the whole timeline.
520
601
  progress('Đang phân tích tổng thể (summary + style + master cast)...');
521
- // Sample up to 12 frames evenly across the whole video.
522
- const p1SampleCount = Math.min(12, frameBuffers.length);
602
+ // Sample up to 30 frames evenly across the whole video — denser sampling
603
+ // is critical for cast RECALL on sparse-narration (ASMR) videos where
604
+ // Phase-1 relies almost entirely on frames (matches AI_Video_Clone).
605
+ const p1SampleCount = Math.min(30, frameBuffers.length);
523
606
  const p1Step = Math.max(1, Math.floor(frameBuffers.length / p1SampleCount));
524
607
  const p1Samples = frameBuffers
525
608
  .filter((_, i) => i % p1Step === 0)
526
609
  .slice(0, p1SampleCount)
527
610
  .map(f => f.thumb.toString('base64'));
528
- const { video_summary, video_style, master_cast_prompt, characters } = await visionExtractPhase1(p1Samples, transcript.text || '', cost);
611
+ // eslint-disable-next-line prefer-const
612
+ let { video_summary, video_style, master_cast_prompt, characters } = await visionExtractPhase1(p1Samples, transcript.text || '', cost);
529
613
  const castContext = characters.length
530
614
  ? characters.map(c => `- ${c.name}: ${c.description}`).join('\n')
531
615
  : '';
@@ -559,6 +643,16 @@ export async function analyzeVideo(url, onProgress) {
559
643
  sceneResults.push(...results.filter((r) => r !== null));
560
644
  }
561
645
  const scenes = sceneResults.sort((a, b) => a.scene_number - b.scene_number);
646
+ // Reconcile cast from EVERY per-scene description (recall fix — Phase-1
647
+ // only saw a sampled set of frames). Falls back to Phase-1 cast on failure.
648
+ progress('Đang đối soát dàn nhân vật từ toàn bộ scene...');
649
+ const reconciled = await reconcileCast(characters, scenes.map(s => s.visual_description || ''), transcript.text || '', video_style, cost);
650
+ if (reconciled && reconciled.characters.length) {
651
+ console.log('[analyze_video] Cast reconciled:', (characters.map(c => c.name).join(', ') || '(none)'), '→', reconciled.characters.map(c => c.name).join(', '));
652
+ characters = reconciled.characters;
653
+ if (reconciled.master_cast_prompt)
654
+ master_cast_prompt = reconciled.master_cast_prompt;
655
+ }
562
656
  return {
563
657
  source_title,
564
658
  duration_sec: Math.round(durationSec),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tuna-agent",
3
- "version": "0.1.146",
3
+ "version": "0.1.148",
4
4
  "description": "Tuna Agent - Run AI coding tasks on your machine",
5
5
  "bin": {
6
6
  "tuna-agent": "dist/cli/index.js"