From ce933a4f321acdceb7d93838e7fddb3403ad33fc Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Sun, 11 Oct 2026 12:51:46 +0200 Subject: [PATCH] Let a drag change only the picked clip in the Sound and Video tab (#403) 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 --- README.md | 2 +- src/editor/sound/WaveCanvas.ts | 81 ++++++++++++++-------------- src/editor/sound/waveView.ts | 32 +++++++++++ src/editor/video/VideoCanvas.ts | 94 ++++++++++++++++++++++++--------- tests/editor/wave-view.test.ts | 41 ++++++++++++++ tools/e2e/34-sound.mjs | 28 +++++++++- tools/e2e/66-video.mjs | 39 ++++++++++++++ 7 files changed, 249 insertions(+), 68 deletions(-) diff --git a/README.md b/README.md index 736484ff..f381da6f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh The browser suite and the three example games need a machine with a GPU: ```bash -node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1994 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 1996 checks (GM_STEPS=20 for one step) # every tool signs in first – see the Accounts page node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs diff --git a/src/editor/sound/WaveCanvas.ts b/src/editor/sound/WaveCanvas.ts index abdc156d..989821c9 100644 --- a/src/editor/sound/WaveCanvas.ts +++ b/src/editor/sound/WaveCanvas.ts @@ -18,16 +18,18 @@ import { axisTween } from '../viewTween'; import { clampWave, type ClipBand, - clipAt, + type ClipGrip, + clipGrip, + clipToPick, coversShown, type CutSpan, draggedSpan, drawnSeconds, - edgeAt, fileReach, fitWave, levelMarks, levelOffset, + movedSpan, rulerLabel, rulerStep, secondsToX, @@ -71,21 +73,21 @@ const RAMP_STEPS = 32; const DRAWN_MARGIN = 0.5; /** - * A drag that spans: an edge of the clip `clipId`, that clip anew, or with `clipId` null a new clip. - * `picks` is what a press selects when it does not move: in the strip a clip or null for the sound, - * over the wave null beside the picked clip. + * A press over the clips: a drag moves what `grip` holds of the clip `clipId`, or with `clipId` null + * spans a new clip. `picks` is what the press selects when it does not move: a clip or null for the + * sound. */ type Gesture = | { kind: 'pan'; pointerX: number; panX: number } | { kind: 'span'; clipId: string | null; - edge: 'start' | 'end' | null; + grip: ClipGrip; from: number; pointerX: number; moved: boolean; span: CutSpan; - picks?: string | null; + picks: string | null; }; /** A file's samples as the canvas draws them: decoded once, its block peaks made on first sight. */ @@ -109,10 +111,11 @@ function waveOf(buffer: AudioBuffer): Wave { /** * The Sound tab's canvas (#343): the whole file as one waveform, the loudest of its channels, under - * a ruler in seconds. The clips of the sound (#357) stand as bands in a strip under the ruler: a - * click picks one, a drag there spans a new one. The picked clip stands between two handles, what - * lies outside it darker; dragging a handle moves that edge, dragging beside them spans it anew. - * With no clip picked, a drag over the wave spans a new one too. One drag is one undo step. The + * a ruler in seconds. The clips of the sound (#357) stand as bands in a strip under the ruler, and + * a click there or over the wave picks the one at that point. With no clip picked a drag in either + * spans a new one. The picked clip stands between two handles, what lies outside it darker: + * dragging a handle moves that edge, dragging its band moves the clip whole, and any other drag + * does nothing (#403). One drag is one undo step. The * wheel zooms, the middle button pans, as in the music tabs; over the level scale on the left the * wheel zooms the wave's height. */ @@ -383,30 +386,19 @@ export class WaveCanvas extends Widget { } if (e.button !== 0 || p.x < 0) return; const from = Math.max(0, Math.min(wave.duration, xToSeconds(this.view, p.x))); - const start = { + const clip = this.pickedClip(); + const span = clip && this.clipSpan(clip, wave); + const inStrip = p.y >= RULER_HEIGHT && p.y < WAVE_TOP; + this.gesture = { kind: 'span', + clipId: clip?.id ?? null, + grip: clipGrip(this.view, span, inStrip, p.x), from, pointerX: p.x, moved: false, - span: { start: from, end: from }, - } as const; - if (p.y >= RULER_HEIGHT && p.y < WAVE_TOP) { - // In the strip a press picks the band under it, a drag spans a new clip. - this.gesture = { - ...start, - clipId: null, - edge: null, - picks: clipAt(this.view, this.bands(asset, wave), p.x), - }; - return; - } - // Over the wave a click beside the picked clip picks the sound again. - const clip = this.pickedClip(); - const span = clip && this.clipSpan(clip, wave); - const edge = span ? edgeAt(this.view, span, p.x) : null; - const beside = - span && !edge && (p.x < secondsToX(this.view, span.start) || p.x > secondsToX(this.view, span.end)); - this.gesture = { ...start, clipId: clip?.id ?? null, edge, picks: beside ? null : undefined }; + span: span ?? { start: from, end: from }, + picks: clipToPick(this.view, this.bands(asset, wave), clip?.id ?? null, p.x), + }; } private onPointerMove(e: FederatedPointerEvent): void { @@ -423,13 +415,21 @@ export class WaveCanvas extends Widget { this.redraw(); return; } - if (!g.moved && g.edge === null && Math.abs(p.x - g.pointerX) < DRAG_START) return; + const onEdge = g.grip === 'start' || g.grip === 'end'; + if (!g.moved && !onEdge && Math.abs(p.x - g.pointerX) < DRAG_START) return; + // A drag that holds nothing does nothing, and is no click either. + if (g.grip === null) { + this.gesture = null; + return; + } g.moved = true; - // Only the picked clip has handles: an edge moves that one. const clip = this.pickedClip(); const before = clip && clip.id === g.clipId ? this.clipSpan(clip, wave) : g.span; const at = xToSeconds(this.view, p.x); - g.span = draggedSpan(before, g.edge, g.from, at, wave.duration, LEAST_SPAN); + g.span = + g.grip === 'whole' + ? movedSpan(before, at - g.from, wave.duration) + : draggedSpan(before, g.grip === 'new' ? null : g.grip, g.from, at, wave.duration, LEAST_SPAN); this.redraw(); } @@ -440,7 +440,7 @@ export class WaveCanvas extends Widget { const asset = this.asset(); const wave = this.wave(); if (!g.moved) { - if (asset && g.picks !== undefined) + if (asset) this.state.select( g.picks ? { kind: 'clip', assetId: asset.id, clipId: g.picks } @@ -469,7 +469,7 @@ export class WaveCanvas extends Widget { const values: PropertyBag = {}; if (next.start !== now.start) values[START_KEY] = next.start; if (next.end !== now.end) values[END_KEY] = next.end; - const label = g.edge ? 'Move clip edge' : 'Span clip'; + const label = g.grip === 'whole' ? 'Move clip' : 'Move clip edge'; if (Object.keys(values).length > 0) this.state.execute(new SetClipPropsCommand(asset.id, clip.id, values, label)); } @@ -491,8 +491,11 @@ export class WaveCanvas extends Widget { private updateCursor(x: number, y: number): void { const span = this.shownClipSpan(); - const inside = x >= 0 && x <= this.areaWidth && y >= WAVE_TOP && y <= this.h; - const cursor = inside && span && edgeAt(this.view, span, x) ? 'ew-resize' : 'default'; + const across = x >= 0 && x <= this.areaWidth; + const inStrip = across && y >= RULER_HEIGHT && y < WAVE_TOP; + const overWave = across && y >= WAVE_TOP && y <= this.h; + const grip = span && (inStrip || overWave) ? clipGrip(this.view, span, inStrip, x) : null; + const cursor = grip === 'whole' ? 'grab' : grip ? 'ew-resize' : 'default'; if (this.cursor !== cursor) this.cursor = cursor; } @@ -751,7 +754,7 @@ export class WaveCanvas extends Widget { [a, 'start'], [b, 'end'], ] as const) { - const held = this.gesture?.kind === 'span' && this.gesture.edge === edge; + const held = this.gesture?.kind === 'span' && this.gesture.grip === edge; this.overlay.rect(x - 1, top, 2, 2 * reach).fill(held ? c.hot : c.accent); const toward = edge === 'start' ? 1 : -1; this.overlay diff --git a/src/editor/sound/waveView.ts b/src/editor/sound/waveView.ts index a477aa22..34ec5ed5 100644 --- a/src/editor/sound/waveView.ts +++ b/src/editor/sound/waveView.ts @@ -196,6 +196,38 @@ export function clipAt(view: WaveView, bands: readonly ClipBand[], x: number): s return null; } +/** + * What a drag that starts at `x` takes hold of (#403): with no clip picked a new clip, in the strip + * and over the wave alike. With one picked an edge of it over the wave, the whole of it on its band + * in the strip, and nothing anywhere else. + */ +export type ClipGrip = 'new' | 'start' | 'end' | 'whole' | null; + +export function clipGrip(view: WaveView, picked: CutSpan | null, inStrip: boolean, x: number): ClipGrip { + if (!picked) return 'new'; + if (!inStrip) return edgeAt(view, picked, x); + return x >= secondsToX(view, picked.start) && x <= secondsToX(view, picked.end) ? 'whole' : null; +} + +/** The clip a click at `x` picks: the picked one where it lies, else the band drawn last there. */ +export function clipToPick( + view: WaveView, + bands: readonly ClipBand[], + pickedId: string | null, + x: number, +): string | null { + const held = bands.find((band) => band.id === pickedId); + if (held && clipAt(view, [held], x)) return held.id; + return clipAt(view, bands, x); +} + +/** The span moved by `by` seconds, its length kept and held inside the file. */ +export function movedSpan(span: CutSpan, by: number, duration: number): CutSpan { + const length = span.end - span.start; + const start = clamp(span.start + by, 0, Math.max(0, duration - length)); + return { start, end: start + length }; +} + /** What a clip stores for a span: seconds to the nearest sample, an end at the end of the file as 0. */ export function storedCut(span: CutSpan, duration: number, rate: number): { start: number; end: number } { const sample = 1 / rate; diff --git a/src/editor/video/VideoCanvas.ts b/src/editor/video/VideoCanvas.ts index 91333b83..70b63ff9 100644 --- a/src/editor/video/VideoCanvas.ts +++ b/src/editor/video/VideoCanvas.ts @@ -23,11 +23,13 @@ import { crispText, type Size, textStyle, Theme, ui, Widget, zoomFactor } from ' import { clampWave, type ClipBand, - clipAt, + type ClipGrip, + clipGrip, + clipToPick, type CutSpan, draggedSpan, - edgeAt, fitWave, + movedSpan, rulerLabel, rulerStep, secondsToX, @@ -64,12 +66,12 @@ type Gesture = | { kind: 'span'; clipId: string | null; - edge: 'start' | 'end' | null; + grip: ClipGrip; from: number; pointerX: number; moved: boolean; span: CutSpan; - picks?: string | null; + picks: string | null; }; /** The tab's own player: the video shown, with its sound, apart from the ones sprites wear. */ @@ -83,7 +85,9 @@ interface Preview { /** * The Video tab's canvas (#402): the video on top, a ruler in seconds under it, the clips as bands * in a strip, and a track where the picked clip stands between two handles. A click in the strip - * picks a clip, a drag there or in the track spans a new one; dragging a handle moves that edge. + * or the track picks the clip at that point. With no clip picked a drag in either spans a new one; + * with one picked, dragging a handle moves that edge, dragging its band moves the clip whole, and + * any other drag does nothing (#403). * One drag is one undo step. A click in the ruler shows that moment; the wheel zooms the time, the * middle button pans, as in the Sound tab. */ @@ -377,30 +381,28 @@ export class VideoCanvas extends Widget { this.seekTo(from); return; } - const start = { + const clip = this.pickedClip(); + const span = clip && this.clipSpan(clip, asset); + this.gesture = { kind: 'span', + clipId: clip?.id ?? null, + grip: clipGrip(this.view, span, y < RULER_HEIGHT + STRIP_HEIGHT, p.x), from, pointerX: p.x, moved: false, - span: { start: from, end: from }, - } as const; - if (y < RULER_HEIGHT + STRIP_HEIGHT) { - this.gesture = { ...start, clipId: null, edge: null, picks: clipAt(this.view, this.bands(asset), p.x) }; - return; - } - const clip = this.pickedClip(); - const span = clip && this.clipSpan(clip, asset); - const edge = span ? edgeAt(this.view, span, p.x) : null; - const beside = - span && !edge && (p.x < secondsToX(this.view, span.start) || p.x > secondsToX(this.view, span.end)); - this.gesture = { ...start, clipId: clip?.id ?? null, edge, picks: beside ? null : undefined }; + span: span ?? { start: from, end: from }, + picks: clipToPick(this.view, this.bands(asset), clip?.id ?? null, p.x), + }; } private onPointerMove(e: FederatedPointerEvent): void { const p = this.toLocal(e.global); const g = this.gesture; const asset = this.asset(); - if (!g || !asset) return; + if (!g || !asset) { + this.updateCursor(p.x, p.y); + return; + } if (g.kind === 'pan') { this.view = clampWave({ ...this.view, panX: g.panX + p.x - g.pointerX }, asset.duration, this.w); this.needsFit = false; @@ -411,22 +413,44 @@ export class VideoCanvas extends Widget { this.seekTo(xToSeconds(this.view, p.x)); return; } - if (!g.moved && g.edge === null && Math.abs(p.x - g.pointerX) < DRAG_START) return; + const onEdge = g.grip === 'start' || g.grip === 'end'; + if (!g.moved && !onEdge && Math.abs(p.x - g.pointerX) < DRAG_START) return; + // A drag that holds nothing does nothing, and is no click either. + if (g.grip === null) { + this.gesture = null; + return; + } g.moved = true; const clip = this.pickedClip(); const before = clip && clip.id === g.clipId ? this.clipSpan(clip, asset) : g.span; - g.span = draggedSpan(before, g.edge, g.from, xToSeconds(this.view, p.x), asset.duration, LEAST_SPAN); - this.seekTo(g.edge === 'end' ? g.span.end : g.span.start); + const at = xToSeconds(this.view, p.x); + g.span = + g.grip === 'whole' + ? movedSpan(before, at - g.from, asset.duration) + : draggedSpan(before, g.grip === 'new' ? null : g.grip, g.from, at, asset.duration, LEAST_SPAN); + this.seekTo(g.grip === 'end' ? g.span.end : g.span.start); this.redraw(); } + /** A grabbing hand over the picked clip's band, arrows over its handles in the track. */ + private updateCursor(x: number, y: number): void { + const asset = this.asset(); + const clip = this.pickedClip(); + const span = asset && clip ? this.clipSpan(clip, asset) : null; + const row = y - this.timelineTop - RULER_HEIGHT; + const onTimeline = row >= 0 && row <= STRIP_HEIGHT + TRACK_HEIGHT; + const grip = span && onTimeline ? clipGrip(this.view, span, row < STRIP_HEIGHT, x) : null; + const cursor = grip === 'whole' ? 'grab' : grip ? 'ew-resize' : 'default'; + if (this.cursor !== cursor) this.cursor = cursor; + } + private onPointerUp(): void { const g = this.gesture; this.gesture = null; if (g?.kind !== 'span') return this.redraw(); const asset = this.asset(); if (!g.moved) { - if (asset && g.picks !== undefined) + if (asset) this.state.select( g.picks ? { kind: 'clip', assetId: asset.id, clipId: g.picks } @@ -454,7 +478,12 @@ export class VideoCanvas extends Widget { if (next.end !== now.end) values[VIDEO_END_KEY] = next.end; if (Object.keys(values).length > 0) this.state.execute( - new SetClipPropsCommand(asset.id, clip.id, values, g.edge ? 'Move clip edge' : 'Span clip'), + new SetClipPropsCommand( + asset.id, + clip.id, + values, + g.grip === 'whole' ? 'Move clip' : 'Move clip edge', + ), ); } @@ -564,12 +593,25 @@ export class VideoCanvas extends Widget { this.syncStrip(placed.map((label) => ({ ...label, y }))); } - /** The video's length lit, what lies outside the picked clip dark, and a handle at each of its edges. */ + /** + * The video's length lit, the clips not picked faint over it as in the Sound tab, and the picked + * clip with a handle at each of its edges. + */ private drawTrack(asset: VideoAsset, left: number, right: number, top: number): void { const c = Theme.colors; const y = top + RULER_HEIGHT + STRIP_HEIGHT; const h = TRACK_HEIGHT; this.overlay.rect(left, y + 4, Math.max(0, right - left), h - 8).fill({ color: c.panel, alpha: 0.8 }); + const lit = this.litClipId(); + for (const { id, span: band } of this.bands(asset)) { + const a = secondsToX(this.view, band.start); + const b = secondsToX(this.view, band.end); + if (id === lit || b < 0 || a > this.w) continue; + this.overlay + .rect(a, y + 4, Math.max(1, b - a), h - 8) + .fill({ color: c.accent, alpha: 0.12 }) + .stroke({ width: 1, color: c.accent, alpha: 0.35, alignment: 1 }); + } const clip = this.pickedClip(); const span = this.gesture?.kind === 'span' && this.gesture.moved @@ -583,7 +625,7 @@ export class VideoCanvas extends Widget { [a, 'start'], [b, 'end'], ] as const) { - const held = this.gesture?.kind === 'span' && this.gesture.edge === edge; + const held = this.gesture?.kind === 'span' && this.gesture.grip === edge; this.overlay.rect(x - 1, y + 2, 2, h - 4).fill(held ? c.hot : c.accent); const toward = edge === 'start' ? 1 : -1; this.overlay diff --git a/tests/editor/wave-view.test.ts b/tests/editor/wave-view.test.ts index 7e4a5e54..ee58df3a 100644 --- a/tests/editor/wave-view.test.ts +++ b/tests/editor/wave-view.test.ts @@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest'; import { clampWave, + clipGrip, + clipToPick, coversShown, draggedSpan, drawnSeconds, @@ -14,6 +16,7 @@ import { levelMarks, MAX_LEVEL, MIN_LEVEL, + movedSpan, rulerLabel, rulerStep, secondsToX, @@ -138,6 +141,44 @@ describe('the cut span', () => { }); }); +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); diff --git a/tools/e2e/34-sound.mjs b/tools/e2e/34-sound.mjs index fe339127..99cacbfe 100644 --- a/tools/e2e/34-sound.mjs +++ b/tools/e2e/34-sound.mjs @@ -3409,12 +3409,36 @@ export async function sound(t) { const besideClip = await json('__gm.soundTab.at(0.6)'); await click(besideClip.x, besideClip.y); const pickedBesideClip = await picked(); - await click(onBand.x, onBand.y); + await click(inClip.x, inClip.y); check( - 'a click over the wave inside the picked clip keeps it, beside it picks the sound', + 'a click over the wave inside the picked clip keeps it, beside it picks the sound, on the clip picks it again (#403)', pickedInClip === clipId && pickedBesideClip === null && (await picked()) === clipId, JSON.stringify({ pickedInClip, pickedBesideClip, picked: await picked() }), ); + // #403: with the clip picked, a drag over the wave off its handles and one in the strip beside + // its band change nothing; one on its band moves it whole, as one undo step. + const spanBefore = JSON.stringify(await clips()); + await t.drag(inClip.x, inClip.y, besideClip.x, besideClip.y); + await t.drag(besideClip.x, beside.y, (await json('__gm.soundTab.stripAt(0.7)')).x, beside.y); + const untouched = JSON.stringify(await clips()); + const bandTo = await json('__gm.soundTab.stripAt(0.4)'); + await t.mouse('mouseMoved', onBand.x - 1, onBand.y); + await t.mouse('mouseMoved', onBand.x, onBand.y); + const onBandCursor = await json('__gm.soundTab.cursor()'); + await t.drag(onBand.x, onBand.y, bandTo.x, bandTo.y); + const movedClip = await clips(); + await press('z', { ctrl: true }); + check( + 'with the clip picked a drag off its handles changes nothing, on its band it moves the clip whole, one undo step', + untouched === spanBefore && + movedClip.length === 1 && + Math.abs(movedClip[0].props.start - 0.3) < 0.01 && + Math.abs(movedClip[0].props.end - 0.5) < 0.01 && + onBandCursor === 'grab' && + JSON.stringify(await clips()) === spanBefore && + (await picked()) === clipId, + JSON.stringify({ spanBefore, untouched, movedClip, onBandCursor, undone: await clips() }), + ); dragFrom = await json('__gm.soundTab.at(0.3)'); dragTo = await json('__gm.soundTab.at(0.5)'); await t.mouse('mouseMoved', dragFrom.x - 1, dragFrom.y); diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index 4503c5d6..a185d378 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -152,6 +152,45 @@ export async function video(t) { ); await click(playButton.x, playButton.y); check('Stop holds it', (await json('__gm.videoTab.playing()')) === false); + + // 66e-ii. With the clip picked (#403), a drag in the track off its handles or in the strip beside + // its band changes nothing; one on its band moves it whole, one undo step. A click in the + // track beside it picks the video, on it the clip again. + const track = (s) => json(`__gm.videoTab.trackAt(${s})`); + const strip = (s) => json(`__gm.videoTab.stripAt(${s})`); + const clipsNow = async () => JSON.stringify(await json('__gm.videoTab.clips()')); + const spanBefore = await clipsNow(); + for (const [at, from, to] of [ + [track, 1, 1.8], + [strip, 1.6, 1.9], + ]) { + const a = await at(from); + const b = await at(to); + await drag(a.x, a.y, b.x, b.y); + } + const untouched = await clipsNow(); + const bandFrom = await strip(1); + const bandTo = await strip(1.2); + await drag(bandFrom.x, bandFrom.y, bandTo.x, bandTo.y); + await sleep(150); + const moved = (await json('__gm.videoTab.clips()'))[0]; + await t.undo(); + const undoneMove = await clipsNow(); + const besideTrack = await track(1.8); + await click(besideTrack.x, besideTrack.y); + const pickedBeside = await json('__gm.videoTab.pickedClip()'); + const onTrack = await track(1); + await click(onTrack.x, onTrack.y); + check( + 'with the clip picked a drag off its handles changes nothing, on its band it moves the clip whole; a click in the track picks', + untouched === spanBefore && + Math.abs(moved?.props.start - 0.8) < 0.02 && + Math.abs(moved?.props.end - 1.6) < 0.02 && + undoneMove === spanBefore && + pickedBeside === null && + (await json('__gm.videoTab.pickedClip()')) === clip.id, + JSON.stringify({ spanBefore, untouched, moved, undoneMove, pickedBeside }), + ); await shot('66-video-tab'); // 66f. A sprite wearing the clip shows only its stretch, over and over: green and blue, never red