diff --git a/README.md b/README.md index 41fffb44..6918a9d9 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, 2016 checks (GM_STEPS=20 for one step) +node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2017 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/debug/videoHooks.ts b/src/editor/debug/videoHooks.ts index 8384ee82..de60cadd 100644 --- a/src/editor/debug/videoHooks.ts +++ b/src/editor/debug/videoHooks.ts @@ -28,6 +28,8 @@ export function videoHooks({ state, editor }: HookContext) { /** The middle of the clip strip, and of the track under it, at a second of the video. */ stripAt: (seconds: number) => videoEditor.canvas.stripAt(seconds), trackAt: (seconds: number) => videoEditor.canvas.trackAt(seconds), + /** The time's zoom, and whether a transition is on its way. */ + zoom: () => videoEditor.canvas.zoomInfo(), }, videoPicker: () => { const picker = currentVideoPicker(); diff --git a/src/editor/video/VideoCanvas.ts b/src/editor/video/VideoCanvas.ts index 70b63ff9..d348c25c 100644 --- a/src/editor/video/VideoCanvas.ts +++ b/src/editor/video/VideoCanvas.ts @@ -42,6 +42,7 @@ import { import { openVideo } from '../../viewport/VideoLibrary'; import type { EditorState } from '../state'; import { containRect } from '../texture/imageGrid'; +import { axisTween } from '../viewTween'; const RULER_HEIGHT = 24; /** The strip the clips stand in as bands, as in the Sound tab. */ @@ -110,6 +111,12 @@ export class VideoCanvas extends Widget { private readonly stripTexts: Text[] = []; private readonly hint = crispText({ text: '', style: textStyle('muted') }); private view: WaveView = { panX: FIT_MARGIN, pxPerSecond: 100 }; + /** A zoom or a Fit of the time shown as a transition, as in the Sound tab (#377). */ + private readonly tween = axisTween( + () => ({ scale: this.view.pxPerSecond, offset: this.view.panX }), + ({ scale, offset }) => (this.view = { pxPerSecond: scale, panX: offset }), + () => this.redraw(), + ); private needsFit = true; private assetId = ''; private gesture: Gesture | null = null; @@ -157,6 +164,7 @@ export class VideoCanvas extends Widget { } saveView(): WaveView { + this.tween.land(); return { ...this.view }; } @@ -168,7 +176,13 @@ export class VideoCanvas extends Widget { this.redraw(); } + /** Fit by hand, shown as a transition. */ fit(): void { + this.tween.change(() => this.frame()); + } + + /** The whole video across. */ + private frame(): void { const asset = this.asset(); if (!asset || asset.duration <= 0 || this.w <= 0) { this.needsFit = true; @@ -242,6 +256,11 @@ export class VideoCanvas extends Widget { return clip ? this.clipSpan(clip, asset) : { start: 0, end: asset.duration }; } + /** The zoom of the time as `scale`, and whether a transition is on its way (test hook). */ + zoomInfo(): { scale: number; moving: boolean } { + return { scale: this.view.pxPerSecond, moving: this.tween.moving }; + } + /** The middle of the strip at a second of the video, on screen (test hook). */ stripAt(seconds: number): Vec2 { const origin = this.getGlobalPosition(); @@ -491,19 +510,21 @@ export class VideoCanvas extends Widget { const asset = this.asset(); const p = this.toLocal(e.global); if (!asset || asset.duration <= 0 || p.y < this.timelineTop) return; - this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), asset.duration, this.w); - this.needsFit = false; - this.redraw(); + this.tween.change(() => { + this.view = clampWave(zoomWave(this.view, zoomFactor(e.deltaY), p.x), asset.duration, this.w); + this.needsFit = false; + this.redraw(); + }); e.preventDefault(); } override layout(): void { - if (this.needsFit) this.fit(); + if (this.needsFit) this.frame(); } override redraw(): void { const asset = this.asset(); - if (this.needsFit && asset && asset.duration > 0 && this.w > 0) return this.fit(); + if (this.needsFit && asset && asset.duration > 0 && this.w > 0) return this.frame(); const c = Theme.colors; const top = this.timelineTop; this.bg.clear().rect(0, 0, this.w, this.h).fill(0x000000); diff --git a/tools/e2e/66-video.mjs b/tools/e2e/66-video.mjs index 8491a068..a22420b0 100644 --- a/tools/e2e/66-video.mjs +++ b/tools/e2e/66-video.mjs @@ -1,6 +1,7 @@ import { readFileSync } from 'node:fs'; import { uploadVideo } from '../lib/projects.mjs'; import { LANES, MAKE_OBJECT, pickAssetType } from './lib/scaffold.mjs'; +import { sampleZoom } from './lib/zoomTransition.mjs'; /** * Videos (#402). A WebM goes up and the worker turns it into an MP4 (in CI beside the PHP server, @@ -197,6 +198,9 @@ export async function video(t) { JSON.stringify({ spanBefore, untouched, moved, undoneMove, pickedBeside }), ); await shot('66-video-tab'); + // At 1000 ms a frame after the wheel the time's zoom is on its way, as in the Sound tab (#377). + const timeZoom = await sampleZoom(t, await strip(1), '__gm.videoTab.zoom()'); + check('a zoom of the time is shown on its way and lands where the jump does', timeZoom.ok, timeZoom.detail); // 66f. A sprite wearing the clip shows only its stretch, over and over: green and blue, never red // or white, without sound in the editor.