fix: git commit node modules
This commit is contained in:
@@ -22,3 +22,4 @@ app/storage/processed/*
|
|||||||
.vscode/
|
.vscode/
|
||||||
*.log
|
*.log
|
||||||
celerybeat-schedule
|
celerybeat-schedule
|
||||||
|
node_modules/
|
||||||
|
|||||||
@@ -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
@@ -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!
|
||||||
@@ -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
@@ -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.
|
||||||
@@ -191,29 +191,23 @@ const WaveformLane = ({
|
|||||||
onSplitTrackAtTime,
|
onSplitTrackAtTime,
|
||||||
onEditClipInSubTab,
|
onEditClipInSubTab,
|
||||||
snapValue,
|
snapValue,
|
||||||
bpm
|
bpm,
|
||||||
|
scrollLeft
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
const drawWidth = Math.min(timelineWidth, viewportWidth);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
const scrollLeftVal = scrollLeft || 0;
|
||||||
|
const vWidth = viewportWidth || 1200;
|
||||||
// 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 height = canvas.parentElement ? canvas.parentElement.clientHeight : 96;
|
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 96;
|
||||||
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
||||||
canvas.height = Math.min(Math.round(height * dpr), 32768);
|
canvas.height = Math.min(Math.round(height * dpr), 32768);
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
ctx.imageSmoothingEnabled = false;
|
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.width = `${drawWidth}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d';
|
ctx.fillStyle = isSelected ? '#2a2a2a' : track.id % 2 === 0 ? '#181818' : '#1d1d1d';
|
||||||
@@ -222,8 +216,8 @@ const WaveformLane = ({
|
|||||||
// Grid lines based on Snap value
|
// Grid lines based on Snap value
|
||||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.03)';
|
||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
const tStart = scrollLeft / zoom;
|
const tStart = scrollLeftVal / zoom;
|
||||||
const tEnd = (scrollLeft + drawWidth) / zoom;
|
const tEnd = (scrollLeftVal + drawWidth) / zoom;
|
||||||
let gridSpacing = 1.0;
|
let gridSpacing = 1.0;
|
||||||
if (snapValue && snapValue !== 'free') {
|
if (snapValue && snapValue !== 'free') {
|
||||||
const beatDuration = 60 / parseFloat(bpm || 120);
|
const beatDuration = 60 / parseFloat(bpm || 120);
|
||||||
@@ -456,19 +450,18 @@ const WaveformLane = ({
|
|||||||
ctx.lineWidth = 1;
|
ctx.lineWidth = 1;
|
||||||
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
|
ctx.strokeRect(hlLeftLocal, 0, hlWidth, height);
|
||||||
}
|
}
|
||||||
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm]);
|
}, [track, zoom, timelineWidth, viewportWidth, isSelected, markers, selectionMode, localSelectionTrackId, localSelLeft, localSelRight, snapValue, bpm, scrollLeft]);
|
||||||
return /*#__PURE__*/React.createElement("div", {
|
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "virtual-spacer",
|
||||||
style: {
|
style: {
|
||||||
width: `${timelineWidth}px`,
|
width: `${timelineWidth}px`,
|
||||||
height: '100%',
|
height: '1px',
|
||||||
position: 'relative',
|
pointerEvents: 'none'
|
||||||
overflow: 'hidden'
|
|
||||||
}
|
}
|
||||||
}, /*#__PURE__*/React.createElement("canvas", {
|
}), /*#__PURE__*/React.createElement("canvas", {
|
||||||
ref: canvasRef,
|
ref: canvasRef,
|
||||||
style: {
|
style: {
|
||||||
position: 'absolute',
|
position: 'sticky',
|
||||||
top: 0,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
imageRendering: 'pixelated'
|
imageRendering: 'pixelated'
|
||||||
},
|
},
|
||||||
@@ -476,9 +469,7 @@ const WaveformLane = ({
|
|||||||
onMouseMove: e => {
|
onMouseMove: e => {
|
||||||
if (!canvasRef.current) return;
|
if (!canvasRef.current) return;
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
||||||
const x = e.clientX - rect.left + scrollLeft;
|
|
||||||
const time = x / zoom;
|
const time = x / zoom;
|
||||||
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -547,9 +538,7 @@ const WaveformLane = ({
|
|||||||
// Ignore right-click for local selection drag (context menu handles it)
|
// Ignore right-click for local selection drag (context menu handles it)
|
||||||
if (e.button === 2) return;
|
if (e.button === 2) return;
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
||||||
const x = e.clientX - rect.left + scrollLeft;
|
|
||||||
const time = Math.max(0, x / zoom);
|
const time = Math.max(0, x / zoom);
|
||||||
onSelectTrack(track.id);
|
onSelectTrack(track.id);
|
||||||
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
||||||
@@ -686,9 +675,7 @@ const WaveformLane = ({
|
|||||||
},
|
},
|
||||||
onDoubleClick: e => {
|
onDoubleClick: e => {
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
||||||
const x = e.clientX - rect.left + scrollLeft;
|
|
||||||
const time = Math.max(0, x / zoom);
|
const time = Math.max(0, x / zoom);
|
||||||
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
const clips = track.clips && track.clips.length > 0 ? track.clips : track.buffer ? [{
|
||||||
id: 'default',
|
id: 'default',
|
||||||
@@ -711,13 +698,11 @@ const WaveformLane = ({
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectTrack(track.id);
|
onSelectTrack(track.id);
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
const rect = canvasRef.current.getBoundingClientRect();
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
const x = e.clientX - rect.left + (scrollLeft || 0);
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
||||||
const x = e.clientX - rect.left + scrollLeft;
|
|
||||||
const time = Math.max(0, x / zoom);
|
const time = Math.max(0, x / zoom);
|
||||||
if (onContextMenu) onContextMenu(e, track.id, time);
|
if (onContextMenu) onContextMenu(e, track.id, time);
|
||||||
}
|
}
|
||||||
}));
|
})));
|
||||||
};
|
};
|
||||||
const TempoTrackLane = ({
|
const TempoTrackLane = ({
|
||||||
bpm,
|
bpm,
|
||||||
@@ -726,33 +711,30 @@ const TempoTrackLane = ({
|
|||||||
viewportWidth,
|
viewportWidth,
|
||||||
onPlayheadSet,
|
onPlayheadSet,
|
||||||
snapValue,
|
snapValue,
|
||||||
onRulerMouseDown
|
onRulerMouseDown,
|
||||||
|
scrollLeft
|
||||||
}) => {
|
}) => {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
|
const drawWidth = Math.min(timelineWidth, viewportWidth);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const wrapper = canvas.parentElement ? canvas.parentElement.parentElement : null;
|
const scrollLeftVal = scrollLeft || 0;
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
||||||
const vWidth = viewportWidth || (wrapper ? wrapper.clientWidth : 1200);
|
|
||||||
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
|
||||||
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40;
|
const height = canvas.parentElement ? canvas.parentElement.clientHeight : 40;
|
||||||
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
||||||
canvas.height = Math.min(Math.round(height * dpr), 32768);
|
canvas.height = Math.min(Math.round(height * dpr), 32768);
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
canvas.style.position = 'absolute';
|
|
||||||
canvas.style.left = `${scrollLeft}px`;
|
|
||||||
canvas.style.width = `${drawWidth}px`;
|
canvas.style.width = `${drawWidth}px`;
|
||||||
canvas.style.height = `${height}px`;
|
canvas.style.height = `${height}px`;
|
||||||
ctx.fillStyle = '#1a1a2e';
|
ctx.fillStyle = '#1a1a2e';
|
||||||
ctx.fillRect(0, 0, drawWidth, height);
|
ctx.fillRect(0, 0, drawWidth, height);
|
||||||
const beatDuration = 60 / bpm;
|
const beatDuration = 60 / bpm;
|
||||||
const barDuration = beatDuration * 4;
|
const barDuration = beatDuration * 4;
|
||||||
const tStart = scrollLeft / zoom;
|
const tStart = scrollLeftVal / zoom;
|
||||||
const tEnd = (scrollLeft + drawWidth) / zoom;
|
const tEnd = (scrollLeftVal + drawWidth) / zoom;
|
||||||
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
|
const firstBeat = Math.floor(tStart / beatDuration) * beatDuration;
|
||||||
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
|
for (let t = firstBeat; t <= tEnd; t += beatDuration) {
|
||||||
const beatNum = Math.floor(t / beatDuration) + 1;
|
const beatNum = Math.floor(t / beatDuration) + 1;
|
||||||
@@ -804,28 +786,25 @@ const TempoTrackLane = ({
|
|||||||
ctx.font = 'bold 10px Inter, sans-serif';
|
ctx.font = 'bold 10px Inter, sans-serif';
|
||||||
ctx.textAlign = 'right';
|
ctx.textAlign = 'right';
|
||||||
ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12);
|
ctx.fillText(`${bpm} BPM`, drawWidth - 6, 12);
|
||||||
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue]);
|
}, [bpm, zoom, timelineWidth, viewportWidth, snapValue, scrollLeft]);
|
||||||
return /*#__PURE__*/React.createElement("div", {
|
return /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", {
|
||||||
|
key: "virtual-spacer-tempo",
|
||||||
style: {
|
style: {
|
||||||
width: `${timelineWidth}px`,
|
width: `${timelineWidth}px`,
|
||||||
height: '100%',
|
height: '1px',
|
||||||
position: 'relative',
|
pointerEvents: 'none'
|
||||||
overflow: 'hidden'
|
|
||||||
}
|
}
|
||||||
}, /*#__PURE__*/React.createElement("canvas", {
|
}), /*#__PURE__*/React.createElement("canvas", {
|
||||||
ref: canvasRef,
|
ref: canvasRef,
|
||||||
style: {
|
style: {
|
||||||
position: 'absolute',
|
position: 'sticky',
|
||||||
top: 0,
|
|
||||||
left: 0,
|
left: 0,
|
||||||
imageRendering: 'pixelated'
|
imageRendering: 'pixelated'
|
||||||
},
|
},
|
||||||
className: "cursor-crosshair",
|
className: "cursor-crosshair",
|
||||||
onMouseDown: e => {
|
onMouseDown: e => {
|
||||||
const wrapper = canvasRef.current?.parentElement?.parentElement;
|
|
||||||
const scrollLeft = wrapper ? wrapper.scrollLeft : 0;
|
|
||||||
const rect = canvasRef.current.getBoundingClientRect();
|
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);
|
const time = Math.max(0, x / zoom);
|
||||||
if (e.shiftKey) {
|
if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -837,7 +816,7 @@ const TempoTrackLane = ({
|
|||||||
onPlayheadSet(time, e.shiftKey);
|
onPlayheadSet(time, e.shiftKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
})));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Sub-Tab Waveform Component (LOOP_EDITOR_2.md §1.2 & SUB_EDITOR.md) ──
|
// ── 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 vWidth = wrapper ? wrapper.clientWidth : 1200;
|
||||||
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
||||||
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
||||||
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
canvas.width = Math.round(drawWidth * dpr);
|
||||||
canvas.height = Math.min(Math.round(h * dpr), 32768);
|
canvas.height = Math.round(h * dpr);
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
canvas.style.position = 'absolute';
|
canvas.style.position = 'absolute';
|
||||||
@@ -2178,8 +2157,8 @@ const GraphEditorCanvas = ({
|
|||||||
const vWidth = wrapper ? wrapper.clientWidth : 1200;
|
const vWidth = wrapper ? wrapper.clientWidth : 1200;
|
||||||
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
const drawWidth = Math.min(timelineWidth, Math.max(vWidth, 1200));
|
||||||
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
const h = canvas.parentElement ? canvas.parentElement.clientHeight : 200;
|
||||||
canvas.width = Math.min(Math.round(drawWidth * dpr), 32768);
|
canvas.width = Math.round(drawWidth * dpr);
|
||||||
canvas.height = Math.min(Math.round(h * dpr), 32768);
|
canvas.height = Math.round(h * dpr);
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
ctx.imageSmoothingEnabled = false;
|
ctx.imageSmoothingEnabled = false;
|
||||||
canvas.style.position = 'absolute';
|
canvas.style.position = 'absolute';
|
||||||
@@ -2985,9 +2964,7 @@ const App = () => {
|
|||||||
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
|
const [localSelectionTrackId, setLocalSelectionTrackId] = useState(null);
|
||||||
const [localSelectionStart, setLocalSelectionStart] = useState(null);
|
const [localSelectionStart, setLocalSelectionStart] = useState(null);
|
||||||
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
|
const [localSelectionEnd, setLocalSelectionEnd] = useState(null);
|
||||||
const [mainZoom, setMainZoom] = useState(100);
|
const [zoom, setZoom] = useState(100);
|
||||||
const [subTabZoom, setSubTabZoom] = useState(100);
|
|
||||||
let zoom = 100;
|
|
||||||
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
|
const [isLoopingSelection, setIsLoopingSelection] = useState(false);
|
||||||
const [beginBar, setBeginBar] = useState(1);
|
const [beginBar, setBeginBar] = useState(1);
|
||||||
const [endBar, setEndBar] = useState(1);
|
const [endBar, setEndBar] = useState(1);
|
||||||
@@ -3105,7 +3082,6 @@ const App = () => {
|
|||||||
|
|
||||||
// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
// ── Tab System (LOOP_EDITOR_2.md §1) ──
|
||||||
const [activeTab, setActiveTab] = useState('main');
|
const [activeTab, setActiveTab] = useState('main');
|
||||||
zoom = activeTab === 'main' ? mainZoom : subTabZoom;
|
|
||||||
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
|
const [subTabSelectedNodeTime, setSubTabSelectedNodeTime] = useState(null);
|
||||||
const [subTabNormVal, setSubTabNormVal] = useState(0);
|
const [subTabNormVal, setSubTabNormVal] = useState(0);
|
||||||
const [subTabGainVal, setSubTabGainVal] = useState(100);
|
const [subTabGainVal, setSubTabGainVal] = useState(100);
|
||||||
@@ -3266,10 +3242,12 @@ const App = () => {
|
|||||||
}, [activeTool, activeTab]);
|
}, [activeTool, activeTab]);
|
||||||
const timelineWrapperRef = useRef(null);
|
const timelineWrapperRef = useRef(null);
|
||||||
const tcpContainerRef = useRef(null);
|
const tcpContainerRef = useRef(null);
|
||||||
|
const [scrollLeft, setScrollLeft] = useState(0);
|
||||||
const handleTimelineScroll = e => {
|
const handleTimelineScroll = e => {
|
||||||
if (tcpContainerRef.current) {
|
if (tcpContainerRef.current) {
|
||||||
tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop;
|
tcpContainerRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||||
}
|
}
|
||||||
|
setScrollLeft(e.currentTarget.scrollLeft);
|
||||||
};
|
};
|
||||||
const handleTCPScroll = e => {
|
const handleTCPScroll = e => {
|
||||||
if (timelineWrapperRef.current) {
|
if (timelineWrapperRef.current) {
|
||||||
@@ -3821,8 +3799,8 @@ const App = () => {
|
|||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
const dpr = window.devicePixelRatio || 1;
|
const dpr = window.devicePixelRatio || 1;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
canvas.width = Math.min(Math.round(rect.width * dpr), 32768);
|
canvas.width = rect.width * dpr;
|
||||||
canvas.height = Math.min(Math.round(rect.height * dpr), 32768);
|
canvas.height = rect.height * dpr;
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
const w = rect.width;
|
const w = rect.width;
|
||||||
const h = rect.height;
|
const h = rect.height;
|
||||||
@@ -4820,7 +4798,7 @@ const App = () => {
|
|||||||
}, [zoom, maxDuration, viewportWidth]);
|
}, [zoom, maxDuration, viewportWidth]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (zoom < minZoom) {
|
if (zoom < minZoom) {
|
||||||
if (activeTab === 'main') setMainZoom(minZoom); else setSubTabZoom(minZoom);
|
setZoom(minZoom);
|
||||||
}
|
}
|
||||||
}, [minZoom]);
|
}, [minZoom]);
|
||||||
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
|
const playheadLeftPos = useMemo(() => currentTime * zoom, [currentTime, zoom]);
|
||||||
@@ -4908,7 +4886,7 @@ const App = () => {
|
|||||||
const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft;
|
const mouseXInCanvas = mouseXInViewport + timeline.scrollLeft;
|
||||||
const anchorTime = mouseXInCanvas / zoom;
|
const anchorTime = mouseXInCanvas / zoom;
|
||||||
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
const zoomFactor = e.deltaY > 0 ? 0.9 : 1.1;
|
||||||
const updateZoom = prevZoom => {
|
setZoom(prevZoom => {
|
||||||
let newZoom = prevZoom * zoomFactor;
|
let newZoom = prevZoom * zoomFactor;
|
||||||
if (newZoom < minZoom) newZoom = minZoom;
|
if (newZoom < minZoom) newZoom = minZoom;
|
||||||
if (newZoom > 50000) newZoom = 50000;
|
if (newZoom > 50000) newZoom = 50000;
|
||||||
@@ -4917,8 +4895,7 @@ const App = () => {
|
|||||||
timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport;
|
timeline.scrollLeft = newMouseXInCanvas - mouseXInViewport;
|
||||||
});
|
});
|
||||||
return newZoom;
|
return newZoom;
|
||||||
};
|
});
|
||||||
if (activeTab === 'main') setMainZoom(updateZoom(mainZoom)); else setSubTabZoom(updateZoom(subTabZoom));
|
|
||||||
} else if (e.shiftKey) {
|
} else if (e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
timeline.scrollLeft += e.deltaY;
|
timeline.scrollLeft += e.deltaY;
|
||||||
|
|||||||
Reference in New Issue
Block a user