Spaces:
Paused
Paused
| /** | |
| * ============================================ | |
| * π¬ 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]; | |
| } | |
| } | |