Files
tilt/animation.html
2026-08-03 16:04:46 -05:00

173 lines
13 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>tilt — animation studio</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><text y='13' font-size='13'>🎞️</text></svg>">
<style>
:root {
color-scheme:dark; --bg:#080b10; --panel:#0e141d; --panel2:#111a25; --line:#223245;
--muted:#718399; --text:#dce7f0; --accent:#68e0c2; --warm:#f6c85f; --danger:#ef6b73;
}
* { box-sizing:border-box; }
[hidden] { display:none !important; }
html,body { margin:0; min-height:100%; background:var(--bg); color:var(--text);
font-family:Inter,ui-sans-serif,system-ui,-apple-system,sans-serif; }
body { height:100vh; overflow:hidden; display:grid; grid-template-rows:54px minmax(0,1fr) 160px; }
button,input,select { font:inherit; }
button { border:1px solid var(--line); background:#152131; color:var(--text); border-radius:6px;
padding:7px 10px; cursor:pointer; }
button:hover:not(:disabled) { border-color:#4c708f; background:#1a2a3d; }
button:disabled { opacity:.45; cursor:default; }
button.primary { border-color:#338b78; background:#12382f; color:#a8f3df; }
button.danger { color:#ffabb0; }
input,select { min-width:0; border:1px solid var(--line); background:#0b1119; color:var(--text);
border-radius:5px; padding:6px 8px; }
input[type=range] { padding:0; accent-color:var(--accent); }
input[type=checkbox] { accent-color:var(--accent); }
.topbar { min-width:0; display:flex; align-items:center; gap:12px; padding:0 14px; border-bottom:1px solid var(--line);
background:#0b1017; }
.brand { letter-spacing:.22em; font-size:12px; font-weight:800; white-space:nowrap; }
.topbar .brand,.topbar button,.topbar .crumb { flex:0 0 auto; }
.brand span { color:var(--accent); }
.crumb { color:#405064; }
#clipName { width:min(240px,25vw); min-width:120px; flex:0 1 240px; }
.spacer { flex:1; }
.save-state { color:var(--muted); font-size:11px; min-width:74px; max-width:170px; overflow:hidden;
white-space:nowrap; text-overflow:ellipsis; text-align:right; }
.workspace { min-height:0; display:grid; grid-template-columns:minmax(270px,.82fr) minmax(390px,1.45fr) 286px; }
.pane { min-width:0; min-height:0; position:relative; border-right:1px solid var(--line); background:var(--panel); }
.pane-title { height:36px; display:flex; align-items:center; justify-content:space-between; padding:0 11px;
border-bottom:1px solid var(--line); color:#91a4b8; font-size:10px; font-weight:750; letter-spacing:.14em; text-transform:uppercase; }
.video-wrap { position:absolute; inset:36px 0 0; display:grid; place-items:center; overflow:hidden; background:#05070a; }
#referenceVideo { max-width:100%; max-height:100%; width:100%; height:100%; object-fit:contain; }
#poseOverlay { position:absolute; inset:0; width:100%; height:100%; object-fit:contain; pointer-events:none; }
#poseOverlay.picking { pointer-events:auto; cursor:crosshair; }
.video-empty { position:absolute; inset:0; display:grid; place-items:center; padding:24px; text-align:center;
color:var(--muted); font-size:12px; background:radial-gradient(circle at 50% 45%,#111b26,#06080c 72%); }
.video-empty[hidden] { display:none; }
.video-empty strong { display:block; color:var(--text); font-size:17px; margin-bottom:7px; }
.video-empty small { display:block; margin-top:10px; color:#54677b; line-height:1.5; }
.stage-pane { background:#0a0f16; }
#stage { position:absolute; inset:36px 0 0; width:100%; height:calc(100% - 36px); display:block; touch-action:none; }
.stage-hint { position:absolute; left:12px; bottom:10px; color:#587086; font-size:10px; pointer-events:none; }
.confidence { font-variant-numeric:tabular-nums; color:var(--accent); }
.inspector { min-height:0; overflow:auto; background:#0b1119; }
.section { padding:12px; border-bottom:1px solid var(--line); }
.section h2 { margin:0 0 10px; color:#8fa6bb; font-size:10px; letter-spacing:.14em; text-transform:uppercase; }
.stack { display:grid; gap:7px; }
.row { display:flex; gap:7px; align-items:center; }
.row > * { flex:1; }
.field { display:grid; grid-template-columns:58px minmax(0,1fr); gap:7px; align-items:center; color:var(--muted); font-size:11px; }
.field.compact { grid-template-columns:38px minmax(0,1fr) 52px; }
.field input[type=number] { width:52px; padding:5px; text-align:right; }
.toggle { display:flex; align-items:center; gap:7px; color:#91a4b8; font-size:11px; }
#status { min-height:32px; color:var(--muted); font-size:10px; line-height:1.5; }
.hint { color:var(--muted); font-size:10px; line-height:1.5; }
progress { width:100%; height:6px; border:0; accent-color:var(--accent); }
.timeline { min-width:0; display:grid; grid-template-rows:42px 1fr; background:#0a0f16; border-top:1px solid var(--line); }
.transport { display:flex; align-items:center; gap:7px; padding:0 12px; border-bottom:1px solid var(--line); }
.transport button { padding:5px 9px; }
.timecode { width:92px; color:var(--warm); font:600 12px ui-monospace,SFMono-Regular,monospace; }
.timeline-main { position:relative; min-height:0; padding:24px 15px 12px; }
#scrubber { position:absolute; left:15px; right:15px; top:10px; width:calc(100% - 30px); margin:0; z-index:3; }
#markers { position:absolute; left:22px; right:22px; top:49px; height:40px; border-top:1px solid #33475d;
background:repeating-linear-gradient(90deg,transparent 0,transparent calc(5% - 1px),#172536 5%); }
.key-marker { position:absolute; top:-7px; width:12px; height:12px; padding:0; transform:translateX(-50%) rotate(45deg);
border:1px solid #bf953a; border-radius:2px; background:var(--warm); }
.key-marker.current { background:var(--accent); border-color:#b9ffed; box-shadow:0 0 10px #68e0c288; }
.timeline-labels { position:absolute; left:22px; right:22px; top:95px; display:flex; justify-content:space-between;
color:#4c6074; font:10px ui-monospace,SFMono-Regular,monospace; }
#fileInput,#clipInput,#freeMocapInput { display:none; }
@media (max-width:980px) {
body { height:auto; min-height:100vh; overflow:auto; display:block; }
.topbar { height:auto; min-height:54px; flex-wrap:wrap; padding:9px 12px; }
.workspace { grid-template-columns:1fr; grid-template-rows:360px 500px auto; }
.pane { border-right:0; border-bottom:1px solid var(--line); }
.inspector { overflow:visible; }
.timeline { height:160px; position:sticky; bottom:0; z-index:10; }
}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><span>TILT</span> / ANIMATION</div><span class="crumb"></span>
<input id="clipName" value="reference-motion" aria-label="clip name">
<span id="saveState" class="save-state">new clip</span><span class="spacer"></span>
<button id="loadProject">Open saved</button><button id="saveProject">Save</button>
<button id="importClip">Import</button><button id="exportJson">Export JSON</button>
<button id="exportModule" class="primary">Export pose module</button>
</header>
<main class="workspace">
<section class="pane">
<div class="pane-title"><span>Reference video</span><button id="chooseVideo">Choose video</button></div>
<div class="video-wrap">
<video id="referenceVideo" playsinline muted></video><canvas id="poseOverlay"></canvas>
<div id="videoEmpty" class="video-empty"><div><strong>Drop in movement</strong>Upload a side or ¾ view with the full body visible.<small>MP4, WebM, or MOV supported by your browser.<br>The file stays on this device.</small></div></div>
</div>
</section>
<section class="pane stage-pane">
<div class="pane-title"><span>Tilt rig preview</span><span id="confidence" class="confidence">NO TRACK</span></div>
<canvas id="stage"></canvas><div class="stage-hint">drag background to orbit · click a joint to edit · Q/W rotate gizmo</div>
</section>
<aside class="inspector">
<section class="section"><h2>Capture</h2><div class="stack">
<div class="field"><label for="mocapSource">source</label><select id="mocapSource"><option value="browser">Browser MediaPipe</option><option value="freemocap">Local FreeMoCap</option></select></div>
<div id="browserMocapControls" class="stack">
<div class="field"><label for="captureFps">sample fps</label><input id="captureFps" type="number" min="1" max="30" value="12"></div>
<div class="row"><div class="field"><label for="trimIn">in</label><input id="trimIn" type="number" min="0" step="0.01" value="0"></div><div class="field"><label for="trimOut">out</label><input id="trimOut" type="number" min="0" step="0.01" value="0"></div></div>
<button id="extract" class="primary">Generate keys from video</button>
</div>
<div id="freeMocapControls" class="stack" hidden>
<div class="field"><label for="freeMocapFps">fallback fps</label><input id="freeMocapFps" type="number" min="1" max="240" value="30"></div>
<button id="importFreeMocap" class="primary">Process uploaded video with FreeMoCap</button>
<div id="freeMocapState" class="hint">Checking local worker…</div>
<button id="importFreeMocapCsv">Advanced: import existing XYZ CSV</button>
<input id="freeMocapInput" type="file" accept=".csv,text/csv">
</div>
<label class="toggle"><input id="mirrorPose" type="checkbox"> mirror source pose</label>
<progress id="progress" max="1" value="0"></progress>
<div id="status">Choose a video, set a short in/out range, then generate.</div>
</div></section>
<section class="section"><h2>Stick landmarks</h2><div class="stack">
<div class="field"><label for="shotSide">socket</label><select id="shotSide"><option value="right">right shot · right top hand</option><option value="left">left shot · left top hand</option></select></div>
<label class="toggle"><input id="trackStick" type="checkbox"> bake stick with body keys</label>
<div class="row"><button id="seedStick">Mark both ends at IN</button><button id="clearStick">Clear</button></div>
<button id="correctStick">Correct stick on current key</button>
<button id="applyStick" class="primary">Apply guide to rig + key</button>
<button id="flipStick">Flip baked stick direction</button>
<div id="stickStatus" class="hint">Choose the top-hand socket, then mark both shaft ends in either order.</div>
</div></section>
<section class="section"><h2>Keyframe pose</h2><div class="stack">
<div class="field"><label for="boneSelect">bone</label><select id="boneSelect"></select></div>
<div class="field compact"><label>X</label><input class="rot" data-axis="x" type="range" min="-180" max="180" step="1"><input class="rot-num" data-axis="x" type="number" min="-360" max="360" step="1"></div>
<div class="field compact"><label>Y</label><input class="rot" data-axis="y" type="range" min="-180" max="180" step="1"><input class="rot-num" data-axis="y" type="number" min="-360" max="360" step="1"></div>
<div class="field compact"><label>Z</label><input class="rot" data-axis="z" type="range" min="-180" max="180" step="1"><input class="rot-num" data-axis="z" type="number" min="-360" max="360" step="1"></div>
<div class="row"><button id="resetBone">Reset bone</button><button id="setKey" class="primary">Set / update key</button></div>
<div class="row"><button id="deleteKey" class="danger">Delete key</button><button id="smoothKeys">Smooth imported keys</button></div>
</div></section>
<section class="section"><h2>Clip</h2><div class="stack">
<div class="field"><label for="loopClip">playback</label><select id="loopClip"><option value="loop">loop</option><option value="once">play once</option></select></div>
<div class="field"><span>keys</span><strong id="keyCount">0</strong></div>
<div class="field"><span>duration</span><strong id="durationLabel">0.00 s</strong></div>
<button id="newClip">New empty clip</button>
</div></section>
</aside>
</main>
<footer class="timeline">
<div class="transport"><button id="prevKey" title="previous key">◀|</button><button id="playPause" class="primary">▶ Play</button><button id="nextKey" title="next key">|▶</button><span id="timecode" class="timecode">00:00.000</span><span id="timelineSource" class="save-state">clip</span></div>
<div class="timeline-main"><input id="scrubber" type="range" min="0" max="1" step="0.001" value="0"><div id="markers"></div><div class="timeline-labels"><span>0.00</span><span id="timelineEnd">1.00 s</span></div></div>
</footer>
<input id="fileInput" type="file" accept="video/*"><input id="clipInput" type="file" accept="application/json,.json,.tiltanim">
<script type="module" src="/src/studio/animationStudio.js"></script>
</body>
</html>