MyGo 云音乐
Go와 MyGo로 만든 서드파티 네이버 클라우드 뮤직 데스크톱 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
El estudio de producción del animé. Se abre en el navegador del teléfono y desde ahí se produce la serie entera: se genera el banco de personajes y escenarios, se elige la voz de cada uno, se sacan los keyframes, se convierten en clips, se compone el audio y se monta.README 전문 보기 ↗
El estudio de producción del animé. Se abre en el navegador del teléfono y desde ahí se produce la serie entera: se genera el banco de personajes y escenarios, se elige la voz de cada uno, se sacan los keyframes, se convierten en clips, se compone el audio y se monta.
Qué produce. Piezas de vídeo terminadas con su audio, sus subtítulos quemados
en español y su acabado de anime. Hoy la pieza que hay escrita plano a plano es
el teaser de 78 segundos y 24 planos, que se puede generar desde el primer día.
Los doce episodios están en datos/guiones.json con sus 289 escenas y pasan
antes por el desglose, que los convierte en planos.
La revisión de continuidad de episodios y la conservación del material existente
se explican en docs/correcciones-continuidad.md.
Sus pruebas se ejecutan con npm run continuidad y forman parte de npm run comprobar.
Lo que NO es. No es un estudio genérico para hacer animes. Es para este
animé: los personajes, los escenarios, las luces, el estilo y el reparto de voces
están escritos en datos/serie.json y la herramienta no inventa nada que no esté
ahí. Un animé, un camino. Si algún día sirve para otro, será otro proyecto.
Tampoco tiene cuentas, ni login, ni multiproyecto, ni ninguna pantalla para subir imágenes: todo lo visual lo genera la herramienta.
Se pone en marcha una vez, y casi todo lo hace un script. Desde el móvil, sin teclear nada:
▶ Abrir en Cloud Shell e instalar
Clona el repositorio y abre un tutorial con botones que meten cada comando en el terminal. Habilita las APIs, crea o detecta el bucket, le aplica el CORS, crea la cuenta de servicio con sus permisos y su clave, y despliega el montador. Lo que queda a mano es Vercel, y está en docs/despliegue.md.
Lo que Google necesita no se dicta como comandos: vive en despliegue/, en
archivos que el instalador lee.
Para volver a desplegar solo el montador —hay que hacerlo cada vez que ese contenedor aprende algo nuevo, porque se despliega a mano y siempre va por detrás del repositorio— hay su propio tutorial, también con botones:
▶ Volver a desplegar el montador
Y si el panel del tutorial no carga —el editor de Cloud Shell avisa de que los navegadores del móvil no están soportados—, en el terminal:
./m
Ojo: un Cloud Shell abierto a secas empieza en ~, no dentro del
repositorio, y ahí ./m no existe. Se arregla UNA VEZ y ya vale para
siempre, porque la carpeta de inicio de Cloud Shell sobrevive entre sesiones:
ln -s cloudshell_open/LA*/m ~/m
ln -s cloudshell_open/LA*/c ~/c
A partir de ahí, ./m y ./c funcionan desde la carpeta de inicio. Los dos
resuelven el enlace antes de buscar instalar.sh; sin eso, un atajo daría la
carpeta de inicio como si fuera el repositorio.
Tres caracteres. Ese archivo existe por eso y solo por eso: aquí solo hay un móvil, el terminal no deja pegar, y todo lo que haya que ejecutar se teclea con el pulgar. Trae lo último y despliega. No pregunta nada, no toca ni el bucket ni la cuenta, y lee del propio job el bucket y la clave que ya tiene puestos.
Y dice con qué fecha va a desplegar. Si el git pull falla —una carpeta
clonada de otra parte, un remoto que no es GitHub, cambios locales— sigue
adelante con lo que haya, que es lo correcto: mejor desplegar algo viejo que
no desplegar nada. Pero entonces el montador se queda como estaba y desde la
aplicación no hay forma de saberlo: se ve el mismo error de siempre y parece
que el arreglo no funcionó. Con la fecha delante se ve en un segundo. Que la clave salga de ahí y no se genere de nuevo es lo importante:
una clave nueva invalidaría la MONTAJE_KEY de Vercel y el montaje empezaría
a fallar por una razón que no se parece en nada a la verdadera.
Ningún enlace de este repositorio lleva dentro un id de proyecto ni una
cuenta. Cloud Shell pregunta en cuál instalar; el que hace falta añadirle
?authuser= o &project= para su cuenta, se lo añade y no lo escribe aquí:
esto es público.
Se abre la dirección de Vercel en el teléfono y se añade a la pantalla de inicio, que es como se abre a pantalla completa.
Se entra en Salud y se pulsa Comprobar los modelos. No lo hace solo, y esa es la razón: comprobar un modelo es LLAMARLO, y esas llamadas gastan la misma cuota por minuto que hace falta para generar. Cuando se hacía sola al entrar —y Salud es la pantalla en la que abre la aplicación—, abrir el estudio y darle a generar en el mismo minuto se llevaba la cuota entera antes del primer intento: salía «se ha pasado de cuota» en la PRIMERA generación, sin haber generado nada. La pantalla que existe para decir si algo funciona era la que lo impedía.
A partir de ahí, se trabaja por pantallas, en el orden que dice la guía: anclas del banco, resto del banco, escenarios, voces, keyframes, clips, audio y montaje.
Nada depende de que el móvil siga abierto: la cola y el estado viven en el bucket, así que si se cierra la aplicación a mitad de una tirada de cuatrocientos planos, al volver a entrar se reanuda sola.
| Carpeta | Qué hay dentro |
|---|---|
app/ | La aplicación del navegador: cola, estado, progreso y las ocho pantallas, en módulos ES nativos sin build ni framework. |
api/ | La puerta única POST /api/g y su _lib/: firma el token, compone los prompts, habla con Vertex y con el bucket, y censura toda respuesta. |
datos/ | guiones.json (la serie), serie.base.json (cómo se produce, tal y como llegó), opening-ending.json (las dos piezas fijas y sus canciones), voces-gemini.json (las treinta voces de Gemini, sin Chirp) y serie.json (el que lee la herramienta, generado por el parche). |
docs/ | El contrato interno, el plan de construcción, las decisiones, la guía de despliegue y la explicación del parche de datos. |
herramientas/ | Lo que se ejecuta sin red: el parche de datos, los invariantes y el pesaje de las respuestas. |
montador/ | El contenedor de ffmpeg que corre como Job de Cloud Run: recorta, concatena, aplica el acabado, mezcla el audio y quema los subtítulos. |
despliegue/ | Lo que Google Cloud necesita, como archivos y no como comandos que alguien dicte: apis.txt, cors.json, permisos.txt y los dos tutoriales de Cloud Shell —el de instalar y el de volver a desplegar el montador—. instalar.sh los lee. |
En la raíz, index.html (lo único que se sirve), instalar.sh (todo lo de Google
Cloud en un comando), m (tres caracteres para volver a desplegar el montador
desde el móvil), c (tres caracteres para mirar qué hay en Google sin tocar
nada), package.json, vercel.json y este archivo.
Una pieza es cualquier cosa que se produce de principio a fin. La herramienta no distingue entre un teaser de 24 planos y un episodio de 400.
| Pieza | Planos | Duración | Se genera |
|---|---|---|---|
teaser | 24 | 78 s | Una vez. Ya venía desglosada: se puede generar el primer día. |
opening | 27 | 90 s | Una vez para toda la serie. Se pega como capa en los doce episodios. |
ending | 15 | 90 s | Una vez para toda la serie. Igual. |
archivo | 56 | — | Una vez para toda la serie. No es una película: es una biblioteca. |
epNN | ~400 | 22 min | Por episodio, con el desglose. Se añaden al lado. |
El montaje de un episodio queda opening + actos + ending, todo pegado como capa
ya montada. Regenerar el opening en cada episodio serían 504 planos tirados y doce
openings ligeramente distintos, que es lo que hace que una serie parezca hecha a
trozos.
La cripta sale en 24 escenas repartidas por 8 episodios. Los túneles, en 46 repartidas por 11. Si cada escena paga su propio plano de ambiente, son 289 keyframes y 289 clips para enseñar veintiocho sitios.
El archivo son 56 planos de ambiente que se generan una vez y se reutilizan en los doce episodios, igual que el opening, el ending y el banco de música. Son 233 planos que no se pagan, y el espectador no lo nota, porque un plano de ambiente no cuenta nada: dice dónde estamos y se quita. Es exactamente lo que hace el anime de verdad.
Dos por sitio, y a propósito dos cosas distintas:
Dos planos iguales se notarían al repetirse. Un general y un detalle no, porque el ojo no los compara.
Qué NO puede entrar en el archivo, y esto es la regla entera: nada con un
personaje dentro, nada que pase una sola vez, y nada que cuente algo. Si en un
plano de archivo ocurre algo, al repetirlo en el episodio 9 vuelve a ocurrir. Lo
comprueba npm run invariantes, que rechaza un plano de archivo con referencias
de personaje, con encadenado o sin decir cuándo se usa, y avisa —con la palabra
señalada— cuando el texto nombra a alguien sin negarlo.
La excepción son tres sitios cuyo sentido ES la gente: la ciudad trabajando,
la cola de carros en la puerta y el campamento. Vacíos contarían lo contrario de
lo que son. Ahí se admiten figurantes a lo lejos, el plano se marca
figurantes_lejanos, y a cambio se le exige que prometa no faces visible: un
figurante reconocible en un plano que sale en cuatro episodios sería un personaje
sin ficha.
El banco se elige, no se acumula. Al principio se pintaba SIEMPRE debajo de la pieza, en su propia sección, con la idea de que se viera que no era de nadie. En el teléfono se lee al revés: eliges el teaser, ves sus dos pistas, y debajo aparecen dieciocho más — y lo que parece es que la música de la temporada se ha comido la de la pieza. El título de la sección no salva eso: para cuando se llega a él ya se ha desplazado media pantalla.
Ahora el banco es una opción más del mismo selector, al lado del teaser, el
opening y el ending. O una pieza, o el banco; nunca las dos cosas a la vez. Y
mirando el banco, la pantalla dice con cuántas pistas cuenta la pieza que tenías
puesta, para que no haya que ir a comprobarlo. La elección no se guarda en
pieza_activa del estado: esa la comparten Tomas y Montaje, y el banco no es una
pieza para ellas.
Cada pieza de música dice de quién es en su campo pieza; las del banco llevan
temporada. npm run banco dibuja la pantalla y comprueba que con el teaser
salen dos pistas y ni una del banco, y que el selector trae la opción.
El archivo vive dentro de piezas para que toda la maquinaria de Tomas, la
cola y el estado le sirva sin tocar una línea. El precio es que las pantallas que
no deben ofrecerlo tienen que decirlo: Montaje no lo ofrece —montarlo daría
un rollo de cuatro minutos que nadie va a ver, pagado entero— y Audio tampoco
—no tiene ni música ni diálogo—. npm run banco comprueba las dos cosas, porque
es un fallo que no se ve mirando.
Se escriben con node herramientas/escribir-archivo.mjs: son 56 planos de doce
campos, y once de esos doce son siempre lo mismo. A mano son 672 casillas donde
equivocarse en una sola tira una comprobación que no dice dónde.
El ahorro no lo hace el archivo: lo hace el desglose, que es quien decide si un plano se encarga nuevo o se coge del archivo. Por eso el archivo se montó antes de desglosar ningún episodio — hecho después, el dinero ya estaría gastado.
Al desglosar una escena, el modelo recibe la lista de planos de archivo de ese escenario y de esa luz, con lo que se ve, lo que se mueve y para qué está pensado cada uno. Se le dice que los use siempre que el plano que iba a proponer sea sitio y nada más, y que no los use cuando tenga que verse alguien o pasar algo.
Cuando los usa, el plano que devuelve es un puntero, no una descripción:
{ "id": "12-1", "de_archivo": "arch-cripta-a", "imagen": "", "video": "",
"refs": [], "boca_visible": null, "encadena_con": null, "dur": 3 }
Y a partir de ahí, todo el estudio lee ese plano del archivo:
La regla de dónde vive el material de un plano está en un solo sitio,
app/planos.js, porque la necesitan tres pantallas que no se hablan entre ellas.
Escrita tres veces, el día que cambiara cambiaría en dos, y el fallo sería un
episodio montado con un hueco. Eso no se ve leyendo el código: se ve viendo el
vídeo.
Se puede usar menos de lo que dura, nunca más. Coger dos segundos de un plano
de cuatro es montar y no cuesta nada; pedir ocho de uno de cuatro sería encargar
un tramo que no existe. Lo rechazan la comprobación del desglose y
npm run invariantes, cada una en su momento.
El opening y el ending llevan canción con letra, cantada en japonés y
subtitulada en español, como cualquier animé. La letra está en
datos/opening-ending.json.
No hay que confundirla con la canción de la madre, que es un elemento de la historia, va en un idioma que no se habla en ese mundo, y esa decisión es del episodio 10.
Los tiempos del subtítulo se marcan oyendo la canción, no se estiman: Lyria no canta exactamente lo que se le pide, así que la pantalla de Audio deja darle al play y tocar un botón cuando entra cada verso. Un verso sin marcar no se quema como subtítulo.
El lecho y el canto van separados a propósito. Con la voz metida dentro de la misma pista no se puede ni mover ni bajar; separados, el canto se coloca en su segundo exacto y se mezcla a su propio nivel.
El modelo es Lyria 3 Pro, que llega a 184 segundos por pieza. La duración no es un parámetro: se pide con palabras dentro del encargo, y lo que manda después es la duración REAL medida del archivo. Lo que pase de 184 s se parte en varias piezas y se une en el montaje con fundidos de 2,5 s — los cortos suenan a tajo.
El opening y el ending son dos canciones. La música que suena dentro de los episodios es otra cosa, y hasta ahora no existía. Son 289 escenas.
No se compone por escena. Un anime de verdad tiene una biblioteca de temas —una función cada uno— y los repite toda la temporada. Repetirlos no es pobreza: es exactamente lo que hace que doce episodios suenen a una sola serie en vez de a doce encargos distintos. Así que el banco se genera una vez, igual que el opening y el ending, y no se rehace por episodio nunca.
Son dieciocho piezas, treinta y dos minutos, escritas en musica.piezas de
datos/serie.json con "temporada": true, y salen en su propia sección de la
pantalla de Audio, que no cambia al cambiar de pieza.
No están inventadas: salen de leer los doce guiones escena por escena y contar qué se repite de verdad.
| Qué son | Cuántas | Por qué |
|---|---|---|
| Un lecho por esquema de luz | 3 | Lo que más vuelve en la temporada no es una emoción, es una habitación: CRIPTA 39 escenas, NOBLE 88, BARRIO 162 |
| Música de salón, tocada dentro de la escena | 1 | Se paga sola: si la sala ya tiene música, la partitura puede callarse mientras Saharis destruye a un hombre con un elogio |
| El tambor seco: pensar, ejecutar, acercarse | 3 | El motor de doce episodios es un hombre calculando |
| La melodía de la madre, transformada | 5 | Rota, al aire, contaminada, enterrada, y en negativo. Es el hilo de la serie |
| Voz femenina sola | 2 | La hermana en el lago y la niña del valle. La voz es el recurso más caro: solo hay tres sitios donde vale, y el tercero ya existe |
| Piezas de gesto | 4 | Romperse, reconocer, contenerse, y el altar vacío |
El Celebrante no tiene tema propio: tiene el de ella, mal afinado y a tempo de oficina. Eso cuenta el episodio 7 entero sin una línea de guion.
Y hay una decimonovena pieza que es la más usada de todas: el silencio. Está
escrita con todas sus reglas en musica.banco.silencio, y la primera es que no
suena nada debajo de la frase que decide la escena.
Cada pieza de música dice ahora de quién es. Antes se adivinaba por el
principio del id —teaser-lecho era del teaser porque empezaba por teaser-—, y
eso funcionaba solo mientras la única pieza fuera el teaser. Ahora cada entrada
lleva su campo pieza, las del banco llevan temporada, y npm run banco
comprueba que ninguna se quede sin pantalla donde salir: una pieza escrita que no
aparece en ningún sitio no tiene botón que la genere, y eso no se nota mirando.
Dos variables en Vercel, y ninguna más:
| Variable | De dónde sale |
|---|---|
GCP_SERVICE_ACCOUNT | El JSON de la service account, en base64. Lo deja el instalador. |
GCS_BUCKET | El nombre del bucket. Lo deja el instalador. |
Todo lo demás lo sabe la propia herramienta: la región por defecto es
us-central1, y el nombre del Job del montador está en
despliegue/montador.txt, que es el mismo archivo con el que instalar.sh lo
crea. Poner a mano una variable cuyo valor ya conoce el repositorio es trabajo
que no ayuda a nadie y una cosa más que puede escribirse mal.
Cuando Google contesta «Publisher model … was not found or your project does not have access to it», casi nunca es que la cuenta no tenga el modelo. Son dos trampas distintas, y las dos están resueltas en el código:
La región. Los Gemini 3.x y Lyria 3 Pro solo se sirven desde global.
Pedirlos a us-central1 devuelve exactamente el mismo 404 que «no tienes
acceso». La región de cada modelo la declara datos/serie.json y, si no la
declara, se deduce del nombre.
El nombre. Vertex publica el mismo modelo con dos grafías —la de preview
y la definitiva— y cuál de las dos contesta depende del proyecto. Las dos son
reales. Por eso datos/serie.json declara todas las que se conocen, en ids, y
conGrafias() las prueba en orden hasta que una contesta; un nombre que no
existe cuesta un 404, no genera nada y no se cobra, así que probarlos todos es
gratis. La que funciona se recuerda y se prueba primero la próxima vez.
Salud enseña, en cada modelo, con qué nombre ha contestado y cuáles se han probado. Si ninguno contesta, el error los nombra todos con su región: eso es lo que separa «sobra el modelo» de «falta el permiso».
Esto no se cumple solo: npm run invariantes arma la tabla de modelos de verdad
y comprueba que cada uno llega con todas sus grafías y cada grafía con su región,
y que ningún módulo compone la URL de un modelo sin pasar por conGrafias(). Fue
justo lo que se rompió una vez —la tabla entregaba el id y tiraba la lista, así
que se probaba un solo nombre— y por eso hoy se mide en vez de darse por hecho.
Salud — dice quién es esta instalación, si el bucket se lee y se escribe, si cada modelo contesta y cuánto pesan las respuestas, todo comprobado de verdad y antes de gastar un céntimo.
Voces — los personajes ordenados por volumen de diálogo, cada uno con las
voces de Gemini de su género diciendo su frase más difícil, para elegir
escuchando. Chirp queda fuera de todo el proyecto: Chirp lee, Gemini actúa.
Los veintinueve se pueden oír sin tocar ni un archivo: a dieciocho se les
escribió la frase a mano y a los once figurantes se la saca del guion
npm run datos —su línea de riesgo alto, o la de intención más detallada—,
y la pantalla dice cuál de las dos cosas está oyendo.
El que se oye tiene voz propia. En cuanto una voz queda fijada deja de
ofrecerse a los demás, ni siquiera se puede pagar su muestra, y el servidor
rechaza guardar un estado donde dos la compartan indebidamente: dos
personajes con el mismo timbre son el mismo personaje para el oído, y en doce
capítulos eso solo se arregla volviendo a grabar.
La excepción es la del doblaje de toda la vida, y hace falta: con el género bien puesto salen 21 personajes masculinos y solo hay 16 voces masculinas, y las de Gemini son treinta y son fijas. Así que quien dice una o dos líneas en toda la serie puede repetir timbre, pero solo con alguien que no salga en ninguna de sus escenas — hoy son once, y ninguno coincide con otro de los once. La pantalla lo dice antes de que elijas, con el nombre del otro.
Las dos condiciones hacen falta: a quien se oye lo suficiente se le reconoce
aunque nunca coincida, y dos que coinciden se delatan aunque digan una línea
cada uno. comparte y con los calcula npm run datos leyendo el guion, y
la misma regla la aplican la pantalla y el servidor.
El servidor rechaza lo que empeora el reparto, no lo que arrastra. La comprobación está en la única puerta por la que escriben las ocho pantallas, así que mirar solo lo que llega convertía un dato de voces en un cerrojo para todo: con un timbre repetido ya guardado, la Cola no podía apuntar un trabajo ni Audio guardar un bloque; y con dos, soltar una voz dejaba el otro dentro y tampoco entraba — no había forma de deshacerlo sin editar el bucket a mano. Comparando con lo que hay, soltar una voz entra siempre y las demás pantallas siguen guardando, mientras que meter a alguien en un choque o inventarse uno nuevo se rechaza igual. Los repetidos que ya estuvieran guardados se avisan arriba en Voces, con nombres: se toleran para no bloquear nada, pero hay que deshacerlos antes de grabar.
Banco — los personajes y los escenarios de toda la serie: generar, mirar, aprobar y regenerar, con las cadenas de ancla y de edad a la vista.
Todo pasa por la cola y se hace de una en una. Imágenes, clips, voces, música, montajes y las muestras para elegir voz: no hay ninguna pantalla que llame a un modelo por su cuenta. Aunque pidas diez voces de golpe, se genera la primera y las otras nueve esperan su turno, en orden.
No es un ajuste. Aquí había un selector de 1 a 8 que venía a 3, y se ha quitado:
con las cuotas de una cuenta nueva, subirlo no va más rápido, tumba la tanda
entera. Y Vertex pasado de cuota no contesta «has gastado tu cuota»:
contesta un 429 que en pantalla se lee como falta de acceso al modelo, y se
acaba buscando el fallo en los permisos, que es donde nunca está. La variable
CONCURRENCIA también se ha quitado — no la leía nadie.
Y la función tiene un plazo. Cada paso tenía su propio límite —45 s para Vertex, 45 s para el bucket— y ninguno sabía del techo de la plataforma. Sumados se pasaban, y cuando eso ocurre no hay error ni excepción ni una línea en los registros: la plataforma corta la función y devuelve un 504 en bruto. En pantalla se lee «se ha roto algo» y en el servidor no aparece nada. Ahora la puerta abre un plazo al empezar y cada llamada espera lo que le dejen; cuando se acaba, contesta en español diciendo qué estaba haciendo.
Ese plazo son 55 s y no los 300 que pide vercel.json, y la diferencia es
deliberada: maxDuration se pide, pero quien concede es la plataforma según
el plan, y ese número no puede ser una apuesta. Si la función se creyera con más
tiempo del que le dan, volvería a morir cortada en silencio — el fallo que este
plazo existe para impedir. 55 s es el suelo seguro del plan gratuito menos un
margen; si el plan concede más, no se usa y no pasa nada. Para subirlo hay que
comprobar antes, con una generación real que tarde más de un minuto, que la
plataforma lo está concediendo. npm run invariantes no deja subirlo a ciegas.
Y el obrero se pone cuando el trabajo ya está escrito, no antes. Parece un
detalle y era el fallo que dejaba la aplicación sin generar nada: encolar()
mandaba escribir el trabajo al bucket —una petición de red, medio segundo— y sin
esperar llamaba al obrero; el obrero leía la cola, todavía no estaba el trabajo,
decía «no hay nada que hacer» y se iba. Medio segundo después el trabajo
aparecía y ya no había nadie mirando. Se quedaba «pedida» para siempre, sin
error, sin excepción y sin una línea en los registros. Lo vigila
npm run cola, que EJECUTA la cola contra un Google de mentira y un bucket que
tarda lo que tarda uno de verdad — sin ese retardo la carrera no ocurre y la
prueba pasaría mintiendo.
Y con la cuota se tiene paciencia. Cuando lo que falla es un 429, la cola no insiste a los 2, 4, 8 y 16 segundos como con lo demás: espera medio minuto, uno, minuto y medio y luego de dos en dos — nueve minutos repartidos en seis intentos. Las cuotas de Vertex se reponen por minuto, así que insistir en segundos gasta los cuatro intentos dentro del mismo minuto en que la cuota ya estaba agotada y da el trabajo por perdido treinta segundos después de empezar. Un 429 no genera nada y no se cobra: lo único que cuesta es esperar, que es justo lo que hay que hacer.
Portado de Prisma-Negro, otro proyecto del mismo autor que lleva meses generando tandas de cientos de imágenes sin que el usuario vea un solo error de cuota. La idea no es de aquí y funciona; esto es por qué.
Vertex no limita por «cuántas a la vez» sino por minuto. Ir de una en una ya se hacía, pero sin pausa entre llamadas, y cuarenta imágenes seguidas se salen del minuto igual aunque vayan en fila india. Ir de una en una no es ir despacio.
Ahora hay tres capas, y un 429 tiene que atravesarlas todas para llegar a la pantalla:
app/api.js). Una pausa entre llamadas que dobla con cada
429 —empieza en 8 s, que son siete llamadas por minuto, y no pasa de 60— y se
afloja un cuarto cada cinco aciertos seguidos. Sube fuerte y baja despacio:
subir multiplicando y bajar de golpe es un oscilador, no un regulador, y se
pasa la tanda chocando cada pocas imágenes. Solo frena lo que gasta cuota de
modelo: leer el estado habla con el bucket y no se frena, o la aplicación se
arrastraría sin ganar nada.app/api.js). Un 429 no falla: espera 30 s, 60 y
90 y reintenta la misma llamada. Si Google dice cuánto esperar se le hace
caso, con un techo de 2 minutos —una cuota diaria puede contestar «vuelve
dentro de 40.000 segundos», y eso serían once horas quieto—.app/cola.js). Si aun así llega, no era la ventana
del minuto: para todo el estudio 60 s y 180, sin perder el trabajo.Esa era la pregunta buena, y la respuesta estaba en Prisma-Negro: el freno aprende chocando, y lo aprendido hay que guardarlo.
Empieza sin pausa, se estrella una vez contra la cuota, aprende, y a partir de ahí va bien. Pero si lo aprendido se pierde al recargar la página, cada sesión vuelve a estrellarse una vez — y esa una vez es justo la primera generación que se mira. De ahí «falla desde el primer intento» con la cola vacía y nada más corriendo.
Dos cosas lo arreglan, y las dos están en Salud → «Con qué se genera» → El ritmo:
Es un suelo, no un valor fijo: si aun así se choca, el freno sube por encima.
Lo comprueba npm run freno, con un Google de mentira y un reloj de mentira: que
un 429 seguido de un acierto no llegue a quien llamó, que el freno suba a 8 s
y baje a 6, que el estado no lo pague, y que con la ventana cerrada del todo salga
el mensaje de Google tal cual y no una suposición.
Y la cuota la espera la cola entera, no cada trabajo. La cuota es una sola
para toda la cuenta: si Google dice que no hay, no la hay para el siguiente de la
lista tampoco. Antes cada trabajo se apuntaba su espera y la cola cogía
inmediatamente el siguiente, que se estrellaba contra la misma pared en el mismo
segundo. En producción se vio tal cual: seis errores de cuota en seis
segundos, seis trabajos quemados y nada generado. Ahora el primer «no hay
cuota» para el estudio entero, y se vuelve a mirar cada pocos segundos hasta que
la cuota se repone. Lo comprueba npm run cola cuota, que le pone delante un
Google sin cuota y exige que se pruebe una vez y se pare: con el código de antes
hacía ocho llamadas en seis segundos, con este hace una.
El hueco se cuenta sobre el estado del bucket, no sobre la pestaña que tienes delante, así que «una cada vez» vale para todo el estudio aunque dejes otra ventana abierta. Para poder distinguir «lo está haciendo otro» de «lo cogió un navegador que ya no existe», quien trabaja refresca la hora de su trabajo cada quince segundos; sin latido durante cuarenta y cinco, ese trabajo vuelve a la cola. Por eso, al recargar la página, lo que se quedó a medias vuelve solo en menos de un minuto en vez de bloquear el estudio durante cuatro.
Un fallo de Google, de la cuenta o del bucket sale en la tarjeta donde lo pediste, con su explicación. Si en cambio aparece arriba del todo un aviso rojo que dice «algo se ha roto por dentro del estudio y nadie lo ha recogido», eso no es tu cuenta: es un defecto de este código. Ese aviso enseña el detalle sin tener que abrirlo —el mensaje y la primera línea de dónde ocurrió—, porque es la única línea que sirve para arreglarlo y esconderla no ayuda a nadie.
Para que ese detalle diga siempre de dónde viene, boton() recoge lo que
devuelve el manejador: la mitad de los botones del estudio son async —elegir
voz, cambiarla, montar, aprobar— y antes su fallo se perdía sin nombre. Ahora
llega con el del botón delante: «Al pulsar «Oír esta voz»: …», entregado en
mano con un evento. Relanzarlo para que saliera por el camino de siempre era
peor el remedio: un error lanzado fuera de la pila no tiene ningún archivo al que
Safari pueda atribuirlo, y entonces Safari lo tapa y lo entrega como «Script
error.», sin mensaje y sin línea. El intento de decir de dónde venía era justo lo
que lo borraba.
Y si alguna vez sale «Script error.» de todas formas, el estudio ya no se lo apunta: eso es el navegador negándose a contarlo, y solo lo hace con lo que no puede atribuir a un archivo de esta página —una extensión, un bloqueador de contenido—. Se dice tal cual, porque disfrazarlo de fallo del estudio manda a buscar donde no está.
La pantalla de Cola reventaba entera —Can't find variable: operacion— y solo
cuando había un clip de Veo generándose.
En JavaScript, { pieza, toma, operacion } significa operacion: operacion. Si
esa variable no existe, no queda un campo vacío: es un error que tumba la
función entera. Y la sintaxis es perfecta, así que no lo ve ningún
comprobador de sintaxis.
Venía de un cambio correcto: el nombre de la operación de Veo dejó de viajar al navegador porque lleva el project id dentro. Se quitó la variable y se quedó el campo. Ahí siguió, escrito, esperando a que alguien lanzara un vídeo — que es justo cuando esa pantalla hace falta, y con el clip ya pagado.
Dos cosas para que no vuelva:
npm run invariantes recoge todos los nombres que declara cada archivo y
rechaza cualquier campo en forma corta que nombre uno que no está.npm run pantalla ejecuta la pantalla de Cola contra un estado con un
vídeo en vuelo. Ese estado no lo construía nunca nadie, y por eso el fallo
podía vivir ahí meses: un estado vacío no prueba nada, porque casi todas las
funciones de una pantalla salen por la puerta de «no hay nada que enseñar».Las dos se probaron contra el código de antes, y las dos lo cazan. La primera versión de la comprobación de invariantes no lo cazaba —se daba por buena a sí misma— y eso se vio ejecutándola contra el commit anterior, no leyéndola.
Un clip de Veo se lanza y se consulta minutos después, y la operación vive colgada del modelo que la creó: se pregunta a ese modelo o Google contesta que no existe.
El estudio decidía a qué modelo preguntar por lo que llevaba escrito el plano o por lo que estuviera elegido en Salud. Cuando eso no coincidía con el modelo real —porque el nivel se tocó mientras el clip se generaba, o porque el estado se perdió y solo quedó el apunte del bucket, que no llevaba el nivel— se plantaba y daba el clip por perdido. Terminado, pagado, y con la puerta cerrada.
Y era innecesario. El nombre de la operación dice con qué modelo y en qué región se creó: viene dentro, escrito por Google. Si nuestra idea no coincide con el nombre, quien se equivoca es nuestra idea — y preguntar no cuesta nada ni genera nada, así que hacerle caso no tiene ningún riesgo.
Ahora se busca ese modelo entre los tres niveles de la serie y se pregunta ahí,
diciendo con cuál se lanzó de verdad. Solo se falla cuando el modelo del nombre
no es ninguno de los tres, que ya no es un descuadre nuestro. Y el apunte del
bucket —el que existe para que no se pierda un clip— lleva ahora también el
nivel, porque un salvavidas incompleto no es un salvavidas. npm run veo lo
ejecuta.
El mismo aviso, cerrado y vuelto a salir, cerrado y vuelto a salir. Eran dos defectos encima del mismo sitio:
Y el que más se repetía era precisamente el que el navegador se niega a identificar, que por definición no viene de aquí. O sea: una tarjeta roja tapando el plano que estabas mirando, con un botón de recargar que no iba a arreglar nada, por algo que este código no puede tocar.
Ahora un fallo repetido es una tarjeta con un contador («ha vuelto a pasar 12
veces»), cerrarla lo silencia para el resto de la sesión, y los que el navegador
no atribuye a esta página se pintan como nota y sin botón de recargar. Los
que sí son del estudio siguen gritando en rojo, que es lo que tienen que hacer.
npm run fallos lo ejecuta con un DOM de mentira y repite el fallo doce veces
para verlo.
Salud → «Con qué se genera». Ahí se elige el modelo de imagen, el de vídeo y la resolución de las imágenes, y se ve el id exacto de cada uno. Es la decisión que más dinero mueve de toda la herramienta:
| Se elige entre | Qué cambia | |
|---|---|---|
| Imágenes | calidad · medio · económico | Del más caro al más barato. Por defecto, el que diga serie.json. |
| Resolución | La que dé Google · 1K · 2K | La misma imagen a 2K cuesta bastante más. Para juzgar un keyframe y para dárselo a Veo, 1K sobra: Veo entrega 720p. |
| Vídeos | calidad · medio · económico | Además de «el que lleve cada plano», que es lo que hay escrito en serie.json plano a plano. |
Los tres niveles existían desde el principio, pero solo se podían cambiar con una variable de Vercel y un redespliegue. Eso estaba mal: lo elige quien paga, sobre la marcha, no quien despliega. Ahora vive en el estado —o sea en el bucket—, así que vale para todas las pestañas y sobrevive a cerrar el móvil. Se aplica a lo que se genere a partir de ese momento; lo que ya esté generándose sigue con lo suyo, y un clip lanzado se consulta siempre con el nivel con el que se lanzó (se apunta al lado de la operación), porque preguntar por él a otro modelo de Veo sería un clip pagado y perdido.
«La que dé Google» no es un tamaño: es no pedir ninguno. Y con el modelo medio suele ser lo correcto, aunque no por lo que parece. Lo comprobado, con las fuentes delante y sin adornar:
imageConfig, también en el modelo del nivel medio.-preview, lleva desde abril de 2026 sin una sola
respuesta de Google, y está marcado como prioridad baja.O sea que las fuentes no dan un sí ni un no. Por eso no se discute: se mide.
Cada imagen generada enseña en su esquina su tamaño real en píxeles y cómo lo
llamaría Google — 2048×1152 · 2K, 928×1152 · 1K—. Pidiendo 2K y mirando esa
esquina se sabe, para esta cuenta y hoy, si la resolución se está respetando o
no. Ninguna documentación puede contestar eso mejor que la propia imagen.
(Los ids concretos salen de datos/serie.json, que es de donde salen siempre;
aquí no se escriben para que este texto no envejezca mintiendo.)
Y aparte de eso, a veces es la única que funciona. Vertex reparte la cuota de imagen por modelo Y por resolución,
en cubos separados, y eso no se ve leyendo nada: se vio en la consola de cuotas de
una cuenta real, con el cubo gemini-3.1-flash-image_default_res a 34 millones
por minuto y un 0 % de uso, mientras las peticiones que sí decían resolución
volvían con un 429. Ante un error de cuota o de tiempo agotado en las imágenes,
esta es la primera opción que hay que probar. Que el campo imageSize NO viaje
—que es distinto de mandarlo vacío— lo comprueba npm run ajustes.
Precios: la pantalla no los pone, y es a propósito. Este estudio no los sabe
de cierto, y un precio inventado es peor que ninguno porque alguien decidiría con
él. Lo que sí dice es el orden —cuál es el caro y cuál el barato— y el id exacto
del modelo, que es lo que hay que buscar en la tarifa de Vertex AI. Lo que sí se
cuenta de verdad es cuántas imágenes y cuántos segundos de vídeo se llevan
generados con cada nivel, en estado.gasto.
Google bloquea con IMAGE_PROHIBITED_CONTENT cualquier imagen de un menor
descrita con un catálogo de carencias sobre su cuerpo. No distingue un drama
serio de algo dañino: ve las palabras y corta, y hace bien.
Aquí se disparaba con Saharis a los cinco años, que estaba escrito así:
gaunt boy of five … filthy oversized rag tunic, bare feet
Demacrado, sucio y descalzo. La historia es la misma sin eso: la pobreza se cuenta por la ropa, no por el cuerpo del niño. Ahora lleva una túnica grande y gastada, remendada, con mangas largas y zapatos de tela. Se lee igual de pobre y no describe a un niño desnutrido y desnudo. Lo mismo con sus manos y con el de diez años.
OP03 estaba escrito como un primer plano de los ojos de un recién nacido en la
cripta, y eso no era cuestión de palabras. Un bebé dentro de un ritual de culto
es justo lo que ese filtro existe para no hacer, y buscarle la vuelta sería
saltarse la protección, no arreglar un error. Así que el plano se cuenta sin
enseñarlo, que además es mejor cine:
La mano abierta de la madre cayendo laxa sobre el altar de piedra, los dedos abriéndose, la luz del fuego rozando la piedra pulida — y al lado de su mano, dos puntos pequeños de luz roja reflejados en la piedra. Sin figuras.
La mano se posa y se detiene. Los dos reflejos rojos se quedan fijos y no parpadean con el fuego. Empuje muy lento.
El presagio está entero y el niño no sale. Y el plano encadena con OP02, que es
la madre en el mismo altar, así que la secuencia gana en vez de perder.
Los guiones tienen más de esto, y conviene saberlo antes de llegar. El episodio 1 tiene el parto y al celebrante levantando al recién nacido; hay una escena de un niño «desnudo, flaco, lleno de cicatrices» ante los sacerdotes. Esas tampoco se van a generar mostrando lo que describen, y la salida es la misma: el plano cuenta lo que pasa sin ponerlo en cuadro.
Esto se descubrió tres veces, y las tres pagando la llamada y leyendo el error rojo en el móvil:
| Qué se pidió | Qué pasó |
|---|---|
| La placa de Saharis a los cinco años: gaunt, filthy, bare feet | Bloqueada. Y es un ancla: sin ella no se genera ni un plano suyo en toda la serie |
saharis-barrio, con «diecisiete años» y una cicatriz en la palma | Bloqueada, y sin decir con qué palabra |
| El plano C2 del teaser: un recién nacido con la cara salpicada de sangre | Bloqueado. Google no va a generar eso nunca |
La regla estaba escrita. Lo que faltaba era comprobarla. npm run invariantes
saca ahora la lista de personajes menores de su propia ficha —no de una
lista escrita a mano, que se queda vieja en cuanto se añade un personaje— y
rechaza cualquier plano o placa que junte a uno de ellos con una palabra de
daño: sangre, cicatriz, herida, desnudo, hambriento, sucio, golpeado.
Un plano que no se puede generar es peor que uno mal escrito. El mal escrito sale feo y se rehace; este no sale, y la primera noticia es un error rojo con la llamada ya pagada.
Cuando el modelo contesta sin imagen, lo que importa no es solo qué dice Google, sino en qué campo lo dice. La misma palabra significa dos cosas distintas según de dónde venga:
| Dónde lo dice Google | Qué significa | Qué se hace |
|---|---|---|
promptFeedback.blockReason — cualquier valor, OTHER incluido | Ha leído el prompt y lo ha rechazado antes de dibujar nada. Ese campo solo aparece cuando bloquea | No se reintenta. Repetir da lo mismo siempre: hay que cambiar el texto |
candidates[].finishReason con nombre de filtro (IMAGE_PROHIBITED_CONTENT, SAFETY, BLOCKLIST…) | El filtro ha cortado a mitad, y con su nombre | No se reintenta. Igual: hay que cambiar el texto |
candidates[].finishReason: OTHER, o nada de nada | «No digo por qué»: se quedó a medias | Se reintenta. Muchas veces sale a la siguiente |
Esto se ha corregido dos veces, y cada vez por pasarse a un lado.
Primero todo caía en «no se reintenta»: el mensaje mandaba a reescribir una descripción y la cola daba el trabajo por muerto, incluso con placas que no tienen nada que pueda molestar a un filtro — un plano medio de un adulto vestido, de tres cuartos, sin expresión.
Luego se miró solo la palabra, junta de los dos campos, y entonces un OTHER en
promptFeedback — que sí es un bloqueo, solo que sin decir con qué palabra —
se reintentaba en balde una vez tras otra. Que es exactamente lo que no se
quiere: mandar a hacer una generación que va a fallar diez veces.
Ahora manda el campo. Cuando el bloqueo es del prompt, el mensaje lo dice con
esas palabras y sugiere por dónde mirar: heridas, cicatrices, cuerpo desnudo o
edad. Comprobado en herramientas/probar-ajustes.mjs con las dos formas de
respuesta, la de promptFeedback y la de finishReason.
La música sigue la misma regla. Lyria contestaba «OTHER» y el estudio decía
«repetir da el mismo resultado», que con una pieza de tres minutos es
sencillamente falso: ahí «OTHER» es «no digo por qué» y muchas veces sale a la
siguiente. Ahora la cola la reintenta sola, y solo se planta cuando el bloqueo
viene en promptFeedback o el filtro se nombra a sí mismo. npm run audio lo
comprueba con las dos formas de respuesta.
Y por eso las descripciones no nombran heridas. El bloqueo cayó sobre
Saharis, que es un personaje de diecisiete años: menor. Google es mucho más
estricto con los menores, y basta con que en el mismo texto aparezcan la edad y
una palabra de daño. Así que en datos/serie.base.json la cicatriz del pacto ya
no es «a long old scar», es «a thin old pale line»; el pelo ya no está «cut short
by his own hand … hacked off» sino «roughly cut and never groomed»; y la garganta
ya no va «bare» sino con la camisa cerrada al cuello y el cuello del abrigo
bajado. Se ve exactamente igual. Lo único que cambia es que no hay ninguna
palabra que un filtro pueda leer como una herida en un menor.
Salía esto, que es la peor combinación posible:
«La imagen se ha generado bien y está guardada en el bucket. Lo único que ha fallado es apuntarla en el estado de la producción.»
Generada, subida y cobrada, y sin apuntar. La causa solo se ve mirando la secuencia entera:
Pero en esos cuarenta segundos el navegador ha escrito el latido de la cola dos o tres veces y la versión ya va por la 103. El paso 3 no era una carrera desafortunada: era un choque garantizado. Y como solo había un reintento, ese choque seguro se lo comía entero y el segundo intento se estrellaba con cualquier latido que cayera en su ventana.
Dos cambios, y el primero es el que importa:
npm run anotar lo comprueba con latidos cayendo encima: que releyendo se apunta
a la primera, que con el estado viejo se apunta igual aunque cueste dos intentos,
y que ante una tormenta de verdad se rinde diciendo que fue una carrera.
Un escenario se genera una vez y su placa viaja como referencia de objeto en todos los planos que ocurren ahí — es lo que hace que once planos de la cripta sean la misma cripta. Y de ahí sale una trampa que no es evidente:
si la placa sale con figurantes de relleno, esos figurantes se heredan en los once planos, aparecen al lado del personaje que sí toca, y no hay forma de quitarlos después sin regenerar la placa y todo lo hecho contra ella.
Y el modelo los pone solo. A un «plano general de un santuario con altar y antorchas» le salen encapuchados sin que nadie los pida, porque es lo que se ve en cualquier imagen parecida. Así que se dice, y por los dos lados:
Y un segundo freno para lo que ya esté generado: la instrucción que acompaña a la placa en cada keyframe dice ahora «si aparece gente en esta referencia, ignórala por completo: no es parte del sitio». Antes decía «copia los objetos y dónde están» y nada sobre la gente, así que un figurante colado se leía como mobiliario.
La gente la ponen los planos, cada uno la suya, con sus placas de personaje. Lo comprueban los invariantes: si alguien quita cualquiera de los dos frenos, el árbol de código no pasa.
Las otras ocho pantallas sirven para hacer la serie. Esta sirve para sacarla.
Cada pieza montada se descarga con su ficha al lado: el título con el que se sube, la descripción y las etiquetas. No se escriben a mano, y no es solo por comodidad: la descripción no puede contar el final ni nombrar a quien todavía no ha aparecido, y eso, escrito con prisa en el teclado de un móvil doce veces, se falla.
Se pide, se lee y, si no gusta, se vuelve a pedir. El botón de aprobarla está apagado hasta que está delante, igual que en Audio no se aprueba una pista sin oírla: una descripción que cuenta el final se publica una vez y ya no se recoge.
Salen de una lista escrita en difusion.etiquetas.lista de datos/serie.json y
el modelo solo elige de ahí. Las que se invente se tiran, una a una, y si
quedan menos de ocho la ficha se rechaza y se vuelve a pedir.
Dos razones, y las dos cuestan dinero si se ignoran:
#anime, #seinen, #darkanime— y
ninguna es de esta serie. Una etiqueta propia solo la busca quien ya conoce el
animé, y todavía no lo conoce nadie.Tres de la lista (#aianimation, #animacion, #cortoanimado) dicen que está
hecho con animación generada. Se ponen o se quitan según cómo se presente la
pieza: eso lo decide quien publica, no el modelo.
Un botón por pieza: el vídeo y la ficha juntos en un zip. Lo escribe el montador, que es donde está el archivo — un episodio pesa entre 1 y 2 GB y no puede pasar por la función, que tiene 4,5 MB de tope.
El zip va sin comprimir a propósito: dentro hay un MP4, que ya está comprimido, y volver a comprimirlo tarda minutos de máquina y no quita ni un megabyte. El zip aquí no sirve para que ocupe menos; sirve para que no haya que acordarse de descargar dos cosas.
Está escrito a mano, byte a byte, porque este proyecto no tiene ni una
dependencia de npm y Node no trae ninguno. Eso tiene una manera muy fea de
fallar: un zip mal escrito no da ningún error al escribirlo, lo da al abrirlo,
en el teléfono, después de haber descargado un gigabyte y medio. Por eso
npm run zip escribe uno de verdad y lo abre con el unzip del sistema —un
programa que no sabe nada de este proyecto y no perdona nada—, comprueba los CRC
y que lo que sale es byte a byte lo que entró. Y lleva ZIP64 para cuando un
episodio pase de 4 GB, que es donde el formato original se queda corto.
El aviso antes de pulsar dice lo que pesa, porque en un teléfono hay que descargarlo Y dejar libre otro tanto para abrirlo.
El póster oficial de la serie y una miniatura por episodio: trece piezas,
escritas en difusion.posters.piezas de datos/serie.json.
No son fotogramas del capítulo. Se generan aquí, con el mismo modelo de imagen que las placas y con las placas ya aprobadas de los personajes delante como referencia, para que sea la misma cara, la misma luz y el mismo estilo. Un fotograma cualquiera no compone, y una miniatura tiene que leerse del tamaño de una uña.
La primera versión de estos encargos pedía, con estas palabras, «un joven de unos diecisiete de pie, solo, mirando al espectador, centrado, con espacio vacío alrededor». Sale exactamente lo que dice: un retrato de catálogo con el título flotando encima. Y las doce miniaturas compartían el mismo encargo genérico, así que eran doce imágenes intercambiables.
Los trece están reescritos como key visuals, cada miniatura leyendo su propio guion para encontrar la imagen que vende ese episodio. Lo que un encargo tiene que traer, y lo que se comprueba:
npm run difusion comprueba que los trece son trece textos distintos, que
ninguno empieza igual que otro y que ninguno baja de 80 palabras: un encargo
corto no compone.
Este era el fallo de fondo, y se veía en la imagen: el póster salía precioso pero la cripta no era la cripta, el ídolo no era el ídolo y los encapuchados no eran los acólitos. Solo viajaba una referencia, la cara del protagonista.
Un póster lleva ahora dos clases de referencia, y son cupos distintos que no compiten entre sí:
| Campo | Va como | Qué aporta |
|---|---|---|
escenarios | referencia de objeto | El sitio: la arquitectura, los materiales, los objetos y dónde están. |
refs | referencia de personaje | La cara: el mismo rostro, el mismo pelo, la misma edad. |
La regla que ya gobernaba los keyframes vale igual aquí, y por el mismo motivo —«sin la placa, diez planos de la cripta son diez criptas distintas»—, solo que en un póster pesa más: es la única imagen que va a ver alguien que no ha visto nada.
Y hay algo que solo hace la placa del sitio: trae sus objetos dentro. La de
cripta lleva escrito, en sus propias palabras, el ídolo colosal de muchos brazos
con cabeza de calavera de cabra, el altar de piedra oscura, los nichos con
calaveras y los brazaletes de antorcha. Adjuntarla es la única forma de que salgan
esos y no unos parecidos.
Las trece piezas dicen dónde ocurren: la cripta el póster oficial y los episodios 8 y 11, los túneles el 3 y el 12, el archivo de la casa el 7 y el 9, la orilla el 5 y el 6, y cada uno de los demás el suyo.
Esto salió mirando el póster generado: en el centro apareció un enmascarado prominente, de frente a cámara, que no era el celebrante de la serie.
La causa era una contradicción que yo mismo había escrito. Adjuntar la placa de alguien es decirle al modelo «copia esta identidad». Pero el encargo lo nombraba solo como «una mejilla de máscara» dentro de «una masa de espaldas encapuchadas»: sin escala y sin sitio. La instrucción de referencia dice literalmente «redibújalo al tamaño que esta toma requiera», y la toma no requería ninguno. El modelo resolvió la contradicción inventándose una figura suya y poniéndola delante.
La regla queda escrita en difusion.posters.regla_escala:
Una referencia de personaje existe para llevar la identidad, y eso es lo que un ancla es; la pose y el encuadre los describe el encargo. Así que casi todas son anclas.
Pero no es una regla dura, porque a veces el diseño es lo que se ve: el
póster oficial y el episodio 11 llevan celebrante-mascara y no
celebrante-ancla, porque esa máscara de cuero es su identidad visual en la
cripta y el ancla es el mismo hombre sin ella. Una máscara no se hereda de una
cara.
Eso tiene un precio y los invariantes lo avisan en vez de tumbarlo: una placa que no es ancla obliga a aprobarla a ella y al ancla de su personaje, dos imágenes antes de que el botón se encienda. La pantalla dice cuáles faltan, por su nombre.
difusion.posters.nivel es calidad, y no mira lo que esté elegido en Salud.
Son trece imágenes en toda la serie, y son las únicas que va a ver alguien que
no ha visto nada: ahorrar ahí no ahorra nada. Y hay una razón técnica encima:
gemini-3-pro-image es el que admite más imágenes de referencia (6 de objeto + 5
de personaje), que es justo lo que hace falta para llevar el sitio y las caras
en la misma llamada.
Pero encima de las tarjetas hay un selector de modelo, y no es un adorno.
Modelo de calidad + 2K + tres referencias delante es la petición más pesada que hace este estudio entero. Cuando Google contesta un 502 con una página HTML de error —no con un error de la API—, lo que ha fallado está delante del modelo: un balanceador que se ha cansado de esperar. Eso no lo arregla insistir.
Sin el selector, la única salida sería irse a Salud a cambiar el ajuste de todo el estudio para poder generar un póster. Con él se baja un nivel, se genera y se vuelve a subir: dos toques, y las demás pantallas siguen con lo suyo.
Y si falla en los tres niveles, el problema no es la carga: es el modelo. Salud → Comprobar los modelos llama a cada nivel y dice cuál contesta y cuál no. Para eso existe esa pantalla.
El botón de generar no se enciende mientras falte una de esas placas por aprobar, y dice cuál falta. Lo comprueba también la función, que es quien manda: encolar un trabajo que ya se sabe que va a fallar cuesta diez minutos de espera para nada.
Un selector arriba de la sección, 9:16 o 16:9, y lo que se elija es lo que se genera y lo que se mira debajo. Uno solo para las trece tarjetas: en un teléfono, trece selectores iguales son trece formas de equivocarse.
9:16 es lo vertical —el póster de toda la vida, y lo que piden las plataformas
de móvil—; 16:9 es lo que pide una miniatura de YouTube. Cada uno se genera
con su proporción desde el principio: recortar uno para sacar el otro deja la
cabeza fuera del cuadro.
Por eso cada formato es su propia imagen, con sus intentos y su aprobación.
En el estado viven separados, con la pieza y el formato en la clave
(poster-oficial/9-16, poster-oficial/16-9), y en el bucket en carpetas
distintas. Generar el horizontal nunca pisa el vertical que ya estaba
aprobado. Eso lo comprueba npm run difusion ejecutándolo, porque es justo la
clase de fallo que no avisa: se perdería una imagen ya pagada sin un solo error
en pantalla.
Y el marco de la tarjeta toma la forma de lo que enseña. Toda la aplicación mira en 16:9, que es la proporción de la serie, y un póster vertical dentro de un hueco apaisado se recortaba por arriba y por abajo — justo la banda donde va el título. En una pantalla donde aprobar es mirar, esconder lo que hay que juzgar es el peor fallo posible. Ahora la tarjeta acepta su proporción y enseña la imagen entera: dos franjas negras al lado son mejores que un trozo escondido.
LA MIRADA QUE EL MUNDO TEMERÁ se le pide al modelo escrito dentro del
póster, letra por letra, en el propio prompt.
Es una decisión de quien paga, tomada a sabiendas y escrita aquí para que no parezca un descuido: los modelos de imagen escriben mal las tildes y las eñes, así que puede salir con letras inventadas. Se mira y se rehace hasta que salga; por eso el botón de «Otro intento» está siempre a mano y ningún intento se borra, que el bueno puede ser el tercero.
Se cambia en un sitio: difusion.posters.titulo_en_la_imagen a false en
datos/serie.json, y entonces el prompt le dice al modelo que deje vacía la
banda que ya tiene reservada. No un «sin texto» a secas: los encargos hablan de
su banda de título, así que una negación suelta al final los contradiría y esa
pelea la resolvería el modelo a su gusto.
Y el negativo dejó de pelearse con el título. El negativo de la serie lleva
text dentro, porque en un keyframe o en un clip cualquier letra que aparezca es
basura. Pero en un póster el título va DENTRO de la imagen y se pide con todas las
letras: mandar las dos cosas en la misma llamada es pedir una cosa y prohibirla a
la vez.
Ese fallo no da ningún error. El modelo devuelve un título flojo, torcido o
pegado como una pegatina, se cobra la generación igual, y pasa por «así escriben
los modelos» sin que nadie sospeche de la lista de negativos. Ahora, cuando el
título va dentro, se quitan del negativo las palabras que hablan de texto y nada
más: la marca de agua y la firma se quedan, porque esas sobran siempre, y el
resto —paleta shonen, render 3D, ojos brillantes— es lo que hace que el póster se
parezca a la serie. npm run difusion compone el prompt de verdad y lo comprueba.
El formato se le dice como reencuadre, no como composición. La frase que acompaña al 9:16 o al 16:9 dice «enmarca esto como imagen alta / ancha, manteniendo la composición de arriba», y no «pon el sujeto a un lado». La composición la manda el encargo, que es quien sabe dónde está la banda del título; una frase genérica pelearía con él.
Un invariante comprueba que el título que se pide dentro del póster es el mismo
que meta.titulo_es, que los formatos escritos son proporciones que el modelo
acepta, que cada póster referencia placas que existen y que hay una miniatura
por cada guion escrito: si algún día hay un episodio trece, el que se quede sin
la suya se subiría sin portada.
Un reel por pieza: treinta segundos en vertical, armados solos.
No genera nada. Ni una llamada a un modelo, ni un céntimo de Vertex. Coge los clips que YA están elegidos y la música que YA está aprobada y los pone uno detrás de otro. Lo único que gasta es el rato de máquina del montador, igual que cualquier otro montaje. Por eso su botón puede estar apagado con todo bien escrito: lo que falta no es una decisión, es material. Y por eso se rehace sin pensarlo en cuanto hay un clip más.
Barras negras, a propósito. Salen a 1080 × 1920. Todo el material está rodado en 16:9, así que el montador escala cada plano hasta que quepa entero y rellena lo que sobra de negro. Recortar a vertical dejaría media cara fuera de cuadro en casi todos los planos, y eso es peor que una franja.
Cómo se corta, y las cuatro reglas están en difusion.reels de
datos/serie.json para cambiarlas ahí y no en el código:
El orden es el del guion. En los datos no hay ninguna nota que diga qué plano es «el bueno», así que inventarse un orden sería inventarse un criterio. En el orden del guion, el reel del teaser es el teaser contado corto.
Los planos de menos de 1,2 s se saltan. Por debajo de eso un plano parpadea y se lee como un fallo de reproducción, no como montaje.
Los de más de 3,5 s se cortan, y se cortan por el final: se coge su principio, que es donde está lo que se quería contar. En medio minuto caben diez o doce planos; con cuatro de ocho segundos no hay reel, hay un vídeo lento.
Aterriza exactamente en los treinta. Cuando del ajuste sobra un pico —queda un segundo y ya no cabe ni el plano más corto que se acepta—, ese pico se reparte estirando planos desde el último hacia atrás, hasta donde llegue el material de cada clip. Un reel de veintinueve segundos no es «casi treinta»: es un vídeo que se corta antes de tiempo, y se nota.
Y solo si el reel se llenó. Si lo que se acabaron fueron los clips, el hueco no es un pico: es que no hay material. Entonces el reel sale corto y lo dice, en vez de estirar tres planos hasta el medio minuto y entregar tres planos lentísimos.
Sin voz y sin subtítulos. Con el diálogo en japonés no se entiende en el móvil de nadie, y con el diálogo en español un reel de treinta segundos cuenta el capítulo entero. Va la música, desde su segundo cero, recortada a lo que dure el reel y sin agacharse: agacharse es dejarle sitio a una voz que aquí no hay.
La capa con la que se le encarga al montador es pieza, y eso no es un
descuido: un reel es una capa de planos con su audio y sin capas ya montadas
debajo. Decirlo así hace que funcione con el montador que ya está desplegado.
Una capa nueva no daría un error al encolarla: daría un trabajo que falla en la
nube diez minutos después y que obliga a volver a desplegar el montador desde el
móvil. Un invariante comprueba que la capa que usa app/reel.js está en la lista
que conocen tanto la función como montador/montador.mjs.
Y el corte se comprueba ejecutándolo. Un reel mal cortado no da ningún error:
da un vídeo, con un parpadeo o con tres segundos de negro al final, y eso solo se
ve mirándolo, después de esperar los minutos del montador. npm run reel corta
reels contra estados de mentira y comprueba que los planos van pegados, que
ninguno queda por debajo del mínimo, que el total no se pasa, que con poco
material sale corto en vez de estirado — y le pasa el manifiesto al mismo
validador que usa la función en producción. Esa última es la que importa: es
enterarse aquí en un segundo, o enterarse en la nube dentro de diez minutos.
npm run comprobar
Encadena las quince herramientas y no necesita red ni credenciales: regenera
datos/serie.json desde serie.base.json con el parche, comprueba los
invariantes sobre los datos y sobre el árbol de código, ejecuta la cola
contra un Google de mentira, le da audio de verdad al lector de formatos,
sigue el ajuste de con qué se genera hasta el cuerpo de la petición,
frena contra un reloj de mentira, apunta lo generado con latidos cayendo
encima, comprueba que ninguna pieza de música se quede sin pantalla donde
salir, recupera un clip lanzado con otro nivel de Veo, ejecuta la pantalla de
Cola con un vídeo en vuelo, repite un fallo doce veces para ver que el aviso
no se apila, escribe un zip y lo abre con el unzip del sistema, tira las
etiquetas que se inventa el modelo, corta un reel de treinta segundos y le pasa
el manifiesto al mismo validador que usa la función en producción, y pesa la
respuesta de cada modo con material del tamaño real para
verificar que cabe en los 4,5 MB. Casi ninguno se ve leyendo el código: se ven
ejecutándolo y midiendo. Si algo no cumple, sale con error y lo dice en español.
Cada paso se puede lanzar por separado con npm run datos, npm run invariantes,
npm run cola, npm run audio, npm run subtitulos, npm run pantalla-audio,
npm run ajustes,
npm run freno, npm run anotar, npm run banco, npm run veo,
npm run pantalla, npm run fallos, npm run zip, npm run difusion,
npm run reel y npm run pesar. Y
npm run archivo reescribe los 56 planos de ambiente desde su tabla.
Una carpeta puede ser un repositorio de git perfectamente válido y no estar
conectada a GitHub. Si se clonó desde otra carpeta del propio disco —cosa que
pasa sola cuando alguien copia un clon, o cuando Cloud Shell clona encima de otro
clon—, su origin es una ruta local.
Entonces git pull dice:
From .
Already up to date.
Y tiene toda la razón: se lo está trayendo a sí misma. No trae nada. Nunca.
Eso pasó, y costó horas. La aplicación fallaba, se arreglaba el fallo, se subía,
se ejecutaba ./m… y se desplegaba otra vez el mismo código viejo, porque esa
carpeta no podía recibir nada. Se buscó el fallo en Google, en los permisos, en
las APIs, en el instalador y en el propio montador. Estaba en dos caracteres de
la salida de git: ese punto de From ..
Ahora m y c miran de dónde traen antes de traer, y si el origin no es
GitHub se paran, lo dicen con todas las letras e imprimen la línea que lo
arregla. Un despliegue silencioso de código viejo es peor que no desplegar.
Esa comprobación no bastaba, y se vio en un clon recién hecho desde GitHub por
el enlace oficial: el origin era GitHub y git pull seguía diciendo From ..
El motivo está en la propia salida del enlace:
Cloning into '…/LA-MIRADA-QUE-EL-MUNDO-TEMERA'...
Branch main already exists locally. Switching to that branch.
Cloud Shell clona y deja la rama main siguiendo al propio disco, no a
origin. El origin está bien; lo que está mal es lo que sigue la rama. Y git pull a secas se fía de eso.
En un clon recién hecho da igual, porque acaba de bajarlo todo. El problema es el
segundo día: se ejecuta ./m para traer un arreglo y se despliega otra vez lo
mismo de antes, sin un solo mensaje que lo diga.
Se arregla nombrando el remoto y la rama:
git pull --ff-only origin main
Así de dónde viene no depende de cómo quedara configurada la carpeta.
«Vuelve a instalar» es la peor respuesta que se le puede dar a alguien que ya ha instalado. No dice qué pasa, tarda diez minutos, y si el problema no era ese —que casi nunca lo es— deja las cosas donde estaban pero con menos paciencia.
Y la segunda peor es «ábrete Cloud Shell». Aquí solo hay un teléfono. Mandar a la terminal a alguien que tiene un teléfono, para hacer una pregunta que la propia aplicación puede hacerle a Google, es no responderle.
La tarjeta del montador se pintaba en verde con solo existir la variable
MONTAJE_JOB, sin preguntarle a nadie. Y el montaje fallaba con un 403 de
Cloud Run. La pantalla que existe para decir qué está roto enseñaba en verde
exactamente lo roto.
Había un motivo escrito para no preguntar: si a la cuenta solo se le hubiera dado
Cloud Run Invoker, podría lanzar el trabajo pero no leer su ficha, y Salud
pintaría en rojo algo que funciona. Ese motivo ya no valía —despliegue/permisos.txt
le da roles/run.developer, que sí deja leerla—, y mientras tanto pasaba lo
contrario, que es mucho peor.
Ahora se le pregunta, y cada «no» se cuenta por separado porque se arreglan en sitios distintos:
| Lo que contesta | Qué significa | Color |
|---|---|---|
| Contesta | La API encendida, los papeles y el job desplegado | Verde |
403 CONSUMER_INVALID / SERVICE_DISABLED | La API de Cloud Run no está encendida en el proyecto de esta cuenta | Rojo |
403 BILLING_DISABLED | La facturación del proyecto | Rojo |
| 404 | La API va y los papeles están, pero ahí no hay ningún job con ese nombre | Rojo |
403 IAM_PERMISSION_DENIED | Puede lanzar pero no leer. No es un fallo: el montaje funciona | Verde |
Puesto por MONTAJE_URL | No se le puede preguntar sin inventar la dirección | Verde |
El importante es el segundo. Se lee como «no tienes permiso» y no tiene nada que ver con los permisos — y se distingue sin salir de esa pantalla: si los modelos de arriba están en verde, esta misma cuenta está llamando a Google sin problema, y lo que falta es un interruptor del proyecto.
./c./c
./c —o bash instalar.sh comprobar— enseña, sin tocar nada:
! en las que estén apagadas.No enciende, no crea, no despliega y no toca ninguna variable.
Y enseña la que más veces engaña: si el proyecto de Cloud Shell es el mismo que el de la clave que usa Vercel. Si no lo son, todo lo instalado está perfecto y no sirve de nada, porque la aplicación está mirando otro sitio. Eso no lo dice ningún error de Google — se ve comparando lo que sale ahí con lo que dice la pantalla de Salud.
gcloud services enable contesta que sí en cuanto Google acepta el encargo,
no cuando la API se puede usar, y algunas tardan un minuto largo. El instalador
imprimía «✓ Hechas» ahí mismo.
Ahora vuelve a listar las APIs y espera hasta un minuto a que aparezcan de verdad. Si alguna no aparece, se para en vez de seguir: lo que viniera después fallaría con un 403 que se lee como «no tienes permiso», y se perdería la tarde revisando permisos que están perfectos.
El instalador genera una clave, se la graba al job (MONTAJE_CLAVE) y la imprime
al final para que un humano la copie en Vercel como MONTAJE_KEY. Si no coincide,
el montador rechaza el encargo.
Ese paso manual es el que falla. Se copia en Cloud Shell, en un móvil, sin poder pegar. Y cuando no se copia, el montaje falla siempre — después de arrancar la máquina y de esperar los minutos— diciendo algo que suena a permisos.
Encima, Salud llamaba a esa variable «un cinturón de más, no un requisito». Falso: con el instalador normal es obligatoria de hecho. Se buscó el fallo en los permisos, en las APIs y en volver a instalarlo todo.
CLAVE_ACCESO, que es otra variable.MONTAJE_KEY si está en Vercel, manda. Si no está, la función lee la clave
del propio job al encargar el montaje.
Se gana que el montaje funcione sin una copia a mano desde un teléfono. Se pierde
ese hueco estrecho. Está escrito en claveParaElEncargo() para que sea una
decisión y no un descuido — y quien quiera el cinturón de más lo tiene: pone
MONTAJE_KEY y esa lectura ni ocurre.
Salud lo dice en la tarjeta del montador, y nunca enseña el valor: solo si hay clave o no.
Pulsar «Descargar el montaje» abría el vídeo en una pestaña. Se podía ver, y no había forma de guardarlo. Lo mismo con el zip.
El atributo download de un enlace HTML no sirve entre dominios, y aquí
siempre lo son: todo vive en el bucket. El navegador se lo salta sin decir nada.
Lo único que lo cambia es que Google mande Content-Disposition: attachment,
y eso se le pide dentro de la firma. Va firmado, así que nadie puede añadirlo
ni quitarlo por su cuenta: forma parte de la URL o no está.
Cada archivo tiene ahora dos URLs: una para mirar —la que va en el <video> o
el <img>— y otra para guardar. Se piden en dos tandas y se guardan en dos
sitios, porque poner una URL de descarga en un reproductor es un riesgo que no
hace falta correr.
montaje/{trabajo}/ es donde el montador deja lo que ha hecho, y se listaba
entera menos dos archivos. Eso es una lista negra: se rompe sola en cuanto
alguien deja ahí un archivo más.
Pasó al día siguiente de escribirla. Un índice interno acabó en esa carpeta, y el botón de descargar el montaje abría un texto con el nombre de una ejecución de Cloud Run.
Ahora hay lista blanca —solo .mp4, .mov, .mkv, .webm y .zip— y lo
interno vive fuera de esa carpeta. Dos cerrojos para el mismo fallo, porque
el primero ya se demostró que no basta.
Con los dos cerrojos puestos, el paquete de Difusión seguía bajando aquel texto. La ficha decía «paquete hecho, 107 bytes» tan tranquila, y 107 bytes son exactamente lo que pesa una línea con el nombre de una ejecución.
La causa: la cola apunta en estado.montajes lo que había cuando se montó. El
estado es un archivo del bucket, no se rehace solo, y nadie va a ir a mano a
limpiarlo desde un teléfono. Arreglar lo que se ofrece a partir de ahora no toca
lo ya apuntado.
Así que la lista blanca se aplica también al leer, en las tres pantallas. Lo que se apuntó mal deja de aparecer sin que haya que borrar nada.
Mirar un archivo y guardarlo son dos URLs firmadas distintas del mismo archivo. Se piden en dos tandas seguidas, y ahí había dos fallos encadenados:
Ahora cada tanda se intenta pase lo que pase con la otra, y lo que no sale se apunta y se dice, con el botón de volver a pedirlo al lado.
Lo que no se hace es poner ahí la URL de mirar, que sí está: esa abre el archivo en una pestaña en vez de guardarlo, y ese fue el fallo original. Un botón que no cumple lo que dice es peor que ninguno. Hay una comprobación que lo impide.
Los subtítulos salían desplazados: el primero se quedaba pegado después de que la voz terminara, el siguiente entraba tarde, y a partir de ahí el texto dejaba de corresponder con lo que se oía.
La causa: los tiempos de las líneas estaban estimados y no medidos. Y eso era solo una nota que decía «puede entrar o salir un poco desplazado».
Un subtítulo se quema en la imagen. No se arregla después: hay que volver a montar la pieza entera, otra vez los minutos de máquina. Así que ahora, si va a haber subtítulos, un tramo estimado para el montaje, con el mismo criterio que todo lo demás que no se puede deshacer. Se miden con un botón en Audio.
Sin subtítulos sigue siendo una nota: ahí lo estimado solo mueve un poco dónde se corta cada línea, y eso se oye y se juzga.
Dos fallos del mismo sitio, y los dos invisibles.
La pantalla de Audio solo hablaba cuando algo iba mal —sin medir, o repartido a ojo—. Si la medida salía perfecta no aparecía ni una palabra: se pulsaba «Medir los tiempos», terminaba, y no había forma de saber si había medido, si había fallado o si no había hecho nada. Un botón que no contesta se pulsa otra vez, y otra. Ahora lo dice también cuando sale bien, y un bloque con tramos a ojo se ve desde fuera, en su punto de estado.
Y la cola tiraba la marca. Cuando el reconocimiento de voz vuelve con menos
palabras que líneas, la función reparte la duración en proporción al japonés y lo
marca: estimado. La medición hecha desde la pantalla guardaba esa marca; la
que hace la cola sola, después de grabar una voz, guardaba el número y perdía la
marca.
Así que un bloque medido por la cola quedaba indistinguible de uno bien medido —y con él se queman subtítulos a ojo sin que nadie pueda saberlo mirando. Es exactamente lo que se vio en el teaser. Ahora las dos rutas guardan lo mismo.
Con los tiempos ya medidos, el teaser seguía descuadrado. Y era otra cosa.
Las palabras que reconoce Google se repartían entre las líneas en proporción a cuántos caracteres japoneses tiene cada una. Eso da más o menos el sitio. Con voz, «más o menos» significa esto:
Contar caracteres dice por dónde va cada línea. Quien sabe exactamente dónde acaba una y empieza otra es el propio audio: ahí hay un silencio, y no lo hay en medio de una frase.
Ahora el reparto proporcional es solo el esqueleto, y después cada corte se arrastra al silencio más ancho que tenga cerca, sin poder invadir a los vecinos. El esqueleto evita confundir una pausa dramática con un cambio de línea; el silencio evita cortar donde nadie calla.
«Estamos viendo los labios como que está hablando, pero no se escucha la voz, se escucha al rato.»
Y era literal. El plano B2 del teaser es un primerísimo plano de los labios de
la madre, y su prompt le pide a Veo que la boca se mueva todo el plano. B2
va del segundo 21 al 25. Su línea estaba escrita en el 24:
21 ──────────────────────────────── 25 B2, labios moviéndose
24 ─────────────────── 26,5 la voz
└─ tres segundos hablando en silencio ─┘ └─ y el corte se la lleva ─┘
El dato para arreglarlo estaba escrito desde el principio —cada plano dice en
boca_visible de quién es la boca que sale— y el montaje no lo miraba:
colocaba cada línea en su segundo escrito y ya.
Cuadrarlo a mano no era una opción: son doce episodios y nadie va a ir línea por línea ajustando segundos desde un teléfono. Así que se hace solo.
La primera versión sí lo era, y estaba mal. Lo dijo quien lo iba a usar, antes de que llegara a pasar:
la voz puede estar en off, y de repente entra una escena de movimiento de labios, y luego continuar la voz en off
Ahí no hay nada que arreglar: la voz ya está sonando cuando entra el plano de labios. Moverla para «cuadrarla» solo abriría un hueco donde no lo había y descolocaría todo lo de detrás. En el teaser aquella versión acertaba de casualidad —son cuatro frases sueltas con silencio entre ellas—; en un episodio, con el diálogo seguido, habría estropeado más de lo que arreglaba.
La regla de verdad es más floja y más segura:
Si se ven unos labios moviéndose, tiene que oírse voz.
Nada más. No dice dónde empieza la frase ni cuál es —que los labios no van a cuadrar con las palabras se sabe y se acepta—: dice que no puede haber silencio debajo de una boca en marcha.
| Cómo llega el plano de labios | Qué se hace |
|---|---|
| ya tiene su voz encima cuando entra | nada. Es el caso normal en un episodio |
| arranca en silencio | se trae la frase más cercana de esa persona |
| ninguna frase puede cubrirlo | no se inventa: se dice |
Una línea sin ningún plano de boca es voz en off, y su t se respeta tal cual.
La regla tiene una trampa, y se preguntó antes de que llegara a pasar:
se oye una voz en off de una persona hablando y luego pasa a un plano donde se le ve la boca moviéndose a la otra persona que le está respondiendo. ¿Esa voz que se escucha es la correcta?
No basta con que suene una voz: tiene que sonar la suya. Si se ven los labios de B y lo que suena es A, en pantalla parece que B está diciendo las palabras de A — y eso se ve tan mal como el silencio.
De ahí salieron dos cosas. Una ya estaba bien: la cobertura se mira por personaje, así que la voz en off de A nunca da por cubierta la boca de B.
La otra faltaba, y era el fallo de verdad. Al traer la frase de B para tapar su boca muda, nada impedía plantarla encima de la voz en off de A: dos personas hablando a la vez, peor que el silencio que se arreglaba. Dentro de un bloque de voz eso ya estaba guardado, pero un bloque es una persona —o dos— y A y B pueden estar en bloques distintos: ahí no miraba nadie. Ahora una frase no se mueve nunca sobre el hueco de otro.
Y cuando la voz equivocada ya está ahí de fábrica —el plano de B cae en medio de
la narración de A— eso el montaje no puede arreglarlo: haría falta cambiar el
plano o mover el diálogo. Así que es fallo de npm run comprobar, para que
salte antes de gastar un céntimo generando ese clip.
boca_visible no significa «está hablando»Esta distinción estuvo a punto de costar un fallo peor que el que se arreglaba.
boca_visible dice que esa boca está en cuadro. Si habla o está callada lo
dice el prompt de vídeo, que es lo único que Veo anima. En el mismo teaser:
| Plano | Su video | ¿Habla? |
|---|---|---|
B2 | her lips move continuously as she speaks | sí |
D5 | He turns his head to camera. Nothing else moves. | no |
D5 es un retrato de Saharis mirando a cámara sin decir nada, y su frase está
escrita después, sobre el plano siguiente. La primera versión de esto se la
llevaba a D5 «para cuadrar la boca» — es decir, le ponía la voz encima de unos
labios parados, que es justo lo que prohíbe la otra mitad de la regla. Lo cazó
la comprobación que se escribió para esto, antes de subir nada.
Tres cosas que no hace, y las tres a propósito:
datos/serie.json. El segundo escrito se queda escrito.Y lo que no se puede arreglar moviendo —un plano de labios más largo que lo que se dice debajo, o un hueco en medio— sale dicho igual, después de colocarlo todo: eso necesita acortar el plano o escribir más diálogo, y fingir que se arregla solo sería peor que decirlo.
npm run comprobar vigila las dos mitades sobre los datos escritos. Una boca que
arranca muda con una frase cerca es un aviso —el montaje lo cuadra y dice
cuánto—; una boca que se mueve y no tiene nada que decir cerca es un fallo:
eso el montaje no puede inventarlo. Un plano que ya tiene voz encima no aparece ni
como una cosa ni como la otra: no hay nada que decir de él.
En todas las piezas y en todas las capas: el teaser, cada escena de cada
episodio y cada episodio entero. Es la misma función —componerVoz()— la que
compone la voz de cualquier cosa que se monte, así que los doce episodios entran
por aquí desde el primer plano que se desglose. No hay nada que activar.
Este no estaba desplazado: estaba entero. La madre dice, despacio:
No dejes que te vean … este lugar … destruye lo que brilla
con dos pausas largas de por medio. No son de escritura, son de interpretación: no hay ningún punto ahí donde ella calla. Y el subtítulo se plantaba completo desde la primera palabra hasta la última, así que durante los dos segundos de la segunda pausa el texto en pantalla era, a la vez, algo que ya se había dicho y algo que todavía no.
Escribir a mano dónde se parte cada frase de cada episodio no se sostiene: son doce capítulos. Se hace solo.
La medida de tiempos devuelve ahora, además de la entrada y la salida de cada línea, los pedazos en los que se dice —separados por las pausas que tiene el audio de verdad—. El montaje saca un subtítulo por pedazo, y el español se reparte entre ellos en proporción a lo que dura cada uno, prefiriendo cortar donde la frase ya tiene una coma o un punto. Con esa frase sale exactamente:
| Pedazo | Texto |
|---|---|
| 1 | No dejes que te vean. |
| 2 | Este lugar |
| 3 | destruye lo que brilla. |
Tres detalles que no son adorno:
Si la línea se dice del tirón, no vienen pedazos y el subtítulo es uno solo, como siempre.
Los bloques medidos antes de esto hay que volver a medirlos. No se rompe nada —una medida vieja no trae pedazos y el subtítulo sale de una pieza, como antes—, pero ni las pausas ni los cortes por silencio existen hasta que se vuelve a pulsar «Medir los tiempos» en Audio. La voz no hay que volver a generarla: se mide sobre la que ya está.
Los tres salieron de una revisión en la que no se leyó código: se montó el
teaser, el opening y el ending en seco, con datos/serie.json de verdad, y se
miró qué manifiesto salía. Ninguno de los tres se ve leyendo.
Veinte segundos de vídeo mudo y la canción entrando a la mitad.
La causa era una palabra. «El canto» es una voz que entra sobre un lecho
instrumental, y por eso entra tarde: en el teaser, el lecho abre y a los
dieciocho segundos se le pone la voz encima. Para saber si una pista es canto se
mira si su texto lleva cant… y la pista del ending dice «canción con letra
cantada en japonés».
Pero el tema del ending no es una capa: es la canción entera y es la única música de la pieza. No hay ningún lecho debajo que esperar. Ahora el retraso del canto solo se aplica cuando hay algo debajo sobre lo que entrar. El canto del teaser sigue entrando tarde, porque allí sí hay lecho.
Este es el peor de los tres, porque no era un error: era silencio.
componerLetra() lee modelo.letra y modelo.audio, y construirModelo() no
creaba ninguno de los dos, así que la función se rendía en su primera línea.
Se podían marcar los once versos con el dedo, montar, pagar los minutos de
máquina, y el vídeo salía sin letra sin que nada hubiera avisado.
Se comprobó ejecutándolo con los once versos ya marcados: cero subtítulos y cero quejas. El camino de guardar las marcas funcionaba perfectamente; lo que faltaba era pasar la letra al modelo. Dos líneas.
Y de paso se recuperó la red de seguridad: ahora, si la letra no está marcada, eso bloquea el montaje y dice qué hacer, que es lo que la función siempre quiso hacer y nunca llegaba a ejecutar.
Se barrió el archivo entero buscando más campos de esa clase: se leen 16 campos de
modelo y solo esos dos no se creaban nunca.
Simulando el teaser salió esto:
21 → 22,8 «No dejes»
22,8 → 24,9 «que» ← dos segundos con una preposición en pantalla
24,9 → 26,6 «te vean.»
Al repartir el español entre las pausas solo se garantizaba una palabra por pedazo. Ahora hay un tercer tope —doce letras— y una frase corta no se parte: partirla no arregla nada, porque lo que esto vino a resolver es una frase larga que se queda entera mientras se dicen cosas distintas.
Al arreglar lo anterior, esa falta llegó por fin a la pantalla. Y lo que se vio fue la caja de «No se puede montar todavía. Falta esto:» con una barra negra vacía dentro. Ni el fallo, ni una pista, ni por dónde empezar a mirar.
Una falta es {texto, donde} y la pantalla pinta falta.texto. Todas se escriben
así menos esa, que se coló pelada —solo el texto—, así que falta.texto era
undefined y el párrafo salía vacío. Llevaba escrita así desde el principio y no
se veía porque esa función nunca llegaba a ejecutarse. Dos fallos en fila en
el mismo camino, el segundo escondido por el primero.
Ahora hay tres cerrojos, porque una caja vacía que bloquea el montaje sin decir por qué es lo peor que puede salir en un teléfono:
donde, así que además sale el botón de
Ir a Audio.npm run comprobar monta en seco todas las piezas, en todos los estados en
los que se pueden quedar, y exige que cada falta traiga su forma entera.Sobre el tercero conviene un apunte, porque la primera versión no valía: aceptaba una falta que fuera solo texto y pasaba en verde con el fallo dentro. Se comprobó volviendo a meter el fallo a propósito. Una prueba que pasa con el fallo puesto es peor que no tenerla, porque encima tranquiliza.
El opening y el ending llevan letra cantada, y sus subtítulos no se miden: se
marcan con el dedo, oyendo la canción. Para eso hay una función entera y bien
escrita —marcadorDeLetra()— con su botón, su verso resaltado y su guardado.
En pantalla no salía nada. Ni el botón, ni un hueco, ni un error.
La condición que decide si se pinta leía pieza.letra y pieza.audio, y esos
campos viven en pieza.datos: pieza es el envoltorio {id, titulo, datos}.
Así que Array.isArray(undefined) daba falso y el marcador no se pintaba
jamás. Sin nada roto que mirar, y sin forma de montar el opening ni el ending
con subtítulos.
Es el mismo fallo que el de modelo.letra unas líneas más arriba —leer un campo
un nivel por encima de donde vive— cometido dos veces en dos archivos distintos
para lo mismo.
Se avisó de que la opción no salía. Y se contestó que la pantalla existía, porque se había comprobado que la función existía.
Un grep encuentra código escrito. No dice si alguien puede llegar a él. Existir
y ser alcanzable no es lo mismo, y comprobar lo primero no comprueba lo segundo.
Por eso npm run pantalla-audio no mira si las funciones están: pinta la
pantalla con los datos de verdad, con la pista ya generada, y busca el botón
dentro del árbol que sale. Se comprobó en las dos direcciones —se volvió a meter
el fallo a propósito y se puso roja— porque una prueba que no se ha visto fallar
no se sabe si sirve.
«Me gustaría que la voz tuviera más volumen, que se sobreponga más por encima de la música. El volumen de la música está muy bien.»
La mezcla parecía correcta sobre el papel:
| Ganancia | |
|---|---|
| Voz | 0 dB — tal como la entrega el TTS |
| Música | −6 dB |
| Música debajo de una línea | −6 −9 = −15 dB |
Quince decibelios de separación es de sobra. Y aun así la voz quedaba debajo.
Porque esos quince decibelios son relativos a cada origen, y los dos orígenes no entregan al mismo nivel: Gemini TTS devuelve archivos flojos y Lyria los devuelve fuertes. Equilibrar dos cosas por su ganancia relativa, cuando salen de fábrica a niveles distintos, no equilibra nada.
Y debajo había un segundo problema más callado: cada bloque sonaba como le tocara. Dos bloques de la misma persona pueden salir del TTS con varios decibelios de diferencia, así que una escena suena más alta que la siguiente sin que nadie lo haya pedido.
Subir la voz «unos decibelios» a ojo arregla un bloque y estropea el siguiente. Lo que se hace es subir cada bloque a un volumen conocido, medido de su propio archivo.
El nivel se mide al alinear, que es cuando la función ya tiene el WAV delante: no cuesta ni una llamada más, y no hay que regenerar ninguna voz. Salen dos números:
rms_dbfs — lo que se percibe como volumen, medido solo donde se habla.
Se mide por ventanas de 50 ms y se tiran las que están más de 30 dB por debajo
de la más alta: eso deja el habla y descarta los silencios entre frases, que si
contaran dirían que se habla más bajo de lo que se habla.pico_dbfs — la muestra más alta. Dice cuánto se puede subir antes de
recortar.Con eso, el montaje calcula la subida: lo que pide el objetivo (−16 dBFS de habla), limitado por lo que deja el pico (techo −1,5 dBFS). Manda el más pequeño de los dos, con tope de +12 dB para arriba —más subiría el ruido de fondo con la voz— y −6 dB para abajo.
Un bloque flojo sube mucho, uno fuerte sube poco, y los dos acaban sonando igual.
Tres cosas que no hace:
Y nada de esto toca el montador: ganancia_db es un campo del manifiesto que ya
entendía. No hay que redesplegar nada — sube con Vercel como el resto.
Hay que volver a medir los tiempos de cada bloque, una vez. El volumen se mide en esa misma llamada. La voz no hay que regenerarla.
Un montaje que sale se queda apuntado para siempre, y como una pieza se remonta varias veces hasta que queda bien, se acumulan: los buenos, los viejos y los que salieron mal, todos en la misma lista y todos ocupando bucket.
Cada tarjeta de «Lo montado» lleva ahora su botón de Borrar: borra el vídeo del bucket y quita su apunte, en ese orden. Al revés —quitar el apunte y fallar al borrar— dejaría un archivo pagando bucket para siempre sin que nada lo nombre ya.
Lo que no se hace es que un montaje nuevo pise al anterior. Es tentador y es peligroso: si el nuevo sale peor, o falla a mitad, se habría perdido el bueno y volver a tenerlo son otra vez los minutos de máquina. Se borra a mano, mirando el vídeo, y solo lo que se decida borrar. Lo generado —los planos, las voces, la música— no se toca: eso sigue donde está y no hay que pagarlo otra vez.
Porque ahí no hay nadie hablando: hay una canción. Y son dos caminos distintos a propósito, no un olvido.
| Lo que hay | Cómo se marcan los tiempos | |
|---|---|---|
| Teaser y los 12 episodios | diálogo (audio.voz) | botón «Medir los tiempos»: Speech-to-Text mide sobre el WAV |
| Opening y ending | canción (audio.musica) | con el dedo, dándole al play y marcando cada verso |
Lyria devuelve la canción y no devuelve dónde entra cada verso — no hay nada que medir, así que un botón de medir ahí sería un botón que no puede funcionar. Se marcan oyéndola, que además es lo correcto: un verso entra donde entra la música, no donde acaba la palabra anterior.
Así que sí: todo lo de los subtítulos aplica igual a los doce episodios, por el mismo camino que el teaser, porque los episodios llevan diálogo. El opening y el ending son la excepción, y lo son porque son canciones.
Este es el fallo que más caro salió, y era invisible.
El nombre de una ejecución de Cloud Run es
projects/802391847265/locations/us-central1/jobs/…/executions/…: lleva dentro el
número de proyecto. El censor de la puerta lo tacha al salir — eso está bien y
es exactamente su trabajo, porque este repositorio es público.
Pero ese nombre viajaba al navegador, se guardaba en la cola ya roto
(projects/«tachado»/…) y en la vuelta siguiente se le mandaba a Google.
Google contesta 403 CONSUMER_INVALID. Que se lee como «esta service account
no tiene permiso». Y no tiene nada que ver con los permisos.
Se revisaron los papeles de la cuenta. Se revisaron las APIs del proyecto. Se volvió a ejecutar el instalador entero. Todo estaba bien, porque todo estaba bien.
Y era el mismo fallo que ya estaba resuelto para Veo, con esta misma solución
y por este mismo motivo — está escrito en app/cola.js, en el normalizador de
clip-consultar — y no se había llevado al montaje.
La regla, ahora en los dos sitios y escrita en el contrato §2:
Un nombre de recurso de Google no viaja al navegador. Lleva secretos dentro, el censor los tacha, y lo que vuelve es un nombre que ya no sirve. Se guarda en el bucket y el navegador manda una clave suya —la pieza y la toma, o el nombre del trabajo— con la que la función lo busca.
npm run fallos ejecuta el censor de verdad sobre un nombre de ejecución de
verdad y comprueba que se rompe, que la función lo reconoce en vez de mandárselo
a Google, y que el navegador ya no lo manda.
Un 403 de Google puede ser una de estas dos, y se arreglan en sitios distintos:
El mensaje mandaba a revisar las dos. La mitad de las veces, eso es mandar a revisar la que estaba bien: una tarde en la consola de Google mirando permisos perfectos.
Y no hace falta adivinar, porque Google lo dice. Dentro de su respuesta viene
un campo reason, y ahí pone:
reason | Qué es de verdad | Dónde se arregla |
|---|---|---|
IAM_PERMISSION_DENIED | A la cuenta le falta un rol | despliegue/permisos.txt |
SERVICE_DISABLED | La API está apagada | despliegue/apis.txt |
CONSUMER_INVALID | Este proyecto no puede usar esa API | despliegue/apis.txt, o la facturación |
BILLING_DISABLED | La facturación está desactivada | La consola de Google Cloud |
Ese dato se leía y se tiraba. Ahora decide la frase.
CONSUMER_INVALID merece frase propia porque es el más confuso: se lee como «no
tienes permiso» y significa «este proyecto no puede usar esta API», que casi
siempre es que nunca se encendió. Se distingue en un segundo mirando si otra
API del mismo proyecto responde: si las imágenes se generan y el montaje no, la
cuenta está bien y lo que falta es el interruptor.
Y si Google contesta un reason que no conocemos, no se inventa un diagnóstico:
se dice lo que hay y se manda a leer, palabra por palabra, lo que ha contestado.
npm run fallos lo comprueba con las cinco respuestas.
npm run audio existe porque una suposición sobre el formato del audio ya rompió
la producción: el código daba por hecho que todo lo que no fuera WAV era PCM
crudo, así que cuando Lyria contestó con un MP3 (audio/mpeg) el estudio
rechazó una música que estaba perfectamente bien, pidiéndole un muestreo que un
MP3 no tiene por qué declarar aparte.
Ahora el estudio entiende tres cosas, y guarda cada una como lo que es:
| Lo que llega | Se guarda | La duración sale de |
|---|---|---|
| WAV | .wav | su cabecera fmt/data |
| MP3 | .mp3 | su cabecera Xing/VBRI, y si no la trae, de su tasa |
| PCM crudo | .wav, envuelto | el muestreo del propio mimeType |
Lo que no se hace es suponer un muestreo cuando no lo hay: esa duración
coloca la pieza en el montaje y arrastra todo lo que va detrás, así que antes que
inventarse un número se falla y se dice por qué. Y el formato se decide mirando
los bytes, no la etiqueta: un audio/mpeg cuyos bytes no sean de MP3 no se
guarda como MP3.
npm run audio prueba además dónde acaba cada línea dentro del bloque: se le
dan palabras con tiempos puestos a mano —con silencios donde se sabe— y se mira
si los cortes caen ahí y no en la cuenta de caracteres.
npm run subtitulos prueba la frase de verdadherramientas/probar-subtitulos.mjs carga la pantalla de Montaje sin DOM y le
pide que parta la frase que salió mal en el teaser, con los tiempos que tenía,
contra los tres pedazos que se pidieron. Si algún día alguien toca el reparto y
esa frase deja de partirse así, se pone rojo con el texto que sale y el que
debería salir.
Comprueba también lo que hace cuando el reparto no cabe: más pausas que palabras, una sola palabra, texto vacío. Ninguno de esos casos puede dejar un subtítulo sin texto ni un tiempo sin duración, porque un subtítulo se quema en la imagen.
Y prueba la regla de la boca contra el teaser de verdad: que la línea de la
madre se adelanta a B2, que la de Saharis no se adelanta a D5 —ahí tiene
la boca en cuadro pero quieta—, y que el resultado no depende del orden en que
estén escritos los planos.
Antes de escribir una línea, docs/contrato.md: es la única fuente de firmas, nombres de campo y reglas. Si algo no está ahí, se decide y se añade ahí primero.
Lo demás que conviene leer: docs/plan-de-construccion.md (el encargo original,
con las trampas ya pagadas), docs/decisiones.md (lo que hubo que decidir al
construir y qué alternativa se descartó) y docs/patch-datos.md (qué le cambia
el parche a los datos y por qué).
Todo el código y todos los comentarios van en español. Solo van en inglés los
prompts a los modelos de imagen y de vídeo y los encargos a Lyria, porque no
admite otra cosa. Ni un project id, ni un bucket, ni un correo, ni una clave, ni
un id de modelo escritos en el código: salen de variables de entorno y de
datos/serie.json.
Desglose — se elige un episodio y se convierte en planos, una llamada de texto por escena, sin campos de texto y sin pantalla de aprobación.
Tomas — la lista de la pieza activa filtrable por estado: keyframe, aprobar, clip, intentos, elegir.
Audio — la música de cada pieza, el banco de la temporada —las dieciocho piezas que suenan dentro de los episodios, en su propia sección que no cambia al cambiar de pieza— y las voces de cada bloque, con reproductor: nada entra en un montaje sin haber sonado antes aquí. Y el marcador de letra, para poner los subtítulos de las canciones oyéndolas.
Cola — qué se está generando ahora, qué falló y por qué, el botón de detener y el contador de gasto.
Montaje — montar por escenas, actos y episodio, y reproducir o descargar el resultado por URL firmada.
Difusión — lo que hace falta para PUBLICAR, que no es lo mismo que lo que hace falta para producir: la ficha con la que se sube cada pieza, el zip con el vídeo y esa ficha dentro, los reels de treinta segundos en vertical armados solos, y los pósters y miniaturas en 9:16 o 16:9.