Files
feuerwerk/src/core/model/video.ts
T
adminandClaude Opus 5.5 0701fe3856
feuerwerk / gate (push) Successful in 5m5s
feuerwerk / suite (push) Failing after 18m39s
Share the parts the sound devices and timelines repeated (#85)
Round 12 of the clean-up. Duplicate code had grown from 33 clones
(249 lines) after round 11 to 140 clones (1263 lines), almost all of
it in the panels and faces of the sound FX devices (#379 to #401).
It is down to 57 clones (416 lines) now, and src is 816 lines
shorter.

- deviceParts.ts holds what the device panels built by hand: the
  head, rows of knobs, the screen style and the row layout for
  groups of knobs. Twelve panels use it. panel.knobs stays because
  the debug hooks read it.
- DeviceFace is the base of the twelve faces. It finds the node and
  does wired, preview, commit, set, hide, reload and audition.
  compressorWired lives there as paramWired.
- TextPool replaces ten copies of "make texts as needed, hide the
  rest" in the wave and video tabs, the rulers, the piano roll and
  the song and composition timelines. clipStrip.ts is the clip label
  strip the wave and video tabs share.
- core/util/bytes.ts has the concat that ogg, matroska and avcColour
  each wrote out.
- Dead code is gone: clear on the video players (never called since
  #402), posterFileOf and videoTimeAt (only tests called them) and
  easeOut (the same as glide(t, 3)). Values only used in their own
  file lose their export.
- Comments over 125 columns are wrapped, and comments that told the
  history ("as before", "used to") say what is, in src and in the
  e2e modules.
- Left alone on purpose: AnimationEditor and CompositionEditor share
  only the play button, and the curve panels are built differently.
- Chip now places its boxes through put, which rounds. They were
  whole numbers already. The three row layouts are byte-identical
  before and after.
- README lists the video and composition assets and the Videos and
  Compositions pages.

Tests:
- Gate (melektaus-atx): CHECK EXIT 0, 313 files, 4835 tests.
- e2e 25, 34, 64, 65, 66, 69 (melektaus-atx): ALL PASSED, 330 checks.
- TextPool without keeping its texts and framesToRoom without making
  room both go red.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 20:40:05 +02:00

191 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { FROM_ASSET_BUS, MASTER_MIX, mixBusName, pickedComposition } from '../properties/buses';
import { videoLoopsOf, videoSpanOf, videoVolumeOf } from '../properties/videos';
import type { VideoAsset, VideoClip } from './Asset';
import { findAsset, findAssetOf, type Project } from './Project';
import { clipsOf } from './sound';
import { storedFilePattern } from './storedFile';
const FILE_PATTERN = storedFilePattern('mp4');
/**
* How long a run waits for a driven video (#404) it shows to have its first picture before it goes
* anyway: at its start, and a composition (#417) at each item that brings one in.
*/
export const VIDEO_WAIT_MS = 3000;
export function isVideoFile(name: string): boolean {
return FILE_PATTERN.test(name);
}
/** What a texture field names when it names a video: the whole video, or one of its clips. */
export interface VideoRef {
asset: VideoAsset;
clip: VideoClip | null;
}
/** A video or a clip of one, by id; clip ids share the generator with asset ids, as frames do. */
export function findVideo(project: Project, id: string): VideoRef | null {
if (id === '') return null;
const asset = findAsset(project, id);
if (asset) return asset.type === 'video' ? { asset, clip: null } : null;
for (const video of project.assets) {
if (video.type !== 'video') continue;
const clip = clipsOf(video).find((c) => c.id === id);
if (clip) return { asset: video, clip };
}
return null;
}
/** Every id that means "this video": the asset itself and each of its clips. */
export function videoRefIds(asset: VideoAsset): string[] {
return [asset.id, ...clipsOf(asset).map((c) => c.id)];
}
/**
* The stretch a clip plays, inside the video: a start before its end, an end of 0 or past the
* video's length being its end. Without a length (not known yet) the asked values stand.
*/
export function videoSpan(duration: number, start: number, end: number): { start: number; end: number } {
const length = duration > 0 ? duration : Infinity;
const from = Math.min(Math.max(0, start), Number.isFinite(length) ? Math.max(0, length - 0.001) : start);
const to = end > 0 ? Math.min(end, length) : length;
return { start: from, end: to > from ? to : length };
}
/** How a video is played: which file, which stretch of it, whether it repeats, how loud. */
export interface VideoPlay {
file: string;
start: number;
/** Seconds; Infinity for the end of the video, wherever that is. */
end: number;
loop: boolean;
volume: number;
/** The bus of the mix its sound goes into, where a sprite picked one; along its routing where left out. */
bus?: string;
}
export function videoPlayOf(ref: VideoRef): VideoPlay {
const asked = ref.clip ? videoSpanOf(ref.clip.props) : { start: 0, end: 0 };
const span = videoSpan(ref.asset.duration, asked.start, asked.end);
return {
file: ref.asset.file,
start: span.start,
end: ref.asset.duration > 0 && span.end >= ref.asset.duration ? Infinity : span.end,
loop: videoLoopsOf(ref.asset.props),
volume: videoVolumeOf(ref.asset.props),
};
}
/** Seconds a video or clip lasts; 0 while the video's length is not known yet. */
export function videoLengthOf(ref: VideoRef): number {
const play = videoPlayOf(ref);
const end = Number.isFinite(play.end) ? play.end : ref.asset.duration;
return end > play.start ? end - play.start : 0;
}
/** A key of a `Video time` row: seconds into the clip on a frame of the animation. */
export interface FrameValue {
frame: number;
value: number;
}
/**
* Where a driven video playing at 1× from `value` seconds on `frame` reaches the end of a clip
* `length` seconds long (#407): the last whole frame at `fps` before it, and the clip's time there.
* Null when not one frame fits.
*/
export function realTimeEnd(frame: number, value: number, length: number, fps: number): FrameValue | null {
if (!(length > 0) || !(fps > 0) || !Number.isFinite(value)) return null;
const frames = Math.floor((length - value) * fps + 1e-6);
if (frames < 1) return null;
return { frame: frame + frames, value: Math.round((value + frames / fps) * 1e6) / 1e6 };
}
const PREFIX = 'video:';
/**
* The name a playing video goes by where a picture is named by `TextureRef.file` (see
* `generated.ts`): `video:<file>#<start>~<end>~<loop>~<volume>`, and `!<bus>` where its sound goes
* into a bus a sprite picked. No uploaded image looks like it, and one key is one player: two sprites
* wearing the same clip show the same picture.
*/
export function videoKey(play: VideoPlay): string {
const end = Number.isFinite(play.end) ? play.end : 0;
const bus = play.bus ? `!${play.bus}` : '';
return `${PREFIX}${play.file}#${play.start}~${end}~${play.loop ? 1 : 0}~${play.volume}${bus}`;
}
/**
* The bus of the mix the Video bus of a sprite names: null along the routing of its video, the
* composition's for one it picked for a sprite of its items. A bus that is gone is the master.
*/
export function videoMixBus(project: Project, picked: string): string | null {
if (picked === '' || picked === FROM_ASSET_BUS) return null;
const comp = pickedComposition(picked);
if (comp !== null) return findAssetOf(project, comp, 'composition') ? mixBusName(comp) : null;
return findAssetOf(project, picked, 'bus') ? mixBusName(picked) : MASTER_MIX;
}
/** The key of the same video heard on `bus` (#418); along its routing for null. */
export function routedVideoKey(key: string, bus: string | null): string {
const play = parseVideoKey(key);
if (!play || isDrivenVideoKey(key)) return key;
const { bus: _, ...rest } = play;
return videoKey(bus ? { ...rest, bus } : rest);
}
/**
* The key of a player one wearer drives on its own (#404): two sprites driving the same clip must
* not fight over one `<video>`, so the wearer's name goes after an `@`.
*/
export function drivenVideoKey(key: string, wearer: string): string {
return `${key}@${wearer}`;
}
export function isVideoKey(key: string): boolean {
return parseVideoKey(key) !== null;
}
/** Whether the key names a player its wearer drives, see `drivenVideoKey`. */
export function isDrivenVideoKey(key: string): boolean {
return isVideoKey(key) && key.includes('@');
}
export function parseVideoKey(key: string): VideoPlay | null {
if (!key.startsWith(PREFIX)) return null;
const hash = key.indexOf('#');
if (hash < 0) return null;
const file = key.slice(PREFIX.length, hash);
const at = key.indexOf('@', hash);
const body = key.slice(hash + 1, at < 0 ? undefined : at);
const bang = body.indexOf('!');
const parts = (bang < 0 ? body : body.slice(0, bang)).split('~');
const [start, end, loop, volume] = parts.map(Number);
if (!isVideoFile(file) || parts.length !== 4 || ![start, end, loop, volume].every(Number.isFinite))
return null;
const play = { file, start: start!, end: end! > 0 ? end! : Infinity, loop: loop === 1, volume: volume! };
return bang < 0 ? play : { ...play, bus: body.slice(bang + 1) };
}
/** How far before its end a held clip shows its last picture: the end itself is the next one's. */
const LAST_PICTURE = 0.001;
/**
* Where in its file a driven video stands at `time` seconds into its clip (#404). Before the start
* it shows the first picture, past the end it goes round with Loop and holds the last one without.
* `duration` is the file's, which a clip running to the end of the video needs; unknown (NaN, not
* loaded yet) the time is not bounded at the end.
*/
export function drivenTimeOf(
play: Pick<VideoPlay, 'start' | 'end' | 'loop'>,
time: number,
duration: number,
): number {
const end = Number.isFinite(play.end) ? play.end : duration;
const length = end - play.start;
const run = Math.max(0, Number.isFinite(time) ? time : 0);
if (!(length > 0)) return play.start + run;
if (play.loop) return play.start + (run % length);
return play.start + Math.min(run, Math.max(0, length - LAST_PICTURE));
}