flow-bridge: o protocolo postMessage dos templates

Cross-origin lifecycle for play, seek, capture — one library, one canonical data shape, OGraf v1 compatible.

flow-bridge e a unica biblioteca em runtime de um Edge Template. Ela roda dentro do iframe do template, conversa com o host (editor da Insyde, recorder, flow-playout) via postMessage, e fala com o engine do template via uma interface uniforme. O template em si fica magrissimo: declara metodos semanticos (load, playAction, captureFrame, etc.) e o resto e responsabilidade do bridge.

flow-bridge is the only runtime library inside an Edge Template. It runs in the iframe, talks to the host (Insyde editor, recorder, flow-playout) over postMessage, and talks to the template engine through a uniform interface. The template itself stays minimal: declare semantic methods (load, playAction, captureFrame, etc.) and the bridge handles the rest.

1. Arquitetura / Architecture

┌─────────────────────────────────────────────────────────────┐
│  Host (editor / recorder / playout / OGraf renderer)        │
│                                                             │
│  values (canonical) ── flow:update ──────→  │               │
│                                             │ postMessage   │
│  ←── flow:loaded / flow:played / ──────────┤               │
│      flow:error / flow:captureDone          │               │
└─────────────────────────────────────────────┼───────────────┘
                                              │
                                   ┌──────────▼──────────┐
                                   │  flow-bridge.js v2  │
                                   │                     │
                                   │  • bootstrap        │
                                   │  • config.json fetch│
                                   │  • autoParseQS      │
                                   │  • OGraf adapter    │
                                   │  • postMessage I/O  │
                                   │  • error wrapping   │
                                   │  • lifecycle FSM    │
                                   └──────────┬──────────┘
                                              │
                                   ┌──────────▼──────────┐
                                   │  template engine    │
                                   │  Web Component OR   │
                                   │  window.flowEngine  │
                                   └─────────────────────┘

2. Por que postMessage / Why postMessage

A same-origin policy do browser bloqueia iframe.contentWindow.x quando o iframe esta em outro origin. CORS nao desbloqueia isso. document.domain foi descontinuado. A unica saida limpa e postMessage — permite mensagens estruturadas e ate transferir ImageBitmap sem copia (zero-copy) atraves do limite de origem.

Browser same-origin policy blocks iframe.contentWindow.x across origins. CORS does not unlock that. document.domain is deprecated. The only clean path is postMessage — structured messages and zero-copy ImageBitmap transfers across the origin boundary.

3. Wire-up

O template inclui o bridge depois do app.js e da lib de captura. O app.js declara dois facades sobre o mesmo engine: um Web Component (pra OGraf strict) e window.flowEngine (pro bridge encontrar predizivelmente).

The template includes the bridge after app.js and the capture lib. app.js declares two facades over the same engine: a Web Component (for strict OGraf) and window.flowEngine (for the bridge to find predictably).

index.html

<script defer src="vendor/gsap.min.js"></script>
<script defer src="vendor/dom-to-image-more.min.js"></script>
<script defer src="app.js"></script>
<script defer src="vendor/flow-bridge.min.js"></script>

app.js (esqueleto / skeleton)

class Engine {
  async load({ data, isUpdate = false } = {}) {
    // Read canonical (long-name) data.
    if (data.headline != null) headlineEl.textContent = data.headline;
    const bars = Array.isArray(data.bars) ? data.bars : DEFAULTS;
    // ... render DOM, build timeline (paused at 0)
    return { statusCode: 200 };
  }
  async playAction({ skipAnimation } = {}) {
    skipAnimation ? tl.progress(1).pause() : tl.play(0);
    return { statusCode: 200, currentStep: 1 };
  }
  async stopAction({ skipAnimation } = {}) {
    skipAnimation ? tl.pause(0) : tl.reverse();
    return { statusCode: 200 };
  }
  async dispose()        { return { statusCode: 200 }; }
  getDuration()             { return tl.duration() * 1000; }
  seek(timeMs)              { tl.pause(); tl.time(timeMs / 1000); }
  async captureFrame(w, h) { const c = await domtoimage.toCanvas(document.body, { width: w, height: h }); return await createImageBitmap(c); }
}

const engine = new Engine();

// (a) Web Component — OGraf v1 strict
class EleicoesXxx extends HTMLElement {
  static get capabilities() { return ['realtime', 'nonRealtime']; }
  async load(args)        { return engine.load(args); }
  async playAction(args)  { return engine.playAction(args); }
  async stopAction(args)  { return engine.stopAction(args); }
  async dispose()         { return engine.dispose(); }
  getDuration()             { return engine.getDuration(); }
  seek(t)                   { return engine.seek(t); }
  captureFrame(w, h)       { return engine.captureFrame(w, h); }
}
customElements.define('eleicoes-xxx', EleicoesXxx);

