Carregando...
Carregando...
Neste tutorial você vai criar do zero um lower third (tarja) com qualidade de produção — do mesmo tipo que você vê na CBS, na BBC ou em qualquer telejornal. Ele entra deslizando, mostra um nome e um cargo, é atualizado ao vivo e sai deslizando.
Crie uma pasta nova com estes quatro arquivos. Esse é o seu pacote OGraf inteiro — sem ferramentas de build, sem npm, sem framework.
Só isso. Quatro arquivos. Sem node_modules, sem package.json, sem etapa de build. Pacotes OGraf são arquivos web comuns.
O manifesto diz a qualquer sistema OGraf quem é o seu grafismo e do que ele precisa. Quando um operador carrega o grafismo no SPX ou em qualquer controlador, este arquivo é a primeira coisa que ele lê. É dele que sai, automaticamente, o formulário de dados que você viu na demo acima.
{
"$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"
}
}
}
}Identidade
id e name — como os controladores identificam e exibem o seu grafismo.
Comportamento
stepCount: 1 — um passo: ele aparece, fica na tela e some quando é parado.
Ponto de entrada
main — aponta para o arquivo JavaScript com a classe do Web Component.
Schema de dados
schema — define os campos do formulário. Os controladores geram a interface de entrada automaticamente a partir dele.
Um pacote OGraf é uma pasta pequena com um manifesto, um módulo JavaScript, uma folha de estilo e os assets estáticos de que o grafismo precisar. Não existe ponto de entrada HTML — o renderizador monta a classe exportada por padrão sob uma tag própria, então o módulo só precisa exportar uma classe que estende HTMLElement.
lower-third/
├── lower-third.ograf.json
├── graphic.mjs
├── style.css
└── fonts/
├── Inter-Medium.woff2
├── Inter-Bold.woff2
└── LICENSE.txtA pasta fonts/ traz os pesos da Inter que este grafismo usa, junto com a licença (SIL OFL) — máquinas de playout muitas vezes ficam offline, então embutir as fontes evita chamadas a CDN que falhariam sem aviso.
É aqui que mora o design visual. Vamos criar um visual limpo inspirado na CBS: fundo branco, barra de destaque azul à esquerda, cargo em azul e caixa alta. A entrada usa transições CSS com easing cubic-bezier para dar aquela sensação de qualidade de 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;
}Dica de design
O easing cubic-bezier(0.16, 1, 0.3, 1) é o segredo — começa rápido e desacelera suavemente, dando aquele movimento ágil típico de broadcast. A animação de saída usa cubic-bezier(0.76, 0, 0.24, 1) para uma saída rápida e marcante.
Este é o coração do seu grafismo OGraf. É um Web Component padrão que o renderizador controla chamando seis métodos — cinco passos lineares do ciclo de vida, mais customAction para extras específicos do grafismo. Cada um retorna uma Promise: o renderizador espera a sua animação terminar antes de fazer qualquer outra coisa.
load
Recebe os dados
play
Anima a entrada
update
Troca os dados
stop
Anima a saída
dispose
Faz a limpeza
// 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.Como funciona
_initDom() — Um helper privado e idempotente. O primeiro método público a rodar o chama para definir o innerHTML e pegar as referências dos elementos. Assim o grafismo funciona tanto se o renderizador inserir o elemento antes quanto depois de chamar load().
load() — Recebe os dados do operador (nome + cargo) e os coloca no DOM. Ainda sem animação.
playAction() — Calcula para qual passo ir a partir de goto / delta, exatamente como a especificação define. Um lower third tem um passo só, então o primeiro play cai no passo 0: adiciona a classe .visible, espera 700ms pela entrada e informa currentStep: 0. Um segundo play passa do último passo, então o grafismo sai do ar e informa currentStep: undefined — é disso que depende o botão "próximo" de um controlador.
updateAction() — Troca o conteúdo do texto. A verificação é !== undefined, e não por valor truthy, para que um operador que esvazia um campo de fato o limpe. Em produção, você adicionaria uma animação suave de troca de texto.
stopAction() — Adiciona a classe .out para a animação de saída e espera 500ms. Toda ação incrementa _rev, e o stop só esconde o grafismo se nada mais novo tiver começado — senão um operador que apertasse play de novo no meio da saída acabaria com a tela vazia.
customAction() — O OGraf exige que todo grafismo exponha este método, mesmo sem nenhuma ação declarada no manifesto. Ele recebe { id, payload, skipAnimation }; sem nada declarado, responder a qualquer id com um 4xx como statusCode: 404 é o comportamento padrão correto.
dispose() — Limpa o DOM e redefine _initialized para que um novo load reconstrua tudo do zero. É chamado quando o grafismo é removido do renderizador de vez.
Seu grafismo está pronto. Veja como testar:
Opção A: use a demo ao vivo acima
Role para cima — a prévia interativa no topo desta página roda exatamente o mesmo código. Clique em Reproduzir, mude o texto, clique em Atualizar, clique em Parar.
Opção B: verifique o seu pacote
Compacte a pasta num .zip e solte em /check. Você recebe um relatório estruturado com 85 regras e o schema da EBU em vigor.
Abrir o verificadorOpção C: carregue num renderizador OGraf
Publique num renderizador compatível: ograf-server (referência auto-hospedada), SPX-GC (controlador no navegador) ou CasparCG (pelo HTML producer). Os links estão no card de download abaixo.
Um pacote OGraf Graphics Definition v1 de verdade. Um renderizador compatível lê o manifesto e conduz o ciclo de vida. Licença MIT; coloque em qualquer sistema compatível com OGraf.
lower-third.ograf.json
Manifesto — o que o renderizador lê (id, schema, flags de ciclo de vida)
graphic.mjs
Web Component com load / play / update / stop / customAction / dispose
style.css
Folha de estilo, carregada pelo graphic.mjs com uma tag <link>
thumbnail.webp
Prévia em 1920×1080, declarada no manifesto
README.md
Instruções de uso
LICENSE
MIT
Este pacote funciona em qualquer sistema compatível com OGraf — SPX, ograf-server, CasparCG (pelo HTML producer) e outros. Os mesmos arquivos, em qualquer lugar.

Bug / AO VIVO
Indicador de canto com pulso

Ticker de notícias
Manchetes rolando na tela

Citação em tela cheia
Tipografia cinematográfica em tela cheia

Barras de eleição
Gráfico de porcentagens animado

Escalação esportiva
Grade com o elenco do time

Placar
Placar de partida ao vivo

Contagem regressiva
Relógio que avança sozinho

Plantão
Alerta urgente em tela cheia

Previsão do tempo
Condições atuais e previsão de 3 dias

Card de rede social
Post sobreposto com avatar