Lição 36 - Animação
Nesta lição, você vai aprender:
- Como projetar uma API de animação
- Como implementar keyframes declarativos e controladores alinhados à Web Animations API
- Como implementar efeitos de caminho, traço e morphing
- Como formatos como o Lottie se encaixam
Como projetar a API de animação
O Motion é totalmente compatível com animação declarativa no estilo da WAAPI; veja Improvements to Web Animations API. Ele chama o element.animate() nativo do navegador para aceleração de GPU, uma thread de compositor independente e trabalho fora da thread principal. O JavaScript completa o que a WAAPI não oferece:
- Física de mola (a WAAPI só aceita easing de Bézier)
- Propriedades de
transformindependentes (animarx,yescaleseparados, em vez de uma única string de transform combinada) transformOriginpara os centros de escala e rotação- Auxiliares de timeline como
sequence()estagger()
import { animate, stagger } from 'motion';
// Returns a controller you can pause, play, and reverse
const controls = animate(
'.box',
{ x: [0, 100], opacity: [0, 1] }, // keyframes
{ duration: 0.5, delay: stagger(0.1), easing: 'spring(1, 100, 10, 0)' },
);
// Serializable control calls
controls.pause();
controls.play();
controls.reverse();Keyframes e opções são objetos simples e podem ser serializados em JSON. O estado de execução de animate() — vínculos com o DOM, tempo atual, velocidade e outros — não é serializável.
Seguindo a WAAPI
Podemos seguir o polyfill da WAAPI web-animations-js e integrá-lo ao nosso ECS.
Camada de dados: keyframes no estilo da WAAPI (serializáveis)
interface Keyframe {
offset?: number; // 0–1, same role as WAAPI offset
[property: string]: any; // x, y, scale, fill, strokeWidth...
easing?: string; // "ease-out", "spring(1, 100)"
}
interface AnimationOptions {
duration: number; // ms
delay?: number;
iterations?: number | 'infinite';
direction?: 'normal' | 'reverse' | 'alternate';
fill?: 'forwards' | 'backwards' | 'both'; // How the animation appears when not running (before start, after end).
easing?: string; // Global easing if a keyframe omits its own
}Camada de controle: um controlador Animation no estilo da WAAPI. Diferenças em relação a um polyfill completo da WAAPI:
- Sem parsing de string CSS. A WAAPI permite
{ transform: 'translate(100px)' }, que é caro de analisar. Prefira{ x: 100 }(propriedades de transform independentes, no estilo do Motion). ease,ease-in,ease-outelinearembutidos, além despring(mass, stiffness, damping)no estilo do Motion.- Modos de Composite. Como o
composite: 'add' | 'replace'da WAAPI, empilhe animações sobre os valores de base (por exemplo, a posição da entidade mais um deslocamento animado). - Suporte a Timeline. Como o
timeline()do Motion ou oGroupEffectda WAAPI, incluindo stagger entre entidades.
Controlador
const animation = api.animate(
node1,
[
{ x: 100, fill: 'green' },
{ x: 200, fill: 'red' },
],
{
duration: 1000,
direction: 'alternate',
iterations: 'infinite',
easing: 'ease-in-out',
},
);
animation.pause();
animation.play();
animation.finish(); 矩形在 x: 100 ↔ 200 之间往复,填充在 green 与 red 之间插值;可用按钮控制同一 AnimationController。
Interpolação
Escalares como x / y / opacity interpolam de forma trivial. Para fill / stroke, analise as cores com algo como d3-color e depois interpole cada canal rgba.
Para gradientes lineares, contagens de stops iguais interpolam a posição e a cor de cada stop. Contagens diferentes usam as posições de stop combinadas dos dois gradientes, preservando posições repetidas que representam transições duras de cor. Isso mantém o espaçamento não uniforme quando os dois keyframes contêm o mesmo gradiente.
No ECS, fill / fillOpacity e stroke / strokeOpacity atualizam juntos a primeira camada de pintura habilitada. As outras camadas e o estado habilitado delas são preservados. Se nenhuma camada estiver habilitada, animar uma pintura acrescenta uma; animar só a opacidade não cria uma camada nova.
function interpolateValue(from: unknown, to: unknown, t: number) {
if (isFiniteNumber(from) && isFiniteNumber(to)) {
return interpolateNumber(from, to, t);
}
const fromColor = parseColor(from);
const toColor = parseColor(to);
if (fromColor && toColor) {
return colorToRgbaString({
r: interpolateNumber(fromColor.r, toColor.r, t),
g: interpolateNumber(fromColor.g, toColor.g, t),
b: interpolateNumber(fromColor.b, toColor.b, t),
a: interpolateNumber(fromColor.a, toColor.a, t),
});
}
return t < 1 ? from : to;
}Easing
Além das curvas padrão, podemos aceitar spring:
function evaluateEasing(easing: string, t: number) {
const p = clamp01(t);
const bezier = EASING_FUNCTION[easing as keyof typeof EASING_FUNCTION];
if (bezier) {
return clamp01(bezier(p));
}
if (easing.startsWith('spring(')) {
return evaluateSpringEasing(p, easing);
}
return p;
} 切换缓动会重新创建动画(新 easing 在构造时生效)。矩形仍在 x: 100 ↔ 200 往复,便于对比不同曲线的加减速感。
Origem da transformação
transformOrigin: { x: 50, y: 50 }scale: 0.5 ↔ 1.2rotation: Math.PI / 4 ↔ -Math.PI / 4
Efeitos especiais de animação
Animação de caminho
Mover gráficos ao longo de um caminho é comum; o CSS faz isso com Motion Path.
#motion-demo {
animation: move 3000ms infinite alternate ease-in-out;
offset-path: path('M20,20 C20,100 200,0 200,100');
}
@keyframes move {
0% {
offset-distance: 0%;
}
100% {
offset-distance: 100%;
}
}Animação de traço
Precisamos do comprimento do caminho:
const length = api.getTotalLength(path);
api.animate(
path,
[{ strokeDasharray: [0, length] }, { strokeDasharray: [length, 0] }],
{
duration: 3500,
},
);Deslocamento do tracejado
O draw.io usa animação para mostrar a direção do conector:
Exporte o diagrama para um arquivo SVG para incluir a animação do conector ao publicá-lo numa página web ou numa plataforma de conteúdo que aceite imagens SVG.
api.animate(node, [{ strokeDashoffset: -20 }, { strokeDashoffset: 0 }], {
duration: 500,
iterations: Infinity,
});Morphing
Muitas bibliotecas SVG demonstram morphing:
- Paper.js
- O Kute.js oferece Morph e CubicMorph
- O MorphSVGPlugin do GreenSock consegue até renderizar em Canvas
- vectalign
Algumas bibliotecas exigem que a estrutura dos segmentos coincida antes e depois do morph, senão a interpolação falha.
Seguindo o CubicMorph do Kute.js: converta os segmentos do caminho em Béziers cúbicas, use subdivisão simples para normalizar os dois caminhos para a mesma quantidade de segmentos e depois interpole os pontos de controle de cada segmento.
O alinhamento de winding e de ponto inicial só se aplica quando os dois caminhos contêm um único contorno fechado. Caminhos abertos preservam a ordem dos extremos, então transladar uma curva não troca os extremos nem faz a curva colapsar no meio da animação.
function mergePaths(
left: { absolutePath: AbsoluteArray; curve: CurveArray | null },
right: { absolutePath: AbsoluteArray; curve: CurveArray | null },
): [CurveArray, CurveArray, (b: CurveArray) => CurveArray] {
let curve1 = left.curve;
let curve2 = right.curve;
if (!curve1 || curve1.length === 0) {
// convert to curves to do morphing & picking later
// @see http://thednp.github.io/kute.js/svgCubicMorph.html
curve1 = path2Curve(left.absolutePath, false) as CurveArray;
left.curve = curve1;
}
if (!curve2 || curve2.length === 0) {
curve2 = path2Curve(right.absolutePath, false) as CurveArray;
right.curve = curve2;
}
let curves = [curve1, curve2];
if (curve1.length !== curve2.length) {
curves = equalizeSegments(curve1, curve2);
}
const closed = [left.absolutePath, right.absolutePath].every(
(path) =>
path[path.length - 1]?.[0] === 'Z' &&
path.filter(([command]) => command === 'M').length === 1,
);
const curve0 =
closed && getDrawDirection(curves[0]) !== getDrawDirection(curves[1])
? reverseCurve(curves[0])
: (clonePath(curves[0]) as CurveArray);
return [
curve0,
closed ? (getRotatedCurve(curves[1], curve0) as CurveArray) : curves[1],
(pathArray: CurveArray) => pathArray,
];
}Lottie
- lottie json schema
- Tips for rendering
- lottie-parser — seguimos sobretudo a lógica de parsing dele
- velato — um renderizador construído sobre o Vello
Uso
Implementamos um plugin que converte JSON do Lottie em gráficos e keyframes. Destaques:
- Aceita os seguintes elementos de camadas Shape:
- No Lottie,
anchorX/anchorYdefinem o centro de escala e rotação em relação ao canto superior esquerdo da caixa delimitadora da forma — tenha cuidado ao mapear paratransformOrigin - Una várias faixas de animação num único conjunto de keyframes e preencha as propriedades que faltarem
import { loadAnimation } from '@infinite-canvas-tutorial/lottie';
const response = await fetch('/bouncy_ball.json');
const animation = loadAnimation(await response.json(), {
loop: true,
autoplay: true,
onDiagnostic: ({ code, path, message }) =>
console.info(code, path, message),
});
await api.edit(() => animation.render(api), { capture: 'NEVER' });
// On component unmount (also automatically cleaned up on canvas destruction):
// await animation.destroy();Abaixo, o exemplo oficial rodando na nossa montagem: Bouncy Ball
Compatibilidade
Este importador converte Lottie em nós e keyframes do ECS; não é um renderizador Lottie completo. Analisar um campo não significa necessariamente implementar o efeito visual dele.
| Recurso | Comportamento atual |
|---|---|
| Retângulos, elipses, caminhos, grupos; fill/stroke básico | Geometria estática básica, morphing de caminho e posição/rotação/escala 2D; a animação de tamanho e de canto das primitivas continua limitada |
| PolyStar | Estrelas/polígonos, os dois windings, quantidade de pontas, posição, rotação, raio interno/externo e arredondamento; keyframes numéricos com easing temporal e retenção (hold) |
| Repeater | Cópias das formas/grupos anteriores; quantidade, offset, âncora, translação, escala, rotação, opacidade inicial/final e ordem de composição, inclusive keyframes numéricos |
| Solid, Null, parenting, Precomp | Suporte básico; visibilidade de entrada/saída da camada, estiramento de tempo e remapeamento estão incompletos |
| Gradientes e várias pinturas | Gradientes básicos; geometria de gradiente animada, realces radiais e a ordem de várias pinturas/operadores continuam limitados |
| Trim Paths | Aproximação por stroke-dash com cobertura cheia/vazia corrigida, ordenação de extremos e offsets; geometria preenchida, modos de vários caminhos, padrões de tracejado já existentes, caps redondas de comprimento zero animadas, direção e ordem dos modificadores continuam limitados |
| Movimento espacial de Bézier, Skew | Incompleto; reportado durante a inspeção |
| Merge Paths, Round Corners e outros modificadores | Não são renderizados |
| Imagem | Parcial; diretórios de assets e o pré-carregamento não estão implementados |
| Texto, máscaras, Track Mattes, efeitos, modos de mistura, 3D | Não implementado ou não renderizado de forma confiável; capacidades do ECS não implicam suporte à importação do Lottie |
| Expressões | Pré-calculadas na importação; o padrão é o ExpressionManager empacotado do lottie-web, com um ambiente limitado de camada de forma, e não a semântica completa do AE |
inspectLottie(data) verifica lacunas conhecidas sem alterar o JSON nem avaliar expressões. animation.getDiagnostics() e onDiagnostic fornecem um code estável, severity (partial / unsupported), o path em JSON Pointer e uma mensagem. A importação continua com os diagnósticos; uma lista vazia não certifica compatibilidade com o AE. Expanda Notas de compatibilidade nos exemplos para inspecionar as lacunas.
A reprodução usa um único relógio de composição. goTo(value, true) busca quadros relativos à composição; a unidade padrão é segundos, preservando o estado de reproduzir/pausar. stop() pausa no quadro zero. setSpeed() recebe um multiplicador positivo; setDirection(-1) inverte a partir da posição atual e preserva a velocidade. playSegments([start, end]) respeita os dois extremos; extremos descendentes reproduzem para trás. loop numérico conta repetições extras. Os controladores ECS devolvidos por getAnimations() são amostrados pelo reprodutor; controle o tempo pela API do reprodutor.
Chamar render(api) de novo na mesma instância é idempotente; outra tela precisa de uma instância separada. destroy() é idempotente, cancela a reprodução e remove a árvore de nós importada numa edição segura. Os componentes também devem cancelar fetches pendentes e edições enfileiradas ao desmontar. Regressões de navegador comparam quadros fixos com uma versão fixada do lottie-web quanto a posição, Trim Paths com traço de um único caminho, geometria de PolyStar e grupos Repeater em quadros inteiros e fracionários; elas não estabelecem paridade do formato inteiro.
Estrelas e polígonos
O PolyStar (ty: "sr") gera um caminho de Bézier fechado: sy: 1 seleciona uma estrela e sy: 2 um polígono. A quantidade de pontas (pt), a posição (p), a rotação (r), o raio (or / ir) e o arredondamento (os / is) podem animar de forma independente. As propriedades internas só se aplicam a estrelas. d: 3 inverte o winding. Quantidades fracionárias de pontas são arredondadas para baixo, como no lottie-web 5.13; isso não é um morph contínuo entre quantidades de pontas.
O exemplo anima as duas formas. Pause, inverta ou arraste Seek frame para inspecionar a geometria intermediária. Os parâmetros são interpolados antes de construir o caminho, então a rotação preserva o raio e as mudanças na quantidade de pontas entram em vigor no instante certo.
Star (blue) · Polygon (red)
A geometria animada do PolyStar é amostrada por um controlador de plugin no relógio da composição. Guarde o JSON original do Lottie para recarregá-lo: a serialização simples de keyframes do ECS e o editor de animação ainda não preservam nem editam essas faixas de parâmetros procedurais. Tangentes de posição espacial, combinações de modificadores (incluindo Trim Paths sobre geometria que muda), expressões e outras limitações da matriz continuam valendo. Traços curvos também herdam as fendas de transparência que o renderizador ECS já tem em algumas junções tesseladas; os testes de referência verificam a silhueta e a cor, não a paridade do alfa.
Curvas de Bézier no Lottie
vé um array de vértices.ié um array de pontos de tangente “de entrada”, relativos av.oé um array de pontos de tangente “de saída”, relativos av.cé um booleano que determina se a poli-Bézier está fechada. Se estiver, há um segmento de Bézier extra entre o último ponto deve o primeiro.
Repeater
O Repeater (ty: "rp") duplica os operadores que vêm antes dele no grupo de forma atual. Mantenha o fill/stroke dentro do grupo repetido para pintar cada cópia de forma independente. Grupos de forma aninhados e geometria animada de PolyStar podem ser repetidos. Repeaters aninhados ou empilhados hoje usam duplicação recursiva e emitem shape.repeater.nested; a ordem composta e as cópias de origem retidas podem diferir do lottie-web.
As cópias (c) são arredondadas para cima; zero oculta todas as cópias. O offset (o) aceita valores fracionários e negativos. O transform (tr) inclui âncora (a), translação (p), escala não uniforme (s), rotação (r) e opacidade inicial/final (so / eo). O modo de composição (m) muda o empilhamento das cópias. Faixas numéricas aceitam easing temporal e retenção (hold). As transformações seguem o lottie-web 5.13, inclusive o comportamento de cruzar a identidade em offsets inteiros negativos.
Animated copies · Offset · Scale · Rotation · Opacity
O importador aloca um conjunto de cópias a partir do limite superior da faixa de quantidade, incluindo a ultrapassagem do easing. A reprodução e a busca reutilizam esse conjunto. Quantidades grandes e repeaters aninhados multiplicam a quantidade de nós importados. Como no PolyStar, guarde o JSON original do Lottie: a serialização simples de keyframes do ECS e o editor de animação não preservam essas faixas procedurais.
Operadores de pintura depois do Repeater têm suporte parcial e emitem shape.repeater.paint-scope: a herança de pintura por cópia não reproduz a cobertura/opacidade do preenchimento composto compartilhado do lottie-web. A ordem dos modificadores, a inclinação (skew), o movimento espacial e o tempo da camada continuam sujeitos à matriz de compatibilidade. Escalas inversas singulares (por exemplo escala zero com offset negativo) ocultam as cópias afetadas em vez de enviar transforms inválidos.
Expressões
{
"ty": "sh",
"ks": {
"a": 0,
"k": {
"i": [],
"o": [],
"v": []
},
"x": "var group = thisLayer.content(\"Quadratic Points\");\nvar num_points = 3;\nvar points = [];\nvar ip = [];\nvar op = [];\nfor ( var i = 0; i < num_points; i++ )\n{\n var pos = group.content(\"p\" + i).position;\n points.push(pos);\n ip.push(pos);\n op.push(pos);\n}\nvar $bm_rt = {\n v: points,\n i: ip,\n o: op\n};\n"
}
}Camada de texto
Camadas de texto e animadores de texto ainda não são importados.
Máscara de recorte
Modos de máscara, inversão, animação e track mattes não são renderizados de forma confiável.
Efeitos de camada
Os efeitos do Lottie ainda não estão mapeados para o ECS.
Rive

