Route a video's sound through its own Master routing node
An editor recording had no sound while the video tab played a video. The tab played its <video> straight to the speakers, past the Web Audio graph, so the recording tap behind the limiter never heard it. Fixing only that would have left videos as the one sound source the Master routing cannot reach, so they get their own node, like sounds. - A video asset with sound has a routing node labelled "Video". One without sound has none, it would have nothing to send. - Its links take the sound after volume and FX. Several links or another level give the video its own bus, as with a sound. - A new video follows the bus most other videos use. With none linked yet it goes to the Master, not to where the sounds go. Songs and sounds no longer follow videos either. - Players only know their file, so videoBusesOf maps each file to the bus of its first asset. setMix takes that map and moves the gains of running videos at once when the routing changes. - In the game, played in the editor or released, a video's sound follows its links. The tabs (video, composition, animation preview) play straight into the Master, as the sound tabs have since #368. - The video tab's sound now goes through the Master, so its volume, FX and limiter apply there and the editor recording hears it. - The recording badge beside the menu is an HTML element over the canvas now. The second render that drew it on the desynchronized canvas let the screen show a frame without it, so it flickered. The element is never in the copy, and clicks go through it. Tests: - Unit master-routing and sound-library: 3 new, green. Red without video in the routing kinds, without moving the gains, and with the old rule for the links of a new video. - e2e 66e-iii: the tab plays a video linked to a bus, the level is 0.09 on the Master and 0 on the bus. 66g: in the game the bus has 0.09. Both red with the old ways. - e2e 67e reads the badge from a real screenshot, still green. - The README names 2043 e2e checks. - Gate on melektaus-atx: 304 files, 4694 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
846a8b70b3
commit
93190e185c
25 files changed
+376
-99
No files matched your search
@@ -2,7 +2,7 @@ import type { Application, Ticker } from 'pixi.js';
|
||||
import type { Asset } from '../core/model/Asset';
|
||||
import { findAsset, findObjectHolder, ROUTING_ID } from '../core/model/Project';
|
||||
import { assetTitle } from '../core/music/embedded';
|
||||
import { mixOf } from '../core/music/mixer';
|
||||
import { mixOf, videoBusesOf } from '../core/music/mixer';
|
||||
import type { Role } from '../core/store/AuthStore';
|
||||
import type { ProjectStore } from '../core/store/ProjectStore';
|
||||
import { pluginsOf } from '../core/store/projectPlugins';
|
||||
@@ -672,7 +672,7 @@ export class Editor implements PlayHost {
|
||||
this.openDocument(doc);
|
||||
});
|
||||
// The tabs play through the buses and the master as the game would (#325).
|
||||
const syncMix = () => void soundLibrary.setMix(mixOf(state.project));
|
||||
const syncMix = () => void soundLibrary.setMix(mixOf(state.project), videoBusesOf(state.project));
|
||||
syncMix();
|
||||
state.on('project', () => {
|
||||
syncMix();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Container, Graphics, UPDATE_PRIORITY } from 'pixi.js';
|
||||
import { UPDATE_PRIORITY } from 'pixi.js';
|
||||
import { cursorShape, type CursorShape } from '../core/recording/cursors';
|
||||
import {
|
||||
EDITOR_RECORDING_FPS,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
fitPicture,
|
||||
recordingName,
|
||||
} from '../core/recording/recording';
|
||||
import { crispText, renderCount, requestRender, textStyle, Theme, ui } from '../ui';
|
||||
import { renderCount, requestRender, Theme, toHex, ui } from '../ui';
|
||||
import { type FrameSource, Take, type TakeHost, type TakeInfo } from '../viewport/RecordingTake';
|
||||
|
||||
/** What the editor recorder needs of the editor. */
|
||||
@@ -22,8 +22,9 @@ export interface EditorRecorderHost extends TakeHost {
|
||||
* Records the whole editor (main menu → Start recording): the canvas as it is on screen, at its size
|
||||
* in device pixels and 60 fps, with the mouse pointer drawn in, since the system draws that over the
|
||||
* canvas. What the editor draws is copied from the canvas right after each render, so recording costs
|
||||
* no second drawing. The badge beside the menu is drawn onto the screen after that copy, so it is
|
||||
* never in the recording.
|
||||
* no second drawing. The badge beside the menu is an HTML element over the canvas, the one exception
|
||||
* to the single canvas: it is never in the recording, and the canvas is drawn once per frame, which a
|
||||
* `desynchronized` canvas needs to show no half-drawn picture.
|
||||
*/
|
||||
export class EditorRecorder {
|
||||
private take: Take | null = null;
|
||||
@@ -72,6 +73,7 @@ export class EditorRecorder {
|
||||
this.source?.detach();
|
||||
this.source = null;
|
||||
ui().app.ticker.remove(this.tick, this);
|
||||
this.badge.hide();
|
||||
this.host.showBadge(false);
|
||||
requestRender();
|
||||
}
|
||||
@@ -81,23 +83,19 @@ export class EditorRecorder {
|
||||
const source = this.source;
|
||||
if (!take || !source) return;
|
||||
const drawn = renderCount();
|
||||
const text = elapsedText(performance.now() - this.startedAt);
|
||||
if (drawn !== this.seen) {
|
||||
this.seen = drawn;
|
||||
source.copy();
|
||||
this.drawBadge();
|
||||
// The top bar may have moved with this render: the badge follows it.
|
||||
this.shownText = text;
|
||||
this.badge.show(ui().app.canvas, this.host.badgeRoom(), text);
|
||||
} else if (text !== this.shownText) {
|
||||
// An idle editor draws nothing; the time moves on without it.
|
||||
this.shownText = text;
|
||||
this.badge.setText(text);
|
||||
}
|
||||
take.capture();
|
||||
// An idle editor draws nothing, and the time beside the menu has to move on.
|
||||
if (elapsedText(performance.now() - this.startedAt) !== this.shownText) requestRender();
|
||||
}
|
||||
|
||||
private drawBadge(): void {
|
||||
this.shownText = elapsedText(performance.now() - this.startedAt);
|
||||
this.badge.show(this.host.badgeRoom(), this.shownText);
|
||||
const app = ui().app;
|
||||
app.renderer.render({ container: this.badge, clear: false });
|
||||
// Pixi hit-tests the pointer against what it drew on screen last: that has to be the stage.
|
||||
(app.renderer as unknown as { _lastObjectRendered: Container })._lastObjectRendered = app.stage;
|
||||
}
|
||||
|
||||
info(): {
|
||||
@@ -113,25 +111,65 @@ export class EditorRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
/** The red dot and the time beside the menu while the editor records. */
|
||||
class RecordingBadge extends Container {
|
||||
private readonly dot = new Graphics();
|
||||
private readonly time = crispText({ text: '', style: textStyle('body') });
|
||||
/**
|
||||
* The red dot and the time beside the menu while the editor records: an HTML element laid over the
|
||||
* room the top bar keeps free, scaled with the canvas (`?w=&h=`). Clicks go through it to the canvas.
|
||||
*/
|
||||
class RecordingBadge {
|
||||
private element: HTMLDivElement | null = null;
|
||||
private time: HTMLSpanElement | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addChild(this.dot, this.time);
|
||||
show(canvas: HTMLCanvasElement, room: { x: number; y: number; w: number; h: number }, text: string): void {
|
||||
const element = this.element ?? this.build();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const shown = rect.width / ui().app.screen.width;
|
||||
element.style.left = `${rect.left + room.x * shown}px`;
|
||||
element.style.top = `${rect.top + room.y * shown}px`;
|
||||
element.style.width = `${room.w}px`;
|
||||
element.style.height = `${room.h}px`;
|
||||
element.style.transform = shown === 1 ? '' : `scale(${shown})`;
|
||||
this.setText(text);
|
||||
}
|
||||
|
||||
show(room: { x: number; y: number; w: number; h: number }, text: string): void {
|
||||
setText(text: string): void {
|
||||
if (this.time) this.time.textContent = text;
|
||||
}
|
||||
|
||||
hide(): void {
|
||||
this.element?.remove();
|
||||
this.element = null;
|
||||
this.time = null;
|
||||
}
|
||||
|
||||
private build(): HTMLDivElement {
|
||||
const radius = 5;
|
||||
this.position.set(room.x, room.y);
|
||||
this.dot
|
||||
.clear()
|
||||
.circle(radius + 2, room.h / 2, radius)
|
||||
.fill(Theme.colors.danger);
|
||||
this.time.text = text;
|
||||
this.time.position.set(radius * 2 + 7, Math.round((room.h - this.time.height) / 2));
|
||||
const element = document.createElement('div');
|
||||
element.style.cssText = [
|
||||
'position: fixed',
|
||||
'display: flex',
|
||||
'align-items: center',
|
||||
'transform-origin: 0 0',
|
||||
'pointer-events: none',
|
||||
'user-select: none',
|
||||
`font: ${Theme.fontSize}px ${Theme.font}`,
|
||||
`color: ${toHex(Theme.colors.text)}`,
|
||||
'white-space: nowrap',
|
||||
].join(';');
|
||||
const dot = document.createElement('span');
|
||||
dot.style.cssText = [
|
||||
`width: ${radius * 2}px`,
|
||||
`height: ${radius * 2}px`,
|
||||
'border-radius: 50%',
|
||||
`margin: 0 5px 0 2px`,
|
||||
`background: ${toHex(Theme.colors.danger)}`,
|
||||
'flex: none',
|
||||
].join(';');
|
||||
const time = document.createElement('span');
|
||||
element.append(dot, time);
|
||||
document.body.appendChild(element);
|
||||
this.element = element;
|
||||
this.time = time;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { EditorState } from '../state';
|
||||
import { Theme } from '../../ui';
|
||||
import { collectHolderItems } from '../../viewport/items';
|
||||
import { FreeViewport } from '../viewport/FreeViewport';
|
||||
import type { VideoHearing } from '../../viewport/VideoLibrary';
|
||||
import type { Frame } from '../../viewport/WorldViewport';
|
||||
|
||||
/**
|
||||
@@ -88,8 +89,8 @@ export class PreviewCanvas extends FreeViewport {
|
||||
}
|
||||
|
||||
/** Heard while it plays at about 1x, so a cut can be timed by its sound (#404). */
|
||||
protected override hearsVideo(): boolean {
|
||||
return true;
|
||||
protected override hearsVideo(): VideoHearing {
|
||||
return 'preview';
|
||||
}
|
||||
|
||||
private asset(): AnimationAsset | undefined {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { compositionHolder } from '../../core/model/composition';
|
||||
import type { CompositionAsset } from '../../core/model/Asset';
|
||||
import { findAssetOf } from '../../core/model/Project';
|
||||
import { compositionVideosAhead } from '../../viewport/items';
|
||||
import type { VideoHearing } from '../../viewport/VideoLibrary';
|
||||
import type { Frame } from '../../viewport/WorldViewport';
|
||||
import type { EditorState } from '../state';
|
||||
import { SceneViewport } from '../viewport/SceneViewport';
|
||||
@@ -89,7 +90,7 @@ export class CompositionCanvas extends SceneViewport {
|
||||
}
|
||||
|
||||
/** Heard while it plays at about 1x, so a cut can be timed by its sound (#404). */
|
||||
protected override hearsVideo(): boolean {
|
||||
return true;
|
||||
protected override hearsVideo(): VideoHearing {
|
||||
return 'preview';
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ export function routingHooks({ state, editor }: HookContext) {
|
||||
/** The loudest sample on each side of these buses of the mix, tapped on first asking (#359). */
|
||||
peaks: (names: string[]) => Object.fromEntries(soundLibrary.busPeaks(names)),
|
||||
buses: () => soundLibrary.busLevels(),
|
||||
/** Where the sound of each video heard goes: along the routing in a game, else the master. */
|
||||
media: () => soundLibrary.mediaRoutes(),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -75,7 +75,7 @@ export class SpritePreview extends Widget {
|
||||
const shaders = shaderLibrary.source(this.state.project);
|
||||
const worn = wornShader(picture.props, shaders);
|
||||
// A driven video (#404) stands at its Video time here, as in the Scene tab.
|
||||
const ref = picture.texture && videoLibrary.wear(picture.texture, picture.props, this.frameKey, false);
|
||||
const ref = picture.texture && videoLibrary.wear(picture.texture, picture.props, this.frameKey, 'none');
|
||||
const texture = ref && textureLibrary.get(ref, worn?.edge ?? SHEET_EDGE);
|
||||
const pad = quadPadding(worn?.padding ?? 0, texture?.width ?? picture.w, texture?.height ?? picture.h);
|
||||
const ratio = ui().app.renderer.resolution;
|
||||
|
||||
@@ -9,8 +9,8 @@ import { TabHeader } from '../TabHeader';
|
||||
const INFO_WIDTH = 220;
|
||||
|
||||
/**
|
||||
* The Master routing tab (#351), one per project: a node per sound, instrument, pattern, song and bus
|
||||
* and the master, the links between them where each one goes. Edited as the routing of a song is
|
||||
* The Master routing tab (#351), one per project: a node per sound, instrument, pattern, song, video
|
||||
* with sound and bus and the master, the links between them where each one goes. Edited as the routing of a song is
|
||||
* (#350); a click on a node shows its asset in the inspector, a double click opens it.
|
||||
*/
|
||||
export class RoutingEditor extends VStack {
|
||||
|
||||
@@ -111,7 +111,7 @@ export class ShaderPreview extends Widget {
|
||||
const h = picture?.h ?? TEST_IMAGE_SIZE;
|
||||
// A sprite whose picture is still loading shows its plain colour, like the scene does.
|
||||
// A driven video (#404) stands at its Video time here, as in the Scene tab.
|
||||
const ref = picture?.texture && videoLibrary.wear(picture.texture, picture.props, this.wearer, false);
|
||||
const ref = picture?.texture && videoLibrary.wear(picture.texture, picture.props, this.wearer, 'none');
|
||||
const texture = picture ? ((ref && textureLibrary.get(ref, edge)) ?? null) : testTexture(edge);
|
||||
// The shader's padding in pixels of the picture, as the scene counts it; the panel keeps room for it.
|
||||
const pad = quadPadding(worn?.padding ?? 0, texture?.width ?? w, texture?.height ?? h);
|
||||
|
||||
+1
-1
@@ -324,7 +324,7 @@ export class EditorState {
|
||||
this.paused = false;
|
||||
this.project = project;
|
||||
this.revision = revision;
|
||||
// Every sound, instrument, pattern, song and bus has its node in the Master routing (#351).
|
||||
// Every sound, instrument, pattern, song, video with sound and bus has its node in the Master routing (#351).
|
||||
settleRouting(project);
|
||||
this.commands = new CommandStack(project);
|
||||
this.selection = null;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
xToSeconds,
|
||||
zoomWave,
|
||||
} from '../sound/waveView';
|
||||
import { soundLibrary } from '../../viewport/SoundLibrary';
|
||||
import { openVideo } from '../../viewport/VideoLibrary';
|
||||
import { chaseSeeks, seekChase, type SeekChase, seekVideo } from '../../viewport/videoSeek';
|
||||
import type { EditorState } from '../state';
|
||||
@@ -86,6 +87,8 @@ interface Preview {
|
||||
chase: SeekChase;
|
||||
/** The moment last asked for, where it stands once it has its first frame. */
|
||||
asked: number;
|
||||
/** Its sound on the way into the master, made at the first Play. */
|
||||
gain: GainNode | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,7 +217,10 @@ export class VideoCanvas extends Widget {
|
||||
if (!asset || !span || !element || !preview) return;
|
||||
this.playing = span;
|
||||
element.muted = false;
|
||||
element.volume = videoVolumeOf(asset.props);
|
||||
// Through the master like any tab's preview (#368), past the routing: so a recording hears it.
|
||||
preview.gain ??= soundLibrary.mediaInput(element, preview.file, false);
|
||||
if (preview.gain) preview.gain.gain.value = videoVolumeOf(asset.props);
|
||||
else element.volume = videoVolumeOf(asset.props);
|
||||
// A moment still wanted from scrubbing would send it back after the start.
|
||||
preview.chase.wanted = null;
|
||||
const head = preview.asked;
|
||||
@@ -369,6 +375,7 @@ export class VideoCanvas extends Widget {
|
||||
texture: null,
|
||||
chase: seekChase(),
|
||||
asked: held,
|
||||
gain: null,
|
||||
element: openVideo(`/game-maker/data/videos/${asset.file}`, held, (texture) => {
|
||||
if (this.preview !== preview) return texture.destroy(true);
|
||||
preview.texture = texture;
|
||||
@@ -402,6 +409,7 @@ export class VideoCanvas extends Widget {
|
||||
this.playing = null;
|
||||
this.picture.texture = Texture.EMPTY;
|
||||
preview.element.pause();
|
||||
if (preview.gain) soundLibrary.releaseMedia(preview.gain);
|
||||
preview.element.removeAttribute('src');
|
||||
preview.element.load();
|
||||
preview.texture?.destroy(true);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { FullscreenCorner } from '../../viewport/FullscreenCorner';
|
||||
import { collectHolderItems, collectWorldItems } from '../../viewport/items';
|
||||
import { routePointer } from '../../viewport/gamePointer';
|
||||
import { cameraFactor, cameraView, type GameScale, type View } from '../../viewport/view';
|
||||
import type { VideoHearing } from '../../viewport/VideoLibrary';
|
||||
import { type Frame, WorldViewport } from '../../viewport/WorldViewport';
|
||||
import { PerformanceOverlay } from './PerformanceOverlay';
|
||||
|
||||
@@ -80,8 +81,8 @@ export class GameViewport extends WorldViewport<EditorState> {
|
||||
return this.performanceShown;
|
||||
}
|
||||
|
||||
protected override hearsVideo(): boolean {
|
||||
return this.playWorld !== null;
|
||||
protected override hearsVideo(): VideoHearing {
|
||||
return this.playWorld !== null ? 'game' : 'none';
|
||||
}
|
||||
|
||||
setPerformanceShown(on: boolean): void {
|
||||
|
||||
Reference in new issue
Block a user