Created
September 17, 2025 10:32
-
-
Save SqrtRyan/6c776b4f38d24232647eb4c7d8725ce8 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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