Wan2.1/wan/trajectory_editor/templates/index.html
2025-06-17 23:45:47 +02:00

572 lines
19 KiB
HTML
Raw 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.

<!-- Copyright (c) 2024-2025 Bytedance Ltd. and/or its affiliates
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License. -->
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Track Point Editor</title>
<style>
.btn-row {
display: flex;
align-items: center;
margin: 8px 0;
}
.btn-row > * { margin-right: 12px; }
body { font-family: sans-serif; margin: 16px; }
#topControls, #bottomControls { margin-bottom: 12px; }
button, input, select, label { margin: 4px; }
#canvas { border:1px solid #ccc; display: block; margin: auto; }
#canvas { cursor: crosshair; }
#trajProgress { width: 200px; height: 16px; margin-left:12px; }
</style>
</head>
<body>
<h2>Track Point Editor</h2>
<!-- Top controls -->
<div id="topControls" class="btn-row">
<input type="file" id="fileInput" accept="image/*">
<button id="storeBtn">Store Tracks</button>
</div>
<!-- Main drawing canvas -->
<canvas id="canvas"></canvas>
<!-- Track controls -->
<div id="bottomControls">
<div class="btn-row">
<button id="addTrackBtn">Add Freehand Track</button>
<button id="deleteLastBtn">Delete Last Track</button>
<progress id="trajProgress" max="121" value="0" style="display:none;"></progress>
</div>
<div class="btn-row">
<button id="placeCircleBtn">Place Circle</button>
<button id="addCirclePointBtn">Add Circle Point</button>
<label>Radius:
<input type="range" id="radiusSlider" min="10" max="800" value="50" style="display:none;">
</label>
</div>
<div class="btn-row">
<button id="addStaticBtn">Add Static Point</button>
<label>Static Frames:
<input type="number" id="staticFramesInput" value="121" min="1" style="width:60px">
</label>
</div>
<div class="btn-row">
<select id="trackSelect" style="min-width:160px;"></select>
<div id="colorIndicator"
style="
width:16px;
height:16px;
border:1px solid #444;
display:inline-block;
vertical-align:middle;
margin-left:8px;
pointer-events:none;
visibility:hidden;
">
</div>
<button id="deleteTrackBtn">Delete Selected</button>
<button id="editTrackBtn">Edit Track</button>
<button id="duplicateTrackBtn">Duplicate Track</button>
</div>
<!-- Global motion offset -->
<div class="btn-row">
<label>Motion X (px/frame):
<input type="number" id="motionXInput" value="0" style="width:60px">
</label>
<label>Motion Y (px/frame):
<input type="number" id="motionYInput" value="0" style="width:60px">
</label>
<button id="applySelectedMotionBtn">Add to Selected</button>
<button id="applyAllMotionBtn">Add to All</button>
</div>
</div>
<script>
// ——— DOM refs —————————————————————————————————————————
const canvas = document.getElementById('canvas'),
ctx = canvas.getContext('2d'),
fileIn = document.getElementById('fileInput'),
storeBtn = document.getElementById('storeBtn'),
addTrackBtn = document.getElementById('addTrackBtn'),
deleteLastBtn = document.getElementById('deleteLastBtn'),
placeCircleBtn = document.getElementById('placeCircleBtn'),
addCirclePointBtn = document.getElementById('addCirclePointBtn'),
addStaticBtn = document.getElementById('addStaticBtn'),
staticFramesInput = document.getElementById('staticFramesInput'),
radiusSlider = document.getElementById('radiusSlider'),
trackSelect = document.getElementById('trackSelect'),
deleteTrackBtn = document.getElementById('deleteTrackBtn'),
editTrackBtn = document.getElementById('editTrackBtn'),
duplicateTrackBtn = document.getElementById('duplicateTrackBtn'),
trajProg = document.getElementById('trajProgress'),
colorIndicator = document.getElementById('colorIndicator'),
motionXInput = document.getElementById('motionXInput'),
motionYInput = document.getElementById('motionYInput'),
applySelectedMotionBtn = document.getElementById('applySelectedMotionBtn'),
applyAllMotionBtn = document.getElementById('applyAllMotionBtn');
let img, image_id, ext, origW, origH,
scaleX=1, scaleY=1;
// track data
let free_tracks = [], current_track = [], drawing=false, motionCounter=0;
let circle=null, static_trajs=[];
let mode='', selectedTrack=null, editMode=false, editInfo=null, duplicateBuffer=null;
const COLORS=['red','green','blue','cyan','magenta','yellow','black'],
FIXED_LENGTH=121,
editSigma = 5/Math.sqrt(2*Math.log(2));
// ——— Upload & scale image ————————————————————————————
fileIn.addEventListener('change', async e => {
const f = e.target.files[0]; if (!f) return;
const fd = new FormData(); fd.append('image',f);
const res = await fetch('/upload_image',{method:'POST',body:fd});
const js = await res.json();
image_id=js.image_id; ext=js.ext;
origW=js.orig_width; origH=js.orig_height;
if(origW>=origH){
canvas.width=800; canvas.height=Math.round(origH*800/origW);
} else {
canvas.height=800; canvas.width=Math.round(origW*800/origH);
}
scaleX=origW/canvas.width; scaleY=origH/canvas.height;
img=new Image(); img.src=js.image_url;
img.onload=()=>{
free_tracks=[]; current_track=[];
circle=null; static_trajs=[];
mode=selectedTrack=''; editMode=false; editInfo=null; duplicateBuffer=null;
trajProg.style.display='none';
radiusSlider.style.display='none';
trackSelect.innerHTML='';
redraw();
};
});
// ——— Store tracks + depth —————————————————————————
storeBtn.onclick = async () => {
if(!image_id) return alert('Load an image first');
const fh = free_tracks.map(tr=>tr.map(p=>({x:p.x*scaleX,y:p.y*scaleY}))),
ct = (circle?.trajectories||[]).map(tr=>tr.map(p=>({x:p.x*scaleX,y:p.y*scaleY}))),
st = static_trajs.map(tr=>tr.map(p=>({x:p.x*scaleX,y:p.y*scaleY})));
const payload = {
image_id, ext,
tracks: fh,
circle_trajectories: ct.concat(st)
};
const res = await fetch('/store_tracks',{
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const js = await res.json();
img.src=js.overlay_url;
img.onload=()=>ctx.drawImage(img,0,0,canvas.width,canvas.height);
// reset UI
free_tracks=[]; circle=null; static_trajs=[];
mode=selectedTrack=''; editMode=false; editInfo=null; duplicateBuffer=null;
trajProg.style.display='none';
radiusSlider.style.display='none';
trackSelect.innerHTML='';
redraw();
};
// ——— Control buttons —————————————————————————————
addTrackBtn.onclick = ()=>{
mode='free'; drawing=true; current_track=[]; motionCounter=0;
trajProg.max=FIXED_LENGTH; trajProg.value=0;
trajProg.style.display='inline-block';
};
deleteLastBtn.onclick = ()=>{
if(drawing){
drawing=false; current_track=[]; trajProg.style.display='none';
} else if(free_tracks.length){
free_tracks.pop(); updateTrackSelect(); redraw();
}
updateColorIndicator();
};
placeCircleBtn.onclick = ()=>{ mode='placeCircle'; drawing=false; };
addCirclePointBtn.onclick = ()=>{ if(!circle) alert('Place circle first'); else mode='addCirclePt'; };
addStaticBtn.onclick = ()=>{ mode='placeStatic'; };
duplicateTrackBtn.onclick = ()=>{
if(!selectedTrack) return alert('Select a track first');
const arr = selectedTrack.type==='free'
? free_tracks[selectedTrack.idx]
: selectedTrack.type==='circle'
? circle.trajectories[selectedTrack.idx]
: static_trajs[selectedTrack.idx];
duplicateBuffer = arr.map(p=>({x:p.x,y:p.y}));
mode='duplicate'; canvas.style.cursor='copy';
};
radiusSlider.oninput = ()=>{
if(!circle) return;
circle.radius = +radiusSlider.value;
circle.trajectories.forEach((traj,i)=>{
const θ = circle.angles[i];
traj.push({
x: circle.cx + Math.cos(θ)*circle.radius,
y: circle.cy + Math.sin(θ)*circle.radius
});
});
if(selectedTrack?.type==='circle')
trajProg.value = circle.trajectories[selectedTrack.idx].length;
redraw();
};
deleteTrackBtn.onclick = ()=>{
if(!selectedTrack) return;
const {type,idx} = selectedTrack;
if(type==='free') free_tracks.splice(idx,1);
else if(type==='circle'){
circle.trajectories.splice(idx,1);
circle.angles.splice(idx,1);
} else {
static_trajs.splice(idx,1);
}
selectedTrack=null;
trajProg.style.display='none';
updateTrackSelect();
redraw();
updateColorIndicator();
};
editTrackBtn.onclick = ()=>{
if(!selectedTrack) return alert('Select a track first');
editMode=!editMode;
editTrackBtn.textContent = editMode?'Stop Editing':'Edit Track';
};
// ——— Track select & depth init —————————————————————
function updateTrackSelect(){
trackSelect.innerHTML='';
free_tracks.forEach((_,i)=>{
const o=document.createElement('option');
o.value=JSON.stringify({type:'free',idx:i});
o.textContent=`Point ${i+1}`;
trackSelect.appendChild(o);
});
if(circle){
circle.trajectories.forEach((_,i)=>{
const o=document.createElement('option');
o.value=JSON.stringify({type:'circle',idx:i});
o.textContent=`CirclePt ${i+1}`;
trackSelect.appendChild(o);
});
}
static_trajs.forEach((_,i)=>{
const o=document.createElement('option');
o.value=JSON.stringify({type:'static',idx:i});
o.textContent=`StaticPt ${i+1}`;
trackSelect.appendChild(o);
});
if(trackSelect.options.length){
trackSelect.selectedIndex=0;
trackSelect.onchange();
}
updateColorIndicator();
}
function applyMotionToTrajectory(traj, dx, dy) {
traj.forEach((pt, frameIdx) => {
pt.x += dx * frameIdx;
pt.y += dy * frameIdx;
});
}
applySelectedMotionBtn.onclick = () => {
if (!selectedTrack) {
return alert('Please select a track first');
}
const dx = parseFloat(motionXInput.value) || 0;
const dy = parseFloat(motionYInput.value) || 0;
// pick the underlying array
let arr = null;
if (selectedTrack.type === 'free') {
arr = free_tracks[selectedTrack.idx];
} else if (selectedTrack.type === 'circle') {
arr = circle.trajectories[selectedTrack.idx];
} else { // 'static'
arr = static_trajs[selectedTrack.idx];
}
applyMotionToTrajectory(arr, dx, dy);
redraw();
};
// 2) Add motion to every track on the canvas
applyAllMotionBtn.onclick = () => {
const dx = parseFloat(motionXInput.value) || 0;
const dy = parseFloat(motionYInput.value) || 0;
// freehand tracks
free_tracks.forEach(tr => applyMotionToTrajectory(tr, dx, dy));
// circlebased tracks
if (circle) {
circle.trajectories.forEach(tr => applyMotionToTrajectory(tr, dx, dy));
}
// static points (now will move over frames)
static_trajs.forEach(tr => applyMotionToTrajectory(tr, dx, dy));
redraw();
};
trackSelect.onchange = ()=>{
if(!trackSelect.value){
selectedTrack=null;
trajProg.style.display='none';
return;
}
selectedTrack = JSON.parse(trackSelect.value);
if(selectedTrack.type==='circle'){
trajProg.style.display='inline-block';
trajProg.max=FIXED_LENGTH;
trajProg.value=circle.trajectories[selectedTrack.idx].length;
} else if(selectedTrack.type==='free'){
trajProg.style.display='inline-block';
trajProg.max=FIXED_LENGTH;
trajProg.value=free_tracks[selectedTrack.idx].length;
} else {
trajProg.style.display='none';
}
updateColorIndicator();
};
// ——— Canvas drawing ————————————————————————————————
canvas.addEventListener('mousedown', e=>{
const r=canvas.getBoundingClientRect(),
x=e.clientX-r.left, y=e.clientY-r.top;
// place circle
if(mode==='placeCircle'){
circle={cx:x,cy:y,radius:50,angles:[],trajectories:[]};
radiusSlider.max=Math.min(canvas.width,canvas.height)|0;
radiusSlider.value=50; radiusSlider.style.display='inline';
mode=''; updateTrackSelect(); redraw(); return;
}
// add circle point
if(mode==='addCirclePt'){
const dx=x-circle.cx, dy=y-circle.cy;
const θ=Math.atan2(dy,dx);
const px=circle.cx+Math.cos(θ)*circle.radius;
const py=circle.cy+Math.sin(θ)*circle.radius;
circle.angles.push(θ);
circle.trajectories.push([{x:px,y:py}]);
mode=''; updateTrackSelect(); redraw(); return;
}
// add static
if (mode === 'placeStatic') {
// how many frames to “hold” the point
const len = parseInt(staticFramesInput.value, 10) || FIXED_LENGTH;
// duplicate the clickpoint len times
const traj = Array.from({ length: len }, () => ({ x, y }));
// push into free_tracks so it's drawn & edited just like any freehand curve
free_tracks.push(traj);
// reset state
mode = '';
updateTrackSelect();
redraw();
return;
}
// duplicate
if(mode==='duplicate' && duplicateBuffer){
const orig = duplicateBuffer;
// click defines translation by first point
const dx = x - orig[0].x, dy = y - orig[0].y;
const newTr = orig.map(p=>({x:p.x+dx, y:p.y+dy}));
free_tracks.push(newTr);
mode=''; duplicateBuffer=null; canvas.style.cursor='crosshair';
updateTrackSelect(); redraw(); return;
}
// editing
if(editMode && selectedTrack){
const arr = selectedTrack.type==='free'
? free_tracks[selectedTrack.idx]
: selectedTrack.type==='circle'
? circle.trajectories[selectedTrack.idx]
: static_trajs[selectedTrack.idx];
let best=0,bd=Infinity;
arr.forEach((p,i)=>{
const d=(p.x-x)**2+(p.y-y)**2;
if(d<bd){ bd=d; best=i; }
});
editInfo={ trackType:selectedTrack.type,
trackIdx:selectedTrack.idx,
ptIdx:best,
startX:x, startY:y };
return;
}
// freehand start
if(mode==='free'){
drawing=true; motionCounter=0;
current_track=[{x,y}];
redraw();
}
});
canvas.addEventListener('mousemove', e=>{
const r=canvas.getBoundingClientRect(),
x=e.clientX-r.left, y=e.clientY-r.top;
// edit mode
if(editMode && editInfo){
const dx=x-editInfo.startX,
dy=y-editInfo.startY;
const {trackType,trackIdx,ptIdx} = editInfo;
const arr = trackType==='free'
? free_tracks[trackIdx]
: trackType==='circle'
? circle.trajectories[trackIdx]
: static_trajs[trackIdx];
arr.forEach((p,i)=>{
const d=i-ptIdx;
const w=Math.exp(-0.5*(d*d)/(editSigma*editSigma));
p.x+=dx*w; p.y+=dy*w;
});
editInfo.startX=x; editInfo.startY=y;
if(selectedTrack?.type==='circle')
trajProg.value=circle.trajectories[selectedTrack.idx].length;
redraw(); return;
}
// freehand draw
if(drawing && (e.buttons&1)){
motionCounter++;
if(motionCounter%2===0){
current_track.push({x,y});
trajProg.value = Math.min(current_track.length, trajProg.max);
redraw();
}
}
});
canvas.addEventListener('mouseup', ()=>{
if(editMode && editInfo){ editInfo=null; return; }
if(drawing){
free_tracks.push(current_track.slice());
drawing=false; current_track=[];
updateTrackSelect(); redraw();
}
});
function updateColorIndicator() {
const idx = trackSelect.selectedIndex;
if (idx < 0) {
colorIndicator.style.visibility = 'hidden';
return;
}
// Pick the color by index
const col = COLORS[idx % COLORS.length];
colorIndicator.style.backgroundColor = col;
colorIndicator.style.visibility = 'visible';
}
// ——— redraw ———
function redraw(){
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (img.complete) ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
// set a fatter line for all strokes
ctx.lineWidth = 2;
// — freehand (and staticturnedfreehand) tracks —
free_tracks.forEach((tr, i) => {
const col = COLORS[i % COLORS.length];
ctx.strokeStyle = col;
ctx.fillStyle = col;
if (tr.length === 0) return;
// check if every point equals the first
const allSame = tr.every(p => p.x === tr[0].x && p.y === tr[0].y);
if (allSame) {
// draw a filled circle for a “static” dot
ctx.beginPath();
ctx.arc(tr[0].x, tr[0].y, 4, 0, 2 * Math.PI);
ctx.fill();
} else {
// normal polyline
ctx.beginPath();
tr.forEach((p, j) =>
j ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)
);
ctx.stroke();
}
});
if(drawing && current_track.length){
ctx.strokeStyle='black';
ctx.beginPath();
current_track.forEach((p,j)=>
j? ctx.lineTo(p.x,p.y): ctx.moveTo(p.x,p.y));
ctx.stroke();
}
// — circle trajectories —
if (circle) {
// circle outline
ctx.strokeStyle = 'white';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(circle.cx, circle.cy, circle.radius, 0, 2 * Math.PI);
ctx.stroke();
circle.trajectories.forEach((tr, i) => {
const col = COLORS[(free_tracks.length + i) % COLORS.length];
ctx.strokeStyle = col;
ctx.fillStyle = col;
ctx.lineWidth = 2;
if (tr.length <= 1) {
// singlepoint circle trajectory → dot
ctx.beginPath();
ctx.arc(tr[0].x, tr[0].y, 4, 0, 2 * Math.PI);
ctx.fill();
} else {
// normal circle track
ctx.beginPath();
tr.forEach((p, j) =>
j ? ctx.lineTo(p.x, p.y) : ctx.moveTo(p.x, p.y)
);
ctx.stroke();
// white handle at last point
const lp = tr[tr.length - 1];
ctx.fillStyle = 'white';
ctx.beginPath();
ctx.arc(lp.x, lp.y, 4, 0, 2 * Math.PI);
ctx.fill();
}
});
}
// — static_trajs (if you still use them separately) —
static_trajs.forEach((tr, i) => {
const p = tr[0];
ctx.fillStyle = 'orange';
ctx.beginPath();
ctx.arc(p.x, p.y, 5, 0, 2 * Math.PI);
ctx.fill();
});
}
</script>
</body>
</html>