Compare commits

...
Author SHA1 Message Date
xXJsonDeruloXx 2b1405c4f5 Add DroidDeck balancing boot animation 2026-09-25 19:44:08 -04:00
7 changed files with 274 additions and 0 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
.frames/
+18
View File
@@ -0,0 +1,18 @@
# DroidDeck boot animation
A 4.2 second, 1920×1080, 60 fps startup animation built directly from the geometry and colors of `../droiddeck-mark.svg`.
The mark is split into its left bridge-like half, blue orb, and right crescent. The left half drops in and lands rotated as a bridge, the orb bounces onto it, the crescent lands as a cap, the stack teeters, then all three spring back into the exact source-logo geometry. A restrained blue floor glow and final pulse provide the only added visual treatment.
Outputs:
- `droiddeck-boot.webm` — VP9/YUV420p, intended boot-movie asset.
- `droiddeck-boot.mp4` — H.264/YUV420p preview / general playback copy.
To rebuild on macOS with Google Chrome, Node 24+, and ffmpeg installed:
```sh
./build.sh
```
`boot.html` is the animation source. `render.mjs` drives Chrome through the DevTools protocol and captures deterministic frames; no generated imagery is involved.
+176
View File
@@ -0,0 +1,176 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100vw;height:100vh}
</style>
</head>
<body>
<svg id="scene" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
<defs>
<radialGradient id="ambient" cx="50%" cy="62%" r="42%">
<stop offset="0" stop-color="#0b1c2b"/>
<stop offset=".38" stop-color="#05090e"/>
<stop offset="1" stop-color="#000"/>
</radialGradient>
<radialGradient id="orbGlow" cx="50%" cy="50%" r="50%">
<stop offset="0" stop-color="#1A9FFF" stop-opacity=".32"/>
<stop offset=".48" stop-color="#1A9FFF" stop-opacity=".10"/>
<stop offset="1" stop-color="#1A9FFF" stop-opacity="0"/>
</radialGradient>
<filter id="softBlur"><feGaussianBlur stdDeviation="18"/></filter>
<filter id="blueBloom" x="-80%" y="-80%" width="260%" height="260%">
<feGaussianBlur stdDeviation="7" result="b"/>
<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
<filter id="pieceShadow" x="-60%" y="-60%" width="220%" height="220%">
<feDropShadow dx="0" dy="8" stdDeviation="8" flood-color="#000" flood-opacity=".55"/>
</filter>
<mask id="negative-space" maskUnits="userSpaceOnUse" x="-31.98" y="0" width="207.92" height="223.72">
<rect x="-31.98" y="0" width="207.92" height="223.72" fill="#fff"/>
<circle cx="63.98" cy="111.86" r="71.81" fill="#000"/>
<rect x="56.055" y="0" width="15.83" height="45" fill="#000"/>
<rect x="56.055" y="178.72" width="15.83" height="45" fill="#000"/>
</mask>
<clipPath id="leftClip"><rect x="-80" y="-40" width="144" height="310"/></clipPath>
<clipPath id="rightClip"><rect x="63.98" y="-40" width="180" height="310"/></clipPath>
</defs>
<rect width="1920" height="1080" fill="url(#ambient)"/>
<ellipse id="floorGlow" cx="960" cy="760" rx="330" ry="68" fill="#1A9FFF" opacity="0" filter="url(#softBlur)"/>
<ellipse id="floorShadow" cx="960" cy="752" rx="235" ry="24" fill="#000" opacity=".55" filter="url(#softBlur)"/>
<g id="logoRoot" transform="translate(960 530) scale(2.35) translate(-63.98 -111.86)">
<g id="stack">
<g id="leftPiece" filter="url(#pieceShadow)" clip-path="url(#leftClip)">
<path fill="#1b1b1b" mask="url(#negative-space)" d="M-31.98 0H63.97c61.84 0 111.96 50.08 111.96 111.86S125.81 223.72 63.97 223.72H-31.98V0Z"/>
</g>
<g id="rightPiece" filter="url(#pieceShadow)" clip-path="url(#rightClip)">
<path fill="#1b1b1b" mask="url(#negative-space)" d="M-31.98 0H63.97c61.84 0 111.96 50.08 111.96 111.86S125.81 223.72 63.97 223.72H-31.98V0Z"/>
</g>
<g id="orbWrap" filter="url(#blueBloom)">
<circle id="orb" cx="63.98" cy="111.86" r="55.98" fill="#1A9FFF"/>
</g>
</g>
</g>
<circle id="pulse" cx="960" cy="530" r="165" fill="none" stroke="#1A9FFF" stroke-width="2" opacity="0"/>
<rect id="fade" width="1920" height="1080" fill="#000" opacity="1"/>
</svg>
<script>
const left = document.getElementById('leftPiece');
const right = document.getElementById('rightPiece');
const orb = document.getElementById('orbWrap');
const stack = document.getElementById('stack');
const fade = document.getElementById('fade');
const floorGlow = document.getElementById('floorGlow');
const floorShadow = document.getElementById('floorShadow');
const pulse = document.getElementById('pulse');
const C = {lx:16, ly:111.86, cx:63.98, cy:111.86};
const clamp=(v,a=0,b=1)=>Math.max(a,Math.min(b,v));
const mix=(a,b,t)=>a+(b-a)*t;
const outCubic=t=>1-Math.pow(1-clamp(t),3);
const inCubic=t=>Math.pow(clamp(t),3);
const smooth=t=>{t=clamp(t);return t*t*(3-2*t)};
const outBack=t=>{t=clamp(t);const c1=1.70158,c3=c1+1;return 1+c3*Math.pow(t-1,3)+c1*Math.pow(t-1,2)};
const outBounce=t=>{
t=clamp(t); const n=7.5625,d=2.75;
if(t<1/d)return n*t*t;
if(t<2/d){t-=1.5/d;return n*t*t+.75}
if(t<2.5/d){t-=2.25/d;return n*t*t+.9375}
t-=2.625/d;return n*t*t+.984375;
};
function tf(tx,ty,rot,sx=1,sy=1,cx=C.cx,cy=C.cy){
return `translate(${tx.toFixed(3)} ${ty.toFixed(3)}) translate(${cx} ${cy}) rotate(${rot.toFixed(3)}) scale(${sx.toFixed(4)} ${sy.toFixed(4)}) translate(${-cx} ${-cy})`;
}
function setOpacity(el,v){el.setAttribute('opacity',String(clamp(v)))}
function windowed(t,a,b){return clamp((t-a)/(b-a))}
window.setBootTime=(t)=>{
// Overall reveal and end fade.
const intro = smooth(windowed(t,0.00,0.20));
const outro = smooth(windowed(t,3.82,4.14));
fade.setAttribute('opacity', String(1-intro + outro*intro));
// LEFT PIECE: drops first, rotated into a little bridge.
let lp=windowed(t,0.16,0.86);
let leftY=mix(-360,138,outBounce(lp));
let leftR=mix(74,90,outCubic(lp));
if(t>0.86 && t<1.18){
const q=windowed(t,0.86,1.18);
leftY=138 + Math.sin(q*Math.PI*3)*10*(1-q);
leftR=90 + Math.sin(q*Math.PI*2)*2.8*(1-q);
}
const leftVis=smooth(windowed(t,0.08,0.26));
// ORB: falls onto the bridge and gives it a tiny comic bounce.
let op=windowed(t,0.78,1.48);
let orbY=mix(-430,-48,outBounce(op));
let orbS=1;
if(t>1.34 && t<1.62){
const q=windowed(t,1.34,1.62);
orbS=1 + Math.sin(q*Math.PI)*0.055;
}
const orbVis=smooth(windowed(t,0.72,0.92));
// RIGHT CRESCENT: arrives last as a curved cap.
let rp=windowed(t,1.32,2.02);
let rightY=mix(-500,-92,outBounce(rp));
let rightX=mix(44,8,outCubic(rp));
let rightR=mix(-38,-92,outCubic(rp));
const rightVis=smooth(windowed(t,1.24,1.46));
// Stack balances after the third piece lands.
let wobble=0, wobbleX=0, wobbleY=0;
if(t>=1.88 && t<2.70){
const q=windowed(t,1.88,2.70);
const amp=7.5*(1-q*.40);
wobble=Math.sin(q*Math.PI*5.4)*amp;
wobbleX=Math.sin(q*Math.PI*5.4)*8*(1-q*.45);
wobbleY=Math.abs(Math.sin(q*Math.PI*5.4))*2.8;
}
// Snap all three pieces back into the exact source logo.
const sp=outBack(windowed(t,2.56,3.08));
if(t>=2.56){
leftY=mix(leftY,0,sp);
leftR=mix(leftR,0,sp);
orbY=mix(orbY,0,sp);
orbS=mix(orbS,1,sp);
rightY=mix(rightY,0,sp);
rightX=mix(rightX,0,sp);
rightR=mix(rightR,0,sp);
wobble*=1-sp; wobbleX*=1-sp; wobbleY*=1-sp;
}
// Tiny final settle/breath.
let settleScale=1;
if(t>=2.86 && t<3.34){
const q=windowed(t,2.86,3.34);
settleScale=1 + Math.sin(q*Math.PI*2)*0.016*(1-q);
}
left.setAttribute('transform',tf(0,leftY,leftR,1,1,C.lx,C.ly));
right.setAttribute('transform',tf(rightX,rightY,rightR));
orb.setAttribute('transform',tf(0,orbY,0,orbS,orbS));
stack.setAttribute('transform',`translate(${wobbleX.toFixed(3)} ${wobbleY.toFixed(3)}) translate(${C.cx} ${C.cy}) rotate(${wobble.toFixed(3)}) scale(${settleScale.toFixed(4)}) translate(${-C.cx} ${-C.cy})`);
setOpacity(left,leftVis); setOpacity(orb,orbVis); setOpacity(right,rightVis);
// Ground response and restrained blue halo.
const impact1=Math.max(0,1-Math.abs(t-.86)/.18);
const impact2=Math.max(0,1-Math.abs(t-1.48)/.20);
const impact3=Math.max(0,1-Math.abs(t-2.02)/.20);
const finalGlow=smooth(windowed(t,2.68,3.18))*(1-outro);
floorGlow.setAttribute('opacity',String(clamp(.035 + impact1*.05 + impact2*.10 + impact3*.07 + finalGlow*.15)));
floorShadow.setAttribute('rx',String(215 + impact1*18 + impact2*10 + finalGlow*20));
const pp=windowed(t,2.90,3.42);
pulse.setAttribute('r',String(mix(150,245,outCubic(pp))));
pulse.setAttribute('opacity',String(pp>0&&pp<1 ? Math.sin(pp*Math.PI)*.16 : 0));
};
window.setBootTime(0);
</script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
rm -rf .frames
mkdir -p .frames
node render.mjs 0 84
node render.mjs 84 168
node render.mjs 168 252
ffmpeg -y -hide_banner -loglevel error -framerate 60 -i .frames/%04d.png \
-c:v libx264 -preset slow -crf 17 -pix_fmt yuv420p -movflags +faststart \
-t 4.2 droiddeck-boot.mp4
ffmpeg -y -hide_banner -loglevel error -framerate 60 -i .frames/%04d.png \
-c:v libvpx-vp9 -row-mt 1 -crf 24 -b:v 0 -pix_fmt yuv420p -an \
-t 4.2 droiddeck-boot.webm
rm -rf .frames
Binary file not shown.
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
import fs from 'node:fs';
import path from 'node:path';
import {spawn} from 'node:child_process';
const here=path.dirname(new URL(import.meta.url).pathname);
const outDir=path.join(here,'.frames');
fs.mkdirSync(outDir,{recursive:true});
const start=Number(process.argv[2]||0);
const end=Number(process.argv[3]||252);
const chrome='/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const port=9333+start;
const profile='/tmp/droiddeck-boot-chrome-'+process.pid+'-'+start;
fs.rmSync(profile,{recursive:true,force:true});
const pageUrl='file://'+path.join(here,'boot.html');
const child=spawn(chrome,[
'--headless=new','--hide-scrollbars','--disable-sync','--no-first-run',
'--disable-background-networking','--disable-component-update',
`--remote-debugging-port=${port}`,`--user-data-dir=${profile}`,
'--window-size=1920,1080',pageUrl
],{stdio:'ignore'});
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
let wsUrl;
for(let i=0;i<80;i++){
try{
const pages=await (await fetch(`http://127.0.0.1:${port}/json`)).json();
wsUrl=pages.find(p=>p.type==='page')?.webSocketDebuggerUrl;
if(wsUrl) break;
}catch{}
await sleep(100);
}
if(!wsUrl) throw new Error('Chrome DevTools endpoint did not start');
const ws=new WebSocket(wsUrl);
await new Promise((resolve,reject)=>{ws.onopen=resolve;ws.onerror=reject});
let nextId=1;
const pending=new Map();
ws.onmessage=e=>{
const msg=JSON.parse(e.data);
if(msg.id && pending.has(msg.id)){
const {resolve,reject}=pending.get(msg.id); pending.delete(msg.id);
msg.error ? reject(new Error(JSON.stringify(msg.error))) : resolve(msg.result);
}
};
function send(method,params={}){
const id=nextId++;
ws.send(JSON.stringify({id,method,params}));
return new Promise((resolve,reject)=>pending.set(id,{resolve,reject}));
}
await send('Page.enable');
await send('Runtime.enable');
await send('Emulation.setDeviceMetricsOverride',{width:1920,height:1080,deviceScaleFactor:1,mobile:false});
await sleep(300);
const fps=60, duration=4.2, frames=Math.ceil(fps*duration);
for(let i=start;i<Math.min(end,frames);i++){
const t=i/fps;
await send('Runtime.evaluate',{expression:`window.setBootTime(${t.toFixed(6)})`,awaitPromise:true});
const shot=await send('Page.captureScreenshot',{format:'png',fromSurface:true,captureBeyondViewport:false});
fs.writeFileSync(path.join(outDir,String(i).padStart(4,'0')+'.png'),Buffer.from(shot.data,'base64'));
if(i%30===0) process.stdout.write(`rendered ${i}/${frames}\n`);
}
ws.close();
child.kill('SIGTERM');
console.log(`done: ${start}-${Math.min(end,frames)-1}`);