mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b1405c4f5 |
No files matched your search
@@ -0,0 +1 @@
|
||||
.frames/
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Executable
+15
@@ -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.
@@ -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}`);
|
||||
Reference in new issue
Block a user