// (b) flow-bridge global facade — same engine instance
window.flowEngine = {
  definition: { name: 'eleicoes-xxx', capabilities: ['realtime', 'nonRealtime'] },
  load: (args)         => engine.load(args),
  playAction: (args)   => engine.playAction(args),
  stopAction: (args)   => engine.stopAction(args),
  dispose: ()          => engine.dispose(),
  getDuration: ()      => engine.getDuration(),
  seek: (t)            => engine.seek(t),
  captureFrame: (w, h) => engine.captureFrame(w, h),
};

Sem bootstrap inline. Sem parseDataFromQS. Sem flag ?record=1. O bridge cuida de tudo. O template fica focado em renderizar.

No inline bootstrap. No parseDataFromQS. No ?record=1 branch. The bridge owns those concerns. The template focuses on rendering.

4. Bootstrap (o que o bridge faz no carregamento) / Bootstrap (what the bridge does on load)

  1. Resolve engine. Procura window.flowEngine; se nao tiver, escaneia o DOM por um custom element com load + playAction. Faz polling ate 5s pra cobrir variacoes de ordem de carregamento.
  2. Fetch ./config.json. Carrega o schema do template (irmao de index.html). Se faltar, cai num parser generico.
  3. autoParseQS. Le a querystring e converte pra shape canonico usando o schema:
    • Scalar: data[field.name] = coerce(params.get(field.key), field.type)
    • Tabela: itera ${col.key}${i} pra cada coluna em cada linha → data[field.name] = [{ <col.key>: value }, ...]
  4. Initial load. engine.load({ data }) — template aplica dados, monta DOM, monta timeline pausado em 0.
  5. Posta flow:ready { definition }. Host sabe que template ta carregado. Drena qualquer mensagem que chegou antes (queue interno).
  6. Auto-play decision:
    • window.parent === window (aba isolada de fato): auto-play.
    • ?autoplay=1 (override explicito, util pra embeds): auto-play mesmo em iframe.
    • Caso contrario (em iframe): NAO auto-play. Espera flow:play do host.

5. Shape canonico de dados / Canonical data shape

Uma unica shape e usada ponta-a-ponta: estado do form na flow-front, payload do flow:update, e o data que o engine recebe.

One shape used end-to-end: flow-front form state, flow:update payload, and the data the engine receives.

{
  // scalars: long names from config.field.name
  headline: 'PESQUISA DE INTENCAO DE VOTOS',
  subheadline: 'SAO PAULO',
  source: 'CNN ATLAS | 1o TURNO',
  showPartyColor: true,
  gfxMode: true,
  barCount: 8,

  // tables: rows array, each row keyed by config.column.key
  bars: [
    { n: 'MARCIO FRANCA', p: 'PL', va: 41, vn: 3, vc: 56 },
    { n: '..', p: 'PSB', va: 36, vn: 9, vc: 55 },
  ],
}

A URL mantem chaves curtas (?hl=...&n1=...&va1=...) por compactness — isso e uma preocupacao de serializacao tratada exclusivamente pelo bridge.

The URL keeps short keys for compactness — that's a serialization concern handled exclusively by the bridge.

6. Engine contract

MethodWhen calledReturnsNotes
load({ data, isUpdate? })on flow:update (host) ou na bootstrap{ statusCode }Aplica dados. isUpdate=true → patch in place; bridge depois chama playAction({skipAnimation:true}) pra snap pro estado final.
playAction({ skipAnimation? })on flow:play{ statusCode, currentStep }Inicia animacao. skipAnimation=true pula direto pro fim sem animar.
stopAction({ skipAnimation? })on flow:stop{ statusCode }Reverse / out animation.
dispose()on flow:dispose{ statusCode }Cleanup — mata timelines, listeners, etc.
next()on flow:nextanyMulti-step templates apenas. Opcional.
getDuration()on flow:getDurationnumber (ms)Necessario pra nonRealtime.
seek(timeMs)on flow:seekvoidNecessario pra nonRealtime. Pausa antes de mover.
captureFrame(w, h)on flow:captureImageBitmapNecessario pra nonRealtime. Roda DENTRO do iframe (resolve @font-face certo).
Sobre { statusCode: 200 }: e o shape esperado pelo OGraf v1, mas o bridge nao valida nem usa o valor — ele so awaita a Promise. Retornar e recomendado pra compliance OGraf, mas nao obrigatorio.
About { statusCode: 200 }: this is the OGraf v1 expected shape, but the bridge does not validate nor consume the value — it only awaits the Promise. Returning it is recommended for OGraf compliance, but not enforced.

