mirror of
				https://github.com/Wan-Video/Wan2.1.git
				synced 2025-11-04 06:15:17 +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>
 |