diff --git a/app/static/js/app.jsx b/app/static/js/app.jsx index 2160f5b..7b562d1 100644 --- a/app/static/js/app.jsx +++ b/app/static/js/app.jsx @@ -443,9 +443,9 @@ const WaveformLane = ({ const barDuration = beatDuration * 4; const leadIn = 0; const CLIP_BUFFER = Math.max(400, barDuration * zoom + 200); - const PADDING_LEFT = 8 / zoom; + const PADDING_LEFT = 0; const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT; - const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + PADDING_LEFT; + const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + CLIP_BUFFER / zoom; const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; let snapDivisor = 1; if (snapValue && snapValue !== 'free') { @@ -1222,10 +1222,10 @@ const TimelineRuler = ({ ctx.lineTo(drawWidth, height - 0.5); ctx.stroke(); - const PADDING_LEFT = 8 / zoom; - const tStart = scrollLeftVal / zoom - PADDING_LEFT; - const tEnd = (scrollLeftVal + drawWidth) / zoom + PADDING_LEFT; const CLIP_BUFFER = 400; + const PADDING_LEFT = 0; + const tStart = scrollLeftVal / zoom - PADDING_LEFT; + const tEnd = (scrollLeftVal + drawWidth) / zoom + CLIP_BUFFER / zoom; // Draw time duration labels with drag-selection markers const minTimePx = 60; @@ -1305,9 +1305,9 @@ const TempoTrackLane = ({ const barDuration = beatDuration * 4; const leadIn = propLeadIn !== undefined ? propLeadIn : 0; const CLIP_BUFFER = Math.max(400, barDuration * zoom + 200); - const PADDING_LEFT = 8 / zoom; + const PADDING_LEFT = 0; const tStart = (scrollLeftVal - leadIn * zoom) / zoom - PADDING_LEFT; - const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + PADDING_LEFT; + const tEnd = (scrollLeftVal + drawWidth - leadIn * zoom) / zoom + CLIP_BUFFER / zoom; const firstBeat = Math.floor(tStart / beatDuration) * beatDuration; for (let t = firstBeat; t <= tEnd; t += beatDuration) { const beatNum = Math.floor(t / beatDuration) + 1; diff --git a/app/static/js/app.precompiled.js b/app/static/js/app.precompiled.js index c0d4c5e..0191e6c 100644 --- a/app/static/js/app.precompiled.js +++ b/app/static/js/app.precompiled.js @@ -19,7 +19,7 @@ this.sourceNode.connect(this.workletNode);// Enable Live Input Monitoring if req if(enableMonitoring&&destinationTrackGainNode){this.sourceNode.connect(destinationTrackGainNode);}}async stop(){this.isRecording=false;if(this.sourceNode&&this.workletNode){try{this.sourceNode.disconnect(this.workletNode);}catch(e){}}if(this.mediaStream){this.mediaStream.getTracks().forEach(track=>track.stop());}// Concatenate PCM Float32Array chunks into a single AudioBuffer const totalSamples=this.pcmChunks.reduce((sum,chunk)=>sum+chunk.length,0);if(totalSamples===0)return null;const audioBuffer=this.audioCtx.createBuffer(1,totalSamples,this.audioCtx.sampleRate);const channelData=audioBuffer.getChannelData(0);let offset=0;for(const chunk of this.pcmChunks){channelData.set(chunk,offset);offset+=chunk.length;}return audioBuffer;// Return compiled AudioBuffer for timeline insertion }}const VolumeKnob=({value,onChange,min=0,max=1})=>{const[isDragging,setIsDragging]=useState(false);const startY=useRef(0);const startValue=useRef(0);const rotation=useMemo(()=>{const percent=(value-min)/(max-min);return-135+percent*270;},[value,min,max]);const handleMouseDown=e=>{setIsDragging(true);startY.current=e.clientY;startValue.current=value;document.addEventListener('mousemove',handleMouseMove);document.addEventListener('mouseup',handleMouseUp);};const handleMouseMove=e=>{const deltaY=startY.current-e.clientY;const sensitivity=0.005;const newValue=Math.max(min,Math.min(max,startValue.current+deltaY*sensitivity));onChange(parseFloat(newValue.toFixed(2)));};const handleMouseUp=()=>{setIsDragging(false);document.removeEventListener('mousemove',handleMouseMove);document.removeEventListener('mouseup',handleMouseUp);};return/*#__PURE__*/React.createElement("div",{className:"knob-container cursor-ns-resize flex flex-col items-center",onMouseDown:handleMouseDown,title:`Volume: ${Math.round(value*100)}%`},/*#__PURE__*/React.createElement("svg",{className:"w-7 h-7",viewBox:"0 0 40 40"},/*#__PURE__*/React.createElement("circle",{cx:"20",cy:"20",r:"16",fill:"#141414",stroke:"#444",strokeWidth:"2"}),/*#__PURE__*/React.createElement("g",{transform:`rotate(${rotation} 20 20)`,className:"knob-dial"},/*#__PURE__*/React.createElement("line",{x1:"20",y1:"20",x2:"20",y2:"6",stroke:"#ef4444",strokeWidth:"3",strokeLinecap:"round"}))));};const WaveformLane=({track,zoom,timelineWidth,viewportWidth,onSelectRange,onPlayheadSet,isSelected,onSelectTrack,markers,selectionMode,localSelectionTrackId,localSelectionStart,currentTime,getLocalAnchor,onClearLocalSelection,onSetSelectionMode,onSetSelectionStart,onSetSelectionEnd,onSetCurrentTime,onSetLocalSelectionTrackId,onSetLocalSelectionStart,onSetLocalSelectionEnd,localSelLeft,localSelRight,onTrackLaneMouseDown,onContextMenu,onClipDragStart,onClipStretchStart,onSectionItemDragStart,onSectionItemResizeStart,onEditSectionInTab,onEditMidiInTab,onSelectionEdgeDragStart,setSelectedClipId,selectedClipId,activeTool,onSplitTrackAtTime,onEditClipInSubTab,snapValue,bpm,scrollLeft,recordingState,recTempMidiNotes,recTempAudioBuffer,recStartTimelineTime,canvasRedrawCount})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);const leadInMargin=0;useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const vWidth=viewportWidth||1200;const height=canvas.parentElement?canvas.parentElement.clientHeight:96;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle=isSelected?'#2a2a2a':track.id%2===0?'#181818':'#1d1d1d';ctx.fillRect(0,0,drawWidth,height);// Grid lines based on Snap value -ctx.strokeStyle='rgba(255, 255, 255, 0.03)';ctx.lineWidth=1;const beatDuration=60.0/(parseFloat(bpm)||120);const barDuration=beatDuration*4;const leadIn=0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=8/zoom;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+PADDING_LEFT;const firstBeat=Math.floor(tStart/beatDuration)*beatDuration;let snapDivisor=1;if(snapValue&&snapValue!=='free'){if(snapValue==='4')snapDivisor=4;else if(snapValue==='1')snapDivisor=1;else if(snapValue==='1/2')snapDivisor=0.5;else if(snapValue==='1/4')snapDivisor=0.25;else if(snapValue==='1/8')snapDivisor=0.125;else if(snapValue==='1/16')snapDivisor=0.0625;else if(snapValue==='1/32')snapDivisor=0.03125;}for(let t=firstBeat;t<=tEnd;t+=beatDuration){const beatNum=Math.floor(t/beatDuration)+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle=isBar?'rgba(255, 255, 255, 0.12)':'rgba(255, 255, 255, 0.04)';ctx.lineWidth=isBar?1.2:0.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();if(isBar&&zoom>=2){ctx.fillStyle='rgba(255, 255, 255, 0.15)';ctx.font='bold 7px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+2,10);}}// Draw waveform lane +ctx.strokeStyle='rgba(255, 255, 255, 0.03)';ctx.lineWidth=1;const beatDuration=60.0/(parseFloat(bpm)||120);const barDuration=beatDuration*4;const leadIn=0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;const firstBeat=Math.floor(tStart/beatDuration)*beatDuration;let snapDivisor=1;if(snapValue&&snapValue!=='free'){if(snapValue==='4')snapDivisor=4;else if(snapValue==='1')snapDivisor=1;else if(snapValue==='1/2')snapDivisor=0.5;else if(snapValue==='1/4')snapDivisor=0.25;else if(snapValue==='1/8')snapDivisor=0.125;else if(snapValue==='1/16')snapDivisor=0.0625;else if(snapValue==='1/32')snapDivisor=0.03125;}for(let t=firstBeat;t<=tEnd;t+=beatDuration){const beatNum=Math.floor(t/beatDuration)+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;ctx.strokeStyle=isBar?'rgba(255, 255, 255, 0.12)':'rgba(255, 255, 255, 0.04)';ctx.lineWidth=isBar?1.2:0.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();if(isBar&&zoom>=2){ctx.fillStyle='rgba(255, 255, 255, 0.15)';ctx.font='bold 7px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+2,10);}}// Draw waveform lane const clips=[...(track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[])];if(recordingState==='RECORDING'&&track.isArmed&&track.inputSource?.deviceType==='MICROPHONE'&&recTempAudioBuffer){clips.push({id:'rec_temp_'+track.id,buffer:recTempAudioBuffer,startTime:recStartTimelineTime,name:'[GHI ÂM...]',speed:1.0,isTemp:true});}if(clips.length>0){clips.forEach(clip=>{const numChannels=clip.buffer.numberOfChannels||1;const dataL=clip.buffer.getChannelData(0);const dataR=numChannels>=2?clip.buffer.getChannelData(1):dataL;const sampleRate=clip.buffer.sampleRate;const totalSamples=dataL.length;const originalDuration=totalSamples/sampleRate;const clipSpeed=clip.speed||1.0;const duration=originalDuration/clipSpeed;const clipStartTime=clip.startTime||0;const clipEndTime=clipStartTime+duration;// Culling: Skip rendering if clip is outside visible viewport window if(clipEndTimetEnd)return;const xStartGlobal=clipStartTime*zoom;const wClip=duration*zoom;const xStartLocal=xStartGlobal-scrollLeft;const xEndLocal=xStartLocal+wClip;// 1. Draw Clip Layer Background & Border const clipIdentifier=clip.id==='default'?'default_'+track.id:clip.id;const isClipSelected=selectedClipId&&selectedClipId.trackId===track.id&&selectedClipId.clipId===clipIdentifier;ctx.fillStyle=clip.isTemp?'rgba(239, 68, 68, 0.25)':isClipSelected?track.color?track.color+'44':'rgba(6, 182, 212, 0.30)':track.color?track.color+'22':'rgba(6, 182, 212, 0.12)';ctx.strokeStyle=clip.isTemp?'#ef4444':isClipSelected?'#fbbf24':track.color||'#06b6d4';ctx.lineWidth=isClipSelected?1:1.5;const clipTop=4;const clipHeight=height-8;ctx.beginPath();if(ctx.roundRect){ctx.roundRect(xStartLocal,clipTop,wClip,clipHeight,4);}else{ctx.rect(xStartLocal,clipTop,wClip,clipHeight);}ctx.fill();ctx.stroke();// 2. Draw Clip Label & Speed Label @@ -57,8 +57,8 @@ if(clickedClip&&(e.altKey||e.ctrlKey)){e.preventDefault();e.stopPropagation();if if(e.ctrlKey){if(onClearLocalSelection)onClearLocalSelection();if(onSetSelectionMode)onSetSelectionMode(null);if(onSetSelectionStart)onSetSelectionStart(null);if(onSetSelectionEnd)onSetSelectionEnd(null);return;}onPlayheadSet(time);if(onTrackLaneMouseDown){onTrackLaneMouseDown(track.id,time,e);}e.stopPropagation();},onDoubleClick:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);const clips=track.clips&&track.clips.length>0?track.clips:track.buffer?[{id:'default',buffer:track.buffer,startTime:track.startTime||0,name:track.name,speed:track.speed||1.0}]:[];// Check double-click on section first const dblSecItems=track.sections||[];let dblSecHit=null;for(const sec of dblSecItems){if(time>=sec.start&&time=midi.startTime&&timetime>=c.startTime&&time{e.preventDefault();e.stopPropagation();onSelectTrack(track.id);const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom-leadInMargin);// Detect section under cursor -const secList=track.sections||[];let hitSectionId=null;for(const sec of secList){if(time>=sec.start&&time{const canvasRef=useRef(null);const RULER_HEIGHT=40;const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const height=RULER_HEIGHT;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#242424';ctx.fillRect(0,0,drawWidth,height);ctx.strokeStyle='rgba(255,255,255,0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,height-0.5);ctx.lineTo(drawWidth,height-0.5);ctx.stroke();const PADDING_LEFT=8/zoom;const tStart=scrollLeftVal/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth)/zoom+PADDING_LEFT;const CLIP_BUFFER=400;// Draw time duration labels with drag-selection markers -const minTimePx=60;const rawSecInt=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInt)||120;if(secInterval*zoomdrawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.12)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.strokeStyle='rgba(255, 180, 100, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,10);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.7)';ctx.font='bold 11px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,24);}},[bpm,zoom,timelineWidth,viewportWidth,scrollLeft,canvasRedrawCount]);return React.createElement(React.Fragment,null,React.createElement("div",{key:"virtual-spacer-ruler",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown)onRulerMouseDown(e);else onPlayheadSet(time,e.shiftKey);}}));};const TempoTrackLane=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount,leadInMargin:propLeadIn})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const height=40;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,height);const beatDuration=60/bpm;const barDuration=beatDuration*4;const leadIn=propLeadIn!==undefined?propLeadIn:0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=8/zoom;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+PADDING_LEFT;const firstBeat=Math.floor(tStart/beatDuration)*beatDuration;for(let t=firstBeat;t<=tEnd;t+=beatDuration){const beatNum=Math.floor(t/beatDuration)+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;if(isBar){ctx.strokeStyle='rgba(255, 255, 255, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.7)';ctx.font='bold 9px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+3,11);}else{ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='7px Inter, sans-serif';const bar=Math.floor((beatNum-1)/4);const beat=(beatNum-1)%4+1;ctx.fillText(`${bar}:${beat}`,localX+2,height-3);}// Draw snap sub-ticks at the bottom +const secList=track.sections||[];let hitSectionId=null;for(const sec of secList){if(time>=sec.start&&time{const canvasRef=useRef(null);const RULER_HEIGHT=40;const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const height=RULER_HEIGHT;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#242424';ctx.fillRect(0,0,drawWidth,height);ctx.strokeStyle='rgba(255,255,255,0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(0,height-0.5);ctx.lineTo(drawWidth,height-0.5);ctx.stroke();const CLIP_BUFFER=400;const PADDING_LEFT=0;const tStart=scrollLeftVal/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth)/zoom+CLIP_BUFFER/zoom;// Draw time duration labels with drag-selection markers +const minTimePx=60;const rawSecInt=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInt)||120;if(secInterval*zoomdrawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.12)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.strokeStyle='rgba(255, 180, 100, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,10);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.7)';ctx.font='bold 11px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,24);}},[bpm,zoom,timelineWidth,viewportWidth,scrollLeft,canvasRedrawCount]);return React.createElement(React.Fragment,null,React.createElement("div",{key:"virtual-spacer-ruler",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown)onRulerMouseDown(e);else onPlayheadSet(time,e.shiftKey);}}));};const TempoTrackLane=({bpm,zoom,timelineWidth,viewportWidth,onPlayheadSet,snapValue,onRulerMouseDown,scrollLeft,canvasRedrawCount,leadInMargin:propLeadIn})=>{const canvasRef=useRef(null);const drawWidth=Math.min(timelineWidth,viewportWidth);useEffect(()=>{const canvas=canvasRef.current;if(!canvas)return;const ctx=canvas.getContext('2d');const dpr=window.devicePixelRatio||1;const scrollLeftVal=scrollLeft||0;const height=40;canvas.width=Math.min(Math.round(drawWidth*dpr),32768);canvas.height=Math.min(Math.round(height*dpr),32768);ctx.scale(dpr,dpr);ctx.imageSmoothingEnabled=false;canvas.style.width=`${drawWidth}px`;canvas.style.height=`${height}px`;ctx.fillStyle='#1a1a2e';ctx.fillRect(0,0,drawWidth,height);const beatDuration=60/bpm;const barDuration=beatDuration*4;const leadIn=propLeadIn!==undefined?propLeadIn:0;const CLIP_BUFFER=Math.max(400,barDuration*zoom+200);const PADDING_LEFT=0;const tStart=(scrollLeftVal-leadIn*zoom)/zoom-PADDING_LEFT;const tEnd=(scrollLeftVal+drawWidth-leadIn*zoom)/zoom+CLIP_BUFFER/zoom;const firstBeat=Math.floor(tStart/beatDuration)*beatDuration;for(let t=firstBeat;t<=tEnd;t+=beatDuration){const beatNum=Math.floor(t/beatDuration)+1;const isBar=beatNum%4===1;const localX=(t-tStart)*zoom;if(localX<-CLIP_BUFFER||localX>drawWidth+CLIP_BUFFER)continue;if(isBar){ctx.strokeStyle='rgba(255, 255, 255, 0.3)';ctx.lineWidth=1.5;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 255, 255, 0.7)';ctx.font='bold 9px Inter, sans-serif';ctx.textAlign='left';ctx.fillText(`${Math.floor((beatNum-1)/4)}`,localX+3,11);}else{ctx.strokeStyle='rgba(255, 255, 255, 0.08)';ctx.lineWidth=1;ctx.beginPath();ctx.moveTo(localX,0);ctx.lineTo(localX,height);ctx.stroke();}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='7px Inter, sans-serif';const bar=Math.floor((beatNum-1)/4);const beat=(beatNum-1)%4+1;ctx.fillText(`${bar}:${beat}`,localX+2,height-3);}// Draw snap sub-ticks at the bottom if(snapValue&&snapValue!=='free'){ctx.strokeStyle='rgba(255, 255, 255, 0.15)';ctx.lineWidth=0.8;let divisor=1;if(snapValue==='4')divisor=4;else if(snapValue==='1')divisor=1;else if(snapValue==='1/2')divisor=0.5;else if(snapValue==='1/4')divisor=0.25;else if(snapValue==='1/8')divisor=0.125;else if(snapValue==='1/16')divisor=0.0625;else if(snapValue==='1/32')divisor=0.03125;const snapInterval=beatDuration*divisor;if(snapInterval*zoom>=4){const firstSnap=Math.floor(tStart/snapInterval)*snapInterval;for(let t=firstSnap;t<=tEnd;t+=snapInterval){const onBeat=Math.abs(t/beatDuration-Math.round(t/beatDuration))<0.001;if(!onBeat){const localX=(t-tStart)*zoom;ctx.beginPath();ctx.moveTo(localX,height-6);ctx.lineTo(localX,height);ctx.stroke();}}}}// Draw time duration labels (restored from original time ruler) const minTimePx=60;const rawSecInterval=Math.max(1,Math.ceil(minTimePx/zoom));const timePowers=[1,2,5,10,30,60];let secInterval=timePowers.find(p=>p>=rawSecInterval)||120;if(secInterval*zoomdrawWidth+CLIP_BUFFER)continue;ctx.strokeStyle='rgba(255, 180, 100, 0.15)';ctx.lineWidth=0.8;ctx.beginPath();ctx.moveTo(localX,16);ctx.lineTo(localX,height);ctx.stroke();ctx.fillStyle='rgba(255, 180, 100, 0.5)';ctx.font='8px monospace';ctx.textAlign='center';ctx.fillText(formatTimeSimple(t),localX,12);}ctx.fillStyle='rgba(255, 255, 255, 0.35)';ctx.font='bold 10px Inter, sans-serif';ctx.textAlign='right';ctx.fillText(`${bpm} BPM`,drawWidth-6,12);},[bpm,zoom,timelineWidth,viewportWidth,snapValue,scrollLeft,canvasRedrawCount,propLeadIn]);return/*#__PURE__*/React.createElement(React.Fragment,null,/*#__PURE__*/React.createElement("div",{key:"virtual-spacer-tempo",style:{width:`${timelineWidth}px`,height:'1px',pointerEvents:'none'}}),/*#__PURE__*/React.createElement("canvas",{ref:canvasRef,style:{position:'sticky',left:0,imageRendering:'pixelated'},className:"cursor-crosshair",onMouseDown:e=>{const rect=canvasRef.current.getBoundingClientRect();const x=e.clientX-rect.left+(scrollLeft||0);const time=Math.max(0,x/zoom);if(e.shiftKey){e.preventDefault();e.stopPropagation();}if(onRulerMouseDown){onRulerMouseDown(e);}else{onPlayheadSet(time,e.shiftKey);}}}));};// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ── const SubTabWaveform=({buffer,subTabId,activeTab,currentTime,selectionStart,selectionEnd,onSelectRange,onPlayheadSet,onContextMenu,activeTool,zoom,timelineWidth,color,name,speed=1.0,onSpeedChange,volumeNodes=[],panningNodes=[],fadeInLen=0,fadeOutLen=0,graphMode=null,onUpdateNodes,onUpdateFade,onModeToggle,selectedNodeTime,setSelectedNodeTime,channelInfo=null})=>{const canvasRef=useRef(null);const isStretchingRef=useRef(false);const stretchStartRef=useRef({mouseX:0,originalDuration:0,originalSpeed:1.0});const subTabAnchorRef=useRef(null);const isStereo=channelInfo?channelInfo.isStereo:buffer&&buffer.numberOfChannels>=2;const channelLabel=channelInfo?channelInfo.label:isStereo?'STEREO':'MONO';// Mono: force volume mode (panning not applicable) diff --git a/wiki.md b/wiki.md index 6008d1a..e4021a8 100644 --- a/wiki.md +++ b/wiki.md @@ -92,6 +92,12 @@ - **Ghi chú/Test (nếu có):** `npm run build` — build passes. --- +### [2026-07-25 11:04] Task: Fix PADDING_LEFT alignment + right-side bar rendering +- **Tóm tắt thay đổi:** Set PADDING_LEFT=0 trong TimelineRuler/WaveformLane/TempoTrackLane để grid column 0 khớp với clip position 0 (trước đây lệch 8px). TempoTrackLane dùng CLIP_BUFFER/zoom cho tEnd thay vì PADDING_LEFT để bar/beat vẽ đủ xa về phía phải khi scroll. +- **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js` +- **Ghi chú/Test (nếu có):** `npm run build` — build passes. +--- + ### [2026-07-25 10:58] Task: Fix clip/section snap to column 0 - **Tóm tắt thay đổi:** Xóa `Math.max(beatSec, ...)` trong clip drag (10325), section drag (10495), section resize (10576) — thay bằng `Math.max(0, ...)` để item có thể snap vào column 0. - **Các file ảnh hưởng:** `app/static/js/app.jsx`, `app/static/js/app.precompiled.js`