fix: git commit node modules

This commit is contained in:
2026-07-21 11:22:43 +07:00
parent ffdb4806f8
commit 2d9744c13a
6 changed files with 605 additions and 69 deletions
+1
View File
@@ -22,3 +22,4 @@ app/storage/processed/*
.vscode/
*.log
celerybeat-schedule
node_modules/
+174
View File
@@ -0,0 +1,174 @@
# Technical Specification: Playhead-Centering Zoom Algorithm
This document specifies the playhead drifting phenomenon during zoom operations and provides the architectural solutions, mathematical formulations, and source code prototypes required to lock the playback cursor as a static physical anchor point on the screen throughout timeline magnification updates.
---
## 1. Visual Symptom & Playhead Drifting Analysis
In standard digital audio workstation (DAW) graphical user interfaces, when an operator executes a mouse wheel zoom gesture (Zoom In/Out), the layout layout engine defaults to treating the leftmost physical pixel coordinate ($0$) of the timeline as the boundary axis for scaling.
### 1.1. Visual Failure Manifestations:
* **During Zoom In:** The red playback cursor (Playhead) positioned at a specific timestamp (e.g., $4.00\text{ s}$) is rapidly shifted toward the right perimeter of the viewport until it flies completely out of view.
* **During Zoom Out:** The playhead is abruptly snapped back toward the left perimeter of the screen viewport.
* **Consequence:** The sound engineer is forced to continuously adjust the horizontal scrollbar (`scrollLeft`) to find the playhead location, severely breaking the workflow during detail editing blocks.
### 1.2. Target Layout State (Playhead-Centering Zoom):
Throughout mouse-driven zoom updates at any scale:
* The playback cursor (Playhead) must act as a static physical anchor point locked to its exact pixel position relative to the visible browser window viewport.
* The multi-channel waveform graphics must stretch or compress symmetrically around the vertical axis of the playback cursor.
---
## 2. Mathematical Modeling for Playhead Anchoring
To guarantee that the on-screen placement of the cursor maps identically before and after a modification to the viewport magnification ratio, we establish a system of equations conserving the pixel coordinates of the playhead.
### 2.1. Operational Variables Mapping:
* $t_{\text{playhead}}$ (seconds): The instantaneous runtime clock position of the playhead (e.g., $4.00\text{ s}$).
* $Z_{\text{current}}$ (px/s): The initial timeline horizontal scaling zoom factor before resizing.
* $Z_{\text{new}}$ (px/s): The target timeline horizontal scaling zoom factor after resizing.
* $S_{\text{current}}$ (pixels): The current initial horizontal scroll offset (`scrollLeft`) of the timeline view.
* $S_{\text{new}}$ (pixels): The target adjusted horizontal scroll offset calculated to overwrite the container state.
* $X_{\text{viewport}}$ (pixels): The physical offset tracking the distance from the left edge of the screen viewport container to the playhead rendering path line.
### 2.2. Coordinate Conservation Formula
The absolute spatial coordinate of the playhead on the global arrangement timeline maps to:
$$X_{\text{absolute}} = t_{\text{playhead}} \times Z$$
The actual visible screen viewport placement of the cursor before executing the zoom factor modification evaluates to:
$$X_{\text{viewport}} = (t_{\text{playhead}} \times Z_{\text{current}}) - S_{\text{current}}$$
To lock the playhead directly to its coordinate position post-zoom ($Z_{\text{new}}$), the variable value $X_{\text{viewport}}$ must remain strictly unchanged:
$$X_{\text{viewport}} = (t_{\text{playhead}} \times Z_{\text{new}}) - S_{\text{new}}$$
Solving the equation systems to calculate the target adjusted scroll offset parameter $S_{\text{new}}$:
$$S_{\text{new}} = (t_{\text{playhead}} \times Z_{\text{new}}) - X_{\text{viewport}}$$
Substituting the initial definition statement of $X_{\text{viewport}}$ back into the calculation loop:
$$S_{\text{new}} = (t_{\text{playhead}} \times Z_{\text{new}}) - \left( (t_{\text{playhead}} \times Z_{\text{current}}) - S_{\text{current}} \right)$$
Compiling the final optimized mathematical reduction model:
$$S_{\text{new}} = S_{\text{current}} + t_{\text{playhead}} \times (Z_{\text{new}} - Z_{\text{current}})$$
*Physical Property Significance:* The calculated target scrollbar position equals the current scroll offset augmented by the absolute coordinate displacement of the playhead triggered by the variance across magnification scales.
---
## 3. Frontend Client Integration Blueprint (React / HTML5)
This mathematical alignment routine is tied directly into the primary mouse `wheel` event handler capturing timeline zoom interactions inside the main `index.html` structure:
```javascript
// Timeline wheel interaction handling segment capturing Playhead-anchored Zoom
const handleTimelineZoom = (e) => {
// Restrict zoom loops exclusively to situations where Ctrl (or Cmd) modifiers are engaged
if (!e.ctrlKey) return;
e.preventDefault();
const timelineWrapper = timelineWrapperRef.current;
if (!timelineWrapper) return;
// 1. Capture absolute layout dimensions before updating state variables
const scrollLeftCurrent = timelineWrapper.scrollLeft;
const zoomCurrent = zoom; // Maps to Z_current
const playheadTime = currentTime; // Maps to t_playhead
// 2. Evaluate target zoom ratio step updates (Enforces fluid scaling profiles)
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
let zoomNew = zoomCurrent * zoomFactor;
// Rigidly clamp calculation bounds within safe operating limits
const minZoomLimit = viewportWidth / maxDuration;
const maxZoomLimit = 2000; // Mitigates graphical memory canvas texture crashes
zoomNew = Math.max(minZoomLimit, Math.min(maxZoomLimit, zoomNew));
// 3. Apply the conservation formula to calculate S_new scroll offsets
const scrollLeftNew = scrollLeftCurrent + playheadTime * (zoomNew - zoomCurrent);
// 4. Propagate updated values synchronously down to State queues and the DOM
setZoom(zoomNew);
// Defer scroll alignment to requestAnimationFrame to execute right as Canvas buffers redraw
requestAnimationFrame(() => {
timelineWrapper.scrollLeft = scrollLeftNew;
});
};
```
---
## 4. Desktop Application Integration Manual (Python PyQt6 / PySide6)
When porting this layout algorithm to a containerized Python desktop context, capture the native `wheelEvent` tracking loop of the underlying `QGraphicsView` or `QScrollArea` layout wrapper:
```python
# [PYTHON PORTING BLUEPRINT] - Lock-step Playhead Zoom tracking over PyQt6 QGraphicsView
from PyQt6.QtWidgets import QGraphicsView, QScrollBar
from PyQt6.QtCore import Qt
class ProAudioTimelineView(QGraphicsView):
def __init__(self, parent=None):
super().__init__(parent)
self.playhead_time_seconds = 4.0 # Maps to t_playhead parameter
self.zoom_level = 100.0 # Maps to Z_current constant (pixels/second)
def wheelEvent(self, event):
# Inspect for active hardware keyboard ControlModifier keys
if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
event.accept()
# 1. Capture absolute workspace metrics before calculating adjustments
h_scrollbar = self.horizontalScrollBar()
scroll_current = h_scrollbar.value() # Maps to S_current
zoom_current = self.zoom_level
t_playhead = self.playhead_time_seconds
# 2. Evaluate target scaling ratio increments
angle_delta = event.angleDelta().y()
zoom_factor = 1.1 if angle_delta > 0 else 0.9
zoom_new = max(10.0, min(2000.0, zoom_current * zoom_factor))
# 3. Apply the coordinate conservation model to isolate scroll_new offsets
scroll_new = scroll_current + t_playhead * (zoom_new - zoom_current)
# 4. Overwrite parameters and prompt vector updates on the QPainter surface
self.zoom_level = zoom_new
self.update_timeline_graphics() # Invokes the multi-channel waveform redraw routines
# Commit updated scroll values immediately to lock playhead layout tracking
h_scrollbar.setValue(int(scroll_new))
else:
# Drop down to default native vertical/horizontal scroll handling patterns
super().wheelEvent(event)
```
---
## 5. UI Operational State Comparison
Based on the verified structural architecture of the system layout:
* **Baseline Initial State:** Audio waveform paths render at standard macro scaling bounds (evaluating approximately to a few hundred pixel columns per second of timeline data). The distinct vertical red playback cursor path line tracking the $4.00\text{ s}$ clock milestone renders centered in the visible workspace view.
* **Post Maximum Zoom-In State:** Symmetrical audio waveform data lines stretch horizontally to their maximum viewport scaling boundaries (exposing granular peak structures explicitly). By executing the conservation equations defined in Section 2.2, the horizontal scroll container shifts rightward, keeping the red cursor line locked to its absolute pixel column coordinate on the screen instead of letting it slip past the viewport limits.
This technical spec document establishes the supreme design token rules for compiling and verifying zooming workflows on the arrangement canvas.
+121
View File
@@ -0,0 +1,121 @@
Here is the translation of the document into English Markdown format:
# Geometric Analysis: Progressive Center Drift During Asymmetrical Zoom & Pre-Roll Gutter Solution
This document analyzes the mathematical root cause of center drift during zoom operations at asymmetric timeline markers (e.g., zooming at $1\text{ s}$ drifts drastically compared to $5\text{ s}$ on a $10\text{ s}$ total track length). It also provides a structural solution using boundary margins (**Pre-roll/Post-roll Gutter**) to lock the absolute anchor point in all interaction scenarios.
---
## 1. Mathematical Proof: Why Zooming at $1\text{ s}$ Drifts Further Than $5\text{ s}$
This visual discrepancy is not caused by random calculation precision errors, but is the mathematical result of boundary clamping (**Scroll Left Clamping**).
### 1.1. Conservation Equation for Mouse/Playhead Anchor Points
To preserve the visual location of time marker $t$ at pixel coordinate $X_{\text{viewport}}$ relative to the display before and after changing the zoom scale factor ($Z_{\text{current}} \rightarrow Z_{\text{new}}$), the required horizontal scroll offset $S_{\text{new}}$ (`scrollLeft`) must satisfy:
$$S_{\text{new}} = (t \times Z_{\text{new}}) - X_{\text{viewport}}$$
### 1.2. Scenario Analysis: Zooming Out at $X_{\text{viewport}} = 300\text{ px}$ (Cursor at Screen Center)
Assume the timeline is zoomed out significantly, reducing the zoom ratio down to $Z_{\text{new}} = 100\text{ px/second}$.
#### Scenario A: Operator zooms at the central symmetrical coordinate $t = 5.0\text{ s}$
Applying the target scroll position calculation:
$$S_{\text{new}} = (5.0 \times 100) - 300 = 500 - 300 = +200\text{ px}$$
* **Result:** Because $+200\text{ px} \ge 0$, the scroll position resides safely within physical boundary limits. The browser sets `scrollLeft = 200` smoothly. The $5.0\text{ s}$ point remains locked at position $300\text{ px}$ on the screen with a spatial drift of $0\text{ px}$.
#### Scenario B: Operator zooms at an asymmetrical coordinate near the left edge $t = 1.0\text{ s}$
Applying the target scroll position calculation:
$$S_{\text{new}} = (1.0 \times 100) - 300 = 100 - 300 = -200\text{ px}$$
* **Critical Issue:** Browsers and operating hardware cannot execute negative scroll values ($scrollLeft < 0$), instantly **clamping the horizontal scroll position at the minimum boundary $S_{\text{clamped}} = 0\text{ px}$**.
* Due to this clamping, the actual on-screen rendering coordinate of the $1.0\text{ s}$ milestone drifts to:
$$X_{\text{viewport\_actual}} = (1.0 \times 100) - 0 = 100\text{ px}$$
* **Visual Discrepancy:** The $1.0\text{ s}$ marker, which should remain stationary at coordinate $300\text{ px}$, is **pulled to the left to coordinate $100\text{ px}$** (resulting in a spatial shift of $200\text{ px}$).
> **Geometric Principle:** The smaller the zoom anchor timestamp $t$ (the closer it sits to the left boundary), the more likely the required scroll position $S_{\text{new}}$ drops below zero to be clamped at $0$, increasing visual waveform displacement during zoom-out operations.
---
## 2. Professional DAW Solution: Pre-Roll & Post-Roll Gutters
To permanently eliminate this behavior and give SonicForge Studio a professional zoom experience similar to Reaper or Adobe Audition, apply a **Pre-roll & Post-roll Gutter (Boundary Margins)**.
```text
|<─────────────────── Actual Timeline Scroll Width ───────────────────>|
+──────────────────────────┬───────────────────────────────────────────+
| [ Pre-roll Gutter ] │ 0:00.000 (Actual music start time) |
| (Width: W_viewport) │ |
| (scrollLeft can run here)│ [ Waveform and track grid start here... ]|
+──────────────────────────┴───────────────────────────────────────────+
│ [ 1.0s anchor point remains 100% stationary here ]
│ Because the scrollbar is allowed to retreat negatively into the gutter!
```
1. **Enabling Visual Negative Scrolling:** Instead of starting the timeline canvas at pixel coordinate $0\text{ px}$ (corresponding to $0.0\text{ s}$), prepend an empty padding region (**Gutter**) equal to the full viewport width $W_{\text{viewport}}$ (e.g., $1200\text{ px}$) before the $0.0\text{ s}$ mark.
2. **Updated Coordinate Mapping Formula:**
The physical pixel coordinate $X$ of timestamp $t$ on the Canvas includes the offset padding:
$$X_t = (t \times Z) + W_{\text{pre\_roll}}$$
3. **Unclamped Scroll Conservation Equation:**
When zooming at any asymmetrical timestamp (including $0.1\text{ s}$ or $0.0\text{ s}$):
$$S_{\text{new}} = (t \times Z_{\text{new}}) + W_{\text{pre\_roll}} - X_{\text{viewport}}$$
* Because $W_{\text{pre\_roll}}$ is added, $S_{\text{new}}$ remains greater than $0$ during standard zoom-out actions, eliminating the clamp at $0$. Your $1.0\text{ s}$ timestamp or playhead stays stationary, the waveform graphics scale symmetrically, and the $0.0\text{ s}$ mark smoothly recedes toward the center of the viewport, exposing a subtle, professional dark gray pre-roll gutter area in front of the track.
---
## 3. Implementing the Boundary Lock Algorithm in Source Code
Below is the upgraded mouse wheel zoom event handler for `index.html`, incorporating pre-roll margin compensation:
```javascript
const handleTimelineZoomWithGutter = (e) => {
if (!e.ctrlKey) return;
e.preventDefault();
const timelineWrapper = timelineWrapperRef.current;
if (!timelineWrapper) return;
const rect = timelineWrapper.getBoundingClientRect();
const mouseXInViewport = e.clientX - rect.left;
// Pre-roll gutter padding equal to half the viewport width to allow scrolling past 0s
const preRollPadding = rect.width / 2;
const scrollLeftCurrent = timelineWrapper.scrollLeft;
const zoomCurrent = zoom;
const anchorTime = (scrollLeftCurrent + mouseXInViewport - preRollPadding) / zoomCurrent;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
let zoomNew = zoomCurrent * zoomFactor;
// Apply zoom constraints
zoomNew = Math.max(minZoom, Math.min(2000, zoomNew));
// Calculate new scroll offset preserving the anchor point under the cursor
const scrollLeftNew = (anchorTime * zoomNew) + preRollPadding - mouseXInViewport;
// Update state
setZoom(zoomNew);
requestAnimationFrame(() => {
timelineWrapper.scrollLeft = scrollLeftNew;
});
};
```
This upgrade enables SonicForge Studio to achieve zero-latency, sample-accurate zooming with studio-grade anchor locking!
+77
View File
@@ -0,0 +1,77 @@
## 💡 Nguyên lý tính toán đúng (Zoom to Mouse Pointer)
Để điểm dưới con trỏ chuột đứng yên tại đúng vị trí đó sau khi zoom, bạn cần giữ nguyên **tỷ lệ thời gian (time ratio)** tại điểm con trỏ chuột so với chiều rộng hiện tại của vùng hiển thị (Viewport).
### **Công thức chuyển đổi:**
Giả sử thanh cuộn (Scrollbar) có vị trí xả hiện tại là `scrollLeft`:
1. **Tìm điểm thời gian tương đối tại vị trí chuột ($T_{mouse}$):**
$$T_{mouse} = \text{scrollLeft} + X_{mouse\_in\_canvas}$$
2. **Tính tỷ lệ zoom mới ($S_{new} / S_{old}$):**
$$\text{ratio} = \frac{\text{scale}_{new}}{\text{scale}_{old}}$$
3. **Cập nhật vị trí cuộn mới (`scrollLeft_{new}`):**
$$\text{scrollLeft}_{new} = (T_{mouse} \times \text{ratio}) - X_{mouse\_in\_canvas}$$
---
## 🛠️ Code mẫu ngắn gọn (Pure JS / Canvas)
Dưới đây là đoạn code lắng nghe sự kiện `wheel` (lăn chuột) trên Waveform Canvas/Container để xử lý zoom đúng chuẩn các phần mềm DAW:
```javascript
const container = document.getElementById('waveform-container');
let pixelsPerSecond = 100; // Tỉ lệ Zoom ban đầu (mức Zoom)
container.addEventListener('wheel', (e) => {
// Chỉ thực hiện zoom khi giữ phím Ctrl (hoặc bạn có thể bỏ condition này nếu muốn lăn chuột là zoom)
if (!e.ctrlKey) return;
e.preventDefault();
// 1. Lấy vị trí con trỏ chuột so với viền trái của Waveform Container (Viewport)
const rect = container.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
// 2. Tính tọa độ thời gian (giây) tại điểm con trỏ chuột đang chỉ vào
const currentScrollLeft = container.scrollLeft;
const timeAtMouse = (currentScrollLeft + mouseX) / pixelsPerSecond;
// 3. Tính tỉ lệ zoom mới (Phóng to / Thu nhỏ)
const zoomFactor = e.deltaY < 0 ? 1.2 : 0.8; // Lăn lên = phóng to, lăn xuống = thu nhỏ
const newPixelsPerSecond = Math.max(10, Math.min(2000, pixelsPerSecond * zoomFactor));
// 4. Cập nhật tỉ lệ zoom mới vào ứng dụng
pixelsPerSecond = newPixelsPerSecond;
// (Thực hiện render lại Waveform với pixelsPerSecond mới tại đây)
renderWaveform();
// 5. CẬP NHẬT SCROLLBAR: Cuộn lại sao cho điểm 'timeAtMouse' vẫn nằm đúng ở 'mouseX'
container.scrollLeft = (timeAtMouse * pixelsPerSecond) - mouseX;
}, { passive: false });
```
---
## 📌 Nhắc nhở thêm nếu dùng thư viện:
* **Nếu bạn dùng Canvas thuần:** Đảm bảo hàm `renderWaveform()` vẽ lại waveform dựa theo `pixelsPerSecond` mới trước khi cập nhật `container.scrollLeft`.
* **Nếu bạn đang dùng `wavesurfer.js`:** Thư viện này đã hỗ trợ sẵn logic này, bạn chỉ cần dùng method:
```javascript
wavesurfer.zoom(newPxPerSec);
```
*(Nếu WaveSurfer bản cũ bị trôi, bạn áp dụng lại công thức tính `scrollLeft` ở trên sau khi gọi lệnh `zoom()`)*.
+186
View File
@@ -0,0 +1,186 @@
# Giải pháp Virtual Viewport Rendering cho Waveform Zoom
Phương pháp này sử dụng kỹ thuật **Virtual Viewport Rendering** (Rendering theo vùng nhìn).
### Cơ chế hoạt động:
1. **Thanh cuộn ảo (Virtual Scrollbar):** Duy trì một thẻ `div` ẩn (hoặc gán chiều rộng cho container) bằng chiều rộng lý thuyết của toàn bộ file audio khi zoom. Nhưng **Canvas thực tế thì luôn cố định chiều rộng bằng khung nhìn (Viewport)**.
2. **Xử lý phần ẩn:** Các phần ngoài khung nhìn sẽ **không được vẽ/render lên Canvas**. Dữ liệu âm thanh gốc (`Audio Buffer` / `Array Data`) vẫn nằm nguyên trong bộ nhớ (RAM/JS Array), không bị ảnh hưởng.
3. **Khi Zoom Out:** Tính toán lại khoảng thời gian `[startTime, endTime]` rộng hơn, lấy mảng dữ liệu sample tương ứng trong khoảng đó và vẽ đè lại lên Canvas.
---
## 1. Kiến trúc tổng quan
```text
[ Toàn bộ Audio Buffer trong Memory: 0s ----------------------> 180s ]
| Khung nhìn |
v (Canvas Fixed) v
[startTime ------------> endTime]
```
---
## 2. Mã nguồn triển khai (Pure HTML5 & JS)
Đoạn code bên dưới minh họa cơ chế zoom chính xác tại vị trí con trỏ chuột mà không sợ quá tải Canvas hay nhảy vị trí:
```html
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<style>
#viewport {
width: 800px; /* Chiều rộng khung nhìn cố định */
height: 150px;
overflow-x: auto; /* Hiện thanh cuộn */
position: relative;
background: #1e1e1e;
}
/* Container giả lập chiều rộng thực tế để tạo thanh cuộn */
#virtual-content {
height: 1px;
pointer-events: none;
}
/* Canvas cố định vị trí luôn đè theo khung nhìn */
#waveform-canvas {
position: sticky;
left: 0;
top: 0;
width: 800px;
height: 150px;
display: block;
}
</style>
</head>
<body>
<div id="viewport">
<div id="virtual-content"></div>
<canvas id="waveform-canvas" width="800" height="150"></canvas>
</div>
<script>
// --- GIẢ LẬP DỮ LIỆU AUDIO (Audio Buffer / Sample Data) ---
const AUDIO_DURATION = 60; // Audio dài 60 giây
const SAMPLE_RATE = 100; // 100 samples/giây
const audioSamples = new Float32Array(AUDIO_DURATION * SAMPLE_RATE);
// Tạo sóng âm giả lập
for (let i = 0; i < audioSamples.length; i++) {
audioSamples[i] = Math.sin(i * 0.05) * 0.8;
}
// --- KHAI BÁO BIẾN TRẠNG THÁI ---
const viewport = document.getElementById('viewport');
const virtualContent = document.getElementById('virtual-content');
const canvas = document.getElementById('waveform-canvas');
const ctx = canvas.getContext('2d');
const VIEWPORT_WIDTH = 800;
const VIEWPORT_HEIGHT = 150;
let pixelsPerSecond = 100; // Mức zoom ban đầu (100px = 1s)
// --- HÀM 1: CHỈ VẼ PHẦN HIỂN THỊ TRONG KHUNG NHÌN ---
function renderVisibleWaveform() {
// 1. Cập nhật độ dài ảo cho thanh cuộn
const totalWidth = AUDIO_DURATION * pixelsPerSecond;
virtualContent.style.width = `${totalWidth}px`;
// 2. Xác định khoảng thời gian đang nằm trong khung nhìn (Viewport)
const scrollLeft = viewport.scrollLeft;
const startTime = scrollLeft / pixelsPerSecond;
const endTime = (scrollLeft + VIEWPORT_WIDTH) / pixelsPerSecond;
// 3. Xóa Canvas cũ
ctx.clearRect(0, 0, VIEWPORT_WIDTH, VIEWPORT_HEIGHT);
ctx.fillStyle = '#00ffcc';
// 4. Lấy các sample âm thanh tương ứng trong khoảng [startTime, endTime]
const startSampleIndex = Math.floor(startTime * SAMPLE_RATE);
const endSampleIndex = Math.ceil(endTime * SAMPLE_RATE);
// 5. Vẽ đúng các sample này lên Canvas (Vẽ từ x = 0 đến VIEWPORT_WIDTH)
const middleY = VIEWPORT_HEIGHT / 2;
for (let i = startSampleIndex; i < endSampleIndex; i++) {
if (i < 0 || i >= audioSamples.length) continue;
// Thời gian của sample này
const sampleTime = i / SAMPLE_RATE;
// Tọa độ X trên Canvas cố định (đã trừ đi scrollLeft)
const x = (sampleTime * pixelsPerSecond) - scrollLeft;
// Chiều cao cột sóng âm
const amplitude = audioSamples[i] * (VIEWPORT_HEIGHT / 2);
ctx.fillRect(x, middleY - amplitude / 2, 2, amplitude);
}
}
// --- HÀM 2: LẮNG NGHE SỰ KIỆN CUỘN VÀ ZOOM ---
// Khi người dùng kéo thanh cuộn
viewport.addEventListener('scroll', () => {
renderVisibleWaveform();
});
// Khi người dùng lăn chuột để ZOOM tại điểm con trỏ
viewport.addEventListener('wheel', (e) => {
e.preventDefault();
// Tọa độ chuột trong khung nhìn Viewport
const rect = viewport.getBoundingClientRect();
const mouseX = e.clientX - rect.left;
// Tính thời điểm (giây) ngay bên dưới con trỏ chuột
const currentScrollLeft = viewport.scrollLeft;
const timeAtMouse = (currentScrollLeft + mouseX) / pixelsPerSecond;
// Hệ số Zoom (Phóng to / Thu nhỏ tùy ý)
const zoomFactor = e.deltaY < 0 ? 1.15 : 1 / 1.15;
// Giới hạn zoom out tối thiểu (vừa vặn khung nhìn) và zoom in tối đa
const minPxPerSec = VIEWPORT_WIDTH / AUDIO_DURATION;
const maxPxPerSec = 50000; // Có thể zoom sâu mà không sợ vỡ DOM
const newPixelsPerSecond = Math.max(minPxPerSec, Math.min(maxPxPerSec, pixelsPerSecond * zoomFactor));
if (newPixelsPerSecond === pixelsPerSecond) return;
// Cập nhật mức zoom mới
pixelsPerSecond = newPixelsPerSecond;
// Cập nhật lại vị trí thanh cuộn sao cho điểm timeAtMouse vẫn nằm đúng ở mouseX
viewport.scrollLeft = (timeAtMouse * pixelsPerSecond) - mouseX;
// Vẽ lại
renderVisibleWaveform();
}, { passive: false });
// Khởi tạo lần đầu
renderVisibleWaveform();
</script>
</body>
</html>
```
---
## 3. Các điểm quan trọng giúp giải quyết bài toán
* **Thẻ Canvas cố định (`position: sticky`):**
Dù zoom $10\times, 100\times$ hay $1000\times$, chiều rộng Canvas không thay đổi (luôn là `800px`). Điều này giúp tránh hoàn toàn việc vượt giới hạn chiều rộng của trình duyệt (`max canvas width limit`).
* **Khôi phục dữ liệu khi Zoom Out:**
Khi zoom out, `pixelsPerSecond` giảm xuống. Hàm `renderVisibleWaveform()` tự động mở rộng khoảng `[startTime, endTime]` (ví dụ từ 2 giây thành 30 giây). Mảng `audioSamples` gốc trong RAM vẫn giữ nguyên, hàm vẽ chỉ cần duyệt mảng rộng hơn và vẽ lại lên Canvas.
* **Tối ưu hiệu năng:**
Hệ thống chỉ tốn tài nguyên GPU/CPU để render đúng số lượng cột sóng xuất hiện trong khung nhìn thay vì vẽ toàn bộ file audio dài.
+46 -69
View File
@@ -191,29 +191,23 @@ const WaveformLane = ({
onSplitTrackAtTime,
onEditClipInSubTab,
snapValue,
bpm
bpm,
scrollLeft
}) => {
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 wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
// Viewport Virtualization: canvas pixel width is strictly capped to visible window width (e.g. 1200px)
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200);
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
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;
// Position canvas element at current scrollLeft inside track container
canvas.style.position = 'absolute';
canvas.style.left = `${scrollLeft}px`;
canvas.style.width = `${drawWidth}px`;
canvas.style.height = `${height}px`;
ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d';
@@ -222,8 +216,8 @@ const WaveformLane = ({
// Grid lines based on Snap value
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
ctx.lineWidth = 1;
const tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + drawWidth) / zoom;
const tStart = scrollLeftVal / zoom;
const tEnd = (scrollLeftVal + drawWidth) / zoom;
let gridSpacing = 1.0;
if (snapValue && snapValue !== 'free') {
const beatDuration = 60 / parseFloat(bpm || 120);
@@ -456,19 +450,18 @@ const WaveformLane = ({
ctx.lineWidth = 1;
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
}
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
return /*#__PURE__*/React.createElement("div", {
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer",
style: {
width: `${timelineWidth}px`,
height: '100%',
position: 'relative',
overflow: 'hidden'
height: '1px',
pointerEvents: 'none'
}
}, /*#__PURE__*/React.createElement("canvas", {
}), /*#__PURE__*/React.createElement("canvas", {
ref: canvasRef,
style: {
position: 'absolute',
top: 0,
position: 'sticky',
left: 0,
imageRendering: 'pixelated'
},
@@ -476,9 +469,7 @@ const WaveformLane = ({
onMouseMove: e => {
if (!canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = x / zoom;
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
@@ -547,9 +538,7 @@ const WaveformLane = ({
// Ignore right-click for local selection drag (context menu handles it)
if (e.button === 2) return;
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
onSelectTrack(track.id);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
@@ -686,9 +675,7 @@ const WaveformLane = ({
},
onDoubleClick: e => {
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
id: 'default',
@@ -711,13 +698,11 @@ const WaveformLane = ({
e.stopPropagation();
onSelectTrack(track.id);
const rect = canvasRef.current.getBoundingClientRect();
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const x = e.clientX - rect.left + scrollLeft;
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
if (onContextMenu) onContextMenu(e, track.id, time);
}
}));
})));
};
const TempoTrackLane = ({
bpm,
@@ -726,33 +711,30 @@ const TempoTrackLane = ({
viewportWidth,
onPlayheadSet,
snapValue,
onRulerMouseDown
onRulerMouseDown,
scrollLeft
}) => {
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 wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200);
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
const scrollLeftVal = scrollLeft || 0;
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 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.position = 'absolute';
canvas.style.left = `${scrollLeft}px`;
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 tStart = scrollLeft / zoom;
const tEnd = (scrollLeft + drawWidth) / zoom;
const tStart = scrollLeftVal / zoom;
const tEnd = (scrollLeftVal + drawWidth) / zoom;
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
const beatNum = Math.floor(t / beatDuration) + 1;
@@ -804,28 +786,25 @@ const TempoTrackLane = ({
ctx.font = 'bold 10px Inter, sans-serif';
ctx.textAlign = 'right';
ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12);
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue]);
return /*#__PURE__*/React.createElement("div", {
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft]);
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
key: "virtual-spacer-tempo",
style: {
width: `${timelineWidth}px`,
height: '100%',
position: 'relative',
overflow: 'hidden'
height: '1px',
pointerEvents: 'none'
}
}, /*#__PURE__*/React.createElement("canvas", {
}), /*#__PURE__*/React.createElement("canvas", {
ref: canvasRef,
style: {
position: 'absolute',
top: 0,
position: 'sticky',
left: 0,
imageRendering: 'pixelated'
},
className: "cursor-crosshair",
onMouseDown: e => {
const wrapper = canvasRef.current?.parentElement?.parentElement;
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
const rect = canvasRef.current.getBoundingClientRect();
const x = e.clientX - rect.left + scrollLeft;
const x = e.clientX - rect.left + (scrollLeft || 0);
const time = Math.max(0, x / zoom);
if (e.shiftKey) {
e.preventDefault();
@@ -837,7 +816,7 @@ const TempoTrackLane = ({
onPlayheadSet(time, e.shiftKey);
}
}
}));
})));
};
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
@@ -892,8 +871,8 @@ const SubTabWaveform = ({
const vWidth = wrapper ? wrapper.clientWidth : 1200;
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
canvas.height = Math.min(Math.round(h * dpr), 32768);
canvas.width = Math.round(drawWidth * dpr);
canvas.height = Math.round(h * dpr);
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
canvas.style.position = 'absolute';
@@ -2178,8 +2157,8 @@ const GraphEditorCanvas = ({
const vWidth = wrapper ? wrapper.clientWidth : 1200;
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
canvas.height = Math.min(Math.round(h * dpr), 32768);
canvas.width = Math.round(drawWidth * dpr);
canvas.height = Math.round(h * dpr);
ctx.scale(dpr, dpr);
ctx.imageSmoothingEnabled = false;
canvas.style.position = 'absolute';
@@ -2985,9 +2964,7 @@ const App = () => {
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
const [localSelectionStart, setLocalSelectionStart] = useState(null);
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
const [mainZoom, setMainZoom] = useState(100);
const [subTabZoom, setSubTabZoom] = useState(100);
let zoom = 100;
const [zoom, setZoom] = useState(100);
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
const [beginBar, setBeginBar] = useState(1);
const [endBar, setEndBar] = useState(1);
@@ -3105,7 +3082,6 @@ const App = () => {
// ── Tab System (LOOP_EDITOR_2.md §1) ──
const [activeTab, setActiveTab] = useState('main');
zoom = activeTab === 'main' ? mainZoom : subTabZoom;
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
const [subTabNormVal, setSubTabNormVal] = useState(0);
const [subTabGainVal, setSubTabGainVal] = useState(100);
@@ -3266,10 +3242,12 @@ const App = () => {
}, [activeTool, activeTab]);
const timelineWrapperRef = useRef(null);
const tcpContainerRef = useRef(null);
const [scrollLeft, setScrollLeft] = useState(0);
const handleTimelineScroll = e => {
if (tcpContainerRef.current) {
tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop;
}
setScrollLeft(e.currentTarget.scrollLeft);
};
const handleTCPScroll = e => {
if (timelineWrapperRef.current) {
@@ -3821,8 +3799,8 @@ const App = () => {
const ctx = canvas.getContext('2d');
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.min(Math.round(rect.width * dpr), 32768);
canvas.height = Math.min(Math.round(rect.height * dpr), 32768);
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
const w = rect.width;
const h = rect.height;
@@ -4820,7 +4798,7 @@ const App = () => {
}, [zoom, maxDuration, viewportWidth]);
useEffect(() => {
if (zoom < minZoom) {
if (activeTab === 'main') setMainZoom(minZoom); else setSubTabZoom(minZoom);
setZoom(minZoom);
}
}, [minZoom]);
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
@@ -4908,7 +4886,7 @@ const App = () => {
const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft;
const anchorTime = mouseXInCanvas / zoom;
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
const updateZoom = prevZoom => {
setZoom(prevZoom => {
let newZoom = prevZoom * zoomFactor;
if (newZoom < minZoom) newZoom = minZoom;
if (newZoom > 50000) newZoom = 50000;
@@ -4917,8 +4895,7 @@ const App = () => {
timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport;
});
return newZoom;
};
if (activeTab === 'main') setMainZoom(updateZoom(mainZoom)); else setSubTabZoom(updateZoom(subTabZoom));
});
} else if (e.shiftKey) {
e.preventDefault();
timeline.scrollLeft += e.deltaY;