Cargando...
Cargando...
En este tutorial vas a crear desde cero un rótulo inferior con calidad de producción — como los que ves en CBS, en la BBC o en cualquier informativo. Entra deslizándose, muestra un nombre y un cargo, se actualiza en directo y sale deslizándose.
Crea una carpeta nueva con estos cuatro archivos. Ese es todo tu paquete OGraf — sin herramientas de build, sin npm, sin framework.
Eso es todo. Cuatro archivos. Sin node_modules, sin package.json, sin paso de build. Los paquetes OGraf son archivos web normales.
El manifiesto le dice a cualquier sistema OGraf quién es tu grafismo y qué necesita. Cuando un operador carga tu grafismo en SPX o en cualquier controlador, este archivo es lo primero que lee. A partir de él se genera automáticamente el formulario de datos que has visto en la demo de arriba.
{
"$schema": "https://ograf.ebu.io/v1/specification/json-schemas/graphics/schema.json",
"id": "dev.ograf.tutorial.lower-third",
"version": "1.0.0",
"name": "CBS-Style Lower Third",
"description": "Clean white and blue lower third with slide-in animation. Built as part of the ograf.dev tutorial.",
"author": {
"name": "ograf.dev",
"url": "https://ograf.dev"
},
"main": "graphic.mjs",
"stepCount": 1,
"supportsRealTime": true,
"supportsNonRealTime": false,
"thumbnails": [
{
"file": "thumbnail.webp",
"resolution": {
"width": 1920,
"height": 1080
}
}
],
"schema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"title": "Name",
"gddType": "single-line",
"default": "Jane Smith"
},
"title": {
"type": "string",
"title": "Title",
"gddType": "single-line",
"default": "Senior Graphics Engineer"
}
}
}
}Identidad
id y name — cómo identifican y muestran tu grafismo los controladores.
Comportamiento
stepCount: 1 — un paso: aparece, se mantiene y desaparece al detenerlo.
Punto de entrada
main — apunta a tu archivo JavaScript con la clase del Web Component.
Schema de datos
schema — define los campos del formulario. Los controladores generan automáticamente la interfaz de entrada a partir de él.
Un paquete OGraf es una carpeta pequeña con un manifiesto, un módulo JavaScript, una hoja de estilos y los recursos estáticos que necesite el grafismo. No hay punto de entrada HTML — el renderizador monta la clase exportada por defecto bajo su propia etiqueta, así que al módulo le basta con exportar una clase que extienda HTMLElement.
lower-third/
├── lower-third.ograf.json
├── graphic.mjs
├── style.css
└── fonts/
├── Inter-Medium.woff2
├── Inter-Bold.woff2
└── LICENSE.txtLa carpeta fonts/ incluye los pesos de Inter que usa este grafismo junto con su licencia (SIL OFL) — los equipos de playout suelen estar sin conexión, así que empaquetar las fuentes evita llamadas a una CDN que fallarían sin avisar.
Aquí vive el diseño visual. Vamos a crear un aspecto limpio inspirado en CBS: fondo blanco, barra de acento azul a la izquierda y cargo en azul y mayúsculas. La entrada usa transiciones CSS con easing cubic-bezier para lograr ese acabado de calidad broadcast.
/* style.css -- loaded via <link> injected by graphic.mjs.
URLs below resolve relative to this file, so the fonts in ./fonts/ just work. */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./fonts/Inter-Medium.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./fonts/Inter-Bold.woff2') format('woff2');
}
.l3rd, .l3rd *, .l3rd *::before, .l3rd *::after {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.l3rd {
position: absolute; /* NOT fixed -- anchor to the renderer's frame */
bottom: 64px;
left: 48px;
font-family: 'Inter', system-ui, sans-serif;
display: flex;
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
}
.l3rd.visible {
transform: translateX(0);
opacity: 1;
filter: blur(0);
transition: transform 0.7s cubic-bezier(0.16, 1, 0.3, 1),
opacity 0.5s ease, filter 0.5s ease;
}
.l3rd.out {
transform: translateX(-120%);
opacity: 0;
filter: blur(4px);
transition: transform 0.5s cubic-bezier(0.76, 0, 0.24, 1),
opacity 0.4s ease 0.1s, filter 0.4s ease 0.1s;
}
.l3rd-accent {
width: 5px;
background: linear-gradient(180deg, #2563eb, #1d4ed8);
border-radius: 3px 0 0 3px;
}
.l3rd-content {
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(20px);
padding: 16px 32px 16px 20px;
border-radius: 0 6px 6px 0;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
}
.l3rd-name {
font-size: 22px;
font-weight: 700;
color: #0f172a;
}
.l3rd-title {
font-size: 13px;
font-weight: 500;
color: #2563eb;
text-transform: uppercase;
letter-spacing: 0.02em;
margin-top: 3px;
}Consejo de diseño
El easing cubic-bezier(0.16, 1, 0.3, 1) es la clave — arranca rápido y decelera con suavidad, lo que da ese movimiento ágil tan propio del broadcast. La animación de salida usa cubic-bezier(0.76, 0, 0.24, 1) para una salida rápida y contundente.
Este es el corazón de tu grafismo OGraf. Es un Web Component estándar que el renderizador controla llamando a seis métodos — cinco pasos lineales del ciclo de vida más customAction para extras propios del grafismo. Cada uno devuelve una Promise: el renderizador espera a que termine tu animación antes de hacer cualquier otra cosa.
load
Recibe datos
play
Anima la entrada
update
Cambia datos
stop
Anima la salida
dispose
Limpia
// Resolve the stylesheet URL relative to this module so it loads no matter
// where the renderer serves the package from.
const STYLE_URL = new URL('./style.css', import.meta.url).href;
const TEMPLATE = `
<link rel="stylesheet" href="${STYLE_URL}">
<div class="l3rd">
<div class="l3rd-accent"></div>
<div class="l3rd-content">
<div class="l3rd-name"></div>
<div class="l3rd-title"></div>
</div>
</div>
`;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
// The spec's step rule: goto wins; otherwise current step (-1 before the
// first play) + delta, which defaults to 1. At or past stepCount → the end.
function resolveTargetStep(currentStep, { goto, delta } = {}, stepCount = 1) {
const target = Number.isInteger(goto) && goto >= 0
? goto
: (currentStep ?? -1) + (Number.isInteger(delta) ? delta : 1);
return target >= stepCount ? undefined : Math.max(target, 0);
}
export default class LowerThird extends HTMLElement {
_initDom() {
if (this._initialized) return; // idempotent
this.innerHTML = TEMPLATE;
this._root = this.querySelector('.l3rd');
this._name = this.querySelector('.l3rd-name');
this._title = this.querySelector('.l3rd-title');
this._step = undefined; // "start": nothing on air yet
this._rev = 0; // bumped by every action
this._initialized = true;
}
async load({ data } = {}) {
this._initDom(); // <-- first line of every public method
if (data?.name !== undefined) this._name.textContent = data.name;
if (data?.title !== undefined) this._title.textContent = data.title;
return { statusCode: 200 };
}
async playAction({ goto, delta, skipAnimation } = {}) {
this._initDom();
const target = resolveTargetStep(this._step, { goto, delta });
if (target === undefined) { // "next" on the last step = go off air
await this.stopAction({ skipAnimation });
return { statusCode: 200, currentStep: undefined };
}
++this._rev;
this._step = target;
this._root.classList.remove('out');
if (skipAnimation) {
this._root.classList.add('visible');
return { statusCode: 200, currentStep: this._step };
}
void this._root.offsetWidth; // force reflow before transition
this._root.classList.add('visible');
await sleep(700);
return { statusCode: 200, currentStep: this._step };
}
async stopAction({ skipAnimation } = {}) {
this._initDom();
const rev = ++this._rev;
this._step = undefined;
if (skipAnimation) {
this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}
this._root.classList.add('out');
await sleep(500);
// A play that arrived while we were animating out wins.
if (rev === this._rev) this._root.classList.remove('visible', 'out');
return { statusCode: 200 };
}
async updateAction({ data } = {}) {
this._initDom();
// !== undefined, not a truthy check: an empty string clears the field.
if (data?.name !== undefined) this._name.textContent = data.name;
if (data?.title !== undefined) this._title.textContent = data.title;
return { statusCode: 200 };
}
// Required on every graphic, even when the manifest declares no customActions.
// The renderer calls customAction({ id, payload, skipAnimation }).
async customAction({ id } = {}) {
return { statusCode: 404, statusMessage: `Unknown custom action: ${id ?? ''}` };
}
async dispose() {
this._rev++; // cancels anything still pending
this.innerHTML = '';
this._initialized = false; // reset so a re-load re-inits
return { statusCode: 200 };
}
}
// Note the absence of customElements.define() -- the renderer picks the tag.Cómo funciona
_initDom() — Un helper privado e idempotente. El primer método público que se ejecuta lo llama para asignar innerHTML y guardar las referencias a los elementos. Así el grafismo funciona tanto si el renderizador inserta el elemento antes de llamar a load() como si lo hace después.
load() — Recibe los datos del operador (nombre + cargo) y los coloca en el DOM. Todavía sin animación.
playAction() — Calcula a qué paso ir a partir de goto / delta, exactamente como lo define la especificación. Un rótulo inferior tiene un solo paso, así que el primer play cae en el paso 0: añade la clase .visible, espera 700ms a que termine la entrada y devuelve currentStep: 0. Un segundo play se pasa del último paso, así que el grafismo sale de antena y devuelve currentStep: undefined — en eso se basa el botón "siguiente" de un controlador.
updateAction() — Cambia el contenido del texto. La comprobación es !== undefined y no si el valor es truthy, de modo que si un operador vacía un campo, este se borra de verdad. En producción añadirías una animación suave para el cambio de texto.
stopAction() — Añade la clase .out para la animación de salida y espera 500ms. Cada acción incrementa _rev, y el stop solo oculta el grafismo si no ha empezado nada más reciente — si no, un operador que volviera a pulsar play en plena salida acabaría con la pantalla vacía.
customAction() — OGraf exige que todo grafismo exponga este método, aunque el manifiesto no declare ninguna. Recibe { id, payload, skipAnimation }; si no hay nada declarado, responder a cualquier id con un 4xx como statusCode: 404 es el comportamiento por defecto correcto.
dispose() — Vacía el DOM y reinicia _initialized para que una nueva carga lo reconstruya todo limpiamente. Se llama cuando el grafismo se elimina del renderizador por completo.
Tu grafismo está listo. Así puedes probarlo:
Opción A: usa la demo en directo de arriba
Sube un poco — la vista previa interactiva de la parte superior de esta página ejecuta exactamente el mismo código. Pulsa Reproducir, cambia el texto, pulsa Actualizar y pulsa Detener.
Opción B: verifica tu paquete
Comprime la carpeta en un .zip y suéltalo en /check. Obtendrás un informe estructurado con 85 reglas y el schema vigente de la EBU.
Abrir el verificadorOpción C: cárgalo en un renderizador OGraf
Despliégalo en un renderizador compatible: ograf-server (referencia autoalojada), SPX-GC (controlador en el navegador) o CasparCG (mediante el HTML producer). Tienes los enlaces en la tarjeta de descarga de abajo.
Un paquete OGraf Graphics Definition v1 real. Un renderizador compatible lee el manifiesto y gestiona el ciclo de vida. Con licencia MIT; úsalo en cualquier sistema compatible con OGraf.
lower-third.ograf.json
Manifiesto — lo que lee el renderizador (id, schema, flags del ciclo de vida)
graphic.mjs
Web Component con load / play / update / stop / customAction / dispose
style.css
Hoja de estilos, cargada por graphic.mjs mediante una etiqueta <link>
thumbnail.webp
Vista previa de 1920×1080, declarada en el manifiesto
README.md
Notas de uso
LICENSE
MIT
Despliégalo en un renderizador OGraf compatible
Este paquete funciona con cualquier sistema compatible con OGraf — SPX, ograf-server, CasparCG (mediante el HTML producer) y más. Los mismos archivos, en todas partes.

Mosca / EN DIRECTO
Indicador de esquina con pulso

Ticker de noticias
Titulares en desplazamiento

Cita a pantalla completa
Tipografía cinematográfica a pantalla completa

Barras electorales
Gráfico de porcentajes animado

Alineación deportiva
Cuadrícula con la plantilla del equipo

Marcador
Marcador de partido en directo

Cuenta atrás
Reloj que avanza solo

Última hora
Alerta urgente a pantalla completa

Previsión meteorológica
Condiciones actuales y previsión a 3 días

Tarjeta de redes sociales
Publicación sobreimpresa con avatar