mirror of
https://github.com/Wan-Video/Wan2.1.git
synced 2025-11-05 14:33:15 +00:00
572 lines
19 KiB
HTML
572 lines
19 KiB
HTML
<!-- 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));
|
||
// circle‑based 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 click‐point 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 static‑turned‑freehand) 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) {
|
||
// single‑point 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>
|