zelin-bot / src /script-writer.js
Z User
v5.8.5: Gemma 4, MC Wiki, MC Player, anti-hallucination, CPU optimizations
ee826ee
/**
* ============================================
* 🎬 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];
}
}