Improve timeline wheel zoom (#7)
Co-authored-by: haixin.yang <haixin.yang@weimob.com>
This commit is contained in:
committed by
GitHub
co-authored by
haixin.yang
parent
7c84a3ff4e
commit
2023e8bdd4
@@ -20,6 +20,20 @@ test("Add all keyframes animates a real uploaded visual clip", async ({ page })
|
||||
await expect(page.locator("video.preview-video")).toBeVisible();
|
||||
await expect(page.getByText("/ 00:10.02", { exact: false })).toBeVisible();
|
||||
await expect(page.locator(".audio-clip.is-source")).toBeVisible({ timeout: 30_000 });
|
||||
const visualClip = page.locator(".image-clip");
|
||||
const zoomReadout = page.getByTestId("timeline-zoom-readout");
|
||||
const initialZoomReadout = await zoomReadout.innerText();
|
||||
await visualClip.hover();
|
||||
await page.mouse.wheel(0, -480);
|
||||
await expect.poll(() => zoomReadout.innerText()).not.toBe(initialZoomReadout);
|
||||
const zoomedInReadout = await zoomReadout.innerText();
|
||||
await page.mouse.wheel(0, 480);
|
||||
await expect.poll(() => zoomReadout.innerText()).not.toBe(zoomedInReadout);
|
||||
|
||||
const zoomBeforeEmptyTrackWheel = await zoomReadout.innerText();
|
||||
await page.locator(".caption-track").hover();
|
||||
await page.mouse.wheel(0, -480);
|
||||
await expect.poll(() => zoomReadout.innerText()).toBe(zoomBeforeEmptyTrackWheel);
|
||||
const sourceAudio = page.locator('audio[data-track="source-audio"]');
|
||||
const unlinkSourceAudio = page.getByRole("button", { name: "解除画面与视频原声同步", exact: true });
|
||||
await expect(unlinkSourceAudio).toBeEnabled();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "web-player",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "web-player",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@diffusionstudio/vits-web": "^1.0.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "web-player",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"private": true,
|
||||
"description": "A local-first browser AI video editor for voiceovers, captions, talking avatars, and multi-track timeline export.",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -39,6 +39,12 @@ const TIMELINE_WHEEL_ZOOM_SENSITIVITY = 0.00034;
|
||||
const TIMELINE_WHEEL_ZOOM_COMMIT_DELAY = 180;
|
||||
const TIMELINE_BUTTON_ZOOM_RATIO = 1.25;
|
||||
const VIDEO_FRAME_MIN_COUNT = 1;
|
||||
const TIMELINE_WHEEL_ZOOM_CONTENT_SELECTOR = [
|
||||
".image-clip",
|
||||
".caption-segment",
|
||||
".sticker-segment",
|
||||
".audio-clip",
|
||||
].join(", ");
|
||||
|
||||
function packAudioSegmentsIntoLanes(segments) {
|
||||
const lanes = [];
|
||||
@@ -229,6 +235,9 @@ export function Timeline({
|
||||
const wheelZoomFrameRef = useRef(0);
|
||||
const commitZoomTimerRef = useRef(0);
|
||||
const rulerViewportFrameRef = useRef(0);
|
||||
const wheelZoomActiveRef = useRef(false);
|
||||
const rulerViewportSyncRef = useRef(null);
|
||||
const zoomReadoutRef = useRef(null);
|
||||
const pendingWheelDeltaRef = useRef(0);
|
||||
const pendingWheelAnchorRef = useRef(null);
|
||||
const timelineWheelHandlerRef = useRef(null);
|
||||
@@ -255,11 +264,15 @@ export function Timeline({
|
||||
);
|
||||
};
|
||||
const scheduleRulerViewportUpdate = () => {
|
||||
if (wheelZoomActiveRef.current) {
|
||||
return;
|
||||
}
|
||||
if (rulerViewportFrameRef.current) {
|
||||
return;
|
||||
}
|
||||
rulerViewportFrameRef.current = window.requestAnimationFrame(applyRulerViewportUpdate);
|
||||
};
|
||||
rulerViewportSyncRef.current = scheduleRulerViewportUpdate;
|
||||
const resizeObserver =
|
||||
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleRulerViewportUpdate);
|
||||
|
||||
@@ -275,6 +288,9 @@ export function Timeline({
|
||||
rulerViewportFrameRef.current = 0;
|
||||
}
|
||||
resizeObserver?.disconnect();
|
||||
if (rulerViewportSyncRef.current === scheduleRulerViewportUpdate) {
|
||||
rulerViewportSyncRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [trackScrollRef]);
|
||||
useEffect(() => {
|
||||
@@ -290,6 +306,7 @@ export function Timeline({
|
||||
if (wheelZoomFrameRef.current) {
|
||||
window.cancelAnimationFrame(wheelZoomFrameRef.current);
|
||||
}
|
||||
wheelZoomActiveRef.current = false;
|
||||
window.clearTimeout(commitZoomTimerRef.current);
|
||||
},
|
||||
[],
|
||||
@@ -311,6 +328,9 @@ export function Timeline({
|
||||
const localTrackWidth = `${getTimelineTrackWidthPercent(timelineDuration, localTimelineZoom)}%`;
|
||||
const commitTimelineZoom = (nextZoom, delay = 0) => {
|
||||
window.clearTimeout(commitZoomTimerRef.current);
|
||||
if (delay <= 0) {
|
||||
wheelZoomActiveRef.current = false;
|
||||
}
|
||||
if (delay <= 0) {
|
||||
setTimelineZoom(nextZoom);
|
||||
return;
|
||||
@@ -361,15 +381,31 @@ export function Timeline({
|
||||
anchor.trackContentStart +
|
||||
anchor.pointerTrackRatio * nextTrackWidth -
|
||||
anchor.pointerViewportX;
|
||||
const syncScrollAnchor = () => {
|
||||
anchor.scrollElement.scrollLeft = Math.max(0, nextScrollLeft);
|
||||
};
|
||||
|
||||
adjustTimelineZoom(nextZoom, { commitDelay: TIMELINE_WHEEL_ZOOM_COMMIT_DELAY });
|
||||
window.requestAnimationFrame(syncScrollAnchor);
|
||||
wheelZoomActiveRef.current = true;
|
||||
timelineZoomRef.current = nextZoom;
|
||||
anchor.trackElement.style.width = `${nextTrackWidthPercent}%`;
|
||||
anchor.trackElement.style.setProperty("--timeline-zoom", String(nextZoom));
|
||||
anchor.scrollElement.scrollLeft = Math.max(0, nextScrollLeft);
|
||||
if (zoomReadoutRef.current) {
|
||||
zoomReadoutRef.current.textContent = getTimelineZoomLabel(nextZoom);
|
||||
}
|
||||
|
||||
window.clearTimeout(commitZoomTimerRef.current);
|
||||
commitZoomTimerRef.current = window.setTimeout(() => {
|
||||
wheelZoomActiveRef.current = false;
|
||||
setLocalTimelineZoom(nextZoom);
|
||||
setTimelineZoom(nextZoom);
|
||||
window.requestAnimationFrame(() => rulerViewportSyncRef.current?.());
|
||||
}, TIMELINE_WHEEL_ZOOM_COMMIT_DELAY);
|
||||
};
|
||||
const handleTimelineWheel = (event) => {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
const isOverTimelineContent = Boolean(
|
||||
event.target instanceof Element && event.target.closest(TIMELINE_WHEEL_ZOOM_CONTENT_SELECTOR),
|
||||
);
|
||||
const hasZoomModifier = event.ctrlKey || event.metaKey;
|
||||
|
||||
if (!hasZoomModifier && !isOverTimelineContent) {
|
||||
if (!event.shiftKey && Math.abs(event.deltaY) >= Math.abs(event.deltaX)) {
|
||||
const board = event.currentTarget?.closest?.(".timeline")?.querySelector?.(".timeline-board");
|
||||
if (board && board.scrollHeight > board.clientHeight) {
|
||||
@@ -413,6 +449,7 @@ export function Timeline({
|
||||
pointerTrackRatio,
|
||||
pointerViewportX: event.clientX - scrollRect.left,
|
||||
scrollElement,
|
||||
trackElement,
|
||||
trackContentStart,
|
||||
trackWidth: trackRect.width,
|
||||
};
|
||||
@@ -586,7 +623,7 @@ export function Timeline({
|
||||
<IconButton label={t("zoomOut")} onClick={() => adjustTimelineZoom((zoom) => zoom / TIMELINE_BUTTON_ZOOM_RATIO)}>
|
||||
<MagnifyingGlassMinus size={17} />
|
||||
</IconButton>
|
||||
<span className="zoom-readout">{zoomReadout}</span>
|
||||
<span ref={zoomReadoutRef} className="zoom-readout" data-testid="timeline-zoom-readout">{zoomReadout}</span>
|
||||
<IconButton
|
||||
label={t("fitTimeline")}
|
||||
active={Math.abs(localTimelineZoom - TIMELINE_MIN_ZOOM) < 0.001}
|
||||
|
||||
Reference in New Issue
Block a user