Spaces:
Paused
Paused
File size: 16,954 Bytes
ee826ee | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 | /**
* ============================================
* π¬ 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];
}
}
|