"""Peças pré-renderizadas dos cartões e do 404: o kit de blocos.py animado em loop de 3 s. Rodar da raiz do repositório: blender -b --python 3d/pecas.py Renderiza os quadros em 3d/out/pecas// e o ffmpeg do sistema gera em public/3d/ peca-.webm (VP9), .mp4 (H.264) e .webp (pôster). Mesmos bloco, materiais e luz do pôster do hero. """ import ctypes import math import os import subprocess import sys import bpy from mathutils import Matrix, Vector sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from blocos import ( # noqa: E402 LIMAS, PUBLICO, SAIDA, criar_bloco, criar_materiais, estado_marca, falhar, ligar_cycles, luz_de_estudio, sair, svg_para_mundo, ) QUADROS = 72 # 3 s a 24 por segundo; o quadro 72 seria de novo o 0 LADO = (640, 400) TINTA = "0x0a0a0a" # --ink AMOSTRAS = 64 LIMITE_VIDEO, LIMITE_POSTER = 250_000, 40_000 def encaixe(f, inicio, duracao): """0 antes, 1 assentado: chega rápido e assenta com ease-out cúbico, sem passar do ponto.""" t = min(max((f - inicio) / duracao, 0), 1) return 1 - (1 - t) ** 3 # ---------------------------------------------------------------- peças # Cada peça recebe novo(posição, escala, tom) e devolve pose(f), que põe os blocos no quadro f. # Coordenadas do Blender: X para a direita, -Y para a câmera, Z para cima; bloco de lado 1. def sistema(novo): # planilha deitada de 7 x 5; a linha do fundo, em lima, é o cabeçalho for c in range(7): for r in range(5): novo((c - 3, 2 - r, 0), 1, int(r == 0)) # as barras moram dentro das células das colunas 1 a 5, logo à frente do cabeçalho, e sobem telescópicas barras = [[novo((k - 2, 1, 0), 0.98, int(j == h - 1)) for j in range(h)] for k, h in enumerate((2, 3, 5, 4, 6))] def pose(f): for k, pilha in enumerate(barras): sobe = encaixe(f, 6 + 4 * k, 8) - encaixe(f, 42 + 4 * k, 8) for j, obj in enumerate(pilha): obj.location.z = (1 + j) * sobe return pose def whatsapp(novo): # celular em pé de 9 x 16 (x = c - 4, z = 7.5 - r), de cantos arredondados; o miolo vazio é a tela for c in range(9): for r in range(16): borda = (c in (0, 8) or r in (0, 15)) and not (c in (0, 8) and r in (0, 15)) canto_de_dentro = c in (1, 7) and r in (1, 14) if borda or canto_de_dentro or r == 2: # r = 2 fecha a barra de cima novo((c - 4, 0, 7.5 - r), 1, 0) novo((-2, 0, 6.5), 1, 1) # foto do contato for i in range(4): novo((-0.8 + 0.7 * i, 0, 6.5), 0.7, 0) # nome, nos blocos menores dos balões, para ler como texto # balões de 6 x 3 blocos menores, alternando os lados; o canto de baixo do lado do balão fica reto (é o rabo) p = 0.7 def balao(k, esquerda, tom): borda = -3.15 if esquerda else 3.15 # parede da tela menos meio bloco de folga sentido = 1 if esquerda else -1 topo = 2.95 - 3.15 * k blocos = [] for i in range(6): for j in range(3): if i in (0, 5) and j in (0, 2) and (i, j) != (0, 2): continue alvo = Vector((borda + sentido * (i + 0.5) * p, 0, topo - j * p)) blocos.append((novo(alvo, p, tom), alvo)) return blocos, Vector((borda, 0, topo - 2.5 * p)) baloes = [balao(0, True, 0), balao(1, False, 1), balao(2, True, 0)] pontos = [novo((-2.25 + d, 0, 2.95 - 6.3 - p), 0.55, 0) for d in range(3)] # "digitando", no lugar do terceiro def pose(f): some = min(max((f - 60) / 8, 0), 1) ** 3 # somem juntos, encolhendo para o rabo for (blocos, rabo), chega in zip(baloes, (2, 14, 44)): s = encaixe(f, chega, 8) * (1 - some) for obj, alvo in blocos: obj.location = rabo + (alvo - rabo) * s obj.scale = (p * max(s, 0.001),) * 3 obj.hide_render = s <= 0 aparece = encaixe(f, 26, 4) * (1 - encaixe(f, 41, 3)) for d, obj in enumerate(pontos): pulso = max(0.0, math.sin(math.tau * (f - 28) / 8 - d * math.tau / 6)) # um depois do outro s = aparece * 0.55 * (1 + 0.45 * pulso) obj.scale = (max(s, 0.001),) * 3 obj.hide_render = s <= 0 return pose def quatrocentos(novo): # o quadrado lima mais alto se solta para a frente (passa diante do outro) e cai; volta de cima direto no lugar x0, y1 = svg_para_mundo(*LIMAS[2][:2]) x1, y0 = svg_para_mundo(*LIMAS[2][2:]) solto = [] for x, y, z, sx, sy, sz, tom in estado_marca(): obj = novo((x, -z, y), (sx, sz, sy), tom) if tom and x0 < x < x1 and y0 < y < y1: solto.append((obj, Vector((x, -z, y)))) centro = sum((alvo for _, alvo in solto), Vector()) / len(solto) def pose(f): if f < 32: queda = min(max((f - 12) / 12, 0), 1) ** 2 # ease-in: a gravidade acelera d = Vector((0, -1.4 * encaixe(f, 8, 4), -16 * queda)) giro = Matrix.Rotation(0.6 * queda, 3, "Y") # tomba de lado enquanto cai else: d = Vector((0, 0, 12 * (1 - encaixe(f, 40, 8)))) giro = Matrix.Identity(3) for obj, alvo in solto: obj.location = centro + giro @ (alvo - centro) + d obj.rotation_euler = giro.to_euler() return pose def selo_conferido(novo, x0, z0, y, passo): """Selo lima de 7 x 7 com o V de conferido vazado; (x0, z0) é o centro do bloco de cima à esquerda.""" desenho = ("....#", "...##", "#.##.", "###..", ".#...") vazado = {(1 + i, 1 + j) for j, linha in enumerate(desenho) for i, x in enumerate(linha) if x == "#"} blocos = [] for c in range(7): for r in range(7): if (c, r) not in vazado: alvo = Vector((x0 + c * passo, y, z0 - r * passo)) blocos.append((novo(alvo, passo, 1), alvo)) return blocos def fiscal(novo): # nota em pé de 11 x 16; as quatro linhas de texto são vãos na folha, a partir da coluna 1 linhas = {2: 7, 4: 9, 6: 6, 8: 8} for c in range(11): for r in range(16): if not 1 <= c <= linhas.get(r, 0): novo((c - 5, 0, 7.5 - r), 1, 0) selo = selo_conferido(novo, -1, -1.5, -1, 1) # à frente do canto de baixo à direita; a folha aparece pelo V de_fora = Vector((22, -6, 4)) # chega pela direita, vindo da frente para_cima = Vector((6, -4, 20)) def pose(f): sai = min(max((f - 46) / 10, 0), 1) ** 3 # solta devagar e sai acelerando d = de_fora * (1 - encaixe(f, 6, 10)) + para_cima * sai for obj, alvo in selo: obj.location = alvo + d return pose REVISAO_CAMERA = (-0.25, -0.95, 0.15) def revisao(novo): # janela de sistema de 13 x 13 (x = c - 6, z = 6 - r): moldura, três pontos, barra e quatro linhas de conteúdo linhas = {4: 8, 6: 10, 8: 6, 10: 9} # três blocos das linhas, da esquerda para a direita, saltados para a frente e girados: (deslocamento, giro) fora = { (3, 6): ((-0.4, -2.4, 1.1), (0.6, 0.3, 0.7)), (5, 4): ((0.5, -2.0, -0.9), (-0.5, 0.4, -0.6)), (7, 10): ((-0.3, -2.6, 0.9), (0.4, -0.6, 0.5)), } soltos = [] for c in range(13): for r in range(13): if c in (0, 12) or r in (0, 2, 12) or (r == 1 and c in (2, 4, 6)) or 2 <= c <= linhas.get(r, 0): obj = novo(Vector((c - 6, 0, 6 - r)), 1, 0) if (c, r) in fora: desloc, giro = fora[(c, r)] soltos.append((obj, obj.location.copy(), Vector(desloc), Vector(giro))) # lupa: aro octogonal em volta de um vão de 3 x 3 e cabo na diagonal, para baixo e à direita lupa = [] for dx in range(-2, 3): for dz in range(-2, 3): if 1.5 < math.hypot(dx, dz) <= 2.6: lupa.append((novo((0, 0, 0), 1, 1), Vector((dx, 0, dz)))) for k in range(3): d = (3.0 + 0.95 * k) / math.sqrt(2) cabo = novo((0, 0, 0), 1, 1) cabo.rotation_euler = (0, math.pi / 4, 0) lupa.append((cabo, Vector((d, 0, -d)))) lima = lupa[0][0].material_slots[0].material porcelana = soltos[0][0].material_slots[0].material # a lupa anda 3,5 à frente da janela, na linha de visão de cada bloco solto: o bloco aparece no meio do aro rumo = Vector(REVISAO_CAMERA).normalized() def na_frente(p): return Vector(p) + rumo * (3.5 / -rumo.y) a, b, c = (alvo for _, alvo, _, _ in soltos) caminho = [(0, na_frente((-18, 0, 1))), (9, na_frente(a)), (13, na_frente(a)), (21, na_frente(b)), (25, na_frente(b)), (33, na_frente(c)), (37, na_frente(c)), (47, na_frente((28, 0, -3)))] # fmt: skip chegadas = (9, 21, 33) selo = selo_conferido(novo, 2.3, -3.2, -1, 0.6) # menor, por dentro do canto de baixo à direita de_fora = Vector((0, 0, -11)) # chega e sai reto por baixo, sem invadir a direita do quadro def lupa_em(f): for (f0, p0), (f1, p1) in zip(caminho, caminho[1:]): if f <= f1: t = min(max((f - f0) / (f1 - f0), 0), 1) return p0.lerp(p1, t * t * (3 - 2 * t)) return caminho[-1][1] def pose(f): centro = lupa_em(f) for obj, desvio in lupa: obj.location = centro + desvio for i, ((obj, alvo, desloc, giro), chega) in enumerate(zip(soltos, chegadas)): s = encaixe(f, chega, 6) - encaixe(f, 62 + 2 * i, 5) # no fim voltam a saltar, para o loop emendar obj.location = alvo + desloc * (1 - s) obj.rotation_euler = giro * (1 - s) obj.material_slots[0].material = lima if chega <= f < chega + 10 else porcelana d = de_fora * (1 - encaixe(f, 44, 8) + min(max((f - 60) / 6, 0), 1) ** 3) for obj, alvo in selo: obj.location = alvo + d return pose # nome, peça, direção da câmera, distância, alvo, quadro do pôster (peça montada) PECAS = [ ("sistema", sistema, (-0.5, -0.8, 0.6), 44, (0, 0.5, 2.3), 36), ("whatsapp", whatsapp, (-0.3, -0.93, 0.18), 64, (-3.8, 0, 0), 56), ("404", quatrocentos, (-0.42, -0.86, 0.2), 46, (0, 0, 0), 0), ("fiscal", fiscal, (-0.42, -0.86, 0.2), 72, (0, 0, 0), 36), ("revisao", revisao, REVISAO_CAMERA, 68, (6.3, 0, -0.6), 20), ] # ------------------------------------------------------------- ffmpeg def ffmpeg(*args): fundo = ["-f", "lavfi", "-i", f"color=c={TINTA}:s={LADO[0]}x{LADO[1]}:r=24"] subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *fundo, *args], check=True) def codificar(pasta, nome, poster): destino = os.path.join(PUBLICO, f"peca-{nome}") # quadros com alfa sobre a tinta opaca, em yuv420p BT.709 (o padrão dos navegadores) compor = "[0][1]overlay=shortest=1:format=rgb,scale=out_color_matrix=bt709:out_range=tv,format=yuv420p" entrada = ["-framerate", "24", "-i", os.path.join(pasta, "%04d.png"), "-filter_complex", compor, "-an"] cor = ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"] ffmpeg(*entrada, *cor, "-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-row-mt", "1", "-cpu-used", "1", f"{destino}.webm") # fmt: skip ffmpeg(*entrada, *cor, "-c:v", "libx264", "-preset", "slow", "-crf", "22", "-movflags", "+faststart", f"{destino}.mp4") # fmt: skip ffmpeg("-i", poster, "-filter_complex", "[0][1]overlay", "-frames:v", "1", "-c:v", "libwebp", "-quality", "80", f"{destino}.webp") # fmt: skip for ext, limite in (("webm", LIMITE_VIDEO), ("mp4", LIMITE_VIDEO), ("webp", LIMITE_POSTER)): tamanho = os.path.getsize(f"{destino}.{ext}") print(f"peca-{nome}.{ext}: {tamanho} bytes", flush=True) if tamanho > limite: falhar(f"peca-{nome}.{ext} tem {tamanho} bytes, limite {limite}") # ------------------------------------------------------------- Blender def main(): bpy.ops.wm.read_factory_settings(use_empty=True) cena = bpy.context.scene cena.view_settings.view_transform = "Standard" cena.world = bpy.data.worlds.new("fundo") cena.render.use_persistent_data = True ligar_cycles(cena) luz_de_estudio(cena) bloco = criar_bloco() cena.collection.objects.unlink(bloco) # a malha fica só nas instâncias malha = bloco.data bpy.data.objects.remove(bloco) materiais = criar_materiais() malha.materials.append(materiais[0]) camera = bpy.data.objects.new("camera", bpy.data.cameras.new("camera")) cena.collection.objects.link(camera) camera.data.lens = 85 cena.camera = camera colecoes = {} for nome, peca, direcao, distancia, alvo, quadro_poster in PECAS: col = bpy.data.collections.new(nome) cena.collection.children.link(col) colecoes[nome] = col def novo(posicao, escala, tom, col=col): obj = bpy.data.objects.new(f"{col.name}.{len(col.objects):03d}", malha) col.objects.link(obj) obj.location = posicao obj.scale = escala if isinstance(escala, tuple) else (escala,) * 3 obj.material_slots[0].link = "OBJECT" obj.material_slots[0].material = materiais[tom] return obj pose = peca(novo) for outro, c in colecoes.items(): c.hide_render = outro != nome direcao = Vector(direcao).normalized() camera.location = Vector(alvo) + direcao * distancia camera.rotation_euler = (-direcao).to_track_quat("-Z", "Y").to_euler() pasta = os.path.join(SAIDA, "pecas", nome) os.makedirs(pasta, exist_ok=True) for f in range(QUADROS): pose(f) sair(cena, os.path.join(pasta, f"{f:04d}.png"), LADO, "PNG", "RGBA", AMOSTRAS, True) codificar(pasta, nome, os.path.join(pasta, f"{quadro_poster:04d}.png")) ctypes.CDLL(None).fflush(None) # despeja o buffer do C (banner do Blender) antes do OK print(f"OK pecas={len(PECAS)}", flush=True) sys.exit(0) # sai já, para o OK ser a última linha main()