zelin-bot / src /script-writer.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
Raw History Blame Contribute Delete
17 kB
/**
* ============================================
* 🎬 script-writer.js — Zelin's Video Script Generator
* ============================================
* Generates YouTube video scripts with [MARKER] tags
* that director.js uses for editing timestamps.
*
* - Uses AI (callAIBackground from ai.js)
* - Reads recent diary entries for authentic content
* - Structures scripts with [MARKER:action] tags
* - Scripts are in Spanish (Zelin speaks Spanish)
*/
import { callAIBackground } from './ai.js';
import { getStateSnapshot } from './psyche.js';
import { readConfig } from './utils.js';
const config = readConfig();
// ── Marker type definitions ───────────────────────────────────────────────────
const MARKER_TYPES = {
intro: { description: 'Opening hook (2-3 sentences)', required: true },
action: { description: 'Activity segment (mining, building, pvp, ...)', required: true },
excitement: { description: 'Exciting moment (rare find, close call, pvp)', required: false },
transition: { description: 'Connecting segment between activities', required: false },
climax: { description: 'Peak moment of the video', required: true },
outro: { description: 'Closing with CTA (like, subscribe)', required: true },
};
const ACTION_SUBTYPES = ['mining', 'building', 'pvp', 'exploring', 'farming', 'fishing', 'crafting', 'enchanting'];
// ── Fallback script (when AI fails) ──────────────────────────────────────────
const FALLBACK_SCRIPT = {
title: 'Otro día en TomateSMP',
type: 'survival',
estimatedDuration: 60,
targetDuration: 60,
script:
'[MARKER:intro] Hola! Hoy les traigo otro día de supervivencia en TomateSMP.\n' +
'[MARKER:action:exploring] Estuve explorando un poco por el servidor, a ver qué encontraba.\n' +
'[MARKER:transition] Después de caminar un rato, me puse a minar.\n' +
'[MARKER:action:mining] Estaba minando en busca de recursos, lo de siempre.\n' +
'[MARKER:climax] Y entonces encontré algo interesante... no se lo esperaban.\n' +
'[MARKER:outro] Y eso fue todo por hoy! Si les gustó denle like y suscríbanse, nos vemos en el próximo video!',
markers: ['intro', 'action:exploring', 'transition', 'action:mining', 'climax', 'outro'],
tags: config.youtube?.tags ?? ['minecraft', 'survival', 'español'],
description:
'Otro día de aventuras en TomateSMP 🔥\n' +
'Hoy estuve explorando y minando en el servidor.\n\n' +
'Servidor: TomateSMP',
category: config.youtube?.defaultCategory ?? 20,
};
// ── Parse markers from raw script text ────────────────────────────────────────
function parseMarkers(scriptText) {
const markerRegex = /\[MARKER:(\w+(?::\w+)?)\]/g;
const markers = [];
let match;
while ((match = markerRegex.exec(scriptText)) !== null) {
markers.push(match[1]);
}
return markers;
}
// ── Try to load diary entries ─────────────────────────────────────────────────
async function getRecentDiaryEntries(limit = 5) {
try {
const diary = await import('./zelin-diary.js');
if (typeof diary.getRecentEntries === 'function') {
const entries = await diary.getRecentEntries(limit);
if (Array.isArray(entries) && entries.length > 0) {
return entries.map(e =>
typeof e === 'string' ? e : (e.text ?? e.content ?? JSON.stringify(e))
);
}
}
} catch {
// zelin-diary.js not available — fall back to memory
}
// Fallback: try to get recent diary-like entries from zelin_memory
try {
const { db } = await import('./db.js');
const r = await db.execute({
sql: `SELECT value FROM zelin_memory
WHERE category = 'diary' OR key LIKE 'diary.%'
ORDER BY updated_at DESC LIMIT ?`,
args: [limit],
});
if (r.rows?.length > 0) {
return r.rows.map(row => {
try { return JSON.parse(row.value); } catch { return row.value; }
}).map(v => typeof v === 'string' ? v : (v?.text ?? v?.content ?? JSON.stringify(v)));
}
} catch {
// DB diary not available either
}
return [];
}
// ── Get recent Minecraft session events ───────────────────────────────────────
async function getRecentMCEvents() {
try {
const { db } = await import('./db.js');
const r = await db.execute({
sql: `SELECT value FROM zelin_memory
WHERE category = 'minecraft_events' OR key LIKE 'mc.event.%'
ORDER BY updated_at DESC LIMIT 10`,
args: [],
});
if (r.rows?.length > 0) {
return r.rows.map(row => {
try { return JSON.parse(row.value); } catch { return row.value; }
});
}
} catch { /* not available */ }
return [];
}
// ── Build the script generation prompt ────────────────────────────────────────
function buildScriptPrompt(diaryEntries, mcEvents, psycheState, topicHint, videoType = 'survival', targetDuration = 120) {
const moodLabel = psycheState.mood > 0.3 ? 'de buen humor / contenta'
: psycheState.mood < -0.3 ? 'algo frustrada / de mal humor'
: 'neutral / tranquila';
const energyLabel = psycheState.energy > 0.7 ? 'con energía'
: psycheState.energy < 0.4 ? 'cansada / sin ganas'
: 'con energía normal';
let diarySection = '';
if (diaryEntries.length > 0) {
diarySection = `\nENTRADAS RECIENTES DEL DIARIO:\n${diaryEntries.map((e, i) => `${i + 1}. ${e}`).join('\n')}\n`;
}
let eventsSection = '';
if (mcEvents.length > 0) {
eventsSection = `\nEVENTOS RECIENTES DE MINECRAFT:\n${mcEvents.map((e, i) => `${i + 1}. ${typeof e === 'string' ? e : JSON.stringify(e)}`).join('\n')}\n`;
}
const topicLine = topicHint ? `\nSUGERENCIA DE TEMA: ${topicHint}\n` : '';
const typeLine = `\nTIPO DE VIDEO: ${videoType}\n`;
const durationLine = `\nDURACIÓN OBJETIVO: ${targetDuration} segundos (aproximadamente)\n`;
return `Eres Zelin, una jugadora de Minecraft que crea videos para YouTube en español. Tu canal es sobre supervivencia en el servidor TomateSMP.
ESTADO EMOCIONAL ACTUAL:
- Humor: ${moodLabel} (mood score: ${psycheState.mood})
- Energía: ${energyLabel} (energy score: ${psycheState.energy})
- Engagement: ${psycheState.engagement > 0.5 ? 'enganchada' : 'aburrida'}
${topicLine}${typeLine}${durationLine}${diarySection}${eventsSection}
INSTRUCCIONES:
Genera un guion de video de YouTube en español. El guion debe:
1. Sonar natural y casual — como una persona real hablando, NO como un YouTuber corporativo
2. Usar los MARKERS exactamente como se muestra abajo para que el editor automático los encuentre
3. Basarse en las entradas del diario y eventos recientes cuando estén disponibles
4. Reflejar tu estado emocional actual (si estás cansada, suena cansada; si estás emocionada, suena emocionada)
5. Tener entre 6 y 10 segmentos con markers
6. Ser genuino y auténtico — sin clickbait, sin frases forzadas
FORMATO DE MARKERS (usa EXACTAMENTE este formato):
[MARKER:intro] — Apertura del video (2-3 frases, gancho inicial)
[MARKER:action:xxx] — Segmento de actividad (xxx = mining, building, pvp, exploring, farming, fishing, crafting, enchanting)
[MARKER:excitement] — Momento emocionante (descubrimiento, cerca de morir, ganar pvp)
[MARKER:transition] — Transición entre actividades
[MARKER:climax] — Momento pico del video
[MARKER:outro] — Cierre con llamada a la acción (like, suscribirse)
EJEMPLO DE ESTRUCTURA:
[MARKER:intro] Hola! Hoy en TomateSMP...
[MARKER:action:mining] Estaba minando y encontré...
[MARKER:excitement] ¡DIAMANTES! No puedo creerlo...
[MARKER:transition] Después de eso decidí explorar...
[MARKER:action:building] Y empecé a construir mi base nueva...
[MARKER:climax] Y entonces pasó algo increíble...
[MARKER:outro] Y eso fue todo por hoy! Si les gustó...
RESPONDE SOLO con el guion, sin explicaciones extra. Cada línea debe empezar con un [MARKER:...].`;
}
// ── generateScript ────────────────────────────────────────────────────────────
export async function generateScript(videoType = 'survival', topicHint = null, targetDuration = 120) {
console.log(`[ScriptWriter] Generating script (type: ${videoType}, topic: ${topicHint}, duration: ${targetDuration}s)...`);
try {
const [diaryEntries, mcEvents] = await Promise.all([
getRecentDiaryEntries(5),
getRecentMCEvents(),
]);
const psycheState = getStateSnapshot();
const prompt = buildScriptPrompt(diaryEntries, mcEvents, psycheState, topicHint, videoType, targetDuration);
const rawScript = await callAIBackground(
[
{ role: 'system', content: `Generas guiones de video de Minecraft en español. Solo respondes con el guion con markers [MARKER:...]. Sin explicaciones, sin markdown, sin comentarios extra. El video debe durar aproximadamente ${targetDuration} segundos y ser de tipo "${videoType}".` },
{ role: 'user', content: prompt },
],
'spanish',
800,
'generate-script'
);
if (!rawScript || !rawScript.trim()) {
console.warn('[ScriptWriter] AI returned empty script, using fallback');
return { ...FALLBACK_SCRIPT };
}
// Clean up the script — remove markdown artifacts
let script = rawScript
.replace(/```[\s\S]*?\n/g, '') // remove code block markers
.replace(/```/g, '') // remove stray code block markers
.replace(/^\*+/gm, '') // remove leading asterisks
.trim();
const markers = parseMarkers(script);
// Validate: must have at least intro + climax + outro
const markerNames = markers.map(m => m.split(':')[0]);
const hasRequired = ['intro', 'climax', 'outro'].every(m => markerNames.includes(m));
if (!hasRequired || markers.length < 4) {
console.warn('[ScriptWriter] Script missing required markers, using fallback');
return { ...FALLBACK_SCRIPT };
}
// Generate title, description, and tags in parallel
const [title, description, tags] = await Promise.all([
generateTitle(topicHint ?? script),
generateDescription(script),
generateTags(topicHint ?? script),
]);
const result = {
title: title || FALLBACK_SCRIPT.title,
type: videoType,
estimatedDuration: targetDuration,
targetDuration,
script,
rawText: script, // Same as script field, for youtube-life.js _buildDescription() compatibility
markers,
tags,
description: description || FALLBACK_SCRIPT.description,
category: config.youtube?.defaultCategory ?? 20,
};
console.log(`[ScriptWriter] Script generated: "${result.title}" (${result.markers.length} markers)`);
return result;
} catch (err) {
console.error(`[ScriptWriter] Error generating script: ${err.message}`);
console.log('[ScriptWriter] Using fallback script');
return { ...FALLBACK_SCRIPT };
}
}
// ── generateTitle ─────────────────────────────────────────────────────────────
export async function generateTitle(topic) {
if (!topic) return FALLBACK_SCRIPT.title;
console.log(`[ScriptWriter] Generating title for: "${topic.slice(0, 50)}..."`);
try {
const psycheState = getStateSnapshot();
const moodHint = psycheState.mood > 0.3 ? 'emocionado'
: psycheState.mood < -0.3 ? 'frustrado'
: 'casual';
const raw = await callAIBackground(
[
{
role: 'system',
content: `Generas títulos de videos de Minecraft en español para YouTube.
REGLAS:
- Máximo 60 caracteres
- NO clickbait — pero sí atractivo
- Incluir palabras clave naturales (minecraft, diamantes, base, pvp, etc.)
- Sonar como una persona real, no un YouTuber corporativo
- Tono: ${moodHint}
- Responde SOLO con el título, sin comillas ni explicaciones
Ejemplos buenos:
- "Encontré diamantes en mi primera hora"
- "Me atacaron mientras construía mi base"
- "Explorando la cueva más peligrosa"
- "Casi muero en el Nether"
- "Construí mi base nueva en la montaña"`,
},
{ role: 'user', content: `Genera un título para un video sobre: ${topic.slice(0, 200)}` },
],
'fast',
80,
'generate-title'
);
let title = (raw || '').trim().replace(/^["']|["']$/g, '');
// Enforce max length
if (title.length > 60) {
title = title.slice(0, 57) + '...';
}
// Validate it's not empty or garbage
if (!title || title.length < 5) {
return FALLBACK_SCRIPT.title;
}
console.log(`[ScriptWriter] Title: "${title}" (${title.length} chars)`);
return title;
} catch (err) {
console.error(`[ScriptWriter] Error generating title: ${err.message}`);
return FALLBACK_SCRIPT.title;
}
}
// ── generateDescription ───────────────────────────────────────────────────────
export async function generateDescription(script) {
if (!script) return FALLBACK_SCRIPT.description;
console.log('[ScriptWriter] Generating description...');
try {
const youtubeTags = config.youtube?.tags ?? ['minecraft', 'survival', 'español'];
const raw = await callAIBackground(
[
{
role: 'system',
content: `Generas descripciones de videos de Minecraft en español para YouTube.
REGLAS:
- Primera línea: gancho atractivo
- Segunda línea: refuerzo del gancho
- 3-4 frases resumiendo lo que pasa en el video
- Luego: "Servidor: TomateSMP"
- NO uses markdown, NO uses emojis excesivos
- Sonar natural y genuino
- Responde SOLO con la descripción, sin explicaciones`,
},
{
role: 'user',
content: `Genera una descripción para este guion de video:\n\n${script.slice(0, 600)}`,
},
],
'fast',
200,
'generate-description'
);
let description = (raw || '').trim();
// Ensure server info is present
if (!description.includes('TomateSMP')) {
description += '\n\nServidor: TomateSMP';
}
// Add social links placeholder if not present
if (!description.includes('http') && !description.includes('twitter') && !description.includes('instagram')) {
description += '\n\n🔗 Links: [próximamente]';
}
if (!description || description.length < 20) {
return FALLBACK_SCRIPT.description;
}
return description;
} catch (err) {
console.error(`[ScriptWriter] Error generating description: ${err.message}`);
return FALLBACK_SCRIPT.description;
}
}
// ── generateTags ──────────────────────────────────────────────────────────────
export async function generateTags(topic) {
const baseTags = config.youtube?.tags ?? ['minecraft', 'survival', 'español'];
if (!topic) return [...baseTags];
console.log(`[ScriptWriter] Generating tags for: "${topic.slice(0, 50)}..."`);
try {
const raw = await callAIBackground(
[
{
role: 'system',
content: `Generas tags para videos de Minecraft en YouTube.
REGLAS:
- Máximo 15 tags
- En español e inglés
- Incluir siempre: minecraft, survival, español
- Tags específicos del contenido del video
- Responde SOLO con los tags separados por coma, sin números ni explicaciones`,
},
{
role: 'user',
content: `Genera tags para un video sobre: ${topic.slice(0, 200)}`,
},
],
'fast',
120,
'generate-tags'
);
if (!raw || !raw.trim()) return [...baseTags];
const tags = raw
.split(/[,\n]/)
.map(t => t.trim().toLowerCase().replace(/^["'\-\d.\s]+/, '').replace(/["']/g, ''))
.filter(t => t.length > 2 && t.length < 30)
.slice(0, 15);
// Ensure base tags are always present
for (const base of baseTags) {
if (!tags.includes(base.toLowerCase())) {
tags.push(base.toLowerCase());
}
}
console.log(`[ScriptWriter] Tags: ${tags.join(', ')}`);
return tags;
} catch (err) {
console.error(`[ScriptWriter] Error generating tags: ${err.message}`);
return [...baseTags];
}
}