Callbacks atribuidos pelo bridge / Bridge-assigned callbacks

Apos resolveEngine, o bridge sobrescreve tres propriedades do engine pra servir como hooks de notificacao. O engine chama esses metodos quando atinge marcos internos — o bridge converte em mensagens postMessage:

After resolveEngine, the bridge overwrites three properties on the engine to serve as notification hooks. The engine calls these methods when it reaches internal milestones — the bridge converts each call into a postMessage:

Engine callBridge postsQuando / When
this.onAnimationDone(phase) flow:played { phase } Animacao chegou ao fim natural. phase: 'in' ou 'out'. Default 'in' se omitido.
this.onStopped() flow:stopped Out-animation completou (alternativa a esperar o promise de stopAction resolver).
this.onError(err) flow:error { code: 'INVALID_DATA', message } Erro de runtime que o engine quer surfacear pro host.

Esses callbacks sao opcionais — o engine nao precisa cha-los. Se a sua animacao tem fim natural e o host quer saber, dispare onAnimationDone. Se nao, omita.

These callbacks are optional — the engine doesn't need to call them. If your in-animation has a natural end and the host wants to know, fire onAnimationDone. Otherwise omit.

7. Message reference

Host → iframe

TypePayloadEffect
flow:update{ fields, isUpdate?: bool }Aplica dados; bridge devolve flow:loaded. Se isUpdate, snap-to-end automatico.
flow:play{ skipAnimation?: bool }Mapeado pra playAction.
flow:stop{ skipAnimation?: bool }Mapeado pra stopAction.
flow:nextMulti-step.
flow:seek{ t: ms }Resposta: flow:seekDone.
flow:capture{ width, height }Resposta: flow:captureDone { bitmap } (transferable).
flow:getDurationResposta: flow:duration { durationMs }.
flow:disposeCleanup. Bridge para de aceitar mensagens.
flow:helloRe-emite flow:ready + flow:loaded. Util quando um host conecta DEPOIS do bootstrap (ex: recorder herdando o iframe do editor sem reload).

Iframe → host

TypePayloadQuando / When
flow:ready{ definition, bridge }Bootstrap completo + initial load resolveu. Inclui versao do bridge.
flow:loadedQualquer load() resolveu (incluindo isUpdate).
flow:played{ phase: 'in' \| 'out' }Animacao chegou no fim natural (engine dispara via onAnimationDone).
flow:stoppedstopAction terminou.
flow:duration{ durationMs }Resposta de flow:getDuration.
flow:seekDone{ t }Resposta de flow:seek.
flow:captureDone{ bitmap } (ImageBitmap transferable)Resposta de flow:capture.
flow:error{ code, message, recoverable? }Qualquer falha, com codigo padronizado.

Codigos de erro / Error codes

Emitidos pelo bridge / Emitted by the bridge:

Reservados para uso futuro (nao emitidos pelo bridge atual): TIMEOUT, UNSUPPORTED_CAPABILITY. Hosts podem emitir esses codigos em sua propria sinalizacao.

Reserved for future use (not emitted by the current bridge): TIMEOUT, UNSUPPORTED_CAPABILITY. Hosts may emit these codes in their own signalling.

8. Lifecycle state machine

          bootstrap         load(d)         play()
INIT  ──────────────► LOADING ──────► READY ──────────► PLAYING
                         │              │                  │
                         │ error        │ stop()           │ ends naturally
                         ▼              ▼                  ▼
                       ERROR        STOPPING ───────────► PLAYED
                                       │
                                       ▼
                                    STOPPED

Eventos emitidos: flow:ready (READY), flow:loaded (apos cada load), flow:played (PLAYING→PLAYED), flow:stopped (STOPPING→STOPPED), flow:error (qualquer →ERROR).

9. Capabilities

O array definition.capabilities declara o que o template suporta. Hosts (editor, recorder, renderers) usam pra habilitar/desabilitar UI.

config.json → meta.capabilities e metadata editorial (filtragem na galeria do editor). A fonte de verdade em runtime e flow:ready.definition.capabilities.

10. OGraf v1 compatibility

Templates flow-bridge-compliant rodam sem modificacao em qualquer renderer OGraf v1. O contrato bate na semantica:

