Skip to content

Instantly share code, notes, and snippets.

@SqrtRyan
Created September 17, 2025 10:32
Show Gist options
  • Save SqrtRyan/6c776b4f38d24232647eb4c7d8725ce8 to your computer and use it in GitHub Desktop.
Save SqrtRyan/6c776b4f38d24232647eb4c7d8725ce8 to your computer and use it in GitHub Desktop.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Spacetime Hull — Snappy UI (Offline)</title>
<style>
@font-face {
font-family: 'Futura';
src: url('https://github.com/RyannDaGreat/Images/raw/refs/heads/master/fonts/Futura.ttc') format('truetype-collection');
font-weight: normal;
font-style: normal;
}
:root{ --bg:#0b0d12; --fg:#e8eef5; --muted:#a7b3c2; --line:#1e2937; --accent:#67e8f9; }
*{box-sizing:border-box}
body {
margin:0;
background: linear-gradient(135deg, #0f1419 0%, #1a1f2e 25%, #2d1b2e 50%, #3d2352 75%, #4a2c5a 100%);
min-height: 100vh;
color:var(--fg);
font:14px/1.35 Futura,system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}
*, *::before, *::after { font-family:inherit; }
header {
display:flex;
gap:10px;
align-items:center;
padding:20px;
margin: 20px 20px 0 20px;
background: linear-gradient(135deg, rgba(255,255,255,0.08) 0%, rgba(255,255,255,0.03) 100%);
border-radius: 25px;
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.15);
box-shadow:
0 15px 35px rgba(0,0,0,0.2),
inset 0 1px 0 rgba(255,255,255,0.1);
flex-wrap:wrap;
}
label { display:flex; align-items:center; gap:6px; color:var(--muted); }
input[type="range"]{
width:360px;
appearance:none;
height:8px;
border-radius:4px;
background:linear-gradient(90deg, #ff0080 0%, #67e8f9 100%);
outline:none;
}
input[type="range"]::-webkit-slider-thumb{
appearance:none;
width:20px;
height:20px;
border-radius:50%;
background:linear-gradient(45deg, #ff0080, #00ffff);
cursor:pointer;
box-shadow:0 0 10px rgba(255,0,128,0.5);
}
input[type="range"]::-moz-range-thumb{
width:20px;
height:20px;
border-radius:50%;
background:linear-gradient(45deg, #ff0080, #00ffff);
cursor:pointer;
border:none;
box-shadow:0 0 10px rgba(255,0,128,0.5);
}
input[type="number"]{
width:72px;
padding:6px 8px;
background: linear-gradient(135deg, rgba(14,21,32,0.9) 0%, rgba(25,35,50,0.8) 100%);
border:1px solid rgba(103,232,249,0.3);
color:var(--fg);
border-radius:12px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 15px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
transition: all 0.3s ease;
}
input[type="number"]:focus{
outline: none;
border-color: rgba(103,232,249,0.6);
box-shadow: 0 8px 25px rgba(103,232,249,0.4), inset 0 1px 0 rgba(255,255,255,0.2);
}
button, select{
padding:6px 10px;
background: linear-gradient(135deg, rgba(14,21,32,0.9) 0%, rgba(25,35,50,0.8) 100%);
border:1px solid rgba(103,232,249,0.3);
color:var(--fg);
border-radius:12px;
cursor:pointer;
backdrop-filter: blur(10px);
transition: all 0.15s ease;
box-shadow: 0 4px 15px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
}
button:hover, select:hover{
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(103,232,249,0.4), inset 0 1px 0 rgba(255,255,255,0.2);
border-color: rgba(103,232,249,0.6);
background: linear-gradient(135deg, rgba(14,21,32,1) 0%, rgba(25,35,50,0.9) 100%);
}
button:active{
transform: translateY(0px);
box-shadow: 0 2px 8px rgba(103,232,249,0.3), inset 0 1px 0 rgba(255,255,255,0.15);
border-color: rgba(103,232,249,0.8);
background: linear-gradient(135deg, rgba(40,50,70,1) 0%, rgba(60,80,100,1) 100%);
transition: all 0.1s ease;
}
select option{ background:#0e1520; color:var(--fg); }
input[type="file"]{ display:none; }
.file-button{
padding:6px 10px;
background: linear-gradient(135deg, rgba(14,21,32,0.9) 0%, rgba(25,35,50,0.8) 100%);
border:1px solid rgba(103,232,249,0.3);
color:var(--fg);
border-radius:12px;
cursor:pointer;
display:inline-block;
backdrop-filter: blur(10px);
transition: all 0.3s ease;
box-shadow: 0 4px 15px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
}
.file-button:hover{
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(103,232,249,0.4), inset 0 1px 0 rgba(255,255,255,0.2);
border-color: rgba(103,232,249,0.6);
background: linear-gradient(135deg, rgba(14,21,32,1) 0%, rgba(25,35,50,0.9) 100%);
}
.file-button:active{
transform: translateY(0px);
box-shadow: 0 2px 8px rgba(103,232,249,0.3), inset 0 1px 0 rgba(255,255,255,0.15);
border-color: rgba(103,232,249,0.8);
background: linear-gradient(135deg, rgba(40,50,70,1) 0%, rgba(60,80,100,1) 100%);
transition: all 0.1s ease;
}
.file-button.pulse {
animation: pulseGlow 2s ease-in-out infinite;
}
@keyframes pulseGlow {
0%, 100% {
box-shadow: 0 4px 15px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
border-color: rgba(103,232,249,0.3);
}
50% {
box-shadow: 0 8px 30px rgba(103,232,249,0.6), inset 0 1px 0 rgba(255,255,255,0.2);
border-color: rgba(103,232,249,0.8);
}
}
#frameBox{
padding:6px 10px;
background: linear-gradient(135deg, rgba(14,21,32,0.9) 0%, rgba(25,35,50,0.8) 100%);
border:1px solid rgba(103,232,249,0.3);
border-radius:12px;
backdrop-filter: blur(10px);
box-shadow: 0 4px 15px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
font-variant-numeric:tabular-nums;
width:160px;
transition: all 0.3s ease;
}
#playPause{ min-width:32px; text-align:center; }
#msg{ color:#ff7b7b; font-size:12px; margin-left:auto; }
main{
display:flex;
padding: 20px;
gap: 20px;
}
.video-container {
flex: 1;
padding: 20px;
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%);
border-radius: 30px;
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.1);
box-shadow:
0 20px 40px rgba(0,0,0,0.3),
0 10px 20px rgba(0,0,0,0.2),
inset 0 1px 0 rgba(255,255,255,0.1);
}
canvas{
display:block;
width:100%;
height:auto;
background:#000;
cursor:crosshair;
border-radius: 20px;
box-shadow: 0 10px 30px rgba(0,0,0,0.4);
}
.eye-open {
display: inline-block;
width: 14px;
height: 14px;
background-repeat: no-repeat;
background-size: 100% 100%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'%3E%3Cg fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1'%3E%3Cpath d='M7 3.625c-4.187 0-5.945 3.766-5.945 3.844S2.813 11.312 7 11.312s5.945-3.765 5.945-3.843S11.187 3.625 7 3.625M2.169 5.813L.61 4.252m4.525-.354L4.5 1.843m7.331 3.97l1.559-1.56m-4.525-.355L9.5 1.843'/%3E%3Cpath d='M5.306 7.081a1.738 1.738 0 1 0 3.388.776a1.738 1.738 0 1 0-3.388-.776'/%3E%3C/g%3E%3C/svg%3E");
}
.eye-closed {
display: inline-block;
width: 14px;
height: 14px;
background-repeat: no-repeat;
background-size: 100% 100%;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m15 18l-.722-3.25M2 8a10.645 10.645 0 0 0 20 0m-2 7l-1.726-2.05M4 15l1.726-2.05M9 18l.722-3.25'/%3E%3C/svg%3E");
}
.hull-panel {
width: 240px;
padding: 20px;
background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%);
border-radius: 30px;
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.1);
box-shadow:
0 20px 40px rgba(0,0,0,0.3),
0 10px 20px rgba(0,0,0,0.2),
inset 0 1px 0 rgba(255,255,255,0.1);
}
.video-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, rgba(255,255,255,0.1) 0%, rgba(255,255,255,0.05) 100%);
border: 1px solid rgba(255,255,255,0.2);
border-radius: 25px;
padding: 40px 50px;
backdrop-filter: blur(20px);
box-shadow:
0 25px 50px rgba(0,0,0,0.4),
0 10px 20px rgba(0,0,0,0.3),
inset 0 1px 0 rgba(255,255,255,0.15);
text-align: center;
z-index: 100;
pointer-events: none;
}
.simple-color-picker {
position: absolute;
z-index: 1000;
width: 40px;
height: 30px;
border: 1px solid rgba(103,232,249,0.5);
border-radius: 6px;
cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
}
.video-overlay h3 {
margin: 0 0 15px 0;
color: var(--accent);
font-size: 24px;
font-weight: normal;
}
.video-overlay p {
margin: 0;
color: var(--muted);
font-size: 16px;
line-height: 1.4;
}
.bounds-slider {
position: relative;
width: 100%;
height: 4px;
margin: 4px 0 0 0;
}
.bounds-track {
position: absolute;
width: 100%;
height: 4px;
background: rgba(255,255,255,0.2);
border-radius: 2px;
top: 0;
}
.bounds-fill {
position: absolute;
height: 4px;
background: rgba(255,255,255,0.4);
border-radius: 2px;
top: 0;
}
.bounds-thumb {
position: absolute;
width: 12px;
height: 12px;
background: rgba(255,255,255,0.8);
border-radius: 50%;
top: -4px;
cursor: pointer;
transition: all 0.2s ease;
}
.bounds-thumb:hover {
background: rgba(255,255,255,1);
transform: scale(1.1);
}
</style>
</head>
<body>
<header>
<input id="file" type="file" accept="video/*" />
<label for="file" class="file-button">Choose Video</label>
<label>FPS <input id="fps" type="number" min="1" max="120" step="1" value="30"></label>
<button id="playPause">▶</button>
<div style="display: flex; flex-direction: column; gap: 2px;">
<input id="scrubber" type="range" min="0" max="0" step="1" value="0" />
<div class="bounds-slider">
<div class="bounds-track"></div>
<div id="rangeFill" class="bounds-fill"></div>
<div id="startThumb" class="bounds-thumb"></div>
<div id="endThumb" class="bounds-thumb"></div>
</div>
</div>
<span id="frameBox">Frame 0/0 · 0.00s</span>
<button id="clear">Clear Points</button>
<button id="renderVideo">Render 3 Videos</button>
<label style="margin-left:20px;">Preview:
<select id="previewMode">
<option value="gui">GUI</option>
<option value="gray">Gray</option>
<option value="mask">Mask</option>
</select>
</label>
<span id="msg"></span>
</header>
<main>
<div class="video-container" style="position: relative;">
<canvas id="canvas"></canvas>
<video id="video" muted playsinline preload="auto" style="display:none"></video>
<div id="videoOverlay" class="video-overlay">
<p>Please choose a video</p>
</div>
</div>
<div id="hullPanel" class="hull-panel">
<h3 style="margin:0 0 10px 0; font-size:14px;">Hulls</h3>
<div id="hullList"></div>
<button id="addHull" style="width:100%; margin-top:10px;">+ Add Hull</button>
</div>
</main>
<div id="outputContainer" style="display:none; margin:20px; padding:20px; background: linear-gradient(135deg, rgba(255,255,255,0.05) 0%, rgba(255,255,255,0.02) 100%); border-radius: 30px; backdrop-filter: blur(20px); border: 1px solid rgba(255,255,255,0.1); box-shadow: 0 20px 40px rgba(0,0,0,0.3), 0 10px 20px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);">
<h3 style="margin:0 0 20px 0; font-size:18px; color:var(--fg); text-align:center;">Rendered Videos</h3>
<div style="display:flex; gap:20px; width:100%;">
<div style="flex:1; padding:15px; background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); border-radius: 20px; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 10px 20px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05);">
<h4 style="margin:0 0 10px 0; font-size:14px; color:var(--accent); text-align:center;">GUI Video</h4>
<video id="outputVideo1" controls autoplay loop muted style="width:100%; border-radius:12px; box-shadow: 0 6px 20px rgba(0,0,0,0.4);"></video>
</div>
<div style="flex:1; padding:15px; background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); border-radius: 20px; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 10px 20px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05);">
<h4 style="margin:0 0 10px 0; font-size:14px; color:var(--accent); text-align:center;">Gray Video</h4>
<video id="outputVideo2" controls autoplay loop muted style="width:100%; border-radius:12px; box-shadow: 0 6px 20px rgba(0,0,0,0.4);"></video>
</div>
<div style="flex:1; padding:15px; background: linear-gradient(135deg, rgba(255,255,255,0.03) 0%, rgba(255,255,255,0.01) 100%); border-radius: 20px; backdrop-filter: blur(10px); border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 10px 20px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.05);">
<h4 style="margin:0 0 10px 0; font-size:14px; color:var(--accent); text-align:center;">Mask Video</h4>
<video id="outputVideo3" controls autoplay loop muted style="width:100%; border-radius:12px; box-shadow: 0 6px 20px rgba(0,0,0,0.4);"></video>
</div>
</div>
</div>
<script>
// ===== Offline 3D Convex Hull (facet test) =====
function sub(a,b){ return {x:a.x-b.x, y:a.y-b.y, z:a.z-b.z}; }
function cross(u,v){ return {x:u.y*v.z-u.z*v.y, y:u.z*v.x-u.x*v.z, z:u.x*v.y-u.y*v.x}; }
function dot(a,b){ return a.x*b.x+a.y*b.y+a.z*b.z; }
function norm2(a){ return dot(a,a); }
function buildHullFaces(pts){
const n=pts.length, EPS=1e-6;
if(n<2) return {faces:[], edges:[], type:'empty', surfacePoints: new Set()};
if(n<3) return {faces:[], edges:[[0,1]], type:'1d', surfacePoints: new Set([0,1])};
// Try 3D hull first
const faces=[], seen=new Set();
for(let i=0;i<n;i++) for(let j=i+1;j<n;j++) for(let k=j+1;k<n;k++){
const a=pts[i], b=pts[j], c=pts[k];
const nrm = cross(sub(b,a), sub(c,a)); if(norm2(nrm)<EPS) continue;
let pos=false, neg=false, onPlane=0;
for(let m=0;m<n;m++){ if(m===i||m===j||m===k) continue; const d=dot(nrm,sub(pts[m],a)); if(d>EPS) pos=true; else if(d<-EPS) neg=true; else onPlane++; if(pos&&neg) break; }
if(pos&&neg) continue; if(onPlane===n-3) continue; const key=[i,j,k].sort((x,y)=>x-y).join(',');
if(!seen.has(key)){ seen.add(key); faces.push([i,j,k]); }
}
if(faces.length>0) {
// Determine surface points (used in hull faces)
const surfacePoints = new Set();
for(const face of faces){
surfacePoints.add(face[0]);
surfacePoints.add(face[1]);
surfacePoints.add(face[2]);
}
return {faces, edges:[], type:'3d', surfacePoints};
}
// Check if points are collinear (1D manifold)
if(n>=3){
const dir1 = sub(pts[1], pts[0]);
let allCollinear = true;
for(let i=2;i<n;i++){
const dir2 = sub(pts[i], pts[0]);
const crossProd = cross(dir1, dir2);
if(norm2(crossProd) > EPS) { allCollinear = false; break; }
}
if(allCollinear){
// Find extreme points along the line
const dots = pts.map((p,i) => ({i, dot: dot(sub(p,pts[0]), dir1)}));
dots.sort((a,b) => a.dot - b.dot);
const surfacePoints = new Set([dots[0].i, dots[dots.length-1].i]);
return {faces:[], edges:[[dots[0].i, dots[dots.length-1].i]], type:'1d', surfacePoints};
}
}
// Points are coplanar (2D manifold) - compute 2D convex hull
// Find plane normal from first 3 non-collinear points
let normal = null;
for(let i=0;i<n && !normal;i++) for(let j=i+1;j<n && !normal;j++) for(let k=j+1;k<n && !normal;k++){
const nrm = cross(sub(pts[j],pts[i]), sub(pts[k],pts[i]));
if(norm2(nrm) > EPS) normal = nrm;
}
if(!normal) return {faces:[], edges:[], type:'empty', surfacePoints: new Set()};
// Project to 2D plane and compute convex hull
const basis1 = Math.abs(normal.x) < 0.9 ? {x:1,y:0,z:0} : {x:0,y:1,z:0};
const u = cross(normal, basis1); const uLen = Math.sqrt(norm2(u));
const uNorm = {x:u.x/uLen, y:u.y/uLen, z:u.z/uLen};
const v = cross(normal, uNorm);
const pts2d = pts.map(p => ({x:dot(p,uNorm), y:dot(p,v), i:pts.indexOf(p)}));
const hull2d = convexHull2D(pts2d);
const edges = hull2d.map((idx,i) => [hull2d[i], hull2d[(i+1)%hull2d.length]]);
const surfacePoints = new Set(hull2d);
return {faces:[], edges, type:'2d', surfacePoints};
}
function convexHull2D(pts){
if(pts.length < 3) return pts.map(p => p.i);
const sorted = [...pts].sort((a,b) => a.x !== b.x ? a.x - b.x : a.y - b.y);
const lower = [], upper = [];
for(const p of sorted){
while(lower.length >= 2 && cross2D(lower[lower.length-2], lower[lower.length-1], p) <= 0) lower.pop();
lower.push(p);
}
for(let i=sorted.length-1;i>=0;i--){
const p = sorted[i];
while(upper.length >= 2 && cross2D(upper[upper.length-2], upper[upper.length-1], p) <= 0) upper.pop();
upper.push(p);
}
upper.pop(); lower.pop();
return lower.concat(upper).map(p => p.i);
}
function cross2D(a,b,c){ return (b.x-a.x)*(c.y-a.y) - (b.y-a.y)*(c.x-a.x); }
function sliceHullAtFrame(pts, hull, F){
const EPS=1e-6;
if(hull.type === '3d'){
// Original 3D face slicing
const edges=[];
for(const f of hull.faces){
const p0=pts[f[0]],p1=pts[f[1]],p2=pts[f[2]];
const tri=[p0,p1,p2];
const z=tri.map(p=>p.z-F);
const seg=[];
for(let i=0;i<3;i++){
const a=tri[i], b=tri[(i+1)%3];
const za=z[i], zb=z[(i+1)%3];
if(Math.abs(za)<EPS) seg.push({x:a.x,y:a.y});
if(za*zb<0){
const alpha=za/(za-zb);
seg.push({x:a.x+alpha*(b.x-a.x), y:a.y+alpha*(b.y-a.y)});
}
}
if(seg.length===2) edges.push(seg);
}
if(!edges.length) return [];
const verts=[];
for(const e of edges){
for(const p of e){
if(!verts.some(q=>Math.hypot(q.x-p.x,q.y-p.y)<1e-4)) verts.push(p);
}
}
if(verts.length<3) return [];
const cx=verts.reduce((s,p)=>s+p.x,0)/verts.length, cy=verts.reduce((s,p)=>s+p.y,0)/verts.length;
verts.sort((A,B)=>Math.atan2(A.y-cy,A.x-cx)-Math.atan2(B.y-cy,B.x-cx));
return verts;
}
if(hull.type === '2d'){
// Slice 2D hull edges at frame F
const segments = [];
for(const [i,j] of hull.edges){
const a = pts[i], b = pts[j];
const za = a.z - F, zb = b.z - F;
if(Math.abs(za) < EPS) segments.push({x:a.x, y:a.y});
if(Math.abs(zb) < EPS) segments.push({x:b.x, y:b.y});
if(za * zb < 0){
const alpha = za / (za - zb);
segments.push({x: a.x + alpha*(b.x-a.x), y: a.y + alpha*(b.y-a.y)});
}
}
if(segments.length < 2) return [];
// Remove duplicates and sort
const unique = [];
for(const p of segments){
if(!unique.some(q => Math.hypot(q.x-p.x, q.y-p.y) < 1e-4)) unique.push(p);
}
if(unique.length >= 3){
const cx = unique.reduce((s,p)=>s+p.x,0)/unique.length;
const cy = unique.reduce((s,p)=>s+p.y,0)/unique.length;
unique.sort((A,B)=>Math.atan2(A.y-cy,A.x-cx)-Math.atan2(B.y-cy,B.x-cx));
}
return unique;
}
if(hull.type === '1d'){
// Slice line segment at frame F
const [i,j] = hull.edges[0];
const a = pts[i], b = pts[j];
const za = a.z - F, zb = b.z - F;
if(Math.abs(za) < EPS) return [{x:a.x, y:a.y}];
if(Math.abs(zb) < EPS) return [{x:b.x, y:b.y}];
if(za * zb < 0){
const alpha = za / (za - zb);
return [{x: a.x + alpha*(b.x-a.x), y: a.y + alpha*(b.y-a.y)}];
}
return [];
}
return [];
}
// ===== MP4 Frame Counter =====
const find=(v,atom,start=0)=>{for(let i=start;i<v.byteLength-8;i++)if(v.getUint32(i+4)==atom)return i;return-1}
const hasVideo=(v,start)=>{for(let i=start;i<v.byteLength-20;i++)if(v.getUint32(i)==0x68646c72&&v.getUint32(i+12)==0x76696465)return 1}
const getFrames=(v,start)=>{for(let i=start;i<v.byteLength-20;i++)if(v.getUint32(i)==0x7374737a)return v.getUint32(i+12)}
async function parseMP4Frames(file){const v=new DataView(await file.arrayBuffer());const m=find(v,0x6d6f6f76);return m>-1&&hasVideo(v,m)?getFrames(v,m):0}
// ===== App state =====
const file = document.getElementById('file');
const fileButton = document.querySelector('.file-button');
const fpsInput = document.getElementById('fps');
const playPause = document.getElementById('playPause');
const scrubber = document.getElementById('scrubber');
const frameBox = document.getElementById('frameBox');
const clearBtn = document.getElementById('clear');
const addHullBtn = document.getElementById('addHull');
const hullList = document.getElementById('hullList');
const previewMode = document.getElementById('previewMode');
const renderVideoBtn = document.getElementById('renderVideo');
const msg = document.getElementById('msg');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const video = document.getElementById('video');
const outputContainer = document.getElementById('outputContainer');
const outputVideo1 = document.getElementById('outputVideo1');
const outputVideo2 = document.getElementById('outputVideo2');
const outputVideo3 = document.getElementById('outputVideo3');
const videoOverlay = document.getElementById('videoOverlay');
const startThumb = document.getElementById('startThumb');
const endThumb = document.getElementById('endThumb');
const rangeFill = document.getElementById('rangeFill');
let hulls = [{id: 0, name: 'Hull 1', points: [], color: '#67e8f9', visible: true}];
let selectedHullId = 0;
let nextHullId = 1;
let currentPreviewMode = 'gui';
let fps=30; // only for video seeking
let totalFrames=0; // actual frame count from MP4 metadata
let isPlaying=false;
let playInterval;
let isDragging = false;
let dragPointIndex = -1;
let dragHullId = -1;
let startFrame = 0;
let endFrame = 0;
let boundingRect = {x: 0, y: 0, width: 0, height: 0};
let isDraggingRange = false;
let isDraggingBounds = false;
let dragRangeType = ''; // 'start' or 'end'
let dragBoundsType = ''; // 'corner', 'edge-top', etc.
function frameToTime(fi){ return fi / fps; }
let currentFrame = 0;
let seekResolve;
video.addEventListener('seeked', ()=>{ if(seekResolve) seekResolve(); });
async function renderFrame(fi){
const clamped = fi|0;
const t = frameToTime(clamped);
if (isFinite(video.duration)) {
video.currentTime = t;
await new Promise(r => seekResolve = r);
}
ctx.clearRect(0,0,canvas.width,canvas.height);
// Background based on preview mode
if (currentPreviewMode === 'mask') {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
} else if (video.readyState >= 2) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
// Render all visible hulls
for(const hullData of hulls){
if(!hullData.visible) continue; // Skip invisible hulls
const isSelected = hullData.id === selectedHullId;
const hull = buildHullFaces(hullData.points);
const poly = sliceHullAtFrame(hullData.points, hull, clamped);
// Get colors with transparency for unselected hulls
function getHullColor(baseColor, opacity) {
const hex = baseColor.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${opacity})`;
}
// Get tinted white color (mix hull color with white)
function getTintedWhite(baseColor, tintPercent = 0.12) {
const hex = baseColor.slice(1);
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
// Mix with white (255, 255, 255)
const tintedR = Math.round(255 - (255 - r) * tintPercent);
const tintedG = Math.round(255 - (255 - g) * tintPercent);
const tintedB = Math.round(255 - (255 - b) * tintPercent);
return `rgb(${tintedR}, ${tintedG}, ${tintedB})`;
}
// Render hull slice based on preview mode
if (poly.length >= 3){
ctx.beginPath(); for(let i=0;i<poly.length;i++) (i?ctx.lineTo:ctx.moveTo).call(ctx, poly[i].x, poly[i].y); ctx.closePath();
if (currentPreviewMode === 'mask') {
ctx.fillStyle = getTintedWhite(hullData.color);
ctx.fill();
} else if (currentPreviewMode === 'gray') {
ctx.fillStyle = 'rgb(128,128,128)';
ctx.fill();
} else { // GUI mode
if(isSelected){
const tintedWhite = getTintedWhite(hullData.color, 0.15);
ctx.fillStyle = tintedWhite.replace('rgb(', 'rgba(').replace(')', ', 0.5)');
ctx.strokeStyle = hullData.color;
ctx.lineWidth = 2;
} else {
ctx.fillStyle=getHullColor(hullData.color, 0.2); ctx.strokeStyle=getHullColor(hullData.color, 0.6); ctx.lineWidth=1;
}
ctx.fill(); ctx.stroke();
}
} else if (poly.length === 2){
ctx.beginPath(); ctx.moveTo(poly[0].x, poly[0].y); ctx.lineTo(poly[1].x, poly[1].y);
if (currentPreviewMode === 'mask') {
ctx.strokeStyle = getTintedWhite(hullData.color); ctx.lineWidth = 3;
} else if (currentPreviewMode === 'gray') {
ctx.strokeStyle = 'rgb(128,128,128)'; ctx.lineWidth = 3;
} else { // GUI mode
ctx.strokeStyle = isSelected ? hullData.color : getHullColor(hullData.color, 0.6);
ctx.lineWidth = isSelected ? 3 : 2;
}
ctx.stroke();
} else if (poly.length === 1){
ctx.beginPath(); ctx.arc(poly[0].x, poly[0].y, isSelected ? 6 : 4, 0, 2*Math.PI);
if (currentPreviewMode === 'mask') {
ctx.fillStyle = getTintedWhite(hullData.color);
} else if (currentPreviewMode === 'gray') {
ctx.fillStyle = 'rgb(128,128,128)';
} else { // GUI mode
ctx.fillStyle = isSelected ? hullData.color : getHullColor(hullData.color, 0.6);
}
ctx.fill();
}
// Render points based on preview mode
if (currentPreviewMode === 'gui' && isSelected) {
// Only show points for selected hull in GUI mode
for(let i=0; i<hullData.points.length; i++){
const p = hullData.points[i];
const near = clamped === p.z;
const onSurface = hull.surfacePoints && hull.surfacePoints.has(i);
ctx.beginPath();
ctx.arc(p.x, p.y, near ? 5 : 4, 0, 2*Math.PI);
if(onSurface){
ctx.fillStyle = '#fff';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = near ? '#00ffa6' : '#000';
ctx.stroke();
} else {
ctx.fillStyle = near ? '#ffaa00' : '#888';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = '#444';
ctx.stroke();
}
}
} else if (currentPreviewMode === 'mask') {
// Show all points as tinted dots in mask mode
for(let i=0; i<hullData.points.length; i++){
const p = hullData.points[i];
const near = clamped === p.z;
ctx.beginPath();
ctx.arc(p.x, p.y, near ? 5 : 4, 0, 2*Math.PI);
ctx.fillStyle = getTintedWhite(hullData.color);
ctx.fill();
}
}
// Gray mode shows no points
}
// Draw bounding box in GUI mode only when within time range
if (currentPreviewMode === 'gui' && totalFrames > 0 &&
clamped >= startFrame && clamped <= endFrame) {
drawBoundingBox();
}
currentFrame = clamped;
scrubber.value = String(clamped);
frameBox.textContent = `Frame ${clamped+1}/${totalFrames} · ${t.toFixed(2)}s`;
}
function drawBoundingBox() {
ctx.save();
ctx.lineWidth = 3;
ctx.setLineDash([12, 6]);
// Draw white dashed line first
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.strokeRect(boundingRect.x, boundingRect.y, boundingRect.width, boundingRect.height);
// Offset the dash pattern and draw black dashed line
ctx.lineDashOffset = 9;
ctx.strokeStyle = 'rgba(0,0,0,0.5)';
ctx.strokeRect(boundingRect.x, boundingRect.y, boundingRect.width, boundingRect.height);
ctx.restore();
}
function getSelectedHull(){
return hulls.find(h => h.id === selectedHullId);
}
function getCanvasCoords(e){
const r = canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
return {
x: (e.clientX - r.left) * scaleX,
y: (e.clientY - r.top) * scaleY
};
}
function findClosestPoint(x, y, threshold = 15){
let closestHull = null;
let closestIndex = -1;
let closestDist = threshold;
for(const hull of hulls){
if(!hull.visible) continue;
for(let i = 0; i < hull.points.length; i++){
const p = hull.points[i];
const dist = Math.hypot(p.x - x, p.y - y);
if(dist < closestDist){
closestDist = dist;
closestHull = hull;
closestIndex = i;
}
}
}
return closestHull ? {hull: closestHull, index: closestIndex} : null;
}
function updateVideoOverlay(){
if(totalFrames > 0 && isFinite(video.duration)){
videoOverlay.style.display = 'none';
fileButton.classList.remove('pulse');
initializeBounds();
} else {
videoOverlay.style.display = 'block';
fileButton.classList.add('pulse');
}
}
function initializeBounds(){
startFrame = 0;
endFrame = totalFrames - 1;
boundingRect = {x: 0, y: 0, width: canvas.width, height: canvas.height};
updateRangeSlider();
}
function updateRangeSlider(){
if(totalFrames <= 1) return;
const startPercent = (startFrame / (totalFrames - 1)) * 100;
const endPercent = (endFrame / (totalFrames - 1)) * 100;
startThumb.style.left = startPercent + '%';
endThumb.style.left = endPercent + '%';
rangeFill.style.left = startPercent + '%';
rangeFill.style.width = (endPercent - startPercent) + '%';
}
function updateDisplay(){
const selectedHull = getSelectedHull();
const hull = buildHullFaces(selectedHull.points);
const warning = (selectedHull.points.length>=4 && hull.type==='empty') ? 'Need ≥4 points across different times for a 3D hull.' : '';
msg.textContent = warning;
renderFrame(currentFrame);
}
// Pure play/pause state functions
const togglePlay = () => isPlaying ? stopPlay() : startPlay();
const startPlay = () => { isPlaying = true; playPause.textContent = '⏸'; playInterval = setInterval(() => renderFrame((currentFrame + 1) % totalFrames), 1000/fps); };
const stopPlay = () => { isPlaying = false; playPause.textContent = '▶'; clearInterval(playInterval); };
// ===== Event wiring =====
file.addEventListener('change', async e=>{
const f=e.target.files[0]; if(!f) return; if(video.src && video.src.startsWith('blob:')) URL.revokeObjectURL(video.src);
// Get actual frame count from MP4 metadata
totalFrames = await parseMP4Frames(f);
const url=URL.createObjectURL(f); video.src=url; video.load();
video.addEventListener('loadeddata', ()=>{
canvas.width = video.videoWidth; canvas.height = video.videoHeight;
fps = totalFrames / video.duration;
fpsInput.value = Math.round(fps);
scrubber.min = 0; scrubber.max = totalFrames-1; scrubber.step = 1; scrubber.value = 0;
video.muted = true; video.play().then(()=>video.pause()).catch(()=>{});
updateVideoOverlay(); // Hide overlay when video loads
renderFrame(0);
}, { once:true });
video.addEventListener('error', ()=>{ msg.textContent='Unable to decode this file in this browser.'; }, { once:true });
});
// Pure event handlers
playPause.addEventListener('click', togglePlay);
scrubber.addEventListener('input', e=>{ stopPlay(); renderFrame(parseInt(e.target.value,10)); });
// FPS change updates UI scaling (totalFrames stays from MP4 metadata)
fpsInput.addEventListener('change', ()=>{
const newFps = parseInt(fpsInput.value,10);
fps = newFps;
if(isPlaying) { stopPlay(); startPlay(); } // Restart with new fps
else renderFrame(currentFrame);
});
function renderHullList(){
hullList.innerHTML = '';
hulls.forEach(hull => {
const item = document.createElement('div');
item.style.cssText = 'display:flex; align-items:center; gap:8px; padding:4px; border-radius:4px; margin:2px 0; cursor:pointer;';
if(hull.id === selectedHullId) item.style.background = 'rgba(103,232,249,0.2)';
const colorIndicator = document.createElement('div');
colorIndicator.style.cssText = `width:12px; height:12px; border-radius:50%; background:${hull.color}; margin-right:6px; flex-shrink:0;`;
const name = document.createElement('span');
name.textContent = hull.name;
name.style.cssText = 'flex:1; font-size:12px;';
const colorBtn = document.createElement('button');
colorBtn.innerHTML = '🎨';
colorBtn.style.cssText = 'width:20px; height:20px; padding:0; font-size:10px; border:none; background:rgba(100,100,100,0.2); border-radius:2px; cursor:pointer;';
const editBtn = document.createElement('button');
editBtn.innerHTML = '✏️';
editBtn.style.cssText = 'width:20px; height:20px; padding:0; font-size:10px; border:none; background:rgba(100,100,100,0.2); border-radius:2px; cursor:pointer;';
const eyeBtn = document.createElement('button');
eyeBtn.textContent = hull.visible ? '👁' : '👁‍🗨';
eyeBtn.style.cssText = 'width:20px; height:20px; padding:0; font-size:10px; border:none; background:rgba(100,100,100,0.2); border-radius:2px; cursor:pointer;';
const deleteBtn = document.createElement('button');
deleteBtn.textContent = '🗑';
deleteBtn.style.cssText = 'width:20px; height:20px; padding:0; font-size:10px; border:none; background:rgba(255,100,100,0.2); border-radius:2px; cursor:pointer;';
item.appendChild(colorIndicator);
item.appendChild(name);
item.appendChild(colorBtn);
item.appendChild(editBtn);
item.appendChild(eyeBtn);
if(hulls.length > 1) item.appendChild(deleteBtn); // Don't allow deleting last hull
item.addEventListener('click', e => {
if(e.target === deleteBtn) {
if(hulls.length > 1) {
hulls = hulls.filter(h => h.id !== hull.id);
if(selectedHullId === hull.id) selectedHullId = hulls[0].id;
renderHullList();
updateDisplay();
}
} else if(e.target === eyeBtn) {
hull.visible = !hull.visible;
renderHullList();
updateDisplay();
} else if(e.target === colorBtn) {
e.stopPropagation(); // Prevent hull selection
// Remove any existing color picker first
const existingPicker = document.querySelector('.simple-color-picker');
if(existingPicker) {
existingPicker.remove();
}
// Create simple color picker next to button
const colorInput = document.createElement('input');
colorInput.type = 'color';
colorInput.value = hull.color;
colorInput.className = 'simple-color-picker';
// Append to hull panel and position relative to it
const hullPanel = document.getElementById('hullPanel');
hullPanel.appendChild(colorInput);
// Position relative to hull panel
const hullPanelRect = hullPanel.getBoundingClientRect();
const buttonRect = colorBtn.getBoundingClientRect();
colorInput.style.left = (buttonRect.right - hullPanelRect.left + 5) + 'px';
colorInput.style.top = (buttonRect.top - hullPanelRect.top) + 'px';
// Real-time color updates
colorInput.addEventListener('input', (event) => {
hull.color = event.target.value;
renderHullList(); // Update hull list to show new color indicator
updateDisplay(); // Update display immediately
});
// Close when clicking outside
const closeColorPicker = (event) => {
if (!colorInput.contains(event.target) && event.target !== colorBtn) {
colorInput.remove();
document.removeEventListener('click', closeColorPicker);
}
};
setTimeout(() => {
document.addEventListener('click', closeColorPicker);
}, 100);
// Auto-open the color picker
colorInput.click();
} else if(e.target === editBtn) {
// Create an inline text input for editing the name
const newName = prompt('Enter new hull name:', hull.name);
if(newName !== null && newName.trim() !== '') {
hull.name = newName.trim();
renderHullList();
updateDisplay();
}
} else {
selectedHullId = hull.id;
renderHullList();
updateDisplay();
}
});
hullList.appendChild(item);
});
}
function isClickingBoundingEdge(x, y) {
if(currentPreviewMode !== 'gui' || totalFrames <= 0) return false;
if(currentFrame < startFrame || currentFrame > endFrame) return false;
const threshold = 8; // Distance from edge to be considered "on" the edge
// Check top edge
if(Math.abs(y - boundingRect.y) <= threshold &&
x >= boundingRect.x && x <= boundingRect.x + boundingRect.width) {
return 'edge-top';
}
// Check bottom edge
if(Math.abs(y - (boundingRect.y + boundingRect.height)) <= threshold &&
x >= boundingRect.x && x <= boundingRect.x + boundingRect.width) {
return 'edge-bottom';
}
// Check left edge
if(Math.abs(x - boundingRect.x) <= threshold &&
y >= boundingRect.y && y <= boundingRect.y + boundingRect.height) {
return 'edge-left';
}
// Check right edge
if(Math.abs(x - (boundingRect.x + boundingRect.width)) <= threshold &&
y >= boundingRect.y && y <= boundingRect.y + boundingRect.height) {
return 'edge-right';
}
return false;
}
// Mouse down - start dragging or add point
canvas.addEventListener('mousedown', e=>{
if (!isFinite(video.duration)) return;
const coords = getCanvasCoords(e);
// Check if clicking on bounding box edge
const edgeType = isClickingBoundingEdge(coords.x, coords.y);
if(edgeType) {
isDraggingBounds = true;
dragBoundsType = edgeType;
e.preventDefault();
return;
}
const closest = findClosestPoint(coords.x, coords.y);
if(closest && currentPreviewMode === 'gui'){
// Start dragging existing point
isDragging = true;
dragPointIndex = closest.index;
dragHullId = closest.hull.id;
selectedHullId = closest.hull.id; // Select the hull with the point being dragged
canvas.style.cursor = 'grabbing';
e.preventDefault();
}
});
// Mouse move - drag point or bounding box
canvas.addEventListener('mousemove', e=>{
const coords = getCanvasCoords(e);
if(isDraggingBounds) {
// Handle bounding box dragging
const minX = 0, minY = 0;
const maxX = canvas.width, maxY = canvas.height;
if(dragBoundsType === 'edge-top') {
const newHeight = boundingRect.height + (boundingRect.y - Math.max(minY, coords.y));
boundingRect.y = Math.max(minY, coords.y);
boundingRect.height = Math.max(20, newHeight);
} else if(dragBoundsType === 'edge-bottom') {
boundingRect.height = Math.max(20, Math.min(maxY - boundingRect.y, coords.y - boundingRect.y));
} else if(dragBoundsType === 'edge-left') {
const newWidth = boundingRect.width + (boundingRect.x - Math.max(minX, coords.x));
boundingRect.x = Math.max(minX, coords.x);
boundingRect.width = Math.max(20, newWidth);
} else if(dragBoundsType === 'edge-right') {
boundingRect.width = Math.max(20, Math.min(maxX - boundingRect.x, coords.x - boundingRect.x));
}
updateDisplay();
return;
}
if(!isDragging) {
// Update cursor when hovering over edges or points
if(currentPreviewMode === 'gui' && isFinite(video.duration)){
const edgeType = isClickingBoundingEdge(coords.x, coords.y);
if(edgeType) {
if(edgeType === 'edge-top' || edgeType === 'edge-bottom') canvas.style.cursor = 'ns-resize';
else if(edgeType === 'edge-left' || edgeType === 'edge-right') canvas.style.cursor = 'ew-resize';
} else {
const closest = findClosestPoint(coords.x, coords.y);
canvas.style.cursor = closest ? 'grab' : 'crosshair';
}
}
return;
}
const hull = hulls.find(h => h.id === dragHullId);
if(hull && hull.points[dragPointIndex]){
hull.points[dragPointIndex].x = coords.x;
hull.points[dragPointIndex].y = coords.y;
updateDisplay();
}
});
// Mouse up - finish dragging or add point
canvas.addEventListener('mouseup', e=>{
if(isDragging){
isDragging = false;
dragPointIndex = -1;
dragHullId = -1;
canvas.style.cursor = 'crosshair';
} else if(isDraggingBounds){
isDraggingBounds = false;
dragBoundsType = '';
canvas.style.cursor = 'crosshair';
} else if(!isDragging && !isDraggingBounds && isFinite(video.duration)){
// Add new point if we weren't dragging anything
const coords = getCanvasCoords(e);
const closest = findClosestPoint(coords.x, coords.y);
const edgeType = isClickingBoundingEdge(coords.x, coords.y);
if(!closest && !edgeType){ // Only add if not clicking on existing point or edge
const selectedHull = getSelectedHull();
selectedHull.points.push({x: coords.x, y: coords.y, z: currentFrame});
updateDisplay();
}
}
});
// Right-click to delete point
canvas.addEventListener('contextmenu', e=>{
e.preventDefault();
const r=canvas.getBoundingClientRect();
const scaleX = canvas.width / r.width;
const scaleY = canvas.height / r.height;
const x=(e.clientX-r.left) * scaleX;
const y=(e.clientY-r.top) * scaleY;
const selectedHull = getSelectedHull();
// Find closest point within threshold
let closestIndex = -1;
let closestDist = 15; // pixel threshold
for(let i=0; i<selectedHull.points.length; i++){
const dist = Math.hypot(selectedHull.points[i].x - x, selectedHull.points[i].y - y);
if(dist < closestDist){
closestDist = dist;
closestIndex = i;
}
}
if(closestIndex >= 0){
selectedHull.points.splice(closestIndex, 1);
updateDisplay();
}
});
clearBtn.addEventListener('click', ()=>{
const selectedHull = getSelectedHull();
selectedHull.points = [];
msg.textContent = '';
renderFrame(currentFrame);
});
addHullBtn.addEventListener('click', ()=>{
const newHull = {
id: nextHullId++,
name: `Hull ${nextHullId}`,
points: [],
color: ['#67e8f9', '#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#fef160'][hulls.length % 6],
visible: true
};
hulls.push(newHull);
selectedHullId = newHull.id;
renderHullList();
updateDisplay();
});
// Preview mode change
previewMode.addEventListener('change', ()=>{
currentPreviewMode = previewMode.value;
updateDisplay();
});
// Range slider event handlers
startThumb.addEventListener('mousedown', (e) => {
isDraggingRange = true;
dragRangeType = 'start';
e.preventDefault();
});
endThumb.addEventListener('mousedown', (e) => {
isDraggingRange = true;
dragRangeType = 'end';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if(isDraggingRange) {
const rangeContainer = startThumb.parentElement;
const rect = rangeContainer.getBoundingClientRect();
const percent = Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
const frame = Math.round((percent / 100) * (totalFrames - 1));
if(dragRangeType === 'start') {
startFrame = Math.min(frame, endFrame);
} else {
endFrame = Math.max(frame, startFrame);
}
updateRangeSlider();
}
});
document.addEventListener('mouseup', () => {
isDraggingRange = false;
dragRangeType = '';
});
// Initialize hull list and video overlay
renderHullList();
updateVideoOverlay();
// Helper function to render hull in different styles
function renderHullForMode(hull, poly, frame, mode) {
if (poly.length >= 3) {
ctx.beginPath();
for(let i=0;i<poly.length;i++) (i?ctx.lineTo:ctx.moveTo).call(ctx, poly[i].x, poly[i].y);
ctx.closePath();
if (mode === 'blackWhite') {
ctx.fillStyle='white';
ctx.fill();
} else if (mode === 'videoGray') {
ctx.fillStyle='rgb(128,128,128)';
ctx.fill();
} else { // videoTransparent
ctx.fillStyle='rgba(255,255,255,0.5)';
ctx.strokeStyle='#67e8f9';
ctx.lineWidth=2;
ctx.fill();
ctx.stroke();
}
} else if (poly.length === 2) {
ctx.beginPath();
ctx.moveTo(poly[0].x, poly[0].y);
ctx.lineTo(poly[1].x, poly[1].y);
if (mode === 'blackWhite') {
ctx.strokeStyle='white';
ctx.lineWidth=3;
ctx.stroke();
} else if (mode === 'videoGray') {
ctx.strokeStyle='rgb(128,128,128)';
ctx.lineWidth=3;
ctx.stroke();
} else { // videoTransparent
ctx.strokeStyle='#67e8f9';
ctx.lineWidth=3;
ctx.stroke();
}
} else if (poly.length === 1) {
ctx.beginPath();
ctx.arc(poly[0].x, poly[0].y, 6, 0, 2*Math.PI);
if (mode === 'blackWhite') {
ctx.fillStyle='white';
ctx.fill();
} else if (mode === 'videoGray') {
ctx.fillStyle='rgb(128,128,128)';
ctx.fill();
} else { // videoTransparent
ctx.fillStyle='#67e8f9';
ctx.fill();
}
}
}
async function renderSingleVideo(mode) {
const outputFPS = parseInt(fpsInput.value);
const stream = canvas.captureStream(outputFPS);
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'video/webm;codecs=vp9',
videoBitsPerSecond: 50000000
});
const chunks = [];
return new Promise((resolve, reject) => {
mediaRecorder.ondataavailable = e => chunks.push(e.data);
mediaRecorder.onstop = () => {
const blob = new Blob(chunks, { type: 'video/webm' });
resolve(URL.createObjectURL(blob));
};
mediaRecorder.start();
(async () => {
for(let frame = 0; frame < totalFrames; frame++) {
const t = frameToTime(frame);
video.currentTime = t;
ctx.clearRect(0,0,canvas.width,canvas.height);
// Background
if (mode === 'blackWhite') {
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, canvas.width, canvas.height);
} else {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
}
// Render all visible hulls combined
for(const hullData of hulls){
if(!hullData.visible) continue; // Skip invisible hulls
const hull = buildHullFaces(hullData.points);
const poly = sliceHullAtFrame(hullData.points, hull, frame);
renderHullForMode(hull, poly, frame, mode);
// Render points only for videoTransparent mode
if (mode === 'videoTransparent') {
for(let i=0; i<hullData.points.length; i++){
const p = hullData.points[i];
const near = frame === p.z;
const onSurface = hull.surfacePoints && hull.surfacePoints.has(i);
ctx.beginPath();
ctx.arc(p.x, p.y, near ? 5 : 4, 0, 2*Math.PI);
if(onSurface){
ctx.fillStyle = '#fff';
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = near ? '#00ffa6' : '#000';
ctx.stroke();
} else {
ctx.fillStyle = near ? '#ffaa00' : '#888';
ctx.fill();
ctx.lineWidth = 1;
ctx.strokeStyle = '#444';
ctx.stroke();
}
}
}
}
const track = stream.getVideoTracks()[0];
track.requestFrame();
msg.textContent = `Rendering ${mode} ${frame+1}/${totalFrames}`;
await new Promise(resolve => setTimeout(resolve, 1000/outputFPS));
}
mediaRecorder.stop();
})().catch(reject);
});
}
renderVideoBtn.addEventListener('click', async ()=>{
if(!totalFrames) {
msg.textContent = 'Load a video first';
return;
}
renderVideoBtn.disabled = true;
try {
msg.textContent = 'Rendering videos...';
// Render all three videos in correct order
const url1 = await renderSingleVideo('videoTransparent');
const url2 = await renderSingleVideo('videoGray');
const url3 = await renderSingleVideo('blackWhite');
// Set video sources
outputVideo1.src = url1;
outputVideo2.src = url2;
outputVideo3.src = url3;
// Sync video playback
outputVideo1.addEventListener('loadeddata', () => {
outputVideo2.currentTime = outputVideo1.currentTime;
outputVideo3.currentTime = outputVideo1.currentTime;
});
outputVideo1.addEventListener('play', () => {
outputVideo2.play();
outputVideo3.play();
});
outputVideo1.addEventListener('pause', () => {
outputVideo2.pause();
outputVideo3.pause();
});
outputVideo1.addEventListener('seeked', () => {
outputVideo2.currentTime = outputVideo1.currentTime;
outputVideo3.currentTime = outputVideo1.currentTime;
});
outputContainer.style.display = 'block';
msg.textContent = 'All videos rendered!';
} catch(e) {
msg.textContent = 'Error rendering videos: ' + e.message;
} finally {
renderVideoBtn.disabled = false;
}
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment