A drag with a clip picked could span a new clip in the strip or span the picked one anew over the wave. Both happened by accident while working on a clip. Now a drag only makes a new clip while none is picked. With one picked it moves its edges or the whole of it. - With no clip picked, a drag in the strip or over the wave (in the Video tab the track) spans a new clip, as before. - A click over the wave or in the track picks the clip at that point, as a click on its band does. Beside the clips it picks the sound or the video. The picked clip wins where clips lie over each other. - With a clip picked, its handles move its edges as before. A drag on its band in the strip moves the whole clip, its length kept and inside the file. Any other drag does nothing. - The rules live in waveView.ts (clipGrip, clipToPick, movedSpan) and both tabs use them. The cursor shows grab over the picked band. - The Video tab's track draws the clips not picked faint, as the wave does, so a click there has something to aim at. Tests: - Unit wave-view.test.ts: 16 tests (4 new) - Counter-check: a strip drag beside the picked clip spanning a new one again fails the picked-clip rule test - e2e 34 + 66 (atx): ALL PASSED, 162 checks, 1 new check in each - Gate (atx): 295 files, 4527 tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
197 lines
8.3 KiB
TypeScript
197 lines
8.3 KiB
TypeScript
/**
|
||
* The Sound tab's view (#343): seconds across, zoomed about the pointer, and the stretch of the
|
||
* file between two handles – how a drag moves it and what the asset then stores.
|
||
*/
|
||
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
clampWave,
|
||
clipGrip,
|
||
clipToPick,
|
||
coversShown,
|
||
draggedSpan,
|
||
drawnSeconds,
|
||
edgeAt,
|
||
fileReach,
|
||
fitWave,
|
||
levelMarks,
|
||
MAX_LEVEL,
|
||
MIN_LEVEL,
|
||
movedSpan,
|
||
rulerLabel,
|
||
rulerStep,
|
||
secondsToX,
|
||
shownSeconds,
|
||
spanOf,
|
||
spanPeak,
|
||
storedCut,
|
||
wavePeaks,
|
||
xToSeconds,
|
||
zoomLevel,
|
||
zoomWave,
|
||
} from '../../src/editor/sound/waveView';
|
||
|
||
describe('the height of the wave (#357)', () => {
|
||
it('zooms between a quarter and sixteen times full scale', () => {
|
||
expect(zoomLevel(1, 2)).toBe(2);
|
||
expect(zoomLevel(1, 0.5)).toBe(0.5);
|
||
expect(zoomLevel(0.3, 0.5)).toBe(MIN_LEVEL);
|
||
expect(zoomLevel(12, 2)).toBe(MAX_LEVEL);
|
||
});
|
||
|
||
it('marks every 6 dB that lies inside the area and not too close to the middle', () => {
|
||
// Level 1, full scale 100 px from the middle: 0 dB at 100, -6 at 50, -12 at 25, -18 at 12.6.
|
||
const marks = levelMarks(1, 100, 110, 14);
|
||
expect(marks.map((m) => m.db)).toEqual([0, -6, -12]);
|
||
expect(marks[1]!.offset).toBeCloseTo(50.1, 1);
|
||
// Zoomed out, -6 dB lies 12.5 px from the middle and is left out.
|
||
expect(levelMarks(0.25, 100, 110, 14).map((m) => m.db)).toEqual([0]);
|
||
// Zoomed in, 0 dB lies past the edge and the quiet ones show.
|
||
const loud = levelMarks(16, 100, 110, 14).map((m) => m.db);
|
||
expect(loud[0]).toBe(-24);
|
||
expect(loud).not.toContain(0);
|
||
});
|
||
|
||
it('lets the file and its clip reach as high as full scale zoomed out, never past the area', () => {
|
||
// Full scale 100 px from the middle at level 1, 6 px inset, 106 px of room.
|
||
expect(fileReach(1, 100, 6, 106)).toBe(106);
|
||
expect(fileReach(0.25, 100, 6, 106)).toBe(31);
|
||
expect(fileReach(4, 100, 6, 106)).toBe(106);
|
||
});
|
||
});
|
||
|
||
describe('the wave view', () => {
|
||
it('zooms about the pointer and fits the file with a margin either side', () => {
|
||
const view = { panX: 10, pxPerSecond: 100 };
|
||
const zoomed = zoomWave(view, 2, 210);
|
||
expect(xToSeconds(zoomed, 210)).toBeCloseTo(xToSeconds(view, 210));
|
||
expect(zoomed.pxPerSecond).toBe(200);
|
||
const fitted = fitWave(4, 832, 16);
|
||
expect(secondsToX(fitted, 0)).toBe(16);
|
||
expect(secondsToX(fitted, 4)).toBe(816);
|
||
});
|
||
|
||
it('keeps either end of the file from going further in than half the width', () => {
|
||
expect(clampWave({ panX: 900, pxPerSecond: 100 }, 20, 1000).panX).toBe(500);
|
||
expect(clampWave({ panX: -1600, pxPerSecond: 100 }, 20, 1000).panX).toBe(-1500);
|
||
expect(clampWave({ panX: -900, pxPerSecond: 100 }, 20, 1000).panX).toBe(-900);
|
||
// Two seconds fit: they stay on screen.
|
||
expect(clampWave({ panX: 20, pxPerSecond: 100 }, 2, 1000).panX).toBe(20);
|
||
expect(clampWave({ panX: -50, pxPerSecond: 100 }, 2, 1000).panX).toBe(0);
|
||
});
|
||
|
||
it('labels the ruler in steps of 1, 2 or 5, with the decimals the step needs', () => {
|
||
expect(rulerStep(100, 70)).toBe(1);
|
||
expect(rulerStep(40, 70)).toBe(2);
|
||
expect(rulerStep(1000, 70)).toBeCloseTo(0.1);
|
||
expect(rulerStep(10, 70)).toBe(10);
|
||
expect(rulerLabel(1.5, 0.5)).toBe('1.5s');
|
||
expect(rulerLabel(2, 1)).toBe('2s');
|
||
expect(rulerLabel(0.25, 0.05)).toBe('0.25s');
|
||
expect(rulerLabel(75, 5)).toBe('1:15');
|
||
});
|
||
|
||
it('draws the wave past the area by a margin, and a pan within it keeps that drawing (#360)', () => {
|
||
// 20 s at 100 px/s, 1000 px across: seconds 5 to 15 shown, 0 to 20 with 500 px either side.
|
||
const view = { panX: -500, pxPerSecond: 100 };
|
||
expect(shownSeconds(view, 20, 1000)).toEqual({ start: 5, end: 15 });
|
||
const drawn = drawnSeconds(view, 20, 1000, 500);
|
||
expect(drawn).toEqual({ start: 0, end: 20 });
|
||
const near = drawnSeconds({ ...view, panX: -1000 }, 20, 1000, 300);
|
||
expect(near).toEqual({ start: 7, end: 20 });
|
||
// Panned 200 px it still covers what is shown, 400 px past it no longer.
|
||
expect(coversShown(near, shownSeconds({ ...view, panX: -800 }, 20, 1000))).toBe(true);
|
||
expect(coversShown(near, shownSeconds({ ...view, panX: -600 }, 20, 1000))).toBe(false);
|
||
// Nothing of the file in view: nothing to cover, and nothing drawn.
|
||
expect(shownSeconds({ panX: 1200, pxPerSecond: 100 }, 20, 1000)).toBeNull();
|
||
expect(coversShown(null, null)).toBe(true);
|
||
expect(coversShown(null, { start: 0, end: 1 })).toBe(false);
|
||
});
|
||
});
|
||
|
||
describe('the cut span', () => {
|
||
const view = { panX: 0, pxPerSecond: 100 };
|
||
|
||
it('reads an end of 0 or past the file as its end', () => {
|
||
expect(spanOf(0, 0, 3)).toEqual({ start: 0, end: 3 });
|
||
expect(spanOf(1, 9, 3)).toEqual({ start: 1, end: 3 });
|
||
expect(spanOf(2.5, 1, 3)).toEqual({ start: 1, end: 1 });
|
||
});
|
||
|
||
it('finds the handle in reach, the nearer one where both are', () => {
|
||
const span = { start: 1, end: 2 };
|
||
expect(edgeAt(view, span, 103)).toBe('start');
|
||
expect(edgeAt(view, span, 197)).toBe('end');
|
||
expect(edgeAt(view, span, 150)).toBeNull();
|
||
expect(edgeAt(view, { start: 1, end: 1.04 }, 103)).toBe('end');
|
||
});
|
||
|
||
it('moves one edge, never past the other, or spans a new stretch inside the file', () => {
|
||
const span = { start: 1, end: 2 };
|
||
expect(draggedSpan(span, 'start', 1, 0.5, 3, 0.001)).toEqual({ start: 0.5, end: 2 });
|
||
expect(draggedSpan(span, 'start', 1, 2.5, 3, 0.001)).toEqual({ start: 1.999, end: 2 });
|
||
expect(draggedSpan(span, 'end', 2, 9, 3, 0.001)).toEqual({ start: 1, end: 3 });
|
||
expect(draggedSpan(span, null, 2.5, 0.5, 3, 0.001)).toEqual({ start: 0.5, end: 2.5 });
|
||
expect(draggedSpan(span, null, -1, 1, 3, 0.001)).toEqual({ start: 0, end: 1 });
|
||
});
|
||
|
||
it('stores the ends of the file as 0, everything else to the sample', () => {
|
||
expect(storedCut({ start: 0, end: 3 }, 3, 48000)).toEqual({ start: 0, end: 0 });
|
||
expect(storedCut({ start: 0.000001, end: 2.999999 }, 3, 48000)).toEqual({ start: 0, end: 0 });
|
||
expect(storedCut({ start: 1.2345678, end: 2 }, 3, 48000)).toEqual({ start: 1.234563, end: 2 });
|
||
});
|
||
});
|
||
|
||
describe('what a press over the clips does (#403)', () => {
|
||
const view = { panX: 0, pxPerSecond: 100 };
|
||
const picked = { start: 1, end: 2 };
|
||
|
||
it('spans a new clip anywhere while none is picked', () => {
|
||
expect(clipGrip(view, null, true, 150)).toBe('new');
|
||
expect(clipGrip(view, null, false, 250)).toBe('new');
|
||
});
|
||
|
||
it('with one picked, takes an edge over the wave and the whole clip on its band, nothing else', () => {
|
||
expect(clipGrip(view, picked, false, 101)).toBe('start');
|
||
expect(clipGrip(view, picked, false, 199)).toBe('end');
|
||
expect(clipGrip(view, picked, false, 150)).toBeNull();
|
||
expect(clipGrip(view, picked, false, 250)).toBeNull();
|
||
expect(clipGrip(view, picked, true, 150)).toBe('whole');
|
||
expect(clipGrip(view, picked, true, 101)).toBe('whole');
|
||
expect(clipGrip(view, picked, true, 250)).toBeNull();
|
||
});
|
||
|
||
it('picks the picked clip where it lies, else the band drawn last, else nothing', () => {
|
||
const bands = [
|
||
{ id: 'a', span: { start: 0, end: 1.5 } },
|
||
{ id: 'b', span: { start: 1, end: 3 } },
|
||
];
|
||
expect(clipToPick(view, bands, null, 120)).toBe('b');
|
||
expect(clipToPick(view, bands, 'a', 120)).toBe('a');
|
||
expect(clipToPick(view, bands, 'a', 200)).toBe('b');
|
||
expect(clipToPick(view, bands, 'b', 50)).toBe('a');
|
||
expect(clipToPick(view, bands, 'b', 350)).toBeNull();
|
||
});
|
||
|
||
it('moves a clip whole, its length kept and inside the file', () => {
|
||
expect(movedSpan(picked, 0.5, 3)).toEqual({ start: 1.5, end: 2.5 });
|
||
expect(movedSpan(picked, 5, 3)).toEqual({ start: 2, end: 3 });
|
||
expect(movedSpan(picked, -5, 3)).toEqual({ start: 0, end: 1 });
|
||
});
|
||
});
|
||
|
||
describe('the wave peaks', () => {
|
||
it('reads the lowest and highest sample of any stretch, over the blocks and the samples at its ends', () => {
|
||
const left = new Float32Array(1000);
|
||
const right = new Float32Array(1000);
|
||
left[10] = 0.5;
|
||
right[600] = -0.75;
|
||
left[999] = 0.9;
|
||
const peaks = wavePeaks([left, right]);
|
||
expect(peaks.max).toHaveLength(4);
|
||
expect(spanPeak([left, right], peaks, 0, 1000)).toEqual({ min: -0.75, max: Math.fround(0.9) });
|
||
expect(spanPeak([left, right], peaks, 5, 20)).toEqual({ min: 0, max: 0.5 });
|
||
expect(spanPeak([left, right], peaks, 11, 599)).toEqual({ min: 0, max: 0 });
|
||
expect(spanPeak([left, right], peaks, 300.2, 300.4)).toEqual({ min: 0, max: 0 });
|
||
});
|
||
});
|