Manim
Editor de animação
Inspirados por produtos como lottielab e Jitter, construímos um editor de animação leve na camada de Web Components: o painel de animação à direita edita os keyframes do elemento selecionado, e o painel Timeline embaixo mostra a timeline da cena inteira e conduz a cabeça de reprodução dessa tela. Os dois compartilham os mesmos dados de keyframe serializáveis e o relógio da cena em AppState.

Layout
A barra de tarefas expõe dois interruptores independentes:
SHOW_ANIMATION_PANEL→ic-spectrum-animation-panel(lado direito, junto do painel de propriedades)SHOW_TIMELINE_PANEL→ic-spectrum-timeline-panel(encaixe inferior, na largura toda da tela)
Fluxo típico:
- Selecione um único elemento e adicione uma animação, ou edite as faixas de propriedade no painel de animação.
- Abra a Timeline para ver cada camada animada da cena e o intervalo de tempo dela.
- Percorra a cabeça de reprodução ou pressione Play para pré-visualizar a cena inteira num instante.
- Selecionar uma faixa na Timeline também seleciona o elemento dela; o painel de animação à direita mostra os keyframes desse elemento.
Relógio da cena e modo de edição
A Timeline conduz a cabeça de reprodução da tela em AppState, desacoplada de cada AnimationController. Telas que compartilham um mundo ECS mantêm cabeças de reprodução, estado de reprodução e durações de cena independentes:
| Campo | Significado |
|---|---|
animationEditing | Quando true, entra no modo de scrub determinístico: cada controlador desta tela é amostrado no mesmo animationCurrentTime, em vez de correr livre por conta própria |
animationCurrentTime | Posição da cabeça de reprodução desta tela (milissegundos) |
animationPlaying | Se a cabeça de reprodução avança sozinha |
animationLoop | Se a reprodução volta a 0 no fim da cena |
Quando animationEditing === true, o AnimationSystem amostra só as entidades que pertencem a essa tela. Enquanto está pausado, os controladores ficam fixos em animationCurrentTime; enquanto reproduz, a cabeça de reprodução avança pelos deltas de performance.now(). O tempo de relógio de parede em pausa fica de fora quando a reprodução retoma, e a duração da cena é determinada pelas faixas dessa tela.
Depois de sair do modo de edição, os controladores pausados pelo editor retomam o autoplay a partir da posição atual. Controladores que o chamador já tinha pausado continuam pausados, e controladores cancelados não são reiniciados.
Abrir a Timeline chama automaticamente setAnimationEditing(true), para que o scrub e a pré-visualização permaneçam consistentes.
Design do painel Timeline
底部为 ic-spectrum-timeline-panel:拖动播放头 scrub、点击 ▶ 预览整场景; 点击轨道可选中元素并在右侧 Animation 面板编辑 keyframes。Hero 淡入 2s,Orbit 延迟 0.5s 后位移 1.5s。
Dados das faixas
A Timeline não lê o JSON do nó diretamente; ela agrega por api.getAnimatedTracks():
interface Track {
id: string; // node id
name: string; // layer name; falls back to id
properties: string[]; // e.g. ['opacity', 'x']
delay: number; // ms
duration: number; // active animation length (total − delay)
totalDuration: number;
}A duração da cena é o máximo de totalDuration entre todas as faixas. O left / width da barra são calculados como delay * PX_PER_MS e duration * PX_PER_MS.
Interações
| Ação | Comportamento |
|---|---|
| Clicar no rótulo da faixa | layersSelected = [track.id], destaca a faixa e conduz o painel de animação |
| Arrastar a lane / a régua | Percorre a cabeça de reprodução; a tela pré-visualiza esse quadro ao vivo |
| Play / Pause | toggleAnimationPlaying() avança o relógio global no modo de edição |
| Loop | setAnimationLoop() controla se a cena volta ao início no fim |
Painel de animação (funciona com a Timeline)
Componente: packages/webcomponents/src/spectrum/animation-panel.ts.
- Só é editável com uma seleção única; seleção múltipla ou nenhuma seleção mostra um espaço reservado.
- Opções globais:
duration,delay,easingpadrão,iterations(interruptor Loop). - Keyframes agrupados por faixa de propriedade; cada linha tem
offset(0–1), valor,easinge um botão de excluir. - Adicionar keyframe na cabeça de reprodução: lê
animationCurrentTime, converte para um offset normalizado e amostra os valores atuais da propriedade porcontroller.getCurrentValues(). Abra a Timeline e percorra até o instante desejado antes de inserir um keyframe. fill/strokeusam um popover +ic-spectrum-color-picker; as outras propriedades numéricas usamsp-number-field.- A largura e a altura do painel são redimensionáveis; os tamanhos persistem em
localStorage(a mesma interação de alça do painel de propriedades).
Todas as edições passam por setNodeAnimation / updateNodeAnimationKeyframe e participam do histórico de desfazer e da serialização do documento.
Diferenças em relação a editores no estilo do Lottie
A implementação atual permanece simples de propósito, em comparação com ferramentas DCC completas como o lottielab:
- A Timeline usa uma faixa por nó, e não o layout de várias faixas de camada + propriedade do Lottie; os nomes das propriedades aparecem como sufixo do rótulo (
Rect · opacity, x). - Keyframes e o tempo da barra ainda não podem ser arrastados na Timeline; o tempo é editado pelo
offsetno painel de animação. - As expressões usam um pré-cálculo limitado na hora da importação. Camadas de texto e máscaras de recorte não têm suporte confiável, e este editor também não as cria.
Extensões possíveis: subfaixas de propriedade, marcadores de losango nos keyframes, arrastar as bordas da barra para mudar delay/duração, e mais.