OGraf v1flow-bridge
Web Component class registrada via customElements.defineMesmo
load({ data }) => { statusCode }Mesmo (extends com optional isUpdate)
playAction({ skipAnimation })Mesmo
stopAction({ skipAnimation })Mesmo
dispose()Mesmo
Capabilities realtime / nonRealtime / transparentMesmo
getDuration() / seek(timeMs) pra nonRealtimeMesmo
callbacks onAnimationDone(phase) / onStopped() / onError()Mesmo (bridge encaminha)
schema attributedefinition.schema (proximo passo) ou config.json sibling
captureFrame(w, h) — extensao nossa pra recording, opcional

Um renderer OGraf instancia o WC e chama os metodos diretamente — bypass do bridge. flow-bridge usa window.flowEngine (que delega ao mesmo engine instance interno). Os dois caminhos coexistem sem conflito.

11. Capture pipeline

captureFrame(w, h) roda dentro do iframe. Essencial — e a unica forma das URLs de @font-face resolverem. Captura externa cai pra fonte do sistema e o video sai com metricas erradas.

async captureFrame(width, height) {
  // 1. settle layout (em caso de seek recente)
  await new Promise((r) => requestAnimationFrame(() => r()));

  // 2. rasterize document.body em canvas
  const canvas = await window.domtoimage.toCanvas(document.body, { width, height });

  // 3. canvas → ImageBitmap (transferable, zero-copy cross-origin)
  return await createImageBitmap(canvas);
}

O bridge transfere o ImageBitmap com postMessage(payload, '*', [bitmap]). Host pode usar direto em new VideoFrame(bitmap) (WebCodecs) ou desenhar num canvas pra toBlob.

12. Worked example: barras-empilhadas-8 nos 4 contextos / in 4 contexts

(a) Aba isolada / Standalone tab

https://cdn.insyde.one/data-elements/<uuid>/index.html?hl=PESQUISA&shl=SP&n1=A&p1=PL&va1=41&vn1=3&vc1=56&...

Bridge bootstrap → fetch config.json → autoParseQS gera shape canonico → engine.load → (window.parent === window) → engine.playAction. Animacao roda. ✓

(b) Editor preview (flow-front, sem record mode)

Iframe carrega com mesma URL canonica. Bridge bootstrap igual ao (a), mas window.parent !== window e nao tem ?autoplay → nao auto-play. Posta flow:ready. Host (flow-front) recebe e:

  1. Manda flow:update { fields, isUpdate: false } com os values atuais do form.
  2. Recebe flow:loaded.
  3. Manda flow:play.

Animacao roda. Quando user edita um campo, host manda flow:update { fields, isUpdate: true } — bridge faz patch + snap-to-end automatico. Sem reload do iframe, sem flash de fonte. ✓

(c) Editor record mode

Mesmo iframe, mesmo bridge. Host nao chama flow:play; em vez disso roda o loop de seek/capture:

for (let i = 0; i < totalFrames; i++) {
  await bridge.seek(i / fps * 1000);
  const bitmap = await bridge.capture(1920, 1080);
  await encoder.encodeFrame(bitmap, i);
}

Encoder e WebCodecs (mp4-muxer / webm-muxer) ou ffmpeg.wasm fallback. Output mp4 ou webm. ✓

(d) flow-playout / OGraf renderer (live broadcast)

Renderer instancia o WC <eleicoes-barras-empilhadas-8> ou usa o bridge global. Manda load({data}) + playAction(). Animacao roda no ar. onAnimationDone('in') dispara → renderer sabe que terminou a in-animation. ✓

13. Migration recipe

De um template legacy (Web Component com __template + ?record=1) pra v2:

  1. Vendoring: flow-bridge.min.js + dom-to-image-more.min.js em vendor/; declarar em manifest.json → vendor.
  2. index.html: carregar bridge depois do app.js.
  3. Renomear update(fields)load({ data, isUpdate }); ler long names (data.headline em vez de data.hl) e column keys (data.bars[i].n em vez de data.bars[i].name).
  4. Adicionar OGraf shape no return: { statusCode: 200 } em load/playAction/stopAction/dispose.
  5. Renomear play()playAction({ skipAnimation }); stop()stopAction({ skipAnimation }).
  6. Remover parseDataFromQS e o bootstrap inline. O bridge faz.
  7. Expor um Web Component (pra OGraf strict) e window.flowEngine (ambos delegando ao mesmo engine instance).
  8. Declarar capabilities no definition e em manifest.json → meta.capabilities.

14. Testing checklist

15. Referencia / Reference

Source canonico do bridge: flow-playout/public/templates/flow-bridge.js. Vendored em cada template como vendor/flow-bridge.min.js.