Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5ea7524e6 | ||
|
|
b268891f48 | ||
|
|
e53121a0bb | ||
|
|
2333f66ba2 | ||
|
|
b17a924157 | ||
|
|
f9428c5bc2 | ||
|
|
e390d39314 | ||
|
|
53ec1094f4 | ||
|
|
a6a41b6b69 | ||
|
|
5cc1cc9ef6 | ||
|
|
56425fca6a | ||
|
|
2099511695 | ||
|
|
b50c7dbc35 | ||
|
|
bd99f9e79d | ||
|
|
389a7c0fd1 | ||
|
|
b5e071ca68 | ||
|
|
dd2096e0df | ||
|
|
70402aa19a | ||
|
|
e4ccf5f72f | ||
|
|
fb981b5b0f | ||
|
|
e4b0419a53 | ||
|
|
24cd9fdeaa | ||
|
|
2e4c0a09ea | ||
|
|
37779a2070 | ||
|
|
19902a9f02 | ||
|
|
10f5e3ba8f | ||
|
|
0684e76dc5 | ||
|
|
d01e2b832e | ||
|
|
0d8fd6ff52 | ||
|
|
1943539a7a | ||
|
|
f296f427ef | ||
|
|
97e0d93168 | ||
|
|
732989a452 | ||
|
|
0dd671ea26 | ||
|
|
b9d1602b94 | ||
|
|
25af064249 | ||
|
|
48ec722660 | ||
|
|
edef2b6323 | ||
|
|
5b9d084dfe | ||
|
|
0d7672a5af | ||
|
|
925a39a276 | ||
|
|
ce1f25f2eb | ||
|
|
cfd6d7a8df | ||
|
|
a9e0b6d9fa | ||
|
|
2f13f9a0b9 | ||
|
|
406839baea | ||
|
|
0701fe3856 | ||
|
|
df6dbe97c5 | ||
|
|
88e1cf1c7d | ||
|
|
8a49392d7f | ||
|
|
39c509a8e5 | ||
|
|
b1173ba6bb | ||
|
|
abb8fa51d8 | ||
|
|
87b2ee9402 | ||
|
|
77365f17d3 | ||
|
|
4d0a696327 | ||
|
|
875c76026a | ||
|
|
acd12326dc | ||
|
|
77ac9d1aaf | ||
|
|
30b618d9a9 | ||
|
|
31ce6480b2 | ||
|
|
ba68167704 | ||
|
|
d4ff652293 | ||
|
|
9445f39c8d | ||
|
|
2d3b957551 | ||
|
|
6097307b6d | ||
|
|
043c224cc3 | ||
|
|
f92d69d902 | ||
|
|
d844f2734a | ||
|
|
3443368855 | ||
|
|
2ba41c1bd0 | ||
|
|
65d7bf6a1f | ||
|
|
126a5a3a62 | ||
|
|
480db9c4e4 | ||
|
|
0648853356 | ||
|
|
5df2173559 | ||
|
|
7a952cb20d | ||
|
|
64d0d472e5 | ||
|
|
33891acfb3 | ||
|
|
b16fe8c718 | ||
|
|
f171c77f91 | ||
|
|
c425eea004 | ||
|
|
d4fc425233 | ||
|
|
ba01d146e9 | ||
|
|
93190e185c | ||
|
|
846a8b70b3 | ||
|
|
d6a325978c | ||
|
|
080df2d211 | ||
|
|
f177ee90a4 | ||
|
|
c0ba2673ba | ||
|
|
40b6290d24 | ||
|
|
10c2c3c308 | ||
|
|
b2b4cbad28 | ||
|
|
6e6b6a4d9a | ||
|
|
c4650e43f0 | ||
|
|
4f0272638e | ||
|
|
e435c54afb | ||
|
|
0334d2508d | ||
|
|
73e675835e | ||
|
|
597367c804 | ||
|
|
be41b1f0d7 | ||
|
|
322fc39d16 | ||
|
|
e191dc4b2e | ||
|
|
36ffd9a61a | ||
|
|
8118b8042b | ||
|
|
b9fcf48097 | ||
|
|
3a1e420806 | ||
|
|
8679dfbe65 | ||
|
|
dd60a795e1 | ||
|
|
843b110bcb | ||
|
|
43fd22a763 | ||
|
|
774207208c | ||
|
|
c9e8a3f6ac | ||
|
|
a62d1d0faf | ||
|
|
ce933a4f32 | ||
|
|
c815d2c35f | ||
|
|
d48f174caf | ||
|
|
2f376262d3 | ||
|
|
742f240a62 | ||
|
|
6383cc0d8e | ||
|
|
a6c00f7b8b | ||
|
|
b145e1f288 | ||
|
|
f9a953f3d3 | ||
|
|
b6749cc43d | ||
|
|
28909f0adf | ||
|
|
80b25488ce | ||
|
|
8c53ce0cbe | ||
|
|
cb3183227b | ||
|
|
3f7eb03143 | ||
|
|
d4a45c127b | ||
|
|
389d9555fa | ||
|
|
fe4e0d9f1a | ||
|
|
e70954115c | ||
|
|
e8021b1fd3 | ||
|
|
c75e72d4ee | ||
|
|
ab37d3423a |
No files matched your search
@@ -1,20 +1,20 @@
|
||||
# Runs the whole proof on the Gitea runner: gate, the editor suite and the three example games.
|
||||
#
|
||||
# The suite runs on `game-maker-wsl`, the runner in the WSL2 distro game-maker-ci on
|
||||
# melektaus-atx (#283). Its label picks the image built from tools/ci/Dockerfile. The GPU
|
||||
# (/dev/dxg and the Windows driver libraries), /dev/shm and GAME_MAKER_BROWSER=wsl come from that
|
||||
# runner's own config.yaml, not from here – without them chromium falls back to software WebGL and
|
||||
# the harness stops. The gate needs no GPU and runs on `game-maker-ci`, the runner on stein2 with
|
||||
# the same image. Pointing the suite at that label too moves the browser runs back to stein2.
|
||||
# Both jobs run on `feuerwerk-ci`, the runner on stein2. Its label picks the image built from
|
||||
# tools/ci/Dockerfile. The GPU (/dev/dri/renderD128) and /dev/shm come from that runner's own
|
||||
# config.yaml, not from here – without them chromium falls back to software WebGL and the harness
|
||||
# stops. The suite ran on `feuerwerk-wsl` in the WSL2 distro feuerwerk-ci on melektaus-atx (#283),
|
||||
# whose config adds /dev/dxg and FEUERWERK_BROWSER=wsl. Pointing the suite at that label again
|
||||
# moves the browser runs back there. It moved to stein2 because other jobs on melektaus-atx share
|
||||
# its GPU and made the suite's frame timings wobble.
|
||||
#
|
||||
# Two jobs without `needs:`, so they start together. The gate does not stand in front of the
|
||||
# suite: a lint error used to cost the full e2e run before it was reported, and the run reported
|
||||
# it after six minutes instead of one. Both still have to pass for the push to be green.
|
||||
#
|
||||
# On two runners the gate's vitest no longer shares cores with the suite's chromium, which
|
||||
# measures text widths and frame counts. GM_TEST_WORKERS still caps vitest at two workers, for the
|
||||
# day both jobs share one runner again.
|
||||
name: game-maker
|
||||
# On one runner the gate's vitest shares cores with the suite's chromium, which measures text
|
||||
# widths and frame counts. GM_TEST_WORKERS caps vitest at two workers for that.
|
||||
name: feuerwerk
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
|
||||
jobs:
|
||||
gate:
|
||||
runs-on: game-maker-ci
|
||||
runs-on: feuerwerk-ci
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -35,17 +35,23 @@ jobs:
|
||||
env:
|
||||
GM_TEST_WORKERS: 2
|
||||
|
||||
# The API's own rules (#180, #179, #231, #266). Not part of check.sh: the host the editor is built
|
||||
# on has no PHP, the image does.
|
||||
# The API's own rules (#180, #179, #231, #266, #427, #429). Not part of check.sh: the host the
|
||||
# editor is built on has no PHP, the image does. rights.test.php runs the API itself under
|
||||
# `php -S`.
|
||||
- name: PHP tests
|
||||
run: |
|
||||
php tests/php/accounts.test.php
|
||||
php tests/php/snapshots.test.php
|
||||
php tests/php/releases.test.php
|
||||
php tests/php/files.test.php
|
||||
php tests/php/videos.test.php
|
||||
php tests/php/limits.test.php
|
||||
php tests/php/demos.test.php
|
||||
php tests/php/agents.test.php
|
||||
php tests/php/rights.test.php
|
||||
|
||||
suite:
|
||||
runs-on: game-maker-wsl
|
||||
runs-on: feuerwerk-ci
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -4,12 +4,19 @@
|
||||
/data/e2e-password.txt
|
||||
/data/e2e-admin-password.txt
|
||||
/data/sessions/
|
||||
/data/sign-ins/
|
||||
/data/agent-tokens/
|
||||
/data/agent-tickets/
|
||||
/data/mcp-relay.key
|
||||
/data/snapshots/
|
||||
/data/releases/
|
||||
/data/demos/
|
||||
/data/demo-files.json
|
||||
/data/users.json
|
||||
/data/textures/
|
||||
/data/fonts/
|
||||
/data/sounds/
|
||||
/data/videos/
|
||||
/data/pixels/
|
||||
/data/plugins/
|
||||
/dist/
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Game Maker
|
||||
# Feuerwerk
|
||||
|
||||
[](https://git.nockal.com/meleki/game-maker/actions?workflow=ci.yml)
|
||||
[](https://git.nockal.com/meleki/feuerwerk/actions?workflow=ci.yml)
|
||||
|
||||
A browser-based tool "one step before a game engine": create **assets** (scenes, cameras,
|
||||
sprites), place them into scenes, tweak their properties in an inspector and press **Play** –
|
||||
sprites), place them into scenes, tweak their properties in an inspector and press **Play**,
|
||||
all in one program, all drawn inside a single WebGL canvas.
|
||||
|
||||

|
||||
|
||||
Live: <https://nockal.com/game-maker/> · Widget playground: <https://nockal.com/game-maker/?playground>
|
||||
Live: <https://nockal.com/feuerwerk/> · Widget playground: <https://nockal.com/feuerwerk/?playground>
|
||||
|
||||
**The documentation is the wiki:** <https://git.nockal.com/meleki/game-maker/wiki> – every concept,
|
||||
**The documentation is the wiki:** <https://git.nockal.com/meleki/feuerwerk/wiki>: every concept,
|
||||
node, tab and shortcut, plus the architecture and the development workflow. This file is the short
|
||||
way in.
|
||||
|
||||
@@ -19,13 +19,13 @@ way in.
|
||||
- An **asset** is a named, typed thing (scene, camera, sprite, text, behavior, variables, struct,
|
||||
collider, rigidbody, joint, container, clip area, texture, vector art, pixel art, palette,
|
||||
animation, curve, tile, tilemap, state machine, shader, shader function, font, sound, sound FX,
|
||||
instrument, pattern, song, bus, particles) with properties and no position.
|
||||
instrument, pattern, song, bus, video, composition, particles) with properties and no position.
|
||||
- A **scene object** places an asset: position, sparse **overrides** of its properties, an optional
|
||||
parent. Only what differs from the asset is stored; the inspector paints it gold and offers ↺ / ↑.
|
||||
- A **container** is a reusable group edited once and placed many times; its content is resolved
|
||||
from the template on every read. A **screen** is a container switched on and off.
|
||||
- A **behavior** is logic as a node graph, placed like anything else and parented to the object it
|
||||
acts on. Play has no built-in behaviour – everything that happens is a graph.
|
||||
acts on. Play has no built-in behaviour: everything that happens is a graph.
|
||||
- An **account** owns projects: the editor asks for a user name and a password first, and each
|
||||
account sees only its own projects. The Account menu at the far right of the toolbar changes
|
||||
the password and signs out.
|
||||
@@ -39,61 +39,63 @@ Node 22 is required.
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # http://localhost:5175/game-maker/dist/ – API calls are proxied to production
|
||||
npm run check # typecheck + lint + format:check + unit tests – the gate CI runs too
|
||||
npm run dev # http://localhost:5175/; API and data calls are proxied to production
|
||||
npm run check # typecheck + lint + format:check + unit tests; the gate CI runs too
|
||||
npm run build # writes dist/ and regenerates index.html; the folder is what nginx serves
|
||||
```
|
||||
|
||||
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, 1962 checks (GM_STEPS=20 for one step)
|
||||
# every tool signs in first – see the Accounts page
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 2090 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
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
```
|
||||
|
||||
Every push runs all of it on the Gitea runners – the gate as one job, build and suite as another
|
||||
beside it – so locally take the gate plus the step your change touches and leave the full run to
|
||||
Every push runs all of it on the Gitea runners: the gate as one job, build and suite as another
|
||||
beside it. So locally take the gate plus the step your change touches and leave the full run to
|
||||
CI. Details, the GPU requirement and the detached runner are in
|
||||
[Development](https://git.nockal.com/meleki/game-maker/wiki/Development).
|
||||
[Development](https://git.nockal.com/meleki/feuerwerk/wiki/Development).
|
||||
|
||||
## Documentation
|
||||
|
||||
| Page | What it covers |
|
||||
|---|---|
|
||||
| [Concepts](https://git.nockal.com/meleki/game-maker/wiki/Concepts) | Assets, scene objects, overrides, play mode, the camera, the console |
|
||||
| [Accounts](https://git.nockal.com/meleki/game-maker/wiki/Accounts) | Signing in and out, the accounts, who a project belongs to, what the check tools need |
|
||||
| [Scenes](https://git.nockal.com/meleki/game-maker/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph |
|
||||
| [Scene editing](https://git.nockal.com/meleki/game-maker/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
|
||||
| [Behaviors](https://git.nockal.com/meleki/game-maker/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
|
||||
| [Structs](https://git.nockal.com/meleki/game-maker/wiki/Structs) | Struct assets: a named shape that struct variables, fields and `Pack`/`Expand` follow |
|
||||
| [Colliders](https://git.nockal.com/meleki/game-maker/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts |
|
||||
| [Physics](https://git.nockal.com/meleki/game-maker/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step, joints |
|
||||
| [Textures](https://git.nockal.com/meleki/game-maker/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
|
||||
| [Vector art](https://git.nockal.com/meleki/game-maker/wiki/Vector-Art) | Pictures drawn from shapes: the pen, curves, gradients and texture fills, combining shapes |
|
||||
| [Pixel art](https://git.nockal.com/meleki/game-maker/wiki/Pixel-Art) | Pictures painted pixel by pixel: the palette, the tools, layers and frames |
|
||||
| [Fonts](https://git.nockal.com/meleki/game-maker/wiki/Fonts) | Font assets, the picker, what a text falls back to |
|
||||
| [Sounds](https://git.nockal.com/meleki/game-maker/wiki/Sounds) | Sound assets and clips, `Play sound`, buses and the Master routing, sound FX |
|
||||
| [Instruments](https://git.nockal.com/meleki/game-maker/wiki/Instruments) | Instruments as node graphs, Chips and presets, the keys of the Instrument tab, `Play note` |
|
||||
| [Patterns](https://git.nockal.com/meleki/game-maker/wiki/Patterns) | Patterns and the piano roll, playing one in a loop or from a game |
|
||||
| [Songs](https://git.nockal.com/meleki/game-maker/wiki/Songs) | Songs, their tracks and items, routing, recording, playing one from a game |
|
||||
| [Animations](https://git.nockal.com/meleki/game-maker/wiki/Animations) | Animation assets and the Animation tab |
|
||||
| [Curves](https://git.nockal.com/meleki/game-maker/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` |
|
||||
| [Shaders](https://git.nockal.com/meleki/game-maker/wiki/Shaders) | Shader graphs that sprites, texts, particle fields and cameras wear, their node set, passes, shader functions |
|
||||
| [Particles](https://git.nockal.com/meleki/game-maker/wiki/Particles) | Particle graphs: a particle as a function of its age |
|
||||
| [State machines](https://git.nockal.com/meleki/game-maker/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` |
|
||||
| [Containers](https://git.nockal.com/meleki/game-maker/wiki/Containers) | Templates, instances, screens |
|
||||
| [Tiles](https://git.nockal.com/meleki/game-maker/wiki/Tiles) | The tile asset, plain tiles and the two autotile kinds, the pictures as animation steps, the Tile tab |
|
||||
| [Tilemaps](https://git.nockal.com/meleki/game-maker/wiki/Tilemaps) | The tilemap asset, painting in the scene view, layers, what it looks like in the game |
|
||||
| [Plugins](https://git.nockal.com/meleki/game-maker/wiki/Plugins) | Extending the editor: the plugin file, how it is loaded, switching one on and off, API v1 |
|
||||
| [Keyboard](https://git.nockal.com/meleki/game-maker/wiki/Keyboard) | Every shortcut |
|
||||
| [Architecture](https://git.nockal.com/meleki/game-maker/wiki/Architecture) | Stack, source layout, the rules that keep it organised |
|
||||
| [Development](https://git.nockal.com/meleki/game-maker/wiki/Development) | Commands, the e2e suite, the example games, CI |
|
||||
| [API](https://git.nockal.com/meleki/game-maker/wiki/API) | The PHP JSON API and the project document |
|
||||
| [Concepts](https://git.nockal.com/meleki/feuerwerk/wiki/Concepts) | Assets, scene objects, overrides, play mode, the camera, the console |
|
||||
| [Accounts](https://git.nockal.com/meleki/feuerwerk/wiki/Accounts) | Signing in and out, the accounts, who a project belongs to, what the check tools need |
|
||||
| [Scenes](https://git.nockal.com/meleki/feuerwerk/wiki/Scenes) | The edited scene, the start scene, and switching scenes from a graph |
|
||||
| [Scene editing](https://git.nockal.com/meleki/feuerwerk/wiki/Scene-editing) | Placing, z-order, hierarchy, the editor offset |
|
||||
| [Behaviors](https://git.nockal.com/meleki/feuerwerk/wiki/Behaviors) | Node graphs: variables, the node set, structs and lists, loops, nesting, editing |
|
||||
| [Structs](https://git.nockal.com/meleki/feuerwerk/wiki/Structs) | Struct assets: a named shape that struct variables, fields and `Pack`/`Expand` follow |
|
||||
| [Colliders](https://git.nockal.com/meleki/feuerwerk/wiki/Colliders) | Shapes (box, circle, capsule, polygon), collision events, blocking movement, one-way colliders and raycasts |
|
||||
| [Physics](https://git.nockal.com/meleki/feuerwerk/wiki/Physics) | Rigidbodies, the collider material, gravity, the fixed step, joints |
|
||||
| [Textures](https://git.nockal.com/meleki/feuerwerk/wiki/Textures) | Images, frames, nine-slice, the filter, upload limits |
|
||||
| [Vector art](https://git.nockal.com/meleki/feuerwerk/wiki/Vector-Art) | Pictures drawn from shapes: the pen, curves, gradients and texture fills, combining shapes |
|
||||
| [Pixel art](https://git.nockal.com/meleki/feuerwerk/wiki/Pixel-Art) | Pictures painted pixel by pixel: the palette, the tools, layers and frames |
|
||||
| [Fonts](https://git.nockal.com/meleki/feuerwerk/wiki/Fonts) | Font assets, the picker, what a text falls back to |
|
||||
| [Sounds](https://git.nockal.com/meleki/feuerwerk/wiki/Sounds) | Sound assets and clips, `Play sound`, buses and the Master routing, sound FX |
|
||||
| [Instruments](https://git.nockal.com/meleki/feuerwerk/wiki/Instruments) | Instruments as node graphs, Chips and presets, the keys of the Instrument tab, `Play note` |
|
||||
| [Patterns](https://git.nockal.com/meleki/feuerwerk/wiki/Patterns) | Patterns and the piano roll, playing one in a loop or from a game |
|
||||
| [Songs](https://git.nockal.com/meleki/feuerwerk/wiki/Songs) | Songs, their tracks and items, routing, recording, playing one from a game |
|
||||
| [Videos](https://git.nockal.com/meleki/feuerwerk/wiki/Videos) | Video assets and clips, wearing one on a sprite, recording the game view or the editor |
|
||||
| [Animations](https://git.nockal.com/meleki/feuerwerk/wiki/Animations) | Animation assets and the Animation tab |
|
||||
| [Curves](https://git.nockal.com/meleki/feuerwerk/wiki/Curves) | Easings drawn by hand, the Curve tab, using one on a key or a `Lerp` |
|
||||
| [Shaders](https://git.nockal.com/meleki/feuerwerk/wiki/Shaders) | Shader graphs that sprites, texts, particle fields and cameras wear, their node set, passes, shader functions |
|
||||
| [Particles](https://git.nockal.com/meleki/feuerwerk/wiki/Particles) | Particle graphs: a particle as a function of its age |
|
||||
| [State machines](https://git.nockal.com/meleki/feuerwerk/wiki/State-machines) | The asset, named transitions, `State branch`, `Set state` and `State transition` |
|
||||
| [Containers](https://git.nockal.com/meleki/feuerwerk/wiki/Containers) | Templates, instances, screens |
|
||||
| [Compositions](https://git.nockal.com/meleki/feuerwerk/wiki/Compositions) | Objects, videos and sounds laid out in time, the Composition tab, playing one in a game |
|
||||
| [Tiles](https://git.nockal.com/meleki/feuerwerk/wiki/Tiles) | The tile asset, plain tiles and the two autotile kinds, the pictures as animation steps, the Tile tab |
|
||||
| [Tilemaps](https://git.nockal.com/meleki/feuerwerk/wiki/Tilemaps) | The tilemap asset, painting in the scene view, layers, what it looks like in the game |
|
||||
| [Plugins](https://git.nockal.com/meleki/feuerwerk/wiki/Plugins) | Extending the editor: the plugin file, how it is loaded, switching one on and off, API v1 |
|
||||
| [Keyboard](https://git.nockal.com/meleki/feuerwerk/wiki/Keyboard) | Every shortcut |
|
||||
| [Architecture](https://git.nockal.com/meleki/feuerwerk/wiki/Architecture) | Stack, source layout, the rules that keep it organised |
|
||||
| [Development](https://git.nockal.com/meleki/feuerwerk/wiki/Development) | Commands, the e2e suite, the example games, CI |
|
||||
| [API](https://git.nockal.com/meleki/feuerwerk/wiki/API) | The PHP JSON API and the project document |
|
||||
|
||||
## Ideas, gaps and bugs
|
||||
|
||||
They live in the issue tracker: <https://git.nockal.com/meleki/game-maker/issues>. What is not
|
||||
there yet is listed on the wiki's [Home](https://git.nockal.com/meleki/game-maker/wiki) page.
|
||||
They live in the issue tracker: <https://git.nockal.com/meleki/feuerwerk/issues>. What is not
|
||||
there yet is listed on the wiki's [Home](https://git.nockal.com/meleki/feuerwerk/wiki) page.
|
||||
@@ -2,18 +2,36 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Game Maker API.
|
||||
* Feuerwerk API.
|
||||
*
|
||||
* Routing uses the `r` query parameter (e.g. `index.php?r=projects/abc123`) because the
|
||||
* site's nginx configuration does not pass PATH_INFO to PHP.
|
||||
*
|
||||
* GET ?r=auth/session → {user, role} (both null while nobody is signed in)
|
||||
* POST ?r=auth/login {username,password} → {user, role} | 401
|
||||
* POST ?r=auth/login {username,password} → {user, role} | 401 | 429
|
||||
* POST ?r=auth/logout → 204
|
||||
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422
|
||||
* POST ?r=auth/password {currentPassword,newPassword} → 204 | 401 | 422 | 429
|
||||
* (the only auth route that needs a session; ends the account's
|
||||
* other logins, keeps the one asking)
|
||||
*
|
||||
* Five wrong passwords from one address within 15 minutes, and it gets 429 with `Retry-After` for
|
||||
* the next 15 (see LoginThrottle, #427). Wrong agent tokens count alike.
|
||||
*
|
||||
* The MCP relay (#429, tools/mcp/) asks two routes of its own, with the key both sides read from
|
||||
* `data/mcp-relay.key` in `X-Relay-Key`; without it they answer 404 like a route that is not there:
|
||||
*
|
||||
* POST ?r=agent/verify {token,address} → {user, role} | 401 | 429
|
||||
* (`address` is the agent's, which the relay saw; the brake counts it)
|
||||
* POST ?r=agent/redeem {ticket} → {user, role} | 401
|
||||
*
|
||||
* A request that changes something (anything but GET) is refused with 403 when the browser says it
|
||||
* comes from another site, `Sec-Fetch-Site` being `cross-site` or `same-site`: the session cookie is
|
||||
* SameSite=Lax, and every subdomain of the site counts as the same site to it. Tools send no such
|
||||
* header and their own cookie.
|
||||
*
|
||||
* An account takes up at most 1 GB with its uploads, projects and snapshots (see Quota); a write that
|
||||
* would make it larger is refused with 413. Administrators have no limit.
|
||||
*
|
||||
* Everything below needs a signed-in user (401 otherwise), and projects belong to one: a project
|
||||
* of somebody else answers like one that is not there – unless the one asking is an administrator
|
||||
* (#179), who reads and saves it like their own; a save keeps its owner. Deleting a project is
|
||||
@@ -28,6 +46,15 @@ declare(strict_types=1);
|
||||
* (renaming or deleting signs the account out everywhere; a new
|
||||
* password ends its other sessions)
|
||||
*
|
||||
* Agent tokens (#429) open the relay for an agent of the account and nothing else; a ticket gets a
|
||||
* tab onto it (see AgentTokens). Neither is for a guest:
|
||||
*
|
||||
* GET ?r=agent-tokens → [{id,name,createdAt,expiresAt,usedAt}], oldest first
|
||||
* POST ?r=agent-tokens {name,days?} → 201 {id,name,createdAt,expiresAt,usedAt,token} | 409 | 422
|
||||
* (the one answer the token is in; `days` 1–365, 90 without)
|
||||
* DELETE ?r=agent-tokens/{id} → 204 | 404
|
||||
* POST ?r=agent/ticket → {ticket, expiresIn}
|
||||
*
|
||||
* GET ?r=projects → [{id,name,revision,updatedAt}]
|
||||
* POST ?r=projects {name} → {id,name,revision,updatedAt,project}
|
||||
* GET ?r=projects/{id} → {id,name,revision,updatedAt,project}
|
||||
@@ -47,10 +74,19 @@ declare(strict_types=1);
|
||||
* DELETE ?r=projects/{id}/release → 204 | 404
|
||||
* (one link per project, kept when publishing again, #266)
|
||||
*
|
||||
* Released games are the one thing open to everybody, the token being the key:
|
||||
* Released games are open to everybody, the token being the key:
|
||||
*
|
||||
* GET ?r=releases/{token} → {name,publishedAt,project} | 404
|
||||
*
|
||||
* So are the demo copies a guest opens in the editor (#428), frozen copies like a release that an
|
||||
* administrator makes and updates per project:
|
||||
*
|
||||
* GET ?r=demos → [{token,name,publishedAt}], by name
|
||||
* GET ?r=demos/{token} → {name,publishedAt,project} | 404
|
||||
* GET ?r=projects/{id}/demo → {token,revision,publishedAt} | null (administrators only)
|
||||
* POST ?r=projects/{id}/demo → {token,revision,publishedAt} (administrators only)
|
||||
* DELETE ?r=projects/{id}/demo → 204 | 404 (administrators only)
|
||||
*
|
||||
* GET ?r=textures → [{file,name,width,height,bytes,updatedAt,owner,deletable}]
|
||||
* POST ?r=textures&name=x.png raw image bytes → {file,name,width,height,bytes}
|
||||
* DELETE ?r=textures/{file} → 204 | 403 | 404 | 409 (still used)
|
||||
@@ -63,34 +99,59 @@ declare(strict_types=1);
|
||||
* POST ?r=sounds&name=x.wav raw sound bytes → {file,name,bytes}
|
||||
* DELETE ?r=sounds/{file} → 204 | 403 | 404 | 409
|
||||
*
|
||||
* GET ?r=videos → [{file,name,status,…,original,owner,deletable}]
|
||||
* POST ?r=videos&name=x.mov[&convert=0] raw video bytes → {file,name,status,…}
|
||||
* POST ?r=videos/{file}/convert → {file,name,status,…} | 403 | 404
|
||||
* DELETE ?r=videos/{file} → 204 | 403 | 404 | 409
|
||||
* (an upload is converted by the worker in `feuerwerk-video` (#402):
|
||||
* `status` is queued, converting with `progress`, failed with `error`,
|
||||
* or ready with width, height, duration, fps, audio and `poster`;
|
||||
* with `convert=0` it is only kept, `unconverted`, until it is asked
|
||||
* to be. `original` is the upload as it came, null for a video
|
||||
* uploaded before uploads were kept)
|
||||
*
|
||||
* Uploads land in the uploader's own folder, and `file` carries it: `{folder}/{sha256}.{ext}`
|
||||
* (#344). A `file` without a folder belongs to nobody. A list shows the files of the one asking and
|
||||
* nobody's, an administrator's every file; `owner` is the account a file belongs to (null for
|
||||
* nobody, '' for a deleted account). A file goes only at its owner's or an administrator's hand,
|
||||
* and only while no project, snapshot or release uses it.
|
||||
* and only while no project, snapshot, release or demo copy uses it.
|
||||
*
|
||||
* An administrator offers a file to everybody as demo material (#428); every list carries those
|
||||
* files too, with `demo: true`, and without a session a list holds nothing else. Only an
|
||||
* administrator deletes one while it is offered.
|
||||
*
|
||||
* PATCH ?r=textures/{file} {demo} → 204 | 403 | 404 | 422 (administrators only)
|
||||
* PATCH ?r=fonts/{file} {demo} → the same
|
||||
* PATCH ?r=sounds/{file} {demo} → the same
|
||||
* PATCH ?r=videos/{file} {demo} → the same
|
||||
*
|
||||
* POST ?r=pixels raw cel bytes → {file,bytes} (pixel art, #130)
|
||||
*
|
||||
* GET ?r=plugins → [{file,bytes,updatedAt}], sorted by file name
|
||||
* GET ?r=plugins/{file} → the module source as text/javascript
|
||||
* (both without a session as well: a guest's demo may switch one on, #428)
|
||||
*
|
||||
* `name` is the file's original name, kept beside it as `{file}.json` (see OriginalName) and
|
||||
* empty when none is known. It is a caption, never a path: the stored file is always `{hash}.{ext}`
|
||||
* in a folder.
|
||||
*
|
||||
* Stored images, fonts, sounds and cels are served straight from `data/textures/`, `data/fonts/`,
|
||||
* `data/sounds/` and `data/pixels/` as static files; the API only writes them.
|
||||
* Stored images, fonts, sounds, videos and cels are served straight from `data/textures/`, `data/fonts/`,
|
||||
* `data/sounds/`, `data/videos/` and `data/pixels/` as static files; the API only writes them.
|
||||
*/
|
||||
|
||||
require __DIR__ . '/lib/AccountRules.php';
|
||||
require __DIR__ . '/lib/AgentTokens.php';
|
||||
require __DIR__ . '/lib/AtomicFile.php';
|
||||
require __DIR__ . '/lib/Auth.php';
|
||||
require __DIR__ . '/lib/DemoFiles.php';
|
||||
require __DIR__ . '/lib/FileUsage.php';
|
||||
require __DIR__ . '/lib/HashedFileStore.php'; // before the font, sound and texture stores built on it
|
||||
require __DIR__ . '/lib/FontStore.php';
|
||||
require __DIR__ . '/lib/LoginThrottle.php';
|
||||
require __DIR__ . '/lib/OriginalName.php';
|
||||
require __DIR__ . '/lib/PixelStore.php';
|
||||
require __DIR__ . '/lib/PluginStore.php';
|
||||
require __DIR__ . '/lib/Quota.php';
|
||||
require __DIR__ . '/lib/ReleaseStore.php';
|
||||
require __DIR__ . '/lib/Response.php';
|
||||
require __DIR__ . '/lib/SessionStore.php';
|
||||
@@ -99,8 +160,13 @@ require __DIR__ . '/lib/SoundStore.php';
|
||||
require __DIR__ . '/lib/Store.php';
|
||||
require __DIR__ . '/lib/TextureStore.php';
|
||||
require __DIR__ . '/lib/UserStore.php';
|
||||
require __DIR__ . '/lib/VideoQueue.php';
|
||||
require __DIR__ . '/lib/VideoStore.php';
|
||||
|
||||
/** Has to clear the largest single upload below, which is a sound; the web server has its own say. */
|
||||
/**
|
||||
* Has to clear the largest upload held in memory, which is a sound; a video is streamed to disk and
|
||||
* has its own ceiling. The web server and the proxy in front of it have their own say.
|
||||
*/
|
||||
const MAX_BODY_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/** Upload ceiling for a single image. The editor downscales larger pictures before sending. */
|
||||
@@ -112,6 +178,26 @@ const MAX_FONT_BYTES = 4 * 1024 * 1024;
|
||||
/** The same for a sound, far higher: a recorded take is plain WAV, about 46 minutes in mono. */
|
||||
const MAX_SOUND_BYTES = 256 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* A video goes up as it was recorded and is made smaller by the worker, not on the way. It is
|
||||
* streamed to disk, so memory is no limit; nginx and the Brandmauer let 2 GB through (#402).
|
||||
*/
|
||||
const MAX_VIDEO_BYTES = 2048 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Where everything the API keeps lies, and how much of it an account may fill. The rights test
|
||||
* (tests/php/rights.test.php) runs the API under `php -S` on a scratch directory with a small quota;
|
||||
* behind nginx neither can be moved.
|
||||
*/
|
||||
define('DATA_DIR', testSetting('FEUERWERK_DATA') ?? dirname(__DIR__) . '/data');
|
||||
define('QUOTA_BYTES', (int) (testSetting('FEUERWERK_QUOTA_BYTES') ?? Quota::BYTES));
|
||||
|
||||
function testSetting(string $name): ?string
|
||||
{
|
||||
$value = PHP_SAPI === 'cli-server' ? getenv($name) : false;
|
||||
return is_string($value) && $value !== '' ? $value : null;
|
||||
}
|
||||
|
||||
set_error_handler(static function (int $severity, string $message, string $file, int $line): bool {
|
||||
// PHP calls the handler for a call silenced with `@` as well, only with less reported: that
|
||||
// warning was expected and is answered where it happens (an unreadable image is a 422).
|
||||
@@ -125,6 +211,7 @@ set_error_handler(static function (int $severity, string $message, string $file,
|
||||
function routeAuth(array $parts, string $method, Auth $auth): never
|
||||
{
|
||||
$what = $parts[1] ?? '';
|
||||
$throttle = new LoginThrottle(DATA_DIR . '/sign-ins');
|
||||
|
||||
if ($what === 'session') {
|
||||
if ($method !== 'GET') {
|
||||
@@ -141,8 +228,10 @@ function routeAuth(array $parts, string $method, Auth $auth): never
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$body = Response::body();
|
||||
refuseWhileThrottled($throttle);
|
||||
$user = $auth->logIn((string) ($body['username'] ?? ''), (string) ($body['password'] ?? ''));
|
||||
if ($user === null) {
|
||||
countFailure($throttle);
|
||||
// One message for both halves: which of the two was wrong is the attacker's question.
|
||||
Response::error('Wrong user name or password', 401);
|
||||
}
|
||||
@@ -161,9 +250,13 @@ function routeAuth(array $parts, string $method, Auth $auth): never
|
||||
if (!UserStore::isValidPassword($new)) {
|
||||
Response::error('The new password needs at least ' . UserStore::MIN_PASSWORD_LENGTH . ' characters', 422);
|
||||
}
|
||||
// Guessing the current password from an open tab is guessing all the same.
|
||||
refuseWhileThrottled($throttle);
|
||||
if (!$auth->changePassword($user, (string) ($body['currentPassword'] ?? ''), $new)) {
|
||||
countFailure($throttle);
|
||||
Response::error('Wrong password', 401);
|
||||
}
|
||||
agentTokens()->endAllOf($user);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -178,10 +271,47 @@ function routeAuth(array $parts, string $method, Auth $auth): never
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeProjects(array $parts, string $method, string $owner, bool $seesAll): never
|
||||
/** The address a request came from: nginx puts the one the Brandmauer saw there (#427). */
|
||||
function clientAddress(): string
|
||||
{
|
||||
$store = new Store(dirname(__DIR__) . '/data/projects', $owner, $seesAll);
|
||||
return (string) ($_SERVER['REMOTE_ADDR'] ?? '');
|
||||
}
|
||||
|
||||
function countFailure(LoginThrottle $throttle, ?string $address = null): void
|
||||
{
|
||||
$throttle->fail($address ?? clientAddress());
|
||||
// Now and then, so the folder holds the addresses of the last quarter of an hour and no more.
|
||||
if (random_int(1, 20) === 1) {
|
||||
$throttle->prune();
|
||||
}
|
||||
}
|
||||
|
||||
function refuseWhileThrottled(LoginThrottle $throttle, ?string $address = null): void
|
||||
{
|
||||
$wait = $throttle->waitFor($address ?? clientAddress());
|
||||
if ($wait === 0) {
|
||||
return;
|
||||
}
|
||||
header('Retry-After: ' . $wait);
|
||||
Response::error('Too many failed sign-ins. Try again in ' . (int) ceil($wait / 60) . ' minutes', 429);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bytes a project takes up as stored, 0 for one that is not there.
|
||||
*/
|
||||
function projectBytes(string $id): int
|
||||
{
|
||||
$path = DATA_DIR . "/projects/{$id}.json";
|
||||
return is_file($path) ? (int) filesize($path) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $parts
|
||||
* @param Closure(int):void $fits answers 413 when that many more bytes do not fit the quota
|
||||
*/
|
||||
function routeProjects(array $parts, string $method, string $owner, bool $seesAll, Closure $fits): never
|
||||
{
|
||||
$store = new Store(DATA_DIR . '/projects', $owner, $seesAll);
|
||||
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
@@ -193,6 +323,8 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
if ($name === '' || mb_strlen($name) > 120) {
|
||||
Response::error('Project name must be 1–120 characters', 422);
|
||||
}
|
||||
// What an empty project takes up, give or take its name.
|
||||
$fits(1024);
|
||||
Response::json($store->create($name), 201);
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
@@ -203,14 +335,22 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
Response::error('Invalid project id', 400);
|
||||
}
|
||||
|
||||
$snapshots = new SnapshotStore(dirname(__DIR__) . '/data/snapshots');
|
||||
$snapshots = new SnapshotStore(DATA_DIR . '/snapshots');
|
||||
if (($parts[2] ?? null) === 'snapshots') {
|
||||
routeSnapshots($parts, $method, $store, $snapshots, $id);
|
||||
routeSnapshots($parts, $method, $store, $snapshots, $id, $fits);
|
||||
}
|
||||
$releases = new ReleaseStore(dirname(__DIR__) . '/data/releases');
|
||||
$releases = new ReleaseStore(DATA_DIR . '/releases');
|
||||
if (($parts[2] ?? null) === 'release' && count($parts) === 3) {
|
||||
routeRelease($method, $store, $releases, $id);
|
||||
}
|
||||
$demos = new ReleaseStore(DATA_DIR . '/demos');
|
||||
if (($parts[2] ?? null) === 'demo' && count($parts) === 3) {
|
||||
// What guests get to see is the administrators' call (#428).
|
||||
if (!$seesAll) {
|
||||
Response::error('Administrators only', 403);
|
||||
}
|
||||
routeRelease($method, $store, $demos, $id, 'Project has no demo copy');
|
||||
}
|
||||
if (count($parts) > 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
@@ -228,6 +368,11 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
if (!isset($body['revision']) || !is_int($body['revision']) || !is_array($body['project'] ?? null)) {
|
||||
Response::error('Body must contain integer "revision" and object "project"', 422);
|
||||
}
|
||||
// Asked after whether the project is there for them: a 413 would tell a stranger it is.
|
||||
if ($store->load($id) === null) {
|
||||
Response::error('Project not found', 404);
|
||||
}
|
||||
$fits((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) - projectBytes($id));
|
||||
try {
|
||||
$result = $store->save($id, $body['revision'], $body['project']);
|
||||
} catch (StaleRevisionException $e) {
|
||||
@@ -250,6 +395,7 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
}
|
||||
$snapshots->deleteAll($id);
|
||||
$releases->takeDown($id);
|
||||
$demos->takeDown($id);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -260,9 +406,13 @@ function routeProjects(array $parts, string $method, string $owner, bool $seesAl
|
||||
* The snapshots of one project (#231). Whoever may load the project may see, make, restore and
|
||||
* delete its snapshots; for everybody else the project, and with it this route, is not there.
|
||||
*
|
||||
* A snapshot copies the project, and so does restoring one (the stand it replaces is kept), so both
|
||||
* count against the quota with the project's size.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
* @param Closure(int):void $fits
|
||||
*/
|
||||
function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id): never
|
||||
function routeSnapshots(array $parts, string $method, Store $store, SnapshotStore $snapshots, string $id, Closure $fits): never
|
||||
{
|
||||
$doc = $store->load($id);
|
||||
if ($doc === null) {
|
||||
@@ -275,6 +425,7 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$name = snapshotName(Response::body());
|
||||
$fits(projectBytes($id));
|
||||
Response::json($snapshots->create($id, $doc, $name === '' ? gmdate('Y-m-d H:i') . ' UTC' : $name), 201);
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
@@ -307,6 +458,7 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor
|
||||
if (!isset($body['revision']) || !is_int($body['revision'])) {
|
||||
Response::error('Body must contain integer "revision"', 422);
|
||||
}
|
||||
$fits(projectBytes($id));
|
||||
// The same guard a save has: a tab that is behind must not throw away what another one saved.
|
||||
if ($body['revision'] !== $doc['revision']) {
|
||||
Response::error('Project was modified elsewhere', 409, ['revision' => $doc['revision']]);
|
||||
@@ -327,11 +479,17 @@ function routeSnapshots(array $parts, string $method, Store $store, SnapshotStor
|
||||
}
|
||||
|
||||
/**
|
||||
* The released copy of one project (#266). Whoever may load the project may publish it, update it
|
||||
* and take it down; publishing copies the project as stored, not what a tab has not saved yet.
|
||||
* The released copy of one project (#266), or its demo copy (#428). Whoever may load the project may
|
||||
* publish it, update it and take it down; publishing copies the project as stored, not what a tab
|
||||
* has not saved yet.
|
||||
*/
|
||||
function routeRelease(string $method, Store $store, ReleaseStore $releases, string $id): never
|
||||
{
|
||||
function routeRelease(
|
||||
string $method,
|
||||
Store $store,
|
||||
ReleaseStore $releases,
|
||||
string $id,
|
||||
string $none = 'Project is not released',
|
||||
): never {
|
||||
$doc = $store->load($id);
|
||||
if ($doc === null) {
|
||||
Response::error('Project not found', 404);
|
||||
@@ -344,26 +502,32 @@ function routeRelease(string $method, Store $store, ReleaseStore $releases, stri
|
||||
}
|
||||
if ($method === 'DELETE') {
|
||||
if (!$releases->takeDown($id)) {
|
||||
Response::error('Project is not released', 404);
|
||||
Response::error($none, 404);
|
||||
}
|
||||
Response::noContent();
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
/** The one route open without a sign-in: a released game, found by its token. */
|
||||
function routeReleases(array $parts, string $method): never
|
||||
/**
|
||||
* What is open without a sign-in: a released game (#266) and a demo copy (#428), each found by its
|
||||
* token. The demos are listed as well, for a guest to pick one.
|
||||
*/
|
||||
function routeCopies(array $parts, string $method, ReleaseStore $copies, string $missing, bool $listed): never
|
||||
{
|
||||
if ($method !== 'GET' || count($parts) !== 2) {
|
||||
if ($method !== 'GET' || count($parts) > 2 || (count($parts) === 1 && !$listed)) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
$release = (new ReleaseStore(dirname(__DIR__) . '/data/releases'))->load($parts[1]);
|
||||
if ($release === null) {
|
||||
Response::error('Game not found', 404);
|
||||
if (count($parts) === 1) {
|
||||
Response::json($copies->list());
|
||||
}
|
||||
$copy = $copies->load($parts[1]);
|
||||
if ($copy === null) {
|
||||
Response::error($missing, 404);
|
||||
}
|
||||
// The copy goes out under the token, not the project's own id: that one is nobody's business.
|
||||
$project = ['id' => $release['token']] + $release['project'];
|
||||
Response::json(['name' => $release['name'], 'publishedAt' => $release['publishedAt'], 'project' => $project]);
|
||||
$project = ['id' => $copy['token']] + $copy['project'];
|
||||
Response::json(['name' => $copy['name'], 'publishedAt' => $copy['publishedAt'], 'project' => $project]);
|
||||
}
|
||||
|
||||
function snapshotName(array $body): string
|
||||
@@ -375,11 +539,112 @@ function snapshotName(array $body): string
|
||||
return $name;
|
||||
}
|
||||
|
||||
function agentTokens(): AgentTokens
|
||||
{
|
||||
return new AgentTokens(DATA_DIR . '/agent-tokens', DATA_DIR . '/agent-tickets');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the request is the MCP relay's (#429): it carries the key that lies in `data/` for both
|
||||
* sides. Nobody else can read that folder, nginx serves none of it.
|
||||
*/
|
||||
function fromRelay(): bool
|
||||
{
|
||||
$path = DATA_DIR . '/mcp-relay.key';
|
||||
$key = is_file($path) ? trim((string) file_get_contents($path)) : '';
|
||||
$given = (string) ($_SERVER['HTTP_X_RELAY_KEY'] ?? '');
|
||||
return strlen($key) >= 32 && hash_equals($key, $given);
|
||||
}
|
||||
|
||||
/**
|
||||
* The relay asks who is behind an agent's token or a tab's ticket. A wrong token is a wrong password
|
||||
* to the brake, counted against the agent's address, not the relay's.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
*/
|
||||
function routeRelay(array $parts, string $method, Auth $auth): never
|
||||
{
|
||||
if (!fromRelay() || count($parts) !== 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
if ($method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$body = Response::body();
|
||||
$tokens = agentTokens();
|
||||
if ($parts[1] === 'verify') {
|
||||
$throttle = new LoginThrottle(DATA_DIR . '/sign-ins');
|
||||
$address = (string) ($body['address'] ?? '');
|
||||
refuseWhileThrottled($throttle, $address);
|
||||
$user = $tokens->verify((string) ($body['token'] ?? ''));
|
||||
if ($user === null) {
|
||||
countFailure($throttle, $address);
|
||||
Response::error('Unknown or expired agent token', 401);
|
||||
}
|
||||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||||
}
|
||||
if ($parts[1] === 'redeem') {
|
||||
$user = $tokens->redeemTicket((string) ($body['ticket'] ?? ''));
|
||||
if ($user === null) {
|
||||
Response::error('Unknown or used ticket', 401);
|
||||
}
|
||||
Response::json(['user' => $user, 'role' => $auth->role($user)]);
|
||||
}
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeAgentTokens(array $parts, string $method, string $user): never
|
||||
{
|
||||
$tokens = agentTokens();
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($tokens->listOf($user));
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$body = Response::body();
|
||||
$name = (string) ($body['name'] ?? '');
|
||||
$days = $body['days'] ?? AgentTokens::DEFAULT_DAYS;
|
||||
if (!AgentTokens::isValidName($name)) {
|
||||
Response::error('A token name is 1–60 characters', 422);
|
||||
}
|
||||
if (!is_int($days) || $days < 1 || $days > AgentTokens::MAX_DAYS) {
|
||||
Response::error('A token lasts 1–' . AgentTokens::MAX_DAYS . ' days', 422);
|
||||
}
|
||||
try {
|
||||
Response::json($tokens->create($user, $name, $days), 201);
|
||||
} catch (TooManyAgentTokensException $e) {
|
||||
Response::error($e->getMessage(), 409);
|
||||
}
|
||||
}
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
if ($method !== 'DELETE') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
if (!$tokens->revoke($user, $parts[1])) {
|
||||
Response::error('Agent token not found', 404);
|
||||
}
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeAgent(array $parts, string $method, string $user): never
|
||||
{
|
||||
if (($parts[1] ?? '') !== 'ticket' || count($parts) !== 2) {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
if ($method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
Response::json(['ticket' => agentTokens()->issueTicket($user), 'expiresIn' => AgentTokens::TICKET_SECONDS]);
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routeUsers(array $parts, string $method, Auth $auth, UserStore $users): never
|
||||
{
|
||||
$actor = $auth->requireAdmin();
|
||||
$projects = new Store(dirname(__DIR__) . '/data/projects', $actor, true);
|
||||
$projects = new Store(DATA_DIR . '/projects', $actor, true);
|
||||
$shown = static function (string $name) use ($users): array {
|
||||
foreach ($users->accounts() as $account) {
|
||||
if (strcasecmp($account['username'], $name) === 0) {
|
||||
@@ -458,6 +723,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
|
||||
if ($password !== null) {
|
||||
$auth->resetPassword($target, $password);
|
||||
agentTokens()->endAllOf($target);
|
||||
}
|
||||
if ($newRole !== null && $newRole !== $role) {
|
||||
$users->setRole($target, $newRole);
|
||||
@@ -467,6 +733,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
$users->rename($name, $newName);
|
||||
$projects->transfer($name, $newName);
|
||||
$auth->endSessionsOf($name);
|
||||
agentTokens()->endAllOf($name);
|
||||
$name = $newName;
|
||||
}
|
||||
Response::json($shown($name));
|
||||
@@ -483,6 +750,7 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
}
|
||||
$users->remove($target);
|
||||
$auth->endSessionsOf($target);
|
||||
agentTokens()->endAllOf($target);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
@@ -493,10 +761,12 @@ function routeUsers(array $parts, string $method, Auth $auth, UserStore $users):
|
||||
* Images, fonts and sounds, stored under the hash of their bytes in a folder per account (#344).
|
||||
* Everybody signed in uploads into their own folder and lists their own files and nobody's; an
|
||||
* administrator lists every file. Deleting one is for its owner or an administrator, and refused
|
||||
* while a project of any account still uses it.
|
||||
* while a project of any account still uses it. What an administrator offers as demo material
|
||||
* (#428) is in every list.
|
||||
*
|
||||
* @param list<string> $parts
|
||||
* @param class-string<RuntimeException> $invalid what the store throws for bytes it cannot take
|
||||
* @param Closure(int):void $fits
|
||||
*/
|
||||
function routeFiles(
|
||||
array $parts,
|
||||
@@ -508,18 +778,32 @@ function routeFiles(
|
||||
int $maxBytes,
|
||||
string $noun,
|
||||
string $invalid,
|
||||
Closure $fits,
|
||||
): never {
|
||||
$kind = $parts[0];
|
||||
$demos = new DemoFiles(DATA_DIR . '/demo-files.json');
|
||||
if (count($parts) === 1) {
|
||||
if ($method === 'GET') {
|
||||
Response::json($store->list($users->folderOf($user), $isAdmin, $users->folderOwners()));
|
||||
Response::json(filesOffered($store, $kind, $user, $isAdmin, $users));
|
||||
}
|
||||
if ($method === 'POST') {
|
||||
$bytes = Response::rawBody();
|
||||
if (strlen($bytes) > $maxBytes) {
|
||||
Response::error("{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB', 413);
|
||||
}
|
||||
$tooLarge = "{$noun} must be at most " . ($maxBytes / 1024 / 1024) . ' MB';
|
||||
$name = (string) ($_GET['name'] ?? '');
|
||||
$fits((int) ($_SERVER['CONTENT_LENGTH'] ?? 0));
|
||||
try {
|
||||
Response::json($store->store($bytes, (string) ($_GET['name'] ?? ''), $users->folderOf($user, true)), 201);
|
||||
// A video is too big for memory and goes from the request straight to disk.
|
||||
if ($store instanceof VideoStore) {
|
||||
$in = fopen('php://input', 'rb');
|
||||
$convert = ($_GET['convert'] ?? '1') !== '0';
|
||||
Response::json($store->storeStream($in, $maxBytes, $name, $users->folderOf($user, true), $convert), 201);
|
||||
}
|
||||
$bytes = Response::rawBody();
|
||||
if (strlen($bytes) > $maxBytes) {
|
||||
Response::error($tooLarge, 413);
|
||||
}
|
||||
Response::json($store->store($bytes, $name, $users->folderOf($user, true)), 201);
|
||||
} catch (VideoTooLargeException) {
|
||||
Response::error($tooLarge, 413);
|
||||
} catch (RuntimeException $e) {
|
||||
if (!$e instanceof $invalid) {
|
||||
throw $e;
|
||||
@@ -530,6 +814,36 @@ function routeFiles(
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
// A video kept without being converted is converted later, at its owner's word.
|
||||
if ($store instanceof VideoStore && $method === 'POST' && end($parts) === 'convert') {
|
||||
$file = implode('/', array_slice($parts, 1, -1));
|
||||
$folder = HashedFileStore::folderOf($file);
|
||||
if (!$isAdmin && ($folder === '' || $folder !== $users->folderOf($user))) {
|
||||
Response::error('Only its owner may convert this video', 403);
|
||||
}
|
||||
$record = $store->convert($file);
|
||||
if ($record === null) {
|
||||
Response::error("{$noun} not found", 404);
|
||||
}
|
||||
Response::json($record);
|
||||
}
|
||||
|
||||
if ($method === 'PATCH') {
|
||||
$file = implode('/', array_slice($parts, 1));
|
||||
if (!$isAdmin) {
|
||||
Response::error('Administrators only', 403);
|
||||
}
|
||||
if (!$store->exists($file)) {
|
||||
Response::error("{$noun} not found", 404);
|
||||
}
|
||||
$body = Response::body();
|
||||
if (!is_bool($body['demo'] ?? null)) {
|
||||
Response::error('Body must contain boolean "demo"', 422);
|
||||
}
|
||||
$demos->set($kind, $file, $body['demo']);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
if ($method === 'DELETE') {
|
||||
$file = implode('/', array_slice($parts, 1));
|
||||
// Asked before whether there is one: somebody else's folder tells a stranger nothing.
|
||||
@@ -540,24 +854,63 @@ function routeFiles(
|
||||
if (!$store->exists($file)) {
|
||||
Response::error("{$noun} not found", 404);
|
||||
}
|
||||
$uses = (new FileUsage(dirname(__DIR__) . '/data'))->of($file);
|
||||
// Everybody may have it in a project by now; whether that ends is the administrators' call.
|
||||
if (!$isAdmin && $demos->has($kind, $file)) {
|
||||
Response::error('Offered to everybody as demo material: only an administrator can take that back', 409);
|
||||
}
|
||||
$uses = (new FileUsage(DATA_DIR))->of($file);
|
||||
if ($uses !== []) {
|
||||
Response::error(FileUsage::refusal($uses, $user, $isAdmin), 409);
|
||||
}
|
||||
$store->delete($file);
|
||||
$demos->set($kind, $file, false);
|
||||
Response::noContent();
|
||||
}
|
||||
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a picker of `$kind` lists: the files of the one asking and nobody's, an administrator's every
|
||||
* file (#344), and everything offered as demo material (#428), with `demo` saying which those are.
|
||||
* Signed out (`$user` null) it is the demo material alone, without who uploaded it.
|
||||
*
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
function filesOffered(HashedFileStore $store, string $kind, ?string $user, bool $isAdmin, UserStore $users): array
|
||||
{
|
||||
$demo = (new DemoFiles(DATA_DIR . '/demo-files.json'))->of($kind);
|
||||
$owners = $users->folderOwners();
|
||||
$items = $user === null ? [] : $store->list($users->folderOf($user), $isAdmin, $owners);
|
||||
if ($demo !== [] && !$isAdmin) {
|
||||
$listed = array_column($items, 'file');
|
||||
$hidden = $user === null ? ['owner' => null] : [];
|
||||
foreach ($store->list('', true, $owners) as $item) {
|
||||
if (in_array($item['file'], $demo, true) && !in_array($item['file'], $listed, true)) {
|
||||
$items[] = ['deletable' => false, ...$hidden] + $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
return array_map(static fn(array $item): array => $item + ['demo' => in_array($item['file'], $demo, true)], $items);
|
||||
}
|
||||
|
||||
function fileStore(string $kind): HashedFileStore
|
||||
{
|
||||
return match ($kind) {
|
||||
'textures' => new TextureStore(DATA_DIR . '/textures'),
|
||||
'fonts' => new FontStore(DATA_DIR . '/fonts'),
|
||||
'sounds' => new SoundStore(DATA_DIR . '/sounds'),
|
||||
'videos' => new VideoStore(DATA_DIR . '/videos'),
|
||||
};
|
||||
}
|
||||
|
||||
/** @param list<string> $parts */
|
||||
function routePixels(array $parts, string $method): never
|
||||
{
|
||||
if (count($parts) !== 1 || $method !== 'POST') {
|
||||
Response::error('Method not allowed', 405);
|
||||
}
|
||||
$store = new PixelStore(dirname(__DIR__) . '/data/pixels');
|
||||
$store = new PixelStore(DATA_DIR . '/pixels');
|
||||
$bytes = Response::rawBody();
|
||||
if (strlen($bytes) > PixelStore::MAX_BYTES) {
|
||||
Response::error('A cel is at most ' . PixelStore::MAX_BYTES . ' bytes', 413);
|
||||
@@ -572,7 +925,7 @@ function routePixels(array $parts, string $method): never
|
||||
/** @param list<string> $parts */
|
||||
function routePlugins(array $parts, string $method): never
|
||||
{
|
||||
$store = new PluginStore(dirname(__DIR__) . '/data/plugins');
|
||||
$store = new PluginStore(DATA_DIR . '/plugins');
|
||||
|
||||
if (count($parts) === 1) {
|
||||
if ($method !== 'GET') {
|
||||
@@ -600,18 +953,26 @@ function routePlugins(array $parts, string $method): never
|
||||
}
|
||||
|
||||
try {
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > MAX_BODY_BYTES) {
|
||||
Response::error('Request body too large', 413);
|
||||
}
|
||||
|
||||
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
|
||||
$route = trim((string) ($_GET['r'] ?? ''), '/');
|
||||
$parts = $route === '' ? [] : explode('/', $route);
|
||||
|
||||
$site = (string) ($_SERVER['HTTP_SEC_FETCH_SITE'] ?? '');
|
||||
if ($method !== 'GET' && $method !== 'HEAD' && ($site === 'cross-site' || $site === 'same-site')) {
|
||||
Response::error('Requests from other sites are refused', 403);
|
||||
}
|
||||
|
||||
$maxBody = ($parts[0] ?? '') === 'videos' ? MAX_VIDEO_BYTES : MAX_BODY_BYTES;
|
||||
if ((int) ($_SERVER['CONTENT_LENGTH'] ?? 0) > $maxBody) {
|
||||
Response::error('Request body too large', 413);
|
||||
}
|
||||
|
||||
// A project reaches deepest, `projects/{id}/snapshots/{snapshotId}/restore`; a stored file is
|
||||
// `textures/{folder}/{file}`; everything else stops at two parts.
|
||||
// `textures/{folder}/{file}`, a video to convert `videos/{folder}/{file}/convert`; everything
|
||||
// else stops at two parts.
|
||||
$depth = match ($parts[0] ?? '') {
|
||||
'projects' => 5,
|
||||
'videos' => 4,
|
||||
'textures', 'fonts', 'sounds' => 3,
|
||||
default => 2,
|
||||
};
|
||||
@@ -619,8 +980,8 @@ try {
|
||||
Response::error('Not found', 404);
|
||||
}
|
||||
|
||||
$users = new UserStore(dirname(__DIR__) . '/data/users.json');
|
||||
$auth = new Auth($users, new SessionStore(dirname(__DIR__) . '/data/sessions'));
|
||||
$users = new UserStore(DATA_DIR . '/users.json');
|
||||
$auth = new Auth($users, new SessionStore(DATA_DIR . '/sessions'));
|
||||
if ($parts[0] === 'auth') {
|
||||
routeAuth($parts, $method, $auth);
|
||||
}
|
||||
@@ -628,26 +989,50 @@ try {
|
||||
routeUsers($parts, $method, $auth, $users);
|
||||
}
|
||||
if ($parts[0] === 'releases') {
|
||||
routeReleases($parts, $method);
|
||||
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/releases'), 'Game not found', false);
|
||||
}
|
||||
if ($parts[0] === 'demos') {
|
||||
routeCopies($parts, $method, new ReleaseStore(DATA_DIR . '/demos'), 'Demo not found', true);
|
||||
}
|
||||
if ($parts[0] === 'agent' && in_array($parts[1] ?? '', ['verify', 'redeem'], true)) {
|
||||
routeRelay($parts, $method, $auth);
|
||||
}
|
||||
// A guest's editor runs the plugins a demo switches on (#428); they are the server's own code,
|
||||
// put there by its administrator, and only to be read.
|
||||
if ($parts[0] === 'plugins' && $method === 'GET') {
|
||||
routePlugins($parts, $method);
|
||||
}
|
||||
// A guest's pickers list the demo material without a session (#428), and nothing else.
|
||||
if (in_array($parts[0], DemoFiles::KINDS, true) && count($parts) === 1 && $method === 'GET' && $auth->user() === null) {
|
||||
Response::json(filesOffered(fileStore($parts[0]), $parts[0], null, false, $users));
|
||||
}
|
||||
// Being signed in is the price of admission for everything below, so an uploaded file cannot be
|
||||
// dropped in by a stranger.
|
||||
$user = $auth->require();
|
||||
$isAdmin = $auth->role($user) === 'admin';
|
||||
$data = dirname(__DIR__) . '/data';
|
||||
$fits = static function (int $adding) use ($user, $isAdmin, $users): void {
|
||||
if ($isAdmin) {
|
||||
return;
|
||||
}
|
||||
$refusal = (new Quota(DATA_DIR, QUOTA_BYTES))->refusal($user, $users->folderOf($user), $adding);
|
||||
if ($refusal !== null) {
|
||||
Response::error($refusal, 413);
|
||||
}
|
||||
};
|
||||
|
||||
match ($parts[0]) {
|
||||
'projects' => routeProjects($parts, $method, $user, $isAdmin),
|
||||
'projects' => routeProjects($parts, $method, $user, $isAdmin, $fits),
|
||||
'textures' => routeFiles(
|
||||
$parts,
|
||||
$method,
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
new TextureStore("{$data}/textures"),
|
||||
fileStore('textures'),
|
||||
MAX_TEXTURE_BYTES,
|
||||
'Image',
|
||||
InvalidImageException::class,
|
||||
$fits,
|
||||
),
|
||||
'fonts' => routeFiles(
|
||||
$parts,
|
||||
@@ -655,10 +1040,11 @@ try {
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
new FontStore("{$data}/fonts"),
|
||||
fileStore('fonts'),
|
||||
MAX_FONT_BYTES,
|
||||
'Font',
|
||||
InvalidFontException::class,
|
||||
$fits,
|
||||
),
|
||||
'sounds' => routeFiles(
|
||||
$parts,
|
||||
@@ -666,16 +1052,31 @@ try {
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
new SoundStore("{$data}/sounds"),
|
||||
fileStore('sounds'),
|
||||
MAX_SOUND_BYTES,
|
||||
'Sound',
|
||||
InvalidSoundException::class,
|
||||
$fits,
|
||||
),
|
||||
'videos' => routeFiles(
|
||||
$parts,
|
||||
$method,
|
||||
$user,
|
||||
$isAdmin,
|
||||
$users,
|
||||
fileStore('videos'),
|
||||
MAX_VIDEO_BYTES,
|
||||
'Video',
|
||||
InvalidVideoException::class,
|
||||
$fits,
|
||||
),
|
||||
'pixels' => routePixels($parts, $method),
|
||||
'plugins' => routePlugins($parts, $method),
|
||||
'agent-tokens' => routeAgentTokens($parts, $method, $user),
|
||||
'agent' => routeAgent($parts, $method, $user),
|
||||
default => Response::error('Not found', 404),
|
||||
};
|
||||
} catch (Throwable $e) {
|
||||
error_log('[game-maker api] ' . $e->getMessage());
|
||||
error_log('[feuerwerk api] ' . $e->getMessage());
|
||||
Response::error('Internal server error', 500);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The keys of the MCP relay (#429): agent tokens, which an account makes for an agent such as
|
||||
* Claude Code, and tickets, with which an editor tab gets onto the relay.
|
||||
*
|
||||
* A token is shown once, when it is made, and kept as `{dir}/{sha256(token)}.json` like a session:
|
||||
* the file on disk cannot be replayed as the token. It opens the relay and nothing else, the API
|
||||
* takes no token. It ends at its expiry, at its owner's word, and with every login of the account
|
||||
* when the password changes or the account is renamed or deleted.
|
||||
*
|
||||
* A ticket is what a tab gets from the API with its session cookie, and hands to the relay on its
|
||||
* WebSocket: the cookie belongs to the API's folder and never reaches the relay. It lasts a minute
|
||||
* and is taken once.
|
||||
*/
|
||||
final class AgentTokens
|
||||
{
|
||||
public const PREFIX = 'fwa_';
|
||||
public const DEFAULT_DAYS = 90;
|
||||
public const MAX_DAYS = 365;
|
||||
public const MAX_PER_ACCOUNT = 20;
|
||||
public const TICKET_SECONDS = 60;
|
||||
|
||||
/** How often a use is written down: every request of an agent would cost a write otherwise. */
|
||||
private const USE_RESOLUTION_SECONDS = 60;
|
||||
|
||||
/** @param (Closure():int)|null $clock the tests' way to move time on */
|
||||
public function __construct(
|
||||
private readonly string $dir,
|
||||
private readonly string $ticketDir,
|
||||
private readonly ?Closure $clock = null,
|
||||
) {}
|
||||
|
||||
public static function isValidName(string $name): bool
|
||||
{
|
||||
return trim($name) !== '' && mb_strlen($name) <= 60;
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a token for `$user`. The answer is the only place the token itself ever appears.
|
||||
*
|
||||
* @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:null,token:string}
|
||||
*/
|
||||
public function create(string $user, string $name, int $days): array
|
||||
{
|
||||
if (count($this->listOf($user)) >= self::MAX_PER_ACCOUNT) {
|
||||
throw new TooManyAgentTokensException('An account has at most ' . self::MAX_PER_ACCOUNT . ' agent tokens');
|
||||
}
|
||||
$token = self::PREFIX . bin2hex(random_bytes(32));
|
||||
$now = $this->now();
|
||||
$record = [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'username' => $user,
|
||||
'name' => trim($name),
|
||||
'createdAt' => $now,
|
||||
'expiresAt' => $now + max(1, min(self::MAX_DAYS, $days)) * 86400,
|
||||
'usedAt' => null,
|
||||
];
|
||||
$this->write($this->dir . '/' . hash('sha256', $token) . '.json', $record);
|
||||
return self::shown($record) + ['token' => $token];
|
||||
}
|
||||
|
||||
/**
|
||||
* The tokens of `$user` that have not expired, oldest first, without the tokens themselves.
|
||||
*
|
||||
* @return list<array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}>
|
||||
*/
|
||||
public function listOf(string $user): array
|
||||
{
|
||||
$found = [];
|
||||
foreach ($this->records() as $record) {
|
||||
if (strcasecmp($record['username'], $user) === 0) {
|
||||
$found[] = self::shown($record);
|
||||
}
|
||||
}
|
||||
usort($found, static fn(array $a, array $b): int => $a['createdAt'] <=> $b['createdAt']);
|
||||
return $found;
|
||||
}
|
||||
|
||||
/** Ends one token of `$user`; false when they have none by that id. */
|
||||
public function revoke(string $user, string $id): bool
|
||||
{
|
||||
foreach ($this->records() as $path => $record) {
|
||||
if ($record['id'] === $id && strcasecmp($record['username'], $user) === 0) {
|
||||
return @unlink($path);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Ends every token of `$user`, and answers how many there were. */
|
||||
public function endAllOf(string $user): int
|
||||
{
|
||||
$ended = 0;
|
||||
foreach ($this->records() as $path => $record) {
|
||||
if (strcasecmp($record['username'], $user) === 0 && @unlink($path)) {
|
||||
$ended++;
|
||||
}
|
||||
}
|
||||
return $ended;
|
||||
}
|
||||
|
||||
/** The account a token belongs to, or null when it is unknown, malformed or expired. */
|
||||
public function verify(string $token): ?string
|
||||
{
|
||||
if (!preg_match('/^' . self::PREFIX . '[0-9a-f]{64}$/', $token)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->dir . '/' . hash('sha256', $token) . '.json';
|
||||
$record = self::read($path);
|
||||
if ($record === null) {
|
||||
return null;
|
||||
}
|
||||
$now = $this->now();
|
||||
if ($record['expiresAt'] <= $now) {
|
||||
@unlink($path);
|
||||
return null;
|
||||
}
|
||||
if ($record['usedAt'] === null || $now - $record['usedAt'] >= self::USE_RESOLUTION_SECONDS) {
|
||||
$record['usedAt'] = $now;
|
||||
$this->write($path, $record);
|
||||
}
|
||||
return $record['username'];
|
||||
}
|
||||
|
||||
/** A ticket for `$user`'s tab to get onto the relay with. */
|
||||
public function issueTicket(string $user): string
|
||||
{
|
||||
$this->sweepTickets();
|
||||
$ticket = bin2hex(random_bytes(32));
|
||||
$this->write($this->ticketDir . '/' . hash('sha256', $ticket) . '.json', [
|
||||
'username' => $user,
|
||||
'createdAt' => $this->now(),
|
||||
]);
|
||||
return $ticket;
|
||||
}
|
||||
|
||||
/** The account a ticket was issued to, once: a second redemption finds nothing. */
|
||||
public function redeemTicket(string $ticket): ?string
|
||||
{
|
||||
if (!preg_match('/^[0-9a-f]{64}$/', $ticket)) {
|
||||
return null;
|
||||
}
|
||||
$path = $this->ticketDir . '/' . hash('sha256', $ticket) . '.json';
|
||||
// Moved aside before it is read: of two redemptions at once, one gets the file.
|
||||
$taken = $path . '.taken-' . bin2hex(random_bytes(4));
|
||||
if (!@rename($path, $taken)) {
|
||||
return null;
|
||||
}
|
||||
$raw = file_get_contents($taken);
|
||||
@unlink($taken);
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
if (!is_array($data) || !is_string($data['username'] ?? null) || !is_int($data['createdAt'] ?? null)) {
|
||||
return null;
|
||||
}
|
||||
return $this->now() - $data['createdAt'] <= self::TICKET_SECONDS ? $data['username'] : null;
|
||||
}
|
||||
|
||||
/** Throws out the tickets nobody redeemed. */
|
||||
private function sweepTickets(): void
|
||||
{
|
||||
foreach (glob($this->ticketDir . '/*.json*') ?: [] as $path) {
|
||||
if ($this->now() - (int) @filemtime($path) > self::TICKET_SECONDS) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Every stored token by its path; expired ones are dropped on the way.
|
||||
*
|
||||
* @return array<string,array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}>
|
||||
*/
|
||||
private function records(): array
|
||||
{
|
||||
$records = [];
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
|
||||
$record = self::read($path);
|
||||
if ($record === null) {
|
||||
continue;
|
||||
}
|
||||
if ($record['expiresAt'] <= $this->now()) {
|
||||
@unlink($path);
|
||||
continue;
|
||||
}
|
||||
$records[$path] = $record;
|
||||
}
|
||||
return $records;
|
||||
}
|
||||
|
||||
/** @return array{id:string,username:string,name:string,createdAt:int,expiresAt:int,usedAt:?int}|null */
|
||||
private static function read(string $path): ?array
|
||||
{
|
||||
$raw = is_file($path) ? file_get_contents($path) : false;
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
if (
|
||||
!is_array($data)
|
||||
|| !is_string($data['id'] ?? null)
|
||||
|| !is_string($data['username'] ?? null)
|
||||
|| !is_string($data['name'] ?? null)
|
||||
|| !is_int($data['createdAt'] ?? null)
|
||||
|| !is_int($data['expiresAt'] ?? null)
|
||||
|| !(is_int($data['usedAt'] ?? null) || ($data['usedAt'] ?? null) === null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @return array{id:string,name:string,createdAt:int,expiresAt:int,usedAt:?int} */
|
||||
private static function shown(array $record): array
|
||||
{
|
||||
return [
|
||||
'id' => $record['id'],
|
||||
'name' => $record['name'],
|
||||
'createdAt' => $record['createdAt'],
|
||||
'expiresAt' => $record['expiresAt'],
|
||||
'usedAt' => $record['usedAt'],
|
||||
];
|
||||
}
|
||||
|
||||
private function write(string $path, array $data): void
|
||||
{
|
||||
$dir = dirname($path);
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException("Cannot create {$dir}");
|
||||
}
|
||||
AtomicFile::write($path, (string) json_encode($data, JSON_UNESCAPED_SLASHES), AtomicFile::HIDDEN, 'agent token');
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
return $this->clock === null ? time() : ($this->clock)();
|
||||
}
|
||||
}
|
||||
|
||||
final class TooManyAgentTokensException extends RuntimeException {}
|
||||
@@ -109,24 +109,34 @@ final class Auth
|
||||
return (string) ($_COOKIE[SessionStore::COOKIE] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* The cookie has no `Path`: the browser takes the folder of the API as it sees it, which is
|
||||
* `/feuerwerk/api` on nockal.com and `/api` on feuerwerk.nockal.com. nginx sees the second as
|
||||
* the first, so no path this side writes down would be right for both. The editor's pages and
|
||||
* the data never needed the cookie.
|
||||
*
|
||||
* Before that, the cookie was set for the whole site (`/feuerwerk/`). Such an old one is ended
|
||||
* with every new one, or it would still sign a browser in after signing out.
|
||||
*/
|
||||
private function setCookie(string $value, int $expires): void
|
||||
{
|
||||
setcookie(SessionStore::COOKIE, $value, [
|
||||
$options = [
|
||||
'expires' => $expires,
|
||||
'path' => self::basePath(),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Lax',
|
||||
'secure' => ($_SERVER['HTTPS'] ?? '') !== '',
|
||||
]);
|
||||
];
|
||||
setcookie(SessionStore::COOKIE, $value, $options);
|
||||
setcookie(SessionStore::COOKIE, '', ['expires' => time() - 3600, 'path' => self::basePath()] + $options);
|
||||
}
|
||||
|
||||
/**
|
||||
* The path the editor is served under (`/game-maker/`), read off the script rather than
|
||||
* The path the old cookie was set for (`/feuerwerk/`), read off the script rather than
|
||||
* written down: the same checkout answers under a different prefix in CI.
|
||||
*/
|
||||
private static function basePath(): string
|
||||
{
|
||||
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/game-maker/api/index.php');
|
||||
$script = (string) ($_SERVER['SCRIPT_NAME'] ?? '/feuerwerk/api/index.php');
|
||||
$base = dirname(dirname($script));
|
||||
return $base === '/' || $base === '\\' || $base === '.' ? '/' : $base . '/';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Uploaded files an administrator offers to everybody as demo material (#428): every account's
|
||||
* picker lists them beside its own, and a guest's picker lists nothing else. The files stay where
|
||||
* they were uploaded, in their owner's folder; this only says which of them are offered.
|
||||
*
|
||||
* data/demo-files.json { "textures": ["{folder}/{sha256}.png", …], "sounds": […], … }
|
||||
*
|
||||
* Written 0640 like everything that is not a file to hand out.
|
||||
*/
|
||||
final class DemoFiles
|
||||
{
|
||||
public const KINDS = ['textures', 'fonts', 'sounds', 'videos'];
|
||||
|
||||
public function __construct(private readonly string $path) {}
|
||||
|
||||
/** @return list<string> the offered files of one kind, in the order they were offered */
|
||||
public function of(string $kind): array
|
||||
{
|
||||
$files = $this->read()[$kind] ?? [];
|
||||
return array_values(array_filter($files, 'is_string'));
|
||||
}
|
||||
|
||||
public function has(string $kind, string $file): bool
|
||||
{
|
||||
return in_array($file, $this->of($kind), true);
|
||||
}
|
||||
|
||||
/** Offers a file or takes the offer back; answers whether that changed anything. */
|
||||
public function set(string $kind, string $file, bool $offered): bool
|
||||
{
|
||||
if (!in_array($kind, self::KINDS, true)) {
|
||||
throw new InvalidArgumentException("Unknown kind: {$kind}");
|
||||
}
|
||||
$all = $this->read();
|
||||
$files = $this->of($kind);
|
||||
$has = in_array($file, $files, true);
|
||||
if ($has === $offered) {
|
||||
return false;
|
||||
}
|
||||
$all[$kind] = $offered ? [...$files, $file] : array_values(array_diff($files, [$file]));
|
||||
$json = json_encode($all, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||||
if ($json === false) {
|
||||
throw new RuntimeException('Cannot encode the demo files');
|
||||
}
|
||||
AtomicFile::write($this->path, $json . "\n", AtomicFile::HIDDEN, 'demo file list');
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string,list<string>> */
|
||||
private function read(): array
|
||||
{
|
||||
if (!is_file($this->path)) {
|
||||
return [];
|
||||
}
|
||||
$raw = file_get_contents($this->path);
|
||||
$data = $raw === false ? null : json_decode($raw, true);
|
||||
return is_array($data) ? $data : [];
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,9 @@ declare(strict_types=1);
|
||||
/**
|
||||
* Which projects still use a stored image, font or sound (#344) – asked before one is deleted.
|
||||
*
|
||||
* A project counts when the file is named in the project itself, in one of its snapshots or in its
|
||||
* release: restoring a snapshot or playing a released game must not find a hole. Any string value
|
||||
* A project counts when the file is named in the project itself, in one of its snapshots, in its
|
||||
* release or in its demo copy: restoring a snapshot, playing a released game or trying a demo must
|
||||
* not find a hole. Any string value
|
||||
* equal to the stored name counts, wherever in the document it is; that may refuse a file nothing
|
||||
* really draws, but never lets one go that something does.
|
||||
*/
|
||||
@@ -31,7 +32,8 @@ final class FileUsage
|
||||
$used[basename(dirname($path))] = true;
|
||||
}
|
||||
}
|
||||
foreach (glob("{$this->data}/releases/*.json") ?: [] as $path) {
|
||||
// A demo copy (#428) is a release a guest opens in the editor, and stored like one.
|
||||
foreach ([...glob("{$this->data}/releases/*.json") ?: [], ...glob("{$this->data}/demos/*.json") ?: []] as $path) {
|
||||
$release = $this->decoded($path, $file);
|
||||
if ($release !== null && is_string($release['projectId'] ?? null) && self::uses($release['project'] ?? null, $file)) {
|
||||
$used[$release['projectId']] = true;
|
||||
@@ -74,7 +76,7 @@ final class FileUsage
|
||||
$named[] = $others === 1 ? 'a project of another account' : "{$others} projects of other accounts";
|
||||
}
|
||||
$list = count($named) > 1 ? implode(', ', array_slice($named, 0, -1)) . ' and ' . end($named) : $named[0];
|
||||
return "Still used by {$list}, counting snapshots and releases";
|
||||
return "Still used by {$list}, counting snapshots, releases and demo copies";
|
||||
}
|
||||
|
||||
/** Whether a value holds `$file` as a string anywhere inside it. */
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The brake on guessing passwords (#427): five failed sign-ins from one address within 15 minutes,
|
||||
* and that address is refused for 15 minutes after the last of them. The right password is refused
|
||||
* as well while it lasts, or guessing would only have to wait for its hit. A success does not wipe
|
||||
* the count either: with an account of one's own, that would be the way to reset it.
|
||||
*
|
||||
* `{dir}/{sha256(address)}.json` holds the times of the recent failures, written 0640 like
|
||||
* everything else that is private. The address is the one nginx found behind the Brandmauer.
|
||||
*/
|
||||
final class LoginThrottle
|
||||
{
|
||||
public const MAX_FAILURES = 5;
|
||||
public const WINDOW_SECONDS = 15 * 60;
|
||||
|
||||
/** @param (Closure():int)|null $clock the tests' way to move time on */
|
||||
public function __construct(
|
||||
private readonly string $dir,
|
||||
private readonly ?Closure $clock = null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Seconds until another attempt counts, 0 when one may be made now.
|
||||
*
|
||||
* @param list<int> $failures when the recent failures were
|
||||
*/
|
||||
public static function wait(array $failures, int $now): int
|
||||
{
|
||||
$times = self::recent($failures, $now);
|
||||
sort($times);
|
||||
$until = 0;
|
||||
for ($i = self::MAX_FAILURES - 1; $i < count($times); $i++) {
|
||||
if ($times[$i] - $times[$i - self::MAX_FAILURES + 1] < self::WINDOW_SECONDS) {
|
||||
$until = $times[$i] + self::WINDOW_SECONDS;
|
||||
}
|
||||
}
|
||||
return max(0, $until - $now);
|
||||
}
|
||||
|
||||
/** Seconds until `$address` may try again, 0 when it may now. */
|
||||
public function waitFor(string $address): int
|
||||
{
|
||||
$path = $this->path($address);
|
||||
$raw = is_file($path) ? file_get_contents($path) : false;
|
||||
return self::wait($raw === false ? [] : self::decode($raw), $this->now());
|
||||
}
|
||||
|
||||
/** Counts a failed attempt of `$address` and answers the wait that follows from it. */
|
||||
public function fail(string $address): int
|
||||
{
|
||||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||||
throw new RuntimeException('Cannot create the directory of failed sign-ins');
|
||||
}
|
||||
$path = $this->path($address);
|
||||
$handle = fopen($path, 'c+');
|
||||
if ($handle === false) {
|
||||
throw new RuntimeException('Cannot count a failed sign-in');
|
||||
}
|
||||
try {
|
||||
chmod($path, AtomicFile::HIDDEN);
|
||||
flock($handle, LOCK_EX);
|
||||
$now = $this->now();
|
||||
$failures = self::recent(self::decode((string) stream_get_contents($handle)), $now);
|
||||
$failures[] = $now;
|
||||
ftruncate($handle, 0);
|
||||
rewind($handle);
|
||||
fwrite($handle, (string) json_encode(['failures' => $failures]));
|
||||
fflush($handle);
|
||||
return self::wait($failures, $now);
|
||||
} finally {
|
||||
flock($handle, LOCK_UN);
|
||||
fclose($handle);
|
||||
}
|
||||
}
|
||||
|
||||
/** Removes the files of addresses whose failures are all too old to matter. */
|
||||
public function prune(): int
|
||||
{
|
||||
// PHP keeps what it learned of a file for the rest of the request; a time read earlier
|
||||
// would keep a file that has gone stale since, or drop one written since.
|
||||
clearstatcache();
|
||||
$removed = 0;
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
|
||||
if ((int) filemtime($path) < $this->now() - 2 * self::WINDOW_SECONDS && unlink($path)) {
|
||||
$removed++;
|
||||
}
|
||||
}
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failures that can still matter: five of them lie within one window, and the lock lasts one
|
||||
* window after the last, so nothing older than two windows can.
|
||||
*
|
||||
* @param list<int> $failures
|
||||
* @return list<int>
|
||||
*/
|
||||
private static function recent(array $failures, int $now): array
|
||||
{
|
||||
return array_values(array_filter($failures, static fn(int $at): bool => $at > $now - 2 * self::WINDOW_SECONDS));
|
||||
}
|
||||
|
||||
/** @return list<int> */
|
||||
private static function decode(string $raw): array
|
||||
{
|
||||
$data = json_decode($raw, true);
|
||||
$failures = is_array($data) && is_array($data['failures'] ?? null) ? $data['failures'] : [];
|
||||
return array_values(array_map('intval', array_filter($failures, 'is_int')));
|
||||
}
|
||||
|
||||
private function path(string $address): string
|
||||
{
|
||||
return $this->dir . '/' . hash('sha256', $address) . '.json';
|
||||
}
|
||||
|
||||
private function now(): int
|
||||
{
|
||||
return $this->clock === null ? time() : ($this->clock)();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* How much of the disk an account may take (#427): 1 GB for its uploads, its projects and their
|
||||
* snapshots together. Administrators have no limit; the router does not ask for them.
|
||||
*
|
||||
* Only a write that makes an account larger is refused, so one that is full can still save a smaller
|
||||
* project or delete files. Pixel cels are kept apart: they belong to nobody and are small by rule
|
||||
* (`PixelStore::MAX_BYTES`).
|
||||
*/
|
||||
final class Quota
|
||||
{
|
||||
public const BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
/** The upload folders that are counted, each with a folder per account. */
|
||||
private const KINDS = ['textures', 'fonts', 'sounds', 'videos'];
|
||||
|
||||
public function __construct(
|
||||
private readonly string $data,
|
||||
private readonly int $limit = self::BYTES,
|
||||
) {}
|
||||
|
||||
/** Bytes `$owner` takes up: everything in their upload folder, their projects and their snapshots. */
|
||||
public function usage(string $owner, string $folder): int
|
||||
{
|
||||
$bytes = 0;
|
||||
if (preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $folder)) {
|
||||
foreach (self::KINDS as $kind) {
|
||||
$bytes += self::sizeOf("{$this->data}/{$kind}/{$folder}");
|
||||
}
|
||||
}
|
||||
$projects = new Store("{$this->data}/projects", $owner);
|
||||
foreach ($projects->listOf($owner) as $project) {
|
||||
$bytes += (int) @filesize("{$this->data}/projects/{$project['id']}.json");
|
||||
$bytes += self::sizeOf("{$this->data}/snapshots/{$project['id']}");
|
||||
}
|
||||
return $bytes;
|
||||
}
|
||||
|
||||
/** Why `$adding` more bytes do not fit, or null when they do (or add nothing). */
|
||||
public function refusal(string $owner, string $folder, int $adding): ?string
|
||||
{
|
||||
if ($adding <= 0) {
|
||||
return null;
|
||||
}
|
||||
$used = $this->usage($owner, $folder);
|
||||
if ($used + $adding <= $this->limit) {
|
||||
return null;
|
||||
}
|
||||
return sprintf(
|
||||
'Not enough space: this account uses %s of its %s',
|
||||
self::megabytes($used),
|
||||
self::megabytes($this->limit),
|
||||
);
|
||||
}
|
||||
|
||||
private static function megabytes(int $bytes): string
|
||||
{
|
||||
return number_format($bytes / 1024 / 1024, $bytes < 10 * 1024 * 1024 ? 1 : 0, '.', '') . ' MB';
|
||||
}
|
||||
|
||||
private static function sizeOf(string $dir): int
|
||||
{
|
||||
if (!is_dir($dir)) {
|
||||
return 0;
|
||||
}
|
||||
$bytes = 0;
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
|
||||
foreach ($files as $file) {
|
||||
if ($file->isFile()) {
|
||||
$bytes += $file->getSize();
|
||||
}
|
||||
}
|
||||
return $bytes;
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ declare(strict_types=1);
|
||||
*
|
||||
* Like the snapshots, the store checks nothing about who asks: the router reaches the project half
|
||||
* only after `Store::load` found the project for that user.
|
||||
*
|
||||
* Demo copies (#428) are the same thing on `data/demos/`: a frozen copy a guest opens in the editor
|
||||
* instead of the player, made and updated by an administrator.
|
||||
*/
|
||||
final class ReleaseStore
|
||||
{
|
||||
@@ -82,6 +85,25 @@ final class ReleaseStore
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Every copy there is, by name: what a guest picks a demo from (#428), which is this store on a
|
||||
* directory of its own.
|
||||
*
|
||||
* @return list<array{token:string,name:string,publishedAt:string}>
|
||||
*/
|
||||
public function list(): array
|
||||
{
|
||||
$items = [];
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
|
||||
$release = $this->load(basename($path, '.json'));
|
||||
if ($release !== null) {
|
||||
$items[] = ['token' => $release['token'], 'name' => $release['name'], 'publishedAt' => $release['publishedAt']];
|
||||
}
|
||||
}
|
||||
usort($items, static fn(array $a, array $b): int => strnatcasecmp($a['name'], $b['name']));
|
||||
return $items;
|
||||
}
|
||||
|
||||
/** Makes the link dead. False when the project had none. */
|
||||
public function takeDown(string $projectId): bool
|
||||
{
|
||||
|
||||
@@ -14,7 +14,7 @@ final class SessionStore
|
||||
/** How long a login lasts without being used; every request that finds it pushes it out again. */
|
||||
public const LIFETIME_SECONDS = 30 * 24 * 60 * 60;
|
||||
|
||||
public const COOKIE = 'game_maker_session';
|
||||
public const COOKIE = 'feuerwerk_session';
|
||||
|
||||
public function __construct(private readonly string $dir) {}
|
||||
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The one format every uploaded video is turned into (#402), as plain rules: what ffprobe said in,
|
||||
* the ffmpeg arguments out. Nothing here runs a process, so all of it is testable without ffmpeg.
|
||||
*
|
||||
* The target is MP4 with H.264 and AAC, `yuv420p`, at a constant frame rate and with a keyframe
|
||||
* every 15 frames: the editor seeks to clip starts and loops back to them, and a seek can only land
|
||||
* on a keyframe quickly. The picture fits into 3840 × 2160 (2160 × 3840 standing up), so a 4K
|
||||
* recording of the editor stays 4K, and is never made larger than it came.
|
||||
*/
|
||||
final class VideoFormat
|
||||
{
|
||||
public const MAX_LONG_SIDE = 3840;
|
||||
public const MAX_SHORT_SIDE = 2160;
|
||||
public const MAX_FPS = 60;
|
||||
public const KEYFRAME_EVERY = 15;
|
||||
/** Used when a file does not say its frame rate at all. */
|
||||
private const FALLBACK_FPS = '30';
|
||||
|
||||
/**
|
||||
* What the conversion makes of a probed file: the size it is shown at after rotation, scaled to
|
||||
* fit, the frame rate as ffmpeg takes it, and whether there is sound to keep.
|
||||
*
|
||||
* @param array<string,mixed> $probe ffprobe's `-show_streams -show_format` as JSON
|
||||
* @return array{width:int,height:int,fps:string,audio:bool,duration:float}
|
||||
* @throws InvalidArgumentException when there is no picture in it
|
||||
*/
|
||||
public static function plan(array $probe): array
|
||||
{
|
||||
$streams = is_array($probe['streams'] ?? null) ? $probe['streams'] : [];
|
||||
$video = self::picture($probe);
|
||||
$audio = in_array('audio', array_column($streams, 'codec_type'), true);
|
||||
if ($video === null || (int) ($video['width'] ?? 0) <= 0 || (int) ($video['height'] ?? 0) <= 0) {
|
||||
throw new InvalidArgumentException('There is no picture in this file');
|
||||
}
|
||||
|
||||
[$width, $height] = [(int) $video['width'], (int) $video['height']];
|
||||
if (abs(self::rotation($video)) % 180 === 90) {
|
||||
[$width, $height] = [$height, $width];
|
||||
}
|
||||
[$width, $height] = self::fit($width, $height);
|
||||
|
||||
return [
|
||||
'width' => $width,
|
||||
'height' => $height,
|
||||
'fps' => self::fps($video),
|
||||
'audio' => $audio,
|
||||
'duration' => self::duration($probe, $video),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The size inside the box, keeping the shape, both sides even (H.264 in `yuv420p` needs that).
|
||||
*
|
||||
* @return array{0:int,1:int}
|
||||
*/
|
||||
public static function fit(int $width, int $height): array
|
||||
{
|
||||
[$boxW, $boxH] = $width >= $height
|
||||
? [self::MAX_LONG_SIDE, self::MAX_SHORT_SIDE]
|
||||
: [self::MAX_SHORT_SIDE, self::MAX_LONG_SIDE];
|
||||
$scale = min(1.0, $boxW / $width, $boxH / $height);
|
||||
$even = static fn(float $side): int => max(2, 2 * (int) floor(round($side, 3) / 2));
|
||||
return [$even($width * $scale), $even($height * $scale)];
|
||||
}
|
||||
|
||||
/**
|
||||
* The frame rate the file is written at: its own average, unless that is above 60 or unknown.
|
||||
* Kept as ffmpeg's fraction, so 29.97 stays 30000/1001 and frames do not drift against the time.
|
||||
*
|
||||
* @param array<string,mixed> $video
|
||||
*/
|
||||
public static function fps(array $video): string
|
||||
{
|
||||
foreach (['avg_frame_rate', 'r_frame_rate'] as $key) {
|
||||
$rate = self::rate((string) ($video[$key] ?? ''));
|
||||
if ($rate !== null) {
|
||||
return $rate[1] > self::MAX_FPS ? (string) self::MAX_FPS : $rate[0];
|
||||
}
|
||||
}
|
||||
return self::FALLBACK_FPS;
|
||||
}
|
||||
|
||||
/** A fraction such as `30000/1001` as a number of frames per second. */
|
||||
public static function fpsValue(string $fps): float
|
||||
{
|
||||
$rate = self::rate($fps);
|
||||
return $rate === null ? 0.0 : $rate[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{width:int,height:int,fps:string,audio:bool} $plan
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function convertArgs(string $source, string $target, array $plan): array
|
||||
{
|
||||
$args = [
|
||||
'ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
|
||||
'-i', $source,
|
||||
'-map', '0:v:0',
|
||||
];
|
||||
if ($plan['audio']) {
|
||||
$args = [...$args, '-map', '0:a:0'];
|
||||
}
|
||||
$args = [
|
||||
...$args,
|
||||
'-vf', "scale={$plan['width']}:{$plan['height']}:flags=lanczos,setsar=1",
|
||||
'-r', $plan['fps'], '-fps_mode', 'cfr',
|
||||
'-c:v', 'libx264', '-preset', 'veryfast', '-crf', '21',
|
||||
'-pix_fmt', 'yuv420p',
|
||||
'-g', (string) self::KEYFRAME_EVERY, '-keyint_min', (string) self::KEYFRAME_EVERY, '-sc_threshold', '0',
|
||||
];
|
||||
$args = $plan['audio']
|
||||
? [...$args, '-c:a', 'aac', '-b:a', '160k', '-ac', '2', '-ar', '48000']
|
||||
: [...$args, '-an'];
|
||||
return [...$args, '-sn', '-dn', '-map_metadata', '-1', '-movflags', '+faststart', '-progress', 'pipe:1', '-nostats', '-f', 'mp4', $target];
|
||||
}
|
||||
|
||||
/**
|
||||
* A still from a little way in (not the very first frame, which is often black), as wide as the
|
||||
* picker draws it. A JPEG is always read as BT.601 in the full range, so the picture is turned
|
||||
* into that from what the video is; left alone, a red of 255 in BT.709 came out as 229 (#411).
|
||||
*
|
||||
* @param array{matrix:string,range:string} $colour
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function posterArgs(string $video, string $target, float $duration, array $colour): array
|
||||
{
|
||||
$at = sprintf('%.3f', max(0.0, min(1.0, $duration / 2)));
|
||||
$scale = "scale=320:-2:in_color_matrix={$colour['matrix']}:in_range={$colour['range']}"
|
||||
. ':out_color_matrix=bt601:out_range=pc:flags=accurate_rnd+full_chroma_int';
|
||||
return [
|
||||
'ffmpeg', '-nostdin', '-hide_banner', '-loglevel', 'error', '-y',
|
||||
'-ss', $at, '-i', $video, '-frames:v', '1',
|
||||
'-vf', $scale, '-q:v', '4', '-f', 'image2', $target,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How the picture of a probed file is to be read, as ffmpeg's scale filter names it. Without a
|
||||
* word in the file this is Chrome's guess, measured: BT.709 from 720 lines up, BT.601 below.
|
||||
*
|
||||
* @param array<string,mixed> $probe
|
||||
* @return array{matrix:string,range:string}
|
||||
*/
|
||||
public static function colour(array $probe): array
|
||||
{
|
||||
$video = self::picture($probe) ?? [];
|
||||
$matrix = match ($video['color_space'] ?? '') {
|
||||
'bt709' => 'bt709',
|
||||
'smpte170m', 'bt470bg' => 'bt601',
|
||||
'bt2020nc', 'bt2020c' => 'bt2020',
|
||||
'smpte240m' => 'smpte240m',
|
||||
'fcc' => 'fcc',
|
||||
default => (int) ($video['height'] ?? 0) >= 720 ? 'bt709' : 'bt601',
|
||||
};
|
||||
return ['matrix' => $matrix, 'range' => ($video['color_range'] ?? '') === 'pc' ? 'pc' : 'tv'];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public static function probeArgs(string $path): array
|
||||
{
|
||||
return ['ffprobe', '-v', 'error', '-print_format', 'json', '-show_streams', '-show_format', $path];
|
||||
}
|
||||
|
||||
/**
|
||||
* What the editor is told about a converted file, read back from the file itself rather than
|
||||
* the plan: a crop or a rounding inside ffmpeg is what the editor has to draw.
|
||||
*
|
||||
* @param array<string,mixed> $probe
|
||||
* @return array{width:int,height:int,duration:float,fps:float,audio:bool}
|
||||
*/
|
||||
public static function details(array $probe): array
|
||||
{
|
||||
$plan = self::plan($probe);
|
||||
return [
|
||||
'width' => $plan['width'],
|
||||
'height' => $plan['height'],
|
||||
'duration' => round($plan['duration'], 3),
|
||||
'fps' => round(self::fpsValue($plan['fps']), 3),
|
||||
'audio' => $plan['audio'],
|
||||
];
|
||||
}
|
||||
|
||||
/** The share of the work done, from one `out_time_us=` line of ffmpeg's progress. */
|
||||
public static function progress(string $line, float $duration): ?float
|
||||
{
|
||||
if ($duration <= 0 || !preg_match('/^out_time_us=(\d+)$/', trim($line), $m)) {
|
||||
return null;
|
||||
}
|
||||
return max(0.0, min(1.0, ((int) $m[1]) / 1e6 / $duration));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $probe
|
||||
* @return array<string,mixed>|null the first video stream that moves
|
||||
*/
|
||||
private static function picture(array $probe): ?array
|
||||
{
|
||||
foreach (is_array($probe['streams'] ?? null) ? $probe['streams'] : [] as $stream) {
|
||||
// A cover picture in a music file is a video stream as well, one that is a single frame.
|
||||
$still = (int) (($stream['disposition'] ?? [])['attached_pic'] ?? 0) === 1;
|
||||
if (($stream['codec_type'] ?? '') === 'video' && !$still) {
|
||||
return $stream;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $video */
|
||||
private static function rotation(array $video): int
|
||||
{
|
||||
foreach (is_array($video['side_data_list'] ?? null) ? $video['side_data_list'] : [] as $side) {
|
||||
if (isset($side['rotation']) && is_numeric($side['rotation'])) {
|
||||
return (int) $side['rotation'];
|
||||
}
|
||||
}
|
||||
$tag = ($video['tags'] ?? [])['rotate'] ?? null;
|
||||
return is_numeric($tag) ? (int) $tag : 0;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $probe @param array<string,mixed> $video */
|
||||
private static function duration(array $probe, array $video): float
|
||||
{
|
||||
foreach ([$video['duration'] ?? null, ($probe['format'] ?? [])['duration'] ?? null] as $value) {
|
||||
if (is_numeric($value) && (float) $value > 0) {
|
||||
return (float) $value;
|
||||
}
|
||||
}
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
/** @return array{0:string,1:float}|null the fraction as written and its value, null for none */
|
||||
private static function rate(string $text): ?array
|
||||
{
|
||||
if (!preg_match('#^(\d+)(?:/(\d+))?$#', $text, $m)) {
|
||||
return null;
|
||||
}
|
||||
$num = (int) $m[1];
|
||||
$den = isset($m[2]) ? (int) $m[2] : 1;
|
||||
if ($num <= 0 || $den <= 0) {
|
||||
return null;
|
||||
}
|
||||
return [$den === 1 ? (string) $num : "{$num}/{$den}", $num / $den];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* The videos waiting to be converted (#402): `data/videos/queue/{folder}-{hash}.json` says what
|
||||
* became of an upload, and its `source` where the upload lies – kept as it came beside the MP4,
|
||||
* `{folder}/{hash}.source.{ext}`, for downloading it. The API adds to it, the worker in the
|
||||
* container `feuerwerk-video` takes from it, one at a time, oldest first.
|
||||
*
|
||||
* A job is `queued`, `converting` (with `progress` from 0 to 1) or `failed` (with `error`). A done
|
||||
* one is no job any more: its MP4 lies in the account's folder and the job is gone. A failed one
|
||||
* stays, so the picker can say why, until the file is deleted or uploaded again.
|
||||
*
|
||||
* A job from before the uploads were kept has no `source`: its bytes wait hidden in the queue as
|
||||
* `{folder}-{hash}.src` and go once they are converted.
|
||||
*/
|
||||
final class VideoQueue
|
||||
{
|
||||
public const QUEUED = 'queued';
|
||||
public const CONVERTING = 'converting';
|
||||
public const FAILED = 'failed';
|
||||
|
||||
public function __construct(private readonly string $dir)
|
||||
{
|
||||
if (!is_dir($this->dir) && !mkdir($this->dir, 0775, true) && !is_dir($this->dir)) {
|
||||
throw new RuntimeException("Cannot create directory {$this->dir}");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies an upload into a temp file beside the jobs, never more than `$maxBytes` of it: a video
|
||||
* is too big to be held in memory. The caller keeps the file or deletes it.
|
||||
*
|
||||
* @param resource $in
|
||||
* @throws VideoTooLargeException when the stream has more than `$maxBytes`
|
||||
*/
|
||||
public function receive($in, int $maxBytes): string
|
||||
{
|
||||
$tmp = "{$this->dir}/upload-" . bin2hex(random_bytes(8)) . '.tmp';
|
||||
$out = fopen($tmp, 'xb');
|
||||
if ($out === false) {
|
||||
throw new RuntimeException('Cannot write uploaded video');
|
||||
}
|
||||
$copied = stream_copy_to_stream($in, $out, $maxBytes + 1);
|
||||
fclose($out);
|
||||
if ($copied === false || $copied > $maxBytes) {
|
||||
unlink($tmp);
|
||||
throw $copied === false ? new RuntimeException('Cannot write uploaded video') : new VideoTooLargeException();
|
||||
}
|
||||
return $tmp;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queues the upload kept at `$source`, a path inside the videos' directory such as
|
||||
* `{folder}/{hash}.source.mkv`. The upload stays where it is.
|
||||
*/
|
||||
public function add(string $folder, string $hash, string $source, string $name): void
|
||||
{
|
||||
$this->write($folder, $hash, [
|
||||
'folder' => $folder,
|
||||
'hash' => $hash,
|
||||
'source' => $source,
|
||||
'name' => $name,
|
||||
'state' => self::QUEUED,
|
||||
'progress' => 0,
|
||||
'error' => '',
|
||||
'createdAt' => gmdate('c'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
public function job(string $folder, string $hash): ?array
|
||||
{
|
||||
$path = $this->jobPath($folder, $hash);
|
||||
if (!is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
$raw = @file_get_contents($path);
|
||||
$job = $raw === false ? null : json_decode($raw, true);
|
||||
return is_array($job) ? $job : null;
|
||||
}
|
||||
|
||||
/** @return list<array<string,mixed>> every job, oldest first */
|
||||
public function jobs(): array
|
||||
{
|
||||
$jobs = [];
|
||||
foreach (glob($this->dir . '/*.json') ?: [] as $path) {
|
||||
if (!preg_match('/^(' . UserStore::FOLDER_PATTERN . ')-([a-f0-9]{64})\.json$/', basename($path), $m)) {
|
||||
continue;
|
||||
}
|
||||
$job = $this->job($m[1], $m[2]);
|
||||
if ($job !== null) {
|
||||
$jobs[] = $job;
|
||||
}
|
||||
}
|
||||
usort($jobs, static fn(array $a, array $b): int => strcmp((string) $a['createdAt'], (string) $b['createdAt']));
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/** The oldest job nobody has started on, or null when there is none. */
|
||||
public function next(): ?array
|
||||
{
|
||||
foreach ($this->jobs() as $job) {
|
||||
if ($job['state'] === self::QUEUED && is_file($this->source($job['folder'], $job['hash']))) {
|
||||
return $job;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes a job, unless it was deleted in the meantime: then false, and the one converting it
|
||||
* knows to stop.
|
||||
*
|
||||
* @param array<string,mixed> $changes
|
||||
*/
|
||||
public function update(string $folder, string $hash, array $changes): bool
|
||||
{
|
||||
$job = $this->job($folder, $hash);
|
||||
if ($job === null) {
|
||||
return false;
|
||||
}
|
||||
$this->write($folder, $hash, $changes + $job);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** A job that was being converted when the worker stopped is started again. */
|
||||
public function requeueInterrupted(): int
|
||||
{
|
||||
$count = 0;
|
||||
foreach ($this->jobs() as $job) {
|
||||
if ($job['state'] === self::CONVERTING) {
|
||||
$this->update($job['folder'], $job['hash'], ['state' => self::QUEUED, 'progress' => 0]);
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
return $count;
|
||||
}
|
||||
|
||||
/** Drops the job, and an old one's hidden upload; a kept upload is the store's to delete. */
|
||||
public function remove(string $folder, string $hash): bool
|
||||
{
|
||||
$removed = false;
|
||||
foreach ([$this->hiddenUpload($folder, $hash), $this->jobPath($folder, $hash)] as $path) {
|
||||
if (is_file($path)) {
|
||||
$removed = unlink($path) || $removed;
|
||||
}
|
||||
}
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops an old job's hidden upload once it cannot be converted; the job itself says why. A kept
|
||||
* upload stays: it can still be downloaded as it came.
|
||||
*/
|
||||
public function dropSource(string $folder, string $hash): void
|
||||
{
|
||||
$path = $this->hiddenUpload($folder, $hash);
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the bytes of a job lie, for ffmpeg to read. */
|
||||
public function source(string $folder, string $hash): string
|
||||
{
|
||||
$source = (string) ($this->job($folder, $hash)['source'] ?? '');
|
||||
$kept = '#^' . UserStore::FOLDER_PATTERN . '/[a-f0-9]{64}\.source\.[a-z0-9]+$#';
|
||||
return preg_match($kept, $source) ? dirname($this->dir) . "/{$source}" : $this->hiddenUpload($folder, $hash);
|
||||
}
|
||||
|
||||
private function hiddenUpload(string $folder, string $hash): string
|
||||
{
|
||||
return "{$this->dir}/{$folder}-{$hash}.src";
|
||||
}
|
||||
|
||||
private function jobPath(string $folder, string $hash): string
|
||||
{
|
||||
return "{$this->dir}/{$folder}-{$hash}.json";
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $job */
|
||||
private function write(string $folder, string $hash, array $job): void
|
||||
{
|
||||
$json = json_encode($job, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
AtomicFile::write($this->jobPath($folder, $hash), (string) $json, AtomicFile::HIDDEN, 'video job');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Videos (#402): `data/videos/{folder}/{hash}.mp4`, beside it `{hash}.jpg` (the poster),
|
||||
* `{hash}.meta.json` (size, length, frame rate, sound) and `{hash}.source.{ext}`, the upload as it
|
||||
* came, all served as they are.
|
||||
*
|
||||
* What a project plays is the MP4 the worker makes of the upload (see VideoFormat), which waits in
|
||||
* the VideoQueue until then. The upload itself is kept for downloading it, and one sent up without
|
||||
* being converted stays only that until it is asked to be. The hash is the one of the uploaded
|
||||
* bytes, so the name a project stores is known the moment the upload is in, long before the file
|
||||
* exists, and uploading the same file twice converts it once. Videos uploaded before the uploads
|
||||
* were kept have none.
|
||||
*/
|
||||
final class VideoStore extends HashedFileStore
|
||||
{
|
||||
/** An upload kept, waiting for nobody to convert it. */
|
||||
public const UNCONVERTED = 'unconverted';
|
||||
|
||||
/**
|
||||
* What an upload has to start with to be sent to ffmpeg at all: the ISO family (MP4, MOV, M4V,
|
||||
* 3GP), Matroska (MKV, WebM), AVI and MPEG transport streams. ffmpeg has the last word.
|
||||
*/
|
||||
private const SIGNATURE_HINT = 'MP4, MOV, WebM, MKV or AVI';
|
||||
|
||||
/** The endings a kept upload may have, taken from its name; anything else is told by its bytes. */
|
||||
private const SOURCE_EXTENSIONS = ['mp4', 'mov', 'm4v', '3gp', 'mkv', 'webm', 'avi', 'ts', 'mts', 'm2ts'];
|
||||
|
||||
private readonly VideoQueue $queue;
|
||||
|
||||
public function __construct(private readonly string $root)
|
||||
{
|
||||
parent::__construct($root);
|
||||
$this->queue = new VideoQueue("{$root}/queue");
|
||||
}
|
||||
|
||||
protected static function filePattern(): string
|
||||
{
|
||||
return '[a-f0-9]{64}\.mp4';
|
||||
}
|
||||
|
||||
public function queue(): VideoQueue
|
||||
{
|
||||
return $this->queue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes an upload: a file already converted answers as it is, anything else waits in the
|
||||
* queue, a failed one is tried again. Without `$convert` it is only kept.
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
* @throws InvalidVideoException when the bytes cannot be a video
|
||||
*/
|
||||
public function store(string $bytes, string $originalName, string $folder, bool $convert = true): array
|
||||
{
|
||||
$in = fopen('php://memory', 'r+b');
|
||||
fwrite($in, $bytes);
|
||||
rewind($in);
|
||||
try {
|
||||
return $this->storeStream($in, strlen($bytes), $originalName, $folder, $convert);
|
||||
} finally {
|
||||
fclose($in);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like store(), straight from the request: the bytes go to disk as they come, and only their
|
||||
* first ones and their hash are looked at, so a video of 2 GB needs no 2 GB of memory.
|
||||
*
|
||||
* @param resource $in
|
||||
* @return array<string,mixed>
|
||||
* @throws VideoTooLargeException when the stream has more than `$maxBytes`
|
||||
* @throws InvalidVideoException when the bytes cannot be a video
|
||||
*/
|
||||
public function storeStream($in, int $maxBytes, string $originalName, string $folder, bool $convert = true): array
|
||||
{
|
||||
$upload = $this->queue->receive($in, $maxBytes);
|
||||
try {
|
||||
$head = (string) file_get_contents($upload, false, null, 0, 256);
|
||||
if (!self::looksLikeVideo($head)) {
|
||||
throw new InvalidVideoException('Not a video file – use ' . self::SIGNATURE_HINT);
|
||||
}
|
||||
if (!preg_match('/^' . UserStore::FOLDER_PATTERN . '$/', $folder)) {
|
||||
throw new InvalidArgumentException("Invalid folder: {$folder}");
|
||||
}
|
||||
$hash = hash_file('sha256', $upload);
|
||||
$dir = "{$this->root}/{$folder}";
|
||||
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException("Cannot create directory {$dir}");
|
||||
}
|
||||
OriginalName::write($dir, "{$hash}.mp4", $originalName);
|
||||
if ($this->sourceOf($folder, $hash) === null) {
|
||||
$source = "{$dir}/{$hash}.source." . self::sourceExtension($originalName, $head);
|
||||
if (!chmod($upload, AtomicFile::SERVED) || !rename($upload, $source)) {
|
||||
throw new RuntimeException('Cannot write uploaded video');
|
||||
}
|
||||
}
|
||||
if ($convert) {
|
||||
$this->enqueue($folder, $hash);
|
||||
}
|
||||
return $this->record("{$folder}/{$hash}.mp4");
|
||||
} finally {
|
||||
if (is_file($upload)) {
|
||||
unlink($upload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an upload that was kept without: it waits in the queue like any other, a failed one
|
||||
* is tried again. Null when there is no such video.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function convert(string $file): ?array
|
||||
{
|
||||
$folder = self::isValidName($file) ? self::folderOf($file) : '';
|
||||
if ($folder === '') {
|
||||
return null;
|
||||
}
|
||||
$this->enqueue($folder, basename($file, '.mp4'));
|
||||
return $this->record($file);
|
||||
}
|
||||
|
||||
/** Queues a kept upload, unless it is converted or on its way already. */
|
||||
private function enqueue(string $folder, string $hash): void
|
||||
{
|
||||
$source = $this->sourceOf($folder, $hash);
|
||||
if ($source === null || is_file("{$this->root}/{$folder}/{$hash}.mp4")) {
|
||||
return;
|
||||
}
|
||||
$job = $this->queue->job($folder, $hash);
|
||||
if ($job === null || $job['state'] === VideoQueue::FAILED) {
|
||||
$this->queue->add($folder, $hash, $source, OriginalName::read("{$this->root}/{$folder}", "{$hash}.mp4"));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One file as the picker shows it, done or not; null when there is neither file nor job.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
public function record(string $file): ?array
|
||||
{
|
||||
if (!self::isValidName($file)) {
|
||||
return null;
|
||||
}
|
||||
if (is_file("{$this->root}/{$file}")) {
|
||||
$folder = self::folderOf($file);
|
||||
$dir = $folder === '' ? $this->root : "{$this->root}/{$folder}";
|
||||
return [
|
||||
'file' => $file,
|
||||
'name' => OriginalName::read($dir, basename($file)),
|
||||
...$this->details("{$this->root}/{$file}"),
|
||||
'bytes' => (int) filesize("{$this->root}/{$file}"),
|
||||
];
|
||||
}
|
||||
$job = $this->jobOf($file);
|
||||
if ($job !== null) {
|
||||
return $this->pending($job);
|
||||
}
|
||||
$folder = self::folderOf($file);
|
||||
return $folder === '' ? null : $this->unconverted($folder, basename($file, '.mp4'));
|
||||
}
|
||||
|
||||
/** A video is there for deleting while it is converted, failed or only kept, too. */
|
||||
public function exists(string $file): bool
|
||||
{
|
||||
return $this->record($file) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the converted files like every store does, then the ones on their way, then the ones
|
||||
* kept without being converted.
|
||||
*/
|
||||
public function list(string $folder, bool $seesAll, array $owners): array
|
||||
{
|
||||
$items = parent::list($folder, $seesAll, $owners);
|
||||
foreach ($this->queue->jobs() as $job) {
|
||||
if ($seesAll || $job['folder'] === $folder) {
|
||||
$items[] = $this->pending($job) + [
|
||||
'updatedAt' => (string) $job['createdAt'],
|
||||
'owner' => $owners[$job['folder']] ?? '',
|
||||
'deletable' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
foreach (glob("{$this->root}/*/*.source.*") ?: [] as $path) {
|
||||
$owner = basename(dirname($path));
|
||||
if (!preg_match('/^([a-f0-9]{64})\.source\.[a-z0-9]+$/', basename($path), $m) || (!$seesAll && $owner !== $folder)) {
|
||||
continue;
|
||||
}
|
||||
$record = $this->unconverted($owner, $m[1]);
|
||||
if ($record !== null) {
|
||||
$items[] = $record + [
|
||||
'updatedAt' => gmdate('c', (int) filemtime($path)),
|
||||
'owner' => $owners[$owner] ?? '',
|
||||
'deletable' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $items;
|
||||
}
|
||||
|
||||
public function delete(string $file): bool
|
||||
{
|
||||
if (!self::isValidName($file)) {
|
||||
return false;
|
||||
}
|
||||
$folder = self::folderOf($file);
|
||||
$hash = basename($file, '.mp4');
|
||||
$removed = $folder !== '' && $this->queue->remove($folder, $hash);
|
||||
$source = $folder === '' ? null : $this->sourceOf($folder, $hash);
|
||||
if ($source !== null) {
|
||||
$removed = unlink("{$this->root}/{$source}") || $removed;
|
||||
}
|
||||
if (is_file("{$this->root}/{$file}")) {
|
||||
$removed = parent::delete($file) || $removed;
|
||||
$dir = $folder === '' ? $this->root : "{$this->root}/{$folder}";
|
||||
foreach (["{$dir}/{$hash}.jpg", "{$dir}/{$hash}.meta.json"] as $path) {
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
} elseif ($removed) {
|
||||
OriginalName::delete("{$this->root}/{$folder}", "{$hash}.mp4");
|
||||
}
|
||||
return $removed;
|
||||
}
|
||||
|
||||
/** Called by the worker once the MP4 is in place: what the editor reads instead of probing. */
|
||||
public static function writeDetails(string $dir, string $hash, array $details): void
|
||||
{
|
||||
$json = json_encode($details, JSON_UNESCAPED_SLASHES);
|
||||
AtomicFile::write("{$dir}/{$hash}.meta.json", (string) $json, AtomicFile::SERVED, 'video details');
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
protected function details(string $path): array
|
||||
{
|
||||
$meta = substr($path, 0, -strlen('.mp4')) . '.meta.json';
|
||||
$raw = is_file($meta) ? file_get_contents($meta) : false;
|
||||
$details = $raw === false ? null : json_decode($raw, true);
|
||||
$details = is_array($details) ? $details : [];
|
||||
$file = substr($path, strlen($this->root) + 1);
|
||||
$folder = self::folderOf($file);
|
||||
return [
|
||||
'status' => 'ready',
|
||||
'width' => (int) ($details['width'] ?? 0),
|
||||
'height' => (int) ($details['height'] ?? 0),
|
||||
'duration' => (float) ($details['duration'] ?? 0),
|
||||
'fps' => (float) ($details['fps'] ?? 0),
|
||||
'audio' => (bool) ($details['audio'] ?? false),
|
||||
'poster' => substr($file, 0, -strlen('.mp4')) . '.jpg',
|
||||
'original' => $folder === '' ? null : $this->sourceOf($folder, basename($file, '.mp4')),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the upload of a video is kept, `{folder}/{hash}.source.{ext}` as it is served; null for
|
||||
* one uploaded before uploads were kept.
|
||||
*/
|
||||
private function sourceOf(string $folder, string $hash): ?string
|
||||
{
|
||||
foreach (glob("{$this->root}/{$folder}/{$hash}.source.*") ?: [] as $path) {
|
||||
if (preg_match('/^[a-f0-9]{64}\.source\.[a-z0-9]+$/', basename($path))) {
|
||||
return "{$folder}/" . basename($path);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A kept upload that is neither converted nor on its way, as the picker shows it; null when
|
||||
* the video is either, or there is no upload.
|
||||
*
|
||||
* @return array<string,mixed>|null
|
||||
*/
|
||||
private function unconverted(string $folder, string $hash): ?array
|
||||
{
|
||||
$source = $this->sourceOf($folder, $hash);
|
||||
if ($source === null || is_file("{$this->root}/{$folder}/{$hash}.mp4") || $this->queue->job($folder, $hash) !== null) {
|
||||
return null;
|
||||
}
|
||||
return [
|
||||
'file' => "{$folder}/{$hash}.mp4",
|
||||
'name' => OriginalName::read("{$this->root}/{$folder}", "{$hash}.mp4"),
|
||||
'status' => self::UNCONVERTED,
|
||||
'progress' => 0,
|
||||
'error' => '',
|
||||
'bytes' => (int) filesize("{$this->root}/{$source}"),
|
||||
'original' => $source,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The ending a kept upload is served with: the one of its name when that is a video's, else the
|
||||
* one its first bytes tell.
|
||||
*/
|
||||
private static function sourceExtension(string $name, string $head): string
|
||||
{
|
||||
$extension = strtolower(pathinfo($name, PATHINFO_EXTENSION));
|
||||
if (in_array($extension, self::SOURCE_EXTENSIONS, true)) {
|
||||
return $extension;
|
||||
}
|
||||
return match (true) {
|
||||
substr($head, 4, 4) === 'ftyp' => substr($head, 8, 4) === 'qt ' ? 'mov' : 'mp4',
|
||||
str_starts_with($head, "\x1A\x45\xDF\xA3") => str_contains($head, 'webm') ? 'webm' : 'mkv',
|
||||
str_starts_with($head, 'RIFF') => 'avi',
|
||||
default => 'ts',
|
||||
};
|
||||
}
|
||||
|
||||
/** @return array<string,mixed>|null */
|
||||
private function jobOf(string $file): ?array
|
||||
{
|
||||
$folder = self::folderOf($file);
|
||||
return $folder === '' ? null : $this->queue->job($folder, basename($file, '.mp4'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $job
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function pending(array $job): array
|
||||
{
|
||||
return [
|
||||
'file' => "{$job['folder']}/{$job['hash']}.mp4",
|
||||
'name' => (string) $job['name'],
|
||||
'status' => (string) $job['state'],
|
||||
'progress' => (float) $job['progress'],
|
||||
'error' => (string) $job['error'],
|
||||
'bytes' => 0,
|
||||
'original' => $this->sourceOf((string) $job['folder'], (string) $job['hash']),
|
||||
];
|
||||
}
|
||||
|
||||
private static function looksLikeVideo(string $bytes): bool
|
||||
{
|
||||
if (strlen($bytes) < 16) {
|
||||
return false;
|
||||
}
|
||||
return substr($bytes, 4, 4) === 'ftyp'
|
||||
|| str_starts_with($bytes, "\x1A\x45\xDF\xA3")
|
||||
|| (str_starts_with($bytes, 'RIFF') && substr($bytes, 8, 4) === 'AVI ')
|
||||
|| ($bytes[0] === "\x47" && strlen($bytes) > 188 && $bytes[188] === "\x47");
|
||||
}
|
||||
}
|
||||
|
||||
/** Thrown by VideoStore::store when the uploaded bytes cannot be a video. */
|
||||
final class InvalidVideoException extends RuntimeException
|
||||
{
|
||||
}
|
||||
|
||||
/** Thrown by VideoQueue::receive when an upload is bigger than the API takes. */
|
||||
final class VideoTooLargeException extends RuntimeException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Converts what waits in the VideoQueue, one video at a time (#402). Runs in the container
|
||||
* `feuerwerk-video` through `tools/video-worker.php`, and in CI beside the PHP server.
|
||||
*
|
||||
* Each job goes: probe the upload, convert it into a temp file beside its target, read the result
|
||||
* back for its details, cut a poster, then move all three into place and drop the job. Whatever
|
||||
* fails on the way fails the job with ffmpeg's own words, and the temp files go.
|
||||
*/
|
||||
final class VideoWorker
|
||||
{
|
||||
/** How often a running conversion writes its progress, in seconds. */
|
||||
private const PROGRESS_EVERY = 0.5;
|
||||
|
||||
public function __construct(private readonly VideoStore $store, private readonly string $videos)
|
||||
{
|
||||
}
|
||||
|
||||
/** Converts the oldest waiting video; false when there was none. */
|
||||
public function runOnce(): bool
|
||||
{
|
||||
$job = $this->store->queue()->next();
|
||||
if ($job === null) {
|
||||
return false;
|
||||
}
|
||||
$this->convert((string) $job['folder'], (string) $job['hash']);
|
||||
return true;
|
||||
}
|
||||
|
||||
private function convert(string $folder, string $hash): void
|
||||
{
|
||||
$queue = $this->store->queue();
|
||||
if (!$queue->update($folder, $hash, ['state' => VideoQueue::CONVERTING, 'progress' => 0, 'error' => ''])) {
|
||||
return;
|
||||
}
|
||||
$dir = "{$this->videos}/{$folder}";
|
||||
$tag = bin2hex(random_bytes(4));
|
||||
$tmpVideo = "{$dir}/{$hash}.{$tag}.tmp.mp4";
|
||||
$tmpPoster = "{$dir}/{$hash}.{$tag}.tmp.jpg";
|
||||
try {
|
||||
$plan = VideoFormat::plan(self::probe($queue->source($folder, $hash)));
|
||||
$finished = $this->run(
|
||||
VideoFormat::convertArgs($queue->source($folder, $hash), $tmpVideo, $plan),
|
||||
$plan['duration'],
|
||||
static fn(float $p): bool => $queue->update($folder, $hash, ['progress' => round($p, 3)]),
|
||||
);
|
||||
if (!$finished) {
|
||||
return;
|
||||
}
|
||||
$converted = self::probe($tmpVideo);
|
||||
$details = VideoFormat::details($converted);
|
||||
$this->run(VideoFormat::posterArgs($tmpVideo, $tmpPoster, $details['duration'], VideoFormat::colour($converted)), 0, null);
|
||||
if ($queue->job($folder, $hash) === null) {
|
||||
return;
|
||||
}
|
||||
chmod($tmpVideo, AtomicFile::SERVED);
|
||||
chmod($tmpPoster, AtomicFile::SERVED);
|
||||
VideoStore::writeDetails($dir, $hash, $details);
|
||||
rename($tmpPoster, "{$dir}/{$hash}.jpg");
|
||||
rename($tmpVideo, "{$dir}/{$hash}.mp4");
|
||||
$queue->remove($folder, $hash);
|
||||
} catch (Throwable $e) {
|
||||
$queue->dropSource($folder, $hash);
|
||||
$queue->update($folder, $hash, ['state' => VideoQueue::FAILED, 'error' => self::shorten($e->getMessage())]);
|
||||
} finally {
|
||||
foreach ([$tmpVideo, $tmpPoster] as $path) {
|
||||
if (is_file($path)) {
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string,mixed> */
|
||||
private static function probe(string $path): array
|
||||
{
|
||||
[$code, $out, $err] = self::capture(VideoFormat::probeArgs($path));
|
||||
$probe = json_decode($out, true);
|
||||
if ($code !== 0 || !is_array($probe)) {
|
||||
throw new RuntimeException('ffmpeg cannot read this file' . ($err === '' ? '' : ": {$err}"));
|
||||
}
|
||||
return $probe;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs ffmpeg, reporting progress from its `-progress pipe:1` lines to `$report`. Returns false
|
||||
* when `$report` says the job is gone, after stopping ffmpeg.
|
||||
*
|
||||
* @param list<string> $args
|
||||
* @param (callable(float):bool)|null $report
|
||||
*/
|
||||
private function run(array $args, float $duration, ?callable $report): bool
|
||||
{
|
||||
$err = tempnam(sys_get_temp_dir(), 'video-err-');
|
||||
$process = proc_open($args, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['file', $err, 'w']], $pipes);
|
||||
if (!is_resource($process)) {
|
||||
throw new RuntimeException('Cannot start ffmpeg');
|
||||
}
|
||||
$last = 0.0;
|
||||
$alive = true;
|
||||
while (($line = fgets($pipes[1])) !== false) {
|
||||
$share = $report === null ? null : VideoFormat::progress($line, $duration);
|
||||
if ($share !== null && microtime(true) - $last >= self::PROGRESS_EVERY) {
|
||||
$last = microtime(true);
|
||||
if (!$report($share)) {
|
||||
$alive = false;
|
||||
proc_terminate($process);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
fclose($pipes[1]);
|
||||
$code = proc_close($process);
|
||||
$message = trim((string) file_get_contents($err));
|
||||
unlink($err);
|
||||
if (!$alive) {
|
||||
return false;
|
||||
}
|
||||
if ($code !== 0) {
|
||||
throw new RuntimeException($message === '' ? "ffmpeg stopped with code {$code}" : $message);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $args
|
||||
* @return array{0:int,1:string,2:string}
|
||||
*/
|
||||
private static function capture(array $args): array
|
||||
{
|
||||
$process = proc_open($args, [0 => ['file', '/dev/null', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
|
||||
if (!is_resource($process)) {
|
||||
throw new RuntimeException('Cannot start ffprobe');
|
||||
}
|
||||
$out = (string) stream_get_contents($pipes[1]);
|
||||
$err = trim((string) stream_get_contents($pipes[2]));
|
||||
fclose($pipes[1]);
|
||||
fclose($pipes[2]);
|
||||
return [proc_close($process), $out, $err];
|
||||
}
|
||||
|
||||
/** The last lines of what ffmpeg said: enough to tell why, not a log. */
|
||||
private static function shorten(string $message): string
|
||||
{
|
||||
$lines = array_values(array_filter(array_map('trim', explode("\n", $message)), static fn(string $l): bool => $l !== ''));
|
||||
$text = implode(' · ', array_slice($lines, -2));
|
||||
return mb_strlen($text) > 300 ? mb_substr($text, 0, 300) . '…' : $text;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
set -u
|
||||
|
||||
UNIT=game-maker-e2e
|
||||
UNIT=feuerwerk-e2e
|
||||
DIR=$(dirname "$(readlink -f "$0")")
|
||||
LOGDIR=$DIR/logs
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "game-maker",
|
||||
"name": "feuerwerk",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "game-maker",
|
||||
"name": "feuerwerk",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@dimforge/rapier2d-compat": "^0.20.0",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "game-maker",
|
||||
"name": "feuerwerk",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 344 344" width="344" height="344" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<rect width="344" height="344" rx="64" fill="#15171B"/>
|
||||
<path fill="#3FB6A8" d="M39.2 92L39.2 39.2L92 39.2L92 48.8L48.8 48.8L48.8 92ZM252 39.2L304.8 39.2L304.8 92L295.2 92L295.2 48.8L252 48.8ZM304.8 252L304.8 304.8L252 304.8L252 295.2L295.2 295.2L295.2 252ZM92 304.8L39.2 304.8L39.2 252L48.8 252L48.8 295.2L92 295.2Z"/>
|
||||
<path fill="#F5B83D" d="M162.4 42.4L181.6 42.4L181.6 140L162.4 140ZM162.4 204L181.6 204L181.6 301.6L162.4 301.6ZM42.4 162.4L140 162.4L140 181.6L42.4 181.6ZM204 162.4L301.6 162.4L301.6 181.6L204 181.6Z"/>
|
||||
<path fill="#F0782F" d="M208 147.314L246.514 108.8L235.2 97.486L196.686 136ZM147.314 136L108.8 97.486L97.486 108.8L136 147.314ZM196.686 208L235.2 246.514L246.514 235.2L208 196.686ZM136 196.686L97.486 235.2L108.8 246.514L147.314 208Z"/>
|
||||
<path fill="#FFF3D1" d="M172 147.11L196.89 172L172 196.89L147.11 172Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 987 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 344 344" width="344" height="344" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<path fill="#3FB6A8" d="M6 72L6 6L72 6L72 18L18 18L18 72ZM272 6L338 6L338 72L326 72L326 18L272 18ZM338 272L338 338L272 338L272 326L326 326L326 272ZM72 338L6 338L6 272L18 272L18 326L72 326Z"/>
|
||||
<path fill="#F5B83D" d="M160 10L184 10L184 132L160 132ZM160 212L184 212L184 334L160 334ZM10 160L132 160L132 184L10 184ZM212 160L334 160L334 184L212 184Z"/>
|
||||
<path fill="#F0782F" d="M217 141.142L265.142 93L251 78.858L202.858 127ZM141.142 127L93 78.858L78.858 93L127 141.142ZM202.858 217L251 265.142L265.142 251L217 202.858ZM127 202.858L78.858 251L93 265.142L141.142 217Z"/>
|
||||
<path fill="#FFF3D1" d="M172 140.887L203.113 172L172 203.113L140.887 172Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 344 344" width="344" height="344" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<path fill="#1F8F82" d="M6 72L6 6L72 6L72 18L18 18L18 72ZM272 6L338 6L338 72L326 72L326 18L272 18ZM338 272L338 338L272 338L272 326L326 326L326 272ZM72 338L6 338L6 272L18 272L18 326L72 326Z"/>
|
||||
<path fill="#E08A00" d="M160 10L184 10L184 132L160 132ZM160 212L184 212L184 334L160 334ZM10 160L132 160L132 184L10 184ZM212 160L334 160L334 184L212 184Z"/>
|
||||
<path fill="#D9531E" d="M217 141.142L265.142 93L251 78.858L202.858 127ZM141.142 127L93 78.858L78.858 93L127 141.142ZM202.858 217L251 265.142L265.142 251L217 202.858ZM127 202.858L78.858 251L93 265.142L141.142 217Z"/>
|
||||
<path fill="#1B1E24" d="M172 140.887L203.113 172L172 203.113L140.887 172Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460.372 88" width="460.372" height="88" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<path fill="#3FB6A8" d="M1.535 18.419L1.535 1.535L18.419 1.535L18.419 4.605L4.605 4.605L4.605 18.419ZM69.581 1.535L86.465 1.535L86.465 18.419L83.395 18.419L83.395 4.605L69.581 4.605ZM86.465 69.581L86.465 86.465L69.581 86.465L69.581 83.395L83.395 83.395L83.395 69.581ZM18.419 86.465L1.535 86.465L1.535 69.581L4.605 69.581L4.605 83.395L18.419 83.395Z"/>
|
||||
<path fill="#F5B83D" d="M40.93 2.558L47.07 2.558L47.07 33.767L40.93 33.767ZM40.93 54.233L47.07 54.233L47.07 85.442L40.93 85.442ZM2.558 40.93L33.767 40.93L33.767 47.07L2.558 47.07ZM54.233 40.93L85.442 40.93L85.442 47.07L54.233 47.07Z"/>
|
||||
<path fill="#F0782F" d="M55.512 36.106L67.827 23.791L64.209 20.173L51.894 32.488ZM36.106 32.488L23.791 20.173L20.173 23.791L32.488 36.106ZM51.894 55.512L64.209 67.827L67.827 64.209L55.512 51.894ZM32.488 51.894L20.173 64.209L23.791 67.827L36.106 55.512Z"/>
|
||||
<path fill="#FFF3D1" d="M44 36.041L51.959 44L44 51.959L36.041 44Z"/>
|
||||
<path fill="#F2F0EE" d="M117.04 71V19.16H148.252V27.836H125.716V40.76H143.932V49.4H125.716V71ZM169.168 72.08Q163.264 72.08 158.8 69.524Q154.336 66.968 151.834 62.432Q149.332 57.896 149.332 51.992Q149.332 45.62 151.78 40.922Q154.228 36.224 158.584 33.632Q162.94 31.04 168.664 31.04Q174.712 31.04 178.942 33.866Q183.172 36.692 185.224 41.84Q187.276 46.988 186.772 53.972H178.168V50.804Q178.132 44.468 175.936 41.552Q173.74 38.636 169.024 38.636Q163.696 38.636 161.104 41.93Q158.512 45.224 158.512 51.56Q158.512 57.464 161.104 60.704Q163.696 63.944 168.664 63.944Q171.868 63.944 174.19 62.522Q176.512 61.1 177.772 58.4L186.34 60.992Q184.108 66.248 179.446 69.164Q174.784 72.08 169.168 72.08ZM155.776 53.972V47.42H182.524V53.972ZM207.256 72.116Q202.936 72.116 200.128 70.676Q197.32 69.236 195.664 67.004Q194.008 64.772 193.216 62.252Q192.424 59.732 192.19 57.482Q191.956 55.232 191.956 53.828V32.12H200.74V50.84Q200.74 52.64 200.992 54.89Q201.244 57.14 202.162 59.21Q203.08 61.28 204.898 62.612Q206.716 63.944 209.812 63.944Q211.468 63.944 213.088 63.404Q214.708 62.864 216.022 61.55Q217.336 60.236 218.128 57.932Q218.92 55.628 218.92 52.1L224.068 54.296Q224.068 59.264 222.142 63.296Q220.216 67.328 216.472 69.722Q212.728 72.116 207.256 72.116ZM219.964 71V58.94H218.92V32.12H227.632V71ZM253.228 72.08Q247.324 72.08 242.86 69.524Q238.396 66.968 235.894 62.432Q233.392 57.896 233.392 51.992Q233.392 45.62 235.84 40.922Q238.288 36.224 242.644 33.632Q247 31.04 252.724 31.04Q258.772 31.04 263.002 33.866Q267.232 36.692 269.284 41.84Q271.336 46.988 270.832 53.972H262.228V50.804Q262.192 44.468 259.996 41.552Q257.8 38.636 253.084 38.636Q247.756 38.636 245.164 41.93Q242.572 45.224 242.572 51.56Q242.572 57.464 245.164 60.704Q247.756 63.944 252.724 63.944Q255.928 63.944 258.25 62.522Q260.572 61.1 261.832 58.4L270.4 60.992Q268.168 66.248 263.506 69.164Q258.844 72.08 253.228 72.08ZM239.836 53.972V47.42H266.584V53.972ZM276.736 71V32.12H284.404V41.588L283.468 40.364Q284.188 38.42 285.394 36.818Q286.6 35.216 288.292 34.172Q289.732 33.2 291.478 32.642Q293.224 32.084 295.06 31.958Q296.896 31.832 298.624 32.12V40.22Q296.896 39.716 294.646 39.896Q292.396 40.076 290.596 41.156Q288.796 42.128 287.662 43.64Q286.528 45.152 285.988 47.078Q285.448 49.004 285.448 51.236V71ZM311.584 71 299.704 32.048 308.2 32.12 315.796 57.068 323.5 32.12H330.808L338.476 57.068L346.108 32.12H354.604L342.724 71H335.956L327.136 44.252L318.352 71ZM375.16 72.08Q369.256 72.08 364.792 69.524Q360.328 66.968 357.826 62.432Q355.324 57.896 355.324 51.992Q355.324 45.62 357.772 40.922Q360.22 36.224 364.576 33.632Q368.932 31.04 374.656 31.04Q380.704 31.04 384.934 33.866Q389.164 36.692 391.216 41.84Q393.268 46.988 392.764 53.972H384.16V50.804Q384.124 44.468 381.928 41.552Q379.732 38.636 375.016 38.636Q369.688 38.636 367.096 41.93Q364.504 45.224 364.504 51.56Q364.504 57.464 367.096 60.704Q369.688 63.944 374.656 63.944Q377.86 63.944 380.182 62.522Q382.504 61.1 383.764 58.4L392.332 60.992Q390.1 66.248 385.438 69.164Q380.776 72.08 375.16 72.08ZM361.768 53.972V47.42H388.516V53.972ZM398.668 71V32.12H406.336V41.588L405.4 40.364Q406.12 38.42 407.326 36.818Q408.532 35.216 410.224 34.172Q411.664 33.2 413.41 32.642Q415.156 32.084 416.992 31.958Q418.828 31.832 420.556 32.12V40.22Q418.828 39.716 416.578 39.896Q414.328 40.076 412.528 41.156Q410.728 42.128 409.594 43.64Q408.46 45.152 407.92 47.078Q407.38 49.004 407.38 51.236V71ZM425.164 71 425.236 19.16H434.02V50.84L448.24 32.12H459.076L443.992 51.56L460.372 71H448.888L434.02 52.28V71Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 460.372 88" width="460.372" height="88" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<path fill="#1F8F82" d="M1.535 18.419L1.535 1.535L18.419 1.535L18.419 4.605L4.605 4.605L4.605 18.419ZM69.581 1.535L86.465 1.535L86.465 18.419L83.395 18.419L83.395 4.605L69.581 4.605ZM86.465 69.581L86.465 86.465L69.581 86.465L69.581 83.395L83.395 83.395L83.395 69.581ZM18.419 86.465L1.535 86.465L1.535 69.581L4.605 69.581L4.605 83.395L18.419 83.395Z"/>
|
||||
<path fill="#E08A00" d="M40.93 2.558L47.07 2.558L47.07 33.767L40.93 33.767ZM40.93 54.233L47.07 54.233L47.07 85.442L40.93 85.442ZM2.558 40.93L33.767 40.93L33.767 47.07L2.558 47.07ZM54.233 40.93L85.442 40.93L85.442 47.07L54.233 47.07Z"/>
|
||||
<path fill="#D9531E" d="M55.512 36.106L67.827 23.791L64.209 20.173L51.894 32.488ZM36.106 32.488L23.791 20.173L20.173 23.791L32.488 36.106ZM51.894 55.512L64.209 67.827L67.827 64.209L55.512 51.894ZM32.488 51.894L20.173 64.209L23.791 67.827L36.106 55.512Z"/>
|
||||
<path fill="#1B1E24" d="M44 36.041L51.959 44L44 51.959L36.041 44Z"/>
|
||||
<path fill="#1B1E24" d="M117.04 71V19.16H148.252V27.836H125.716V40.76H143.932V49.4H125.716V71ZM169.168 72.08Q163.264 72.08 158.8 69.524Q154.336 66.968 151.834 62.432Q149.332 57.896 149.332 51.992Q149.332 45.62 151.78 40.922Q154.228 36.224 158.584 33.632Q162.94 31.04 168.664 31.04Q174.712 31.04 178.942 33.866Q183.172 36.692 185.224 41.84Q187.276 46.988 186.772 53.972H178.168V50.804Q178.132 44.468 175.936 41.552Q173.74 38.636 169.024 38.636Q163.696 38.636 161.104 41.93Q158.512 45.224 158.512 51.56Q158.512 57.464 161.104 60.704Q163.696 63.944 168.664 63.944Q171.868 63.944 174.19 62.522Q176.512 61.1 177.772 58.4L186.34 60.992Q184.108 66.248 179.446 69.164Q174.784 72.08 169.168 72.08ZM155.776 53.972V47.42H182.524V53.972ZM207.256 72.116Q202.936 72.116 200.128 70.676Q197.32 69.236 195.664 67.004Q194.008 64.772 193.216 62.252Q192.424 59.732 192.19 57.482Q191.956 55.232 191.956 53.828V32.12H200.74V50.84Q200.74 52.64 200.992 54.89Q201.244 57.14 202.162 59.21Q203.08 61.28 204.898 62.612Q206.716 63.944 209.812 63.944Q211.468 63.944 213.088 63.404Q214.708 62.864 216.022 61.55Q217.336 60.236 218.128 57.932Q218.92 55.628 218.92 52.1L224.068 54.296Q224.068 59.264 222.142 63.296Q220.216 67.328 216.472 69.722Q212.728 72.116 207.256 72.116ZM219.964 71V58.94H218.92V32.12H227.632V71ZM253.228 72.08Q247.324 72.08 242.86 69.524Q238.396 66.968 235.894 62.432Q233.392 57.896 233.392 51.992Q233.392 45.62 235.84 40.922Q238.288 36.224 242.644 33.632Q247 31.04 252.724 31.04Q258.772 31.04 263.002 33.866Q267.232 36.692 269.284 41.84Q271.336 46.988 270.832 53.972H262.228V50.804Q262.192 44.468 259.996 41.552Q257.8 38.636 253.084 38.636Q247.756 38.636 245.164 41.93Q242.572 45.224 242.572 51.56Q242.572 57.464 245.164 60.704Q247.756 63.944 252.724 63.944Q255.928 63.944 258.25 62.522Q260.572 61.1 261.832 58.4L270.4 60.992Q268.168 66.248 263.506 69.164Q258.844 72.08 253.228 72.08ZM239.836 53.972V47.42H266.584V53.972ZM276.736 71V32.12H284.404V41.588L283.468 40.364Q284.188 38.42 285.394 36.818Q286.6 35.216 288.292 34.172Q289.732 33.2 291.478 32.642Q293.224 32.084 295.06 31.958Q296.896 31.832 298.624 32.12V40.22Q296.896 39.716 294.646 39.896Q292.396 40.076 290.596 41.156Q288.796 42.128 287.662 43.64Q286.528 45.152 285.988 47.078Q285.448 49.004 285.448 51.236V71ZM311.584 71 299.704 32.048 308.2 32.12 315.796 57.068 323.5 32.12H330.808L338.476 57.068L346.108 32.12H354.604L342.724 71H335.956L327.136 44.252L318.352 71ZM375.16 72.08Q369.256 72.08 364.792 69.524Q360.328 66.968 357.826 62.432Q355.324 57.896 355.324 51.992Q355.324 45.62 357.772 40.922Q360.22 36.224 364.576 33.632Q368.932 31.04 374.656 31.04Q380.704 31.04 384.934 33.866Q389.164 36.692 391.216 41.84Q393.268 46.988 392.764 53.972H384.16V50.804Q384.124 44.468 381.928 41.552Q379.732 38.636 375.016 38.636Q369.688 38.636 367.096 41.93Q364.504 45.224 364.504 51.56Q364.504 57.464 367.096 60.704Q369.688 63.944 374.656 63.944Q377.86 63.944 380.182 62.522Q382.504 61.1 383.764 58.4L392.332 60.992Q390.1 66.248 385.438 69.164Q380.776 72.08 375.16 72.08ZM361.768 53.972V47.42H388.516V53.972ZM398.668 71V32.12H406.336V41.588L405.4 40.364Q406.12 38.42 407.326 36.818Q408.532 35.216 410.224 34.172Q411.664 33.2 413.41 32.642Q415.156 32.084 416.992 31.958Q418.828 31.832 420.556 32.12V40.22Q418.828 39.716 416.578 39.896Q414.328 40.076 412.528 41.156Q410.728 42.128 409.594 43.64Q408.46 45.152 407.92 47.078Q407.38 49.004 407.38 51.236V71ZM425.164 71 425.236 19.16H434.02V50.84L448.24 32.12H459.076L443.992 51.56L460.372 71H448.888L434.02 52.28V71Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-62.876 0 247.962 175.806" width="247.962" height="175.806" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<path fill="#3FB6A8" d="M2.023 24.279L2.023 2.023L24.279 2.023L24.279 6.07L6.07 6.07L6.07 24.279ZM91.721 2.023L113.977 2.023L113.977 24.279L109.93 24.279L109.93 6.07L91.721 6.07ZM113.977 91.721L113.977 113.977L91.721 113.977L91.721 109.93L109.93 109.93L109.93 91.721ZM24.279 113.977L2.023 113.977L2.023 91.721L6.07 91.721L6.07 109.93L24.279 109.93Z"/>
|
||||
<path fill="#F5B83D" d="M53.953 3.372L62.047 3.372L62.047 44.512L53.953 44.512ZM53.953 71.488L62.047 71.488L62.047 112.628L53.953 112.628ZM3.372 53.953L44.512 53.953L44.512 62.047L3.372 62.047ZM71.488 53.953L112.628 53.953L112.628 62.047L71.488 62.047Z"/>
|
||||
<path fill="#F0782F" d="M73.174 47.594L89.408 31.36L84.64 26.592L68.406 42.826ZM47.594 42.826L31.36 26.592L26.592 31.36L42.826 47.594ZM68.406 73.174L84.64 89.408L89.408 84.64L73.174 68.406ZM42.826 68.406L26.592 84.64L31.36 89.408L47.594 73.174Z"/>
|
||||
<path fill="#FFF3D1" d="M58 47.509L68.491 58L58 68.491L47.509 58Z"/>
|
||||
<path fill="#F2F0EE" d="M-62.876 175V137.56H-40.334V143.826H-56.61V153.16H-43.454V159.4H-56.61V175ZM-25.228 175.78Q-29.492 175.78 -32.716 173.934Q-35.94 172.088 -37.747 168.812Q-39.554 165.536 -39.554 161.272Q-39.554 156.67 -37.786 153.277Q-36.018 149.884 -32.872 148.012Q-29.726 146.14 -25.592 146.14Q-21.224 146.14 -18.169 148.181Q-15.114 150.222 -13.632 153.94Q-12.15 157.658 -12.514 162.702H-18.728V160.414Q-18.754 155.838 -20.34 153.732Q-21.926 151.626 -25.332 151.626Q-29.18 151.626 -31.052 154.005Q-32.924 156.384 -32.924 160.96Q-32.924 165.224 -31.052 167.564Q-29.18 169.904 -25.592 169.904Q-23.278 169.904 -21.601 168.877Q-19.924 167.85 -19.014 165.9L-12.826 167.772Q-14.438 171.568 -17.805 173.674Q-21.172 175.78 -25.228 175.78ZM-34.9 162.702V157.97H-15.582V162.702ZM2.28 175.806Q-0.84 175.806 -2.868 174.766Q-4.896 173.726 -6.092 172.114Q-7.288 170.502 -7.86 168.682Q-8.432 166.862 -8.601 165.237Q-8.77 163.612 -8.77 162.598V146.92H-2.426V160.44Q-2.426 161.74 -2.244 163.365Q-2.062 164.99 -1.399 166.485Q-0.736 167.98 0.577 168.942Q1.89 169.904 4.126 169.904Q5.322 169.904 6.492 169.514Q7.662 169.124 8.611 168.175Q9.56 167.226 10.132 165.562Q10.704 163.898 10.704 161.35L14.422 162.936Q14.422 166.524 13.031 169.436Q11.64 172.348 8.936 174.077Q6.232 175.806 2.28 175.806ZM11.458 175V166.29H10.704V146.92H16.996V175ZM35.482 175.78Q31.218 175.78 27.994 173.934Q24.77 172.088 22.963 168.812Q21.156 165.536 21.156 161.272Q21.156 156.67 22.924 153.277Q24.692 149.884 27.838 148.012Q30.984 146.14 35.118 146.14Q39.486 146.14 42.541 148.181Q45.596 150.222 47.078 153.94Q48.56 157.658 48.196 162.702H41.982V160.414Q41.956 155.838 40.37 153.732Q38.784 151.626 35.378 151.626Q31.53 151.626 29.658 154.005Q27.786 156.384 27.786 160.96Q27.786 165.224 29.658 167.564Q31.53 169.904 35.118 169.904Q37.432 169.904 39.109 168.877Q40.786 167.85 41.696 165.9L47.884 167.772Q46.272 171.568 42.905 173.674Q39.538 175.78 35.482 175.78ZM25.81 162.702V157.97H45.128V162.702ZM52.46 175V146.92H57.998V153.758L57.322 152.874Q57.842 151.47 58.713 150.313Q59.584 149.156 60.806 148.402Q61.846 147.7 63.107 147.297Q64.368 146.894 65.694 146.803Q67.02 146.712 68.268 146.92V152.77Q67.02 152.406 65.395 152.536Q63.77 152.666 62.47 153.446Q61.17 154.148 60.351 155.24Q59.532 156.332 59.142 157.723Q58.752 159.114 58.752 160.726V175ZM77.628 175 69.048 146.868 75.184 146.92 80.67 164.938 86.234 146.92H91.512L97.05 164.938L102.562 146.92H108.698L100.118 175H95.23L88.86 155.682L82.516 175ZM123.544 175.78Q119.28 175.78 116.056 173.934Q112.832 172.088 111.025 168.812Q109.218 165.536 109.218 161.272Q109.218 156.67 110.986 153.277Q112.754 149.884 115.9 148.012Q119.046 146.14 123.18 146.14Q127.548 146.14 130.603 148.181Q133.658 150.222 135.14 153.94Q136.622 157.658 136.258 162.702H130.044V160.414Q130.018 155.838 128.432 153.732Q126.846 151.626 123.44 151.626Q119.592 151.626 117.72 154.005Q115.848 156.384 115.848 160.96Q115.848 165.224 117.72 167.564Q119.592 169.904 123.18 169.904Q125.494 169.904 127.171 168.877Q128.848 167.85 129.758 165.9L135.946 167.772Q134.334 171.568 130.967 173.674Q127.6 175.78 123.544 175.78ZM113.872 162.702V157.97H133.19V162.702ZM140.522 175V146.92H146.06V153.758L145.384 152.874Q145.904 151.47 146.775 150.313Q147.646 149.156 148.868 148.402Q149.908 147.7 151.169 147.297Q152.43 146.894 153.756 146.803Q155.082 146.712 156.33 146.92V152.77Q155.082 152.406 153.457 152.536Q151.832 152.666 150.532 153.446Q149.232 154.148 148.413 155.24Q147.594 156.332 147.204 157.723Q146.814 159.114 146.814 160.726V175ZM159.658 175 159.71 137.56H166.054V160.44L176.324 146.92H184.15L173.256 160.96L185.086 175H176.792L166.054 161.48V175Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-62.876 0 247.962 175.806" width="247.962" height="175.806" role="img" aria-label="Feuerwerk">
|
||||
<title>Feuerwerk</title>
|
||||
<path fill="#1F8F82" d="M2.023 24.279L2.023 2.023L24.279 2.023L24.279 6.07L6.07 6.07L6.07 24.279ZM91.721 2.023L113.977 2.023L113.977 24.279L109.93 24.279L109.93 6.07L91.721 6.07ZM113.977 91.721L113.977 113.977L91.721 113.977L91.721 109.93L109.93 109.93L109.93 91.721ZM24.279 113.977L2.023 113.977L2.023 91.721L6.07 91.721L6.07 109.93L24.279 109.93Z"/>
|
||||
<path fill="#E08A00" d="M53.953 3.372L62.047 3.372L62.047 44.512L53.953 44.512ZM53.953 71.488L62.047 71.488L62.047 112.628L53.953 112.628ZM3.372 53.953L44.512 53.953L44.512 62.047L3.372 62.047ZM71.488 53.953L112.628 53.953L112.628 62.047L71.488 62.047Z"/>
|
||||
<path fill="#D9531E" d="M73.174 47.594L89.408 31.36L84.64 26.592L68.406 42.826ZM47.594 42.826L31.36 26.592L26.592 31.36L42.826 47.594ZM68.406 73.174L84.64 89.408L89.408 84.64L73.174 68.406ZM42.826 68.406L26.592 84.64L31.36 89.408L47.594 73.174Z"/>
|
||||
<path fill="#1B1E24" d="M58 47.509L68.491 58L58 68.491L47.509 58Z"/>
|
||||
<path fill="#1B1E24" d="M-62.876 175V137.56H-40.334V143.826H-56.61V153.16H-43.454V159.4H-56.61V175ZM-25.228 175.78Q-29.492 175.78 -32.716 173.934Q-35.94 172.088 -37.747 168.812Q-39.554 165.536 -39.554 161.272Q-39.554 156.67 -37.786 153.277Q-36.018 149.884 -32.872 148.012Q-29.726 146.14 -25.592 146.14Q-21.224 146.14 -18.169 148.181Q-15.114 150.222 -13.632 153.94Q-12.15 157.658 -12.514 162.702H-18.728V160.414Q-18.754 155.838 -20.34 153.732Q-21.926 151.626 -25.332 151.626Q-29.18 151.626 -31.052 154.005Q-32.924 156.384 -32.924 160.96Q-32.924 165.224 -31.052 167.564Q-29.18 169.904 -25.592 169.904Q-23.278 169.904 -21.601 168.877Q-19.924 167.85 -19.014 165.9L-12.826 167.772Q-14.438 171.568 -17.805 173.674Q-21.172 175.78 -25.228 175.78ZM-34.9 162.702V157.97H-15.582V162.702ZM2.28 175.806Q-0.84 175.806 -2.868 174.766Q-4.896 173.726 -6.092 172.114Q-7.288 170.502 -7.86 168.682Q-8.432 166.862 -8.601 165.237Q-8.77 163.612 -8.77 162.598V146.92H-2.426V160.44Q-2.426 161.74 -2.244 163.365Q-2.062 164.99 -1.399 166.485Q-0.736 167.98 0.577 168.942Q1.89 169.904 4.126 169.904Q5.322 169.904 6.492 169.514Q7.662 169.124 8.611 168.175Q9.56 167.226 10.132 165.562Q10.704 163.898 10.704 161.35L14.422 162.936Q14.422 166.524 13.031 169.436Q11.64 172.348 8.936 174.077Q6.232 175.806 2.28 175.806ZM11.458 175V166.29H10.704V146.92H16.996V175ZM35.482 175.78Q31.218 175.78 27.994 173.934Q24.77 172.088 22.963 168.812Q21.156 165.536 21.156 161.272Q21.156 156.67 22.924 153.277Q24.692 149.884 27.838 148.012Q30.984 146.14 35.118 146.14Q39.486 146.14 42.541 148.181Q45.596 150.222 47.078 153.94Q48.56 157.658 48.196 162.702H41.982V160.414Q41.956 155.838 40.37 153.732Q38.784 151.626 35.378 151.626Q31.53 151.626 29.658 154.005Q27.786 156.384 27.786 160.96Q27.786 165.224 29.658 167.564Q31.53 169.904 35.118 169.904Q37.432 169.904 39.109 168.877Q40.786 167.85 41.696 165.9L47.884 167.772Q46.272 171.568 42.905 173.674Q39.538 175.78 35.482 175.78ZM25.81 162.702V157.97H45.128V162.702ZM52.46 175V146.92H57.998V153.758L57.322 152.874Q57.842 151.47 58.713 150.313Q59.584 149.156 60.806 148.402Q61.846 147.7 63.107 147.297Q64.368 146.894 65.694 146.803Q67.02 146.712 68.268 146.92V152.77Q67.02 152.406 65.395 152.536Q63.77 152.666 62.47 153.446Q61.17 154.148 60.351 155.24Q59.532 156.332 59.142 157.723Q58.752 159.114 58.752 160.726V175ZM77.628 175 69.048 146.868 75.184 146.92 80.67 164.938 86.234 146.92H91.512L97.05 164.938L102.562 146.92H108.698L100.118 175H95.23L88.86 155.682L82.516 175ZM123.544 175.78Q119.28 175.78 116.056 173.934Q112.832 172.088 111.025 168.812Q109.218 165.536 109.218 161.272Q109.218 156.67 110.986 153.277Q112.754 149.884 115.9 148.012Q119.046 146.14 123.18 146.14Q127.548 146.14 130.603 148.181Q133.658 150.222 135.14 153.94Q136.622 157.658 136.258 162.702H130.044V160.414Q130.018 155.838 128.432 153.732Q126.846 151.626 123.44 151.626Q119.592 151.626 117.72 154.005Q115.848 156.384 115.848 160.96Q115.848 165.224 117.72 167.564Q119.592 169.904 123.18 169.904Q125.494 169.904 127.171 168.877Q128.848 167.85 129.758 165.9L135.946 167.772Q134.334 171.568 130.967 173.674Q127.6 175.78 123.544 175.78ZM113.872 162.702V157.97H133.19V162.702ZM140.522 175V146.92H146.06V153.758L145.384 152.874Q145.904 151.47 146.775 150.313Q147.646 149.156 148.868 148.402Q149.908 147.7 151.169 147.297Q152.43 146.894 153.756 146.803Q155.082 146.712 156.33 146.92V152.77Q155.082 152.406 153.457 152.536Q151.832 152.666 150.532 153.446Q149.232 154.148 148.413 155.24Q147.594 156.332 147.204 157.723Q146.814 159.114 146.814 160.726V175ZM159.658 175 159.71 137.56H166.054V160.44L176.324 146.92H184.15L173.256 160.96L185.086 175H176.792L166.054 161.48V175Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.6 KiB |
@@ -10,6 +10,7 @@ import {
|
||||
targetAssetOf,
|
||||
targetDefs,
|
||||
trackDef,
|
||||
trackHeld,
|
||||
trackValueAt,
|
||||
} from '../model/animation';
|
||||
import type { AnimationAsset, AnimationKey, AnimationTrack, KeyInterpolation } from '../model/Asset';
|
||||
@@ -215,7 +216,7 @@ export class SetTrackRelativeCommand extends TimelineCommand {
|
||||
/**
|
||||
* Sets the key at `frame`, replacing the value of one already there but keeping its interpolation.
|
||||
* Without a value it takes the value the row has at that frame – what a double click in the timeline
|
||||
* means – and on an empty row the property's default, which for a relative row is no offset at all.
|
||||
* means – and on an empty row what the row's header shows: the object's value, or no offset when relative.
|
||||
*/
|
||||
export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
readonly label = 'Set key';
|
||||
@@ -235,7 +236,7 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
const def = trackDef(project, asset, track);
|
||||
if (!def) throw new CommandError('The row aims at something that is gone');
|
||||
const current = trackValueAt(track.keys, def.kind, this.frame, false, curveLookup(project));
|
||||
const raw = this.value ?? current ?? (track.relative ? 0 : def.default);
|
||||
const raw = this.value ?? current ?? trackHeld(project, asset, track);
|
||||
const key: AnimationKey = { frame: this.frame, value: coerceValue(def, raw) };
|
||||
const interpolation = track.keys.find((k) => k.frame === this.frame)?.interpolation;
|
||||
if (interpolation) key.interpolation = interpolation;
|
||||
@@ -243,6 +244,44 @@ export class SetAnimationKeyCommand extends TimelineCommand {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys a number row straight from one value to another (#407): both keys set, the first one linear,
|
||||
* the keys between them gone – what a driven video needs to run at 1× without a bend.
|
||||
*/
|
||||
export class KeyStraightRunCommand extends TimelineCommand {
|
||||
readonly label = 'Key video at 1×';
|
||||
/** How many keys between the two were taken out. */
|
||||
removed = 0;
|
||||
|
||||
constructor(
|
||||
assetId: string,
|
||||
private readonly trackId: string,
|
||||
private readonly from: AnimationKey,
|
||||
private readonly to: AnimationKey,
|
||||
) {
|
||||
super(assetId);
|
||||
}
|
||||
|
||||
protected apply(project: Project, asset: AnimationAsset): void {
|
||||
const track = requireTrack(asset, this.trackId);
|
||||
requireFrame(this.from.frame, 'A key sits on a whole frame from 0');
|
||||
requireFrame(this.to.frame, 'A key sits on a whole frame from 0');
|
||||
if (this.to.frame <= this.from.frame) throw new CommandError('The run has to end after it starts');
|
||||
const def = trackDef(project, asset, track);
|
||||
if (def?.kind !== 'number') throw new CommandError('Only a number row can run straight');
|
||||
const kept = track.keys.filter((k) => k.frame < this.from.frame || k.frame > this.to.frame);
|
||||
const end = track.keys.find((k) => k.frame === this.to.frame);
|
||||
this.removed = track.keys.filter((k) => k.frame > this.from.frame && k.frame < this.to.frame).length;
|
||||
const last: AnimationKey = { frame: this.to.frame, value: coerceValue(def, this.to.value) };
|
||||
if (end?.interpolation) last.interpolation = end.interpolation;
|
||||
track.keys = normalizeKeys([
|
||||
...kept,
|
||||
{ frame: this.from.frame, value: coerceValue(def, this.from.value) },
|
||||
last,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets how each key blends into the next one. `linear` is stored as no field at all. A key on a row
|
||||
* that cannot blend is left as it is – it jumps whatever it says – and only such keys is refused.
|
||||
|
||||
@@ -10,11 +10,13 @@ import type {
|
||||
TextureAsset,
|
||||
TilemapLayer,
|
||||
VariablesAsset,
|
||||
VideoAsset,
|
||||
} from '../model/Asset';
|
||||
import type { AssetType } from '../model/AssetTypes';
|
||||
import { embeddedIn } from '../music/embedded';
|
||||
import { defaultTracks } from '../music/song';
|
||||
import { heldSongGraph } from '../music/routing';
|
||||
import { newCompositionTrack } from '../model/composition';
|
||||
import { linearCurve } from '../model/curve';
|
||||
import { isFontFile } from '../model/font';
|
||||
import { DEFAULT_PALETTE, PIXEL_CANVAS_DEFAULT } from '../model/pixelart';
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
} from '../model/Scope';
|
||||
import type { SceneObject } from '../model/SceneObject';
|
||||
import { isTextureFile } from '../model/texture';
|
||||
import { isVideoFile } from '../model/video';
|
||||
import { cellsWithout } from '../model/tilemap';
|
||||
import { ASSET_FX_KEY } from '../properties/assetFx';
|
||||
import { coerceValue, defaultsFor, initialOf, initialPropsFor } from '../properties/PropertySchema';
|
||||
@@ -108,6 +111,8 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'font', file: '' };
|
||||
case 'sound':
|
||||
return { ...base, type: 'sound', file: '' };
|
||||
case 'video':
|
||||
return { ...base, type: 'video', file: '', width: 0, height: 0, duration: 0, fps: 0, audio: false };
|
||||
case 'soundfx':
|
||||
return { ...base, type: 'soundfx', graph: createEmptyGraph() };
|
||||
case 'instrument':
|
||||
@@ -122,6 +127,15 @@ export function buildAsset(type: AssetType, name: string, id = newId()): Asset {
|
||||
return { ...base, type: 'bus' };
|
||||
case 'particles':
|
||||
return { ...base, type: 'particles', graph: createEmptyGraph() };
|
||||
case 'composition':
|
||||
return {
|
||||
...base,
|
||||
type: 'composition',
|
||||
objects: [],
|
||||
tracks: [newCompositionTrack(1)],
|
||||
items: [],
|
||||
clips: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,13 +231,17 @@ function copyOf(original: Asset, id: string, name: string): Asset {
|
||||
if (copy.type === 'texture' || copy.type === 'pixelart')
|
||||
for (const frame of copy.frames) frame.id = newId();
|
||||
renewClipIds(copy, new Map());
|
||||
if (copy.type === 'scene' || copy.type === 'container') {
|
||||
// A node names a marker by its id alone (#338), so a copy's markers are told apart.
|
||||
if (copy.type === 'song') for (const marker of copy.markers ?? []) marker.id = newId();
|
||||
if (copy.type === 'scene' || copy.type === 'container' || copy.type === 'composition') {
|
||||
const ids = new Map(copy.objects.map((o) => [o.id, newId()]));
|
||||
for (const object of copy.objects) {
|
||||
object.id = ids.get(object.id)!;
|
||||
object.parentId = ids.get(object.parentId) ?? object.parentId;
|
||||
}
|
||||
swapReferences(copy.objects, (ref) => renamedReference(ref, ids), new Recorder());
|
||||
// An item spawns the object of its own id.
|
||||
if (copy.type === 'composition') for (const item of copy.items) item.id = ids.get(item.id) ?? item.id;
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
@@ -311,6 +329,25 @@ export class CreateSoundCommand extends ImportFileCommand<SoundAsset> {
|
||||
}
|
||||
}
|
||||
|
||||
/** What a video says about itself once the worker has made it (#402), copied into the asset. */
|
||||
export interface VideoDetails {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
audio: boolean;
|
||||
}
|
||||
|
||||
export class CreateVideoCommand extends ImportFileCommand<VideoAsset> {
|
||||
constructor(name: string, file: string, details: VideoDetails) {
|
||||
super(
|
||||
{ id: newId(), name, props: defaultsFor('video'), type: 'video', file, ...details },
|
||||
'video',
|
||||
isVideoFile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes an asset a song kept to itself a project asset (#355): it shows in the lists, and a name a
|
||||
* listed asset has already gets a number.
|
||||
|
||||
@@ -1,29 +1,41 @@
|
||||
/** The clips of a sound (#357): stretches of its file with a name, as frames are of a texture. */
|
||||
/**
|
||||
* The clips of a sound (#357) or a video (#402): stretches of its file with a name, as frames are
|
||||
* of a texture.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { SoundAsset, SoundClip } from '../model/Asset';
|
||||
import type { SoundClip } from '../model/Asset';
|
||||
import type { PropertyBag, PropertyValue } from '../model/Properties';
|
||||
import type { Project } from '../model/Project';
|
||||
import { clipsOf, nextClipName, uniqueClipName } from '../model/sound';
|
||||
import { type ClipOwner, clipsOf, hasClips, nextClipName, uniqueClipName } from '../model/sound';
|
||||
import type { PropertyDef } from '../properties/PropertySchema';
|
||||
import { CLIP_ROWS } from '../properties/sounds';
|
||||
import { VIDEO_CLIP_ROWS } from '../properties/videos';
|
||||
import { type Removed, removeFirst, restoreAt } from '../util/list';
|
||||
import { type Command, CommandError } from './Command';
|
||||
import { requireAsset } from './require';
|
||||
|
||||
function requireSound(project: Project, assetId: string): SoundAsset {
|
||||
/** A clip is the same shape on a sound and on a video; only its rows differ. */
|
||||
type Clip = SoundClip;
|
||||
|
||||
function requireSound(project: Project, assetId: string): ClipOwner {
|
||||
const asset = requireAsset(project, assetId);
|
||||
if (asset.type !== 'sound') throw new CommandError(`${asset.name} is not a sound`);
|
||||
if (!hasClips(asset)) throw new CommandError(`${asset.name} has no clips`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
function requireClip(asset: SoundAsset, clipId: string): SoundClip {
|
||||
const clip = clipsOf(asset).find((c) => c.id === clipId);
|
||||
function requireClip(asset: ClipOwner, clipId: string): Clip {
|
||||
const clip = clipsOf<Clip>(asset).find((c) => c.id === clipId);
|
||||
if (!clip) throw new CommandError(`Clip ${clipId} not found in ${asset.name}`);
|
||||
return clip;
|
||||
}
|
||||
|
||||
/** What a clip may hold: a row of `CLIP_ROWS`, a finite number at least 0, a curve it offers. */
|
||||
function heldClipValue(key: string, value: PropertyValue): PropertyValue {
|
||||
const def = CLIP_ROWS.find((row) => row.key === key);
|
||||
function clipRowsOf(asset: ClipOwner): readonly PropertyDef[] {
|
||||
return asset.type === 'video' ? VIDEO_CLIP_ROWS : CLIP_ROWS;
|
||||
}
|
||||
|
||||
/** What a clip may hold: a row of its asset's clip rows, a finite number at least 0, a curve it offers. */
|
||||
function heldClipValue(asset: ClipOwner, key: string, value: PropertyValue): PropertyValue {
|
||||
const def = clipRowsOf(asset).find((row) => row.key === key);
|
||||
if (!def) throw new CommandError(`A clip has no "${key}"`);
|
||||
if (def.kind === 'number' && (typeof value !== 'number' || !Number.isFinite(value) || value < 0))
|
||||
throw new CommandError(`${def.label} needs a number of at least 0`);
|
||||
@@ -47,14 +59,14 @@ export class AddClipCommand implements Command {
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const props: PropertyBag = {};
|
||||
for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(key, value);
|
||||
for (const [key, value] of Object.entries(this.props)) props[key] = heldClipValue(asset, key, value);
|
||||
// Named per execution: a redo has to fit the names that exist when it runs.
|
||||
asset.clips = [...clipsOf(asset), { id: this.clipId, name: nextClipName(asset), props }];
|
||||
asset.clips = [...clipsOf<Clip>(asset), { id: this.clipId, name: nextClipName(asset), props }];
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
asset.clips = clipsOf(asset).filter((c) => c.id !== this.clipId);
|
||||
asset.clips = clipsOf<Clip>(asset).filter((c) => c.id !== this.clipId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +85,11 @@ export class SetClipPropsCommand implements Command {
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const clip = requireClip(requireSound(project, this.assetId), this.clipId);
|
||||
const held = Object.entries(this.values).map(([key, value]) => [key, heldClipValue(key, value)] as const);
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clip = requireClip(asset, this.clipId);
|
||||
const held = Object.entries(this.values).map(
|
||||
([key, value]) => [key, heldClipValue(asset, key, value)] as const,
|
||||
);
|
||||
this.previous = held.map(([key]) => [key, clip.props[key]]);
|
||||
for (const [key, value] of held) clip.props[key] = value;
|
||||
}
|
||||
@@ -117,7 +132,7 @@ export class RenameClipCommand implements Command {
|
||||
*/
|
||||
export class RemoveClipCommand implements Command {
|
||||
readonly label = 'Delete clip';
|
||||
private removed?: Removed<SoundClip>;
|
||||
private removed?: Removed<Clip>;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
@@ -126,7 +141,7 @@ export class RemoveClipCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
const clips = [...clipsOf<Clip>(asset)];
|
||||
const removed = removeFirst(clips, (c) => c.id === this.clipId);
|
||||
if (!removed) throw new CommandError(`Clip ${this.clipId} not found in ${asset.name}`);
|
||||
this.removed = removed;
|
||||
@@ -136,7 +151,7 @@ export class RemoveClipCommand implements Command {
|
||||
undo(project: Project): void {
|
||||
if (!this.removed) return;
|
||||
const asset = requireSound(project, this.assetId);
|
||||
const clips = [...clipsOf(asset)];
|
||||
const clips = [...clipsOf<Clip>(asset)];
|
||||
restoreAt(clips, [this.removed]);
|
||||
asset.clips = clips;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* Editing a composition (#417). As with a song, one gesture is one command: tracks, items and clips
|
||||
* are written whole once the pointer lets go. An item comes and goes together with its object.
|
||||
*/
|
||||
import type { CompositionAsset, CompositionClip, CompositionItem, CompositionTrack } from '../model/Asset';
|
||||
import { newId } from '../ids';
|
||||
import {
|
||||
clipLength,
|
||||
fittedLength,
|
||||
heldCompositionClips,
|
||||
heldCompositionItems,
|
||||
heldCompositionTracks,
|
||||
} from '../model/composition';
|
||||
import type { ObjectClip } from '../model/clipboard';
|
||||
import { carriedClips, mediaKey, pastedItemsWithClips } from '../model/compositionEdit';
|
||||
import type { Project } from '../model/Project';
|
||||
import type { Command } from './Command';
|
||||
import { oneStep } from './CompoundCommand';
|
||||
import { PasteObjectsCommand } from './pasteCommands';
|
||||
import { requireComposition } from './require';
|
||||
import { PlaceAssetCommand, RemoveSceneObjectsCommand } from './sceneObjectCommands';
|
||||
|
||||
export interface CompositionParts {
|
||||
tracks?: readonly CompositionTrack[];
|
||||
items?: readonly CompositionItem[];
|
||||
clips?: readonly CompositionClip[];
|
||||
}
|
||||
|
||||
/** Writes the tracks, items and clips whole, held to what each may be. */
|
||||
export class SetCompositionCommand implements Command {
|
||||
private previous: {
|
||||
tracks: CompositionTrack[];
|
||||
items: CompositionItem[];
|
||||
clips: CompositionClip[];
|
||||
} | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly parts: CompositionParts,
|
||||
readonly label = 'Edit composition',
|
||||
) {}
|
||||
|
||||
/** Nothing to do when the composition already reads exactly like this – no empty undo step. */
|
||||
static changes(project: Project, assetId: string, parts: CompositionParts): boolean {
|
||||
const asset = requireComposition(project, assetId);
|
||||
return JSON.stringify(held(asset, parts)) !== JSON.stringify([asset.tracks, asset.items, asset.clips]);
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireComposition(project, this.assetId);
|
||||
this.previous = { tracks: asset.tracks, items: asset.items, clips: asset.clips };
|
||||
[asset.tracks, asset.items, asset.clips] = held(asset, this.parts);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (!this.previous) return;
|
||||
const asset = requireComposition(project, this.assetId);
|
||||
({ tracks: asset.tracks, items: asset.items, clips: asset.clips } = this.previous);
|
||||
}
|
||||
}
|
||||
|
||||
function held(
|
||||
asset: CompositionAsset,
|
||||
parts: CompositionParts,
|
||||
): [CompositionTrack[], CompositionItem[], CompositionClip[]] {
|
||||
const tracks = heldCompositionTracks(parts.tracks ?? asset.tracks);
|
||||
const items = heldCompositionItems(parts.items ?? asset.items, tracks, asset.objects);
|
||||
return [tracks, items, heldCompositionClips(parts.clips ?? asset.clips, tracks)];
|
||||
}
|
||||
|
||||
/**
|
||||
* An asset laid on a track as a new item: its object at the composition's origin and the item
|
||||
* spanning `start` and `length`. `objectId` is the item's id, to pick it afterwards.
|
||||
*/
|
||||
export function addCompositionItem(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
placed: string,
|
||||
item: Omit<CompositionItem, 'id'>,
|
||||
): { command: Command; objectId: string } {
|
||||
const place = new PlaceAssetCommand(assetId, placed, { x: 0, y: 0 });
|
||||
const comp = requireComposition(project, assetId);
|
||||
const set = new SetCompositionCommand(assetId, { items: [...comp.items, { ...item, id: place.objectId }] });
|
||||
return { command: oneStep('Add item', [place, set]), objectId: place.objectId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied items put back into a composition: their objects where the copied ones stood, the items
|
||||
* and the clips that came with them as `pastedItemsWithClips` places them. Null when the copy holds
|
||||
* anything but items – a scene's objects have no time to land at. `itemIds` are the new items', to
|
||||
* pick them afterwards.
|
||||
*/
|
||||
export function pasteCompositionItems(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
clip: ObjectClip,
|
||||
at: number,
|
||||
row?: number,
|
||||
): { command: Command; itemIds: string[] } | null {
|
||||
const copied = new Map((clip.items ?? []).map((i) => [i.id, i]));
|
||||
if (clip.roots.length === 0 || clip.roots.some((r) => !copied.has(r.id))) return null;
|
||||
const comp = requireComposition(project, assetId);
|
||||
const paste = new PasteObjectsCommand(assetId, clip, {}, { x: 0, y: 0 });
|
||||
const ids = new Map(clip.roots.map((r, n) => [r.id, paste.rootIds[n]!]));
|
||||
for (const c of [...(clip.clips ?? []), ...(clip.audio ?? [])]) ids.set(c.id, newId());
|
||||
const { items, clips } = pastedItemsWithClips(
|
||||
[...clip.roots.map((r) => copied.get(r.id)!), ...(clip.audio ?? [])],
|
||||
clip.clips ?? [],
|
||||
comp.clips,
|
||||
ids,
|
||||
at,
|
||||
comp.tracks.map((t) => t.id),
|
||||
(c) => Math.max(1, clipLength(project, comp, c)),
|
||||
row,
|
||||
);
|
||||
const set = new SetCompositionCommand(assetId, {
|
||||
items: [...comp.items, ...items],
|
||||
clips: [...comp.clips, ...clips],
|
||||
});
|
||||
return {
|
||||
command: oneStep(items.length === 1 ? 'Paste item' : 'Paste items', [paste, set]),
|
||||
itemIds: items.map((i) => i.id),
|
||||
};
|
||||
}
|
||||
|
||||
/** Items and clips taken off together, the items' objects and the clips that belong to them with them. */
|
||||
export function removeFromComposition(
|
||||
project: Project,
|
||||
assetId: string,
|
||||
itemIds: readonly string[],
|
||||
clipIds: readonly string[],
|
||||
): Command {
|
||||
const comp = requireComposition(project, assetId);
|
||||
const carried = carriedClips(comp.items, comp.clips, new Set(itemIds));
|
||||
const gone = new Set([...itemIds, ...clipIds, ...carried]);
|
||||
const objects = comp.objects.filter((o) => gone.has(o.id)).map((o) => o.id);
|
||||
const set = new SetCompositionCommand(assetId, {
|
||||
items: comp.items.filter((i) => !gone.has(i.id)),
|
||||
clips: comp.clips.filter((c) => !gone.has(c.id)),
|
||||
});
|
||||
const count = itemIds.length + clipIds.length;
|
||||
const label = count === 1 ? (itemIds.length === 1 ? 'Remove item' : 'Remove clip') : 'Remove items';
|
||||
return oneStep(label, objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set]);
|
||||
}
|
||||
|
||||
/** A track taken off with everything on it. */
|
||||
export function removeCompositionTrack(project: Project, assetId: string, trackId: string): Command {
|
||||
const comp = requireComposition(project, assetId);
|
||||
const items = comp.items.filter((i) => i.track === trackId).map((i) => i.id);
|
||||
const clips = comp.clips.filter((c) => c.track === trackId).map((c) => c.id);
|
||||
const objects = comp.objects.filter((o) => items.includes(o.id)).map((o) => o.id);
|
||||
const set = new SetCompositionCommand(assetId, {
|
||||
tracks: comp.tracks.filter((t) => t.id !== trackId),
|
||||
items: comp.items.filter((i) => !items.includes(i.id)),
|
||||
clips: comp.clips.filter((c) => !clips.includes(c.id)),
|
||||
});
|
||||
return oneStep(
|
||||
'Delete track',
|
||||
objects.length > 0 ? [new RemoveSceneObjectsCommand(assetId, objects), set] : [set],
|
||||
);
|
||||
}
|
||||
|
||||
interface Refit {
|
||||
assetId: string;
|
||||
itemId: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any edit, with every item that follows its media fitted to it again where the edit changed what it
|
||||
* wears (#417): a texture turned to a video takes the video's length, one turned back the default.
|
||||
* In the same undo step as the edit, and done again with it.
|
||||
*/
|
||||
export class FitMediaCommand implements Command {
|
||||
private refits: Refit[] | null = null;
|
||||
|
||||
constructor(readonly inner: Command) {}
|
||||
|
||||
get label(): string {
|
||||
return this.inner.label;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const before = this.refits ? null : mediaKeys(project);
|
||||
this.inner.execute(project);
|
||||
this.refits ??= refitsAfter(project, before!);
|
||||
for (const r of this.refits) setLength(project, r, r.to);
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
for (const r of [...(this.refits ?? [])].reverse()) setLength(project, r, r.from);
|
||||
this.inner.undo(project);
|
||||
}
|
||||
}
|
||||
|
||||
function mediaKeys(project: Project): Map<string, string> {
|
||||
const keys = new Map<string, string>();
|
||||
for (const comp of project.assets) {
|
||||
if (comp.type !== 'composition') continue;
|
||||
for (const item of comp.items)
|
||||
if (item.fitMedia) keys.set(`${comp.id}/${item.id}`, mediaKey(project, comp, item));
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
function refitsAfter(project: Project, before: ReadonlyMap<string, string>): Refit[] {
|
||||
const refits: Refit[] = [];
|
||||
for (const comp of project.assets) {
|
||||
if (comp.type !== 'composition') continue;
|
||||
for (const item of comp.items) {
|
||||
const was = before.get(`${comp.id}/${item.id}`);
|
||||
if (!item.fitMedia || was === undefined || was === mediaKey(project, comp, item)) continue;
|
||||
const to = fittedLength(project, comp, item);
|
||||
if (to !== item.length) refits.push({ assetId: comp.id, itemId: item.id, from: item.length, to });
|
||||
}
|
||||
}
|
||||
return refits;
|
||||
}
|
||||
|
||||
function setLength(project: Project, refit: Refit, length: number): void {
|
||||
const comp = requireComposition(project, refit.assetId);
|
||||
comp.items = comp.items.map((i) => (i.id === refit.itemId ? { ...i, length } : i));
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* What a composition keeps to itself (#419): a texture or video laid on a track wears a sprite the
|
||||
* composition owns. It is an asset like any other, so everything that finds an asset by its id finds
|
||||
* it, but the lists leave it out, as with the takes of a song (#355). After every edit the project is
|
||||
* held to that: a composition using the sprite of another gets a copy, one placed anywhere else makes
|
||||
* it a project asset, and one nothing uses any more goes.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, CompositionItem, SpriteAsset } from '../model/Asset';
|
||||
import { isCompositionSprite } from '../model/composition';
|
||||
import { generatesPictures } from '../model/generated';
|
||||
import { findPixelFrame, pixelArtWornId } from '../model/pixelart';
|
||||
import type { PropertyBag } from '../model/Properties';
|
||||
import { findAsset, objectHolders, type Project } from '../model/Project';
|
||||
import { findFrame } from '../model/texture';
|
||||
import { findVideo } from '../model/video';
|
||||
import { spriteFromTexture } from '../properties/aspect';
|
||||
import { type Removed, removeWhere, restoreAt } from '../util/list';
|
||||
import type { Command } from './Command';
|
||||
import { addCompositionItem } from './compositionCommands';
|
||||
import { oneStep } from './CompoundCommand';
|
||||
import { nextAssetName } from './naming';
|
||||
import { SetOverrideCommand } from './sceneObjectCommands';
|
||||
|
||||
/**
|
||||
* What a sprite made of `asset` wears: a texture, a drawn picture (#185) or a video; a sequence of
|
||||
* pixel art frames its first frame. Null for an asset that is no picture.
|
||||
*/
|
||||
export function wornPicture(asset: Asset): string | null {
|
||||
if (asset.type === 'pixelart') return pixelArtWornId(asset);
|
||||
return asset.type === 'texture' || asset.type === 'video' || generatesPictures(asset.type)
|
||||
? asset.id
|
||||
: null;
|
||||
}
|
||||
|
||||
/** What an item dropped on a track wears: the picture its sprite is made of, and a part (#425, #430). */
|
||||
export interface DroppedPicture {
|
||||
picture: string;
|
||||
/**
|
||||
* The clip of a video the item plays or the frame it shows, as an override on its object; null for
|
||||
* what its sprite wears.
|
||||
*/
|
||||
part: string | null;
|
||||
/** The name a new sprite for the picture gets. */
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an asset, a clip of a video (#425) or a frame of a picture (#430) dragged onto a track wears. A
|
||||
* part shares the sprite of its asset, so one composition keeps one sprite per video or picture. Null
|
||||
* for anything that is no picture.
|
||||
*/
|
||||
export function droppedPicture(project: Project, id: string): DroppedPicture | null {
|
||||
const asset = findAsset(project, id);
|
||||
if (asset) {
|
||||
const picture = wornPicture(asset);
|
||||
return picture === null ? null : { picture, part: null, name: asset.name };
|
||||
}
|
||||
const video = findVideo(project, id);
|
||||
if (video?.clip) return { picture: video.asset.id, part: video.clip.id, name: video.asset.name };
|
||||
const frame = findFrame(project, id);
|
||||
if (frame) return { picture: frame.asset.id, part: id, name: frame.asset.name };
|
||||
const pixel = findPixelFrame(project, id);
|
||||
if (!pixel) return null;
|
||||
// Its first frame is what the sprite of a sequence wears already.
|
||||
const picture = pixelArtWornId(pixel.asset);
|
||||
return { picture, part: id === picture ? null : id, name: pixel.asset.name };
|
||||
}
|
||||
|
||||
/** The sprite the composition already keeps for this picture: one serves every item wearing it. */
|
||||
function keptSprite(project: Project, compId: string, textureId: string): SpriteAsset | undefined {
|
||||
return project.assets.find(
|
||||
(a): a is SpriteAsset => isCompositionSprite(a) && a.owner === compId && a.props['texture'] === textureId,
|
||||
);
|
||||
}
|
||||
|
||||
/** A new sprite of the composition's own, wearing the picture at its own size. */
|
||||
function newCompositionSprite(
|
||||
project: Project,
|
||||
compId: string,
|
||||
textureId: string,
|
||||
name: string,
|
||||
): SpriteAsset {
|
||||
const props: PropertyBag = spriteFromTexture(project, textureId);
|
||||
return { id: newId(), type: 'sprite', name, props, owner: compId };
|
||||
}
|
||||
|
||||
/**
|
||||
* A texture, a drawn picture, a video or a part of one laid on a track: an item wearing the sprite the
|
||||
* composition keeps for that picture, made first when it has none yet, in one undo step. A part is
|
||||
* the texture of the item's object.
|
||||
*/
|
||||
export function addPictureItem(
|
||||
project: Project,
|
||||
compId: string,
|
||||
dropped: DroppedPicture,
|
||||
item: Omit<CompositionItem, 'id'>,
|
||||
): { command: Command; objectId: string } {
|
||||
const kept = keptSprite(project, compId, dropped.picture);
|
||||
const sprite = kept ?? newCompositionSprite(project, compId, dropped.picture, dropped.name);
|
||||
const { command, objectId } = addCompositionItem(project, compId, sprite.id, item);
|
||||
const steps = [
|
||||
...(kept ? [] : [new AddSpriteCommand(sprite)]),
|
||||
command,
|
||||
...(dropped.part ? [new SetOverrideCommand(compId, objectId, 'texture', dropped.part)] : []),
|
||||
];
|
||||
return { command: steps.length === 1 ? command : oneStep('Add item', steps), objectId };
|
||||
}
|
||||
|
||||
class AddSpriteCommand implements Command {
|
||||
readonly label = 'Add sprite';
|
||||
|
||||
constructor(private readonly sprite: SpriteAsset) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
project.assets.push(structuredClone(this.sprite));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
project.assets = project.assets.filter((a) => a.id !== this.sprite.id);
|
||||
}
|
||||
}
|
||||
|
||||
interface Retarget {
|
||||
holderId: string;
|
||||
objectId: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
interface Owned {
|
||||
assetId: string;
|
||||
from: { owner: string; name: string };
|
||||
to: { owner: string; name: string };
|
||||
}
|
||||
|
||||
export interface SpriteChange {
|
||||
/** Copies for compositions using a sprite another one keeps. */
|
||||
added: Asset[];
|
||||
retargets: Retarget[];
|
||||
/** Sprites made project assets or handed to the composition now using them. */
|
||||
owners: Owned[];
|
||||
dropped: Set<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds every composition's sprites to what uses them, in place. A sprite placed in a scene or a
|
||||
* container becomes a project asset. Otherwise it stays with its composition while that uses it, else
|
||||
* goes to the first composition that does; every other composition using it gets a copy of its own.
|
||||
*/
|
||||
function settleCompositionSprites(project: Project): SpriteChange {
|
||||
const change: SpriteChange = { added: [], retargets: [], owners: [], dropped: new Set() };
|
||||
const sprites = project.assets.filter(isCompositionSprite);
|
||||
if (sprites.length === 0) return change;
|
||||
const holders = objectHolders(project);
|
||||
for (const sprite of sprites) {
|
||||
const using = holders.filter((h) => h.objects.some((o) => o.assetId === sprite.id));
|
||||
const from = { owner: sprite.owner!, name: sprite.name };
|
||||
if (using.some((h) => h.type !== 'composition')) {
|
||||
const to = { owner: '', name: nextAssetName(project, sprite.name, sprite.id) };
|
||||
setOwner(sprite, to);
|
||||
change.owners.push({ assetId: sprite.id, from, to });
|
||||
continue;
|
||||
}
|
||||
if (using.length === 0) {
|
||||
change.dropped.add(sprite.id);
|
||||
continue;
|
||||
}
|
||||
const keeper = using.find((h) => h.id === sprite.owner) ?? using[0]!;
|
||||
if (keeper.id !== sprite.owner) {
|
||||
const to = { owner: keeper.id, name: sprite.name };
|
||||
setOwner(sprite, to);
|
||||
change.owners.push({ assetId: sprite.id, from, to });
|
||||
}
|
||||
for (const holder of using) {
|
||||
if (holder === keeper) continue;
|
||||
const copy: SpriteAsset = { ...structuredClone(sprite), id: newId(), owner: holder.id };
|
||||
change.added.push(copy);
|
||||
for (const object of holder.objects) {
|
||||
if (object.assetId !== sprite.id) continue;
|
||||
change.retargets.push({ holderId: holder.id, objectId: object.id, from: sprite.id, to: copy.id });
|
||||
object.assetId = copy.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.assets.push(...change.added);
|
||||
return change;
|
||||
}
|
||||
|
||||
function setOwner(asset: Asset, to: { owner: string; name: string }): void {
|
||||
if (to.owner) asset.owner = to.owner;
|
||||
else delete asset.owner;
|
||||
asset.name = to.name;
|
||||
}
|
||||
|
||||
function retarget(project: Project, retargets: readonly Retarget[], to: (r: Retarget) => string): void {
|
||||
for (const r of retargets) {
|
||||
const holder = objectHolders(project).find((h) => h.id === r.holderId);
|
||||
const object = holder?.objects.find((o) => o.id === r.objectId);
|
||||
if (object) object.assetId = to(r);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Any edit, with the compositions' sprites settled after it (#419). Worked out once, so a redo brings
|
||||
* back the very same copies; taken back along with the edit.
|
||||
*/
|
||||
export class KeepCompositionSpritesCommand implements Command {
|
||||
private change: SpriteChange | null = null;
|
||||
private removed: Removed<Asset>[] = [];
|
||||
|
||||
constructor(readonly inner: Command) {}
|
||||
|
||||
get label(): string {
|
||||
return this.inner.label;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
this.inner.execute(project);
|
||||
if (!this.change) this.change = settleCompositionSprites(project);
|
||||
else {
|
||||
for (const o of this.change.owners) setOwner(findAsset(project, o.assetId)!, o.to);
|
||||
project.assets.push(...this.change.added);
|
||||
retarget(project, this.change.retargets, (r) => r.to);
|
||||
}
|
||||
const dropped = this.change.dropped;
|
||||
this.removed = removeWhere(project.assets, (a) => dropped.has(a.id));
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
if (this.change) {
|
||||
restoreAt(project.assets, this.removed);
|
||||
retarget(project, this.change.retargets, (r) => r.from);
|
||||
const added = new Set(this.change.added.map((a) => a.id));
|
||||
project.assets = project.assets.filter((a) => !added.has(a.id));
|
||||
for (const o of [...this.change.owners].reverse()) setOwner(findAsset(project, o.assetId)!, o.from);
|
||||
}
|
||||
this.inner.undo(project);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { annotationList, type Graph } from '../graph/Graph';
|
||||
import { isTerminalType, terminalNode } from '../graph/nested';
|
||||
import { nodeDef } from '../graph/NodeRegistry';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset } from '../model/Asset';
|
||||
import type { GraphClip, ObjectClip } from '../model/clipboard';
|
||||
import { findGroup } from '../model/hierarchy';
|
||||
import { isLiveInstance } from '../model/instance';
|
||||
@@ -28,6 +29,7 @@ function freshIds(ids: readonly string[]): Map<string, string> {
|
||||
export class PasteObjectsCommand implements Command {
|
||||
readonly label: string;
|
||||
private readonly ids: Map<string, string>;
|
||||
private restored: Asset[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly holderId: string,
|
||||
@@ -46,8 +48,10 @@ export class PasteObjectsCommand implements Command {
|
||||
|
||||
execute(project: Project): void {
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
// A cut took the sprites a composition kept for it along (#419); who keeps them is settled after.
|
||||
const restored = (this.clip.sprites ?? []).filter((s) => !findAsset(project, s.id));
|
||||
for (const object of this.clip.objects) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
const asset = findAsset(project, object.assetId) ?? restored.find((s) => s.id === object.assetId);
|
||||
if (!asset) throw new CommandError('A copied object belongs to an asset that no longer exists');
|
||||
const rejection = placementRejection(project, holder, asset);
|
||||
if (rejection) throw new CommandError(rejection);
|
||||
@@ -55,6 +59,8 @@ export class PasteObjectsCommand implements Command {
|
||||
for (const parentId of new Set(Object.values(this.parents)))
|
||||
if (parentId && isLiveInstance(project, holder, requireStructuralObject(project, holder, parentId)))
|
||||
throw new CommandError('A placed container shows its template; unpack it to put objects inside');
|
||||
this.restored = structuredClone(restored);
|
||||
project.assets.push(...this.restored);
|
||||
const roots = new Map(this.clip.roots.map((root) => [root.id, root]));
|
||||
const pasted = this.clip.objects.map((object): SceneObject => {
|
||||
const copy = structuredClone(object);
|
||||
@@ -87,6 +93,8 @@ export class PasteObjectsCommand implements Command {
|
||||
const holder = requireHolder(project, this.holderId);
|
||||
const ids = new Set(this.ids.values());
|
||||
holder.objects = holder.objects.filter((o) => !ids.has(o.id));
|
||||
const restored = new Set(this.restored.map((s) => s.id));
|
||||
project.assets = project.assets.filter((a) => !restored.has(a.id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { findNode, type Graph, type GraphEdge, type GraphNode } from '../graph/G
|
||||
import { type NodeDef, nodeDef } from '../graph/NodeRegistry';
|
||||
import type {
|
||||
Asset,
|
||||
CompositionAsset,
|
||||
CurveAsset,
|
||||
PatternAsset,
|
||||
SongAsset,
|
||||
@@ -155,6 +156,12 @@ export function requireSong(project: Project, assetId: string): SongAsset {
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireComposition(project: Project, assetId: string): CompositionAsset {
|
||||
const asset = findAssetOf(project, assetId, 'composition');
|
||||
if (!asset) throw new CommandError(`Composition ${assetId} not found`);
|
||||
return asset;
|
||||
}
|
||||
|
||||
export function requireVariablesAsset(project: Project, assetId: string): VariablesAsset {
|
||||
const asset = findAssetOf(project, assetId, 'variables');
|
||||
if (!asset) throw new CommandError(`Variables ${assetId} not found`);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { EdgeSend, Graph } from '../graph/Graph';
|
||||
import type { Asset, SongAsset } from '../model/Asset';
|
||||
import { findGraphAsset, type Project } from '../model/Project';
|
||||
import { settleEmbedded } from '../music/embedded';
|
||||
import { heldSongMarkers, type SongMarker } from '../music/markers';
|
||||
import { routedKind, sendsTapped } from '../music/projectRouting';
|
||||
import { heldSend, heldSongGraph } from '../music/routing';
|
||||
import { heldSongItems, heldSongTracks, type SongItem, type SongTrack } from '../music/song';
|
||||
@@ -75,6 +76,36 @@ export class SetSongCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes the markers of a song whole (#338): one gesture, one step to undo. */
|
||||
export class SetSongMarkersCommand implements Command {
|
||||
private previous: SongMarker[] | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly markers: readonly SongMarker[],
|
||||
readonly label = 'Edit markers',
|
||||
) {}
|
||||
|
||||
/** Nothing to do when the song already has exactly these markers – no empty undo step. */
|
||||
static changes(asset: SongAsset, markers: readonly SongMarker[]): boolean {
|
||||
return JSON.stringify(asset.markers ?? []) !== JSON.stringify(heldSongMarkers(markers));
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const asset = requireSong(project, this.assetId);
|
||||
this.previous = asset.markers;
|
||||
const held = heldSongMarkers(this.markers);
|
||||
if (held.length > 0) asset.markers = held;
|
||||
else delete asset.markers;
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const asset = requireSong(project, this.assetId);
|
||||
if (this.previous) asset.markers = this.previous;
|
||||
else delete asset.markers;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How loud a link of a song's routing sends, and where it taps its track (#350); the same for a link
|
||||
* of the master routing (#351), where only a bus and a song may tap anywhere but after their level.
|
||||
|
||||
@@ -227,6 +227,11 @@ export class MakeStructTypeCommand implements Command {
|
||||
private readonly path: readonly string[],
|
||||
) {}
|
||||
|
||||
/** The Struct asset it made, once it has run. */
|
||||
get madeId(): string | undefined {
|
||||
return this.asset?.id;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const owner = requireVariableOwner(project, this.ownerId);
|
||||
const def = requireStructVariable(project, this.ownerId, this.key);
|
||||
@@ -372,6 +377,11 @@ export class MakeListItemTypeCommand implements Command {
|
||||
|
||||
constructor(private readonly target: ListTarget) {}
|
||||
|
||||
/** The Struct asset it made, once it has run. */
|
||||
get madeId(): string | undefined {
|
||||
return this.asset?.id;
|
||||
}
|
||||
|
||||
execute(project: Project): void {
|
||||
const { def, decl } = requireListDecl(project, this.target);
|
||||
if (decl.itemType && structAssetOf(project, decl.itemType))
|
||||
|
||||
@@ -72,7 +72,7 @@ export class PaintTilesCommand implements Command {
|
||||
if (edit.tileId) layer.cells[key] = edit.tileId;
|
||||
else delete layer.cells[key];
|
||||
}
|
||||
cellsChanged(layer.cells);
|
||||
cellsChanged(layer.cells, this.previous.keys());
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
@@ -81,7 +81,7 @@ export class PaintTilesCommand implements Command {
|
||||
if (value) layer.cells[key] = value;
|
||||
else delete layer.cells[key];
|
||||
}
|
||||
cellsChanged(layer.cells);
|
||||
cellsChanged(layer.cells, this.previous.keys());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,48 @@ export class AddVariableCommand implements Command {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares variables of one behavior in another as they are declared there (#434): default, Keep
|
||||
* and Private included, for copied nodes that name them. A key the target has already is left alone.
|
||||
*/
|
||||
export class CarryVariablesCommand implements Command {
|
||||
readonly label = 'Carry variables';
|
||||
private added: string[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly assetId: string,
|
||||
private readonly fromId: string,
|
||||
private readonly keys: readonly string[],
|
||||
) {}
|
||||
|
||||
execute(project: Project): void {
|
||||
const from = requireBehavior(project, this.fromId);
|
||||
const to = requireBehavior(project, this.assetId);
|
||||
this.added = [];
|
||||
for (const key of this.keys) {
|
||||
const def = from.variables.find((v) => v.key === key);
|
||||
if (!def) throw new CommandError(`${from.name} has no variable "${key}"`);
|
||||
if (to.variables.some((v) => v.key === key)) continue;
|
||||
to.variables.push(structuredClone(def));
|
||||
to.props[key] = structuredClone(from.props[key] ?? def.default);
|
||||
if (isKeptVariable(from, key)) setKeptVariable(to, key, true);
|
||||
if (isPrivateVariable(from, key)) setPrivateVariable(to, key, true);
|
||||
this.added.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
undo(project: Project): void {
|
||||
const to = requireBehavior(project, this.assetId);
|
||||
const added = new Set(this.added);
|
||||
to.variables = to.variables.filter((v) => !added.has(v.key));
|
||||
for (const key of added) {
|
||||
delete to.props[key];
|
||||
setKeptVariable(to, key, false);
|
||||
setPrivateVariable(to, key, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One placed object's own value for a variable, remembered so that undo can put it back. */
|
||||
interface TakenOverride {
|
||||
holderId: string;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { AnimationPlayer, EaseShare } from '../model/animation';
|
||||
import type { CompositionPlayer } from '../model/composition';
|
||||
import type { Mat } from '../model/matrix';
|
||||
import type { Position, PropertyBag } from '../model/Properties';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import type { HeardPosition, Quantize } from '../music/beats';
|
||||
import type { RecordingSettings } from '../recording/recording';
|
||||
import type { VideoReading, VideoSteer } from '../model/videoClock';
|
||||
import { type ChainTransform, composeAlongChain } from '../model/walk';
|
||||
import { enabledAlongChain } from '../properties/enabled';
|
||||
import { localMatrixOf, positionOf, transformOf } from '../properties/transform';
|
||||
@@ -17,6 +21,15 @@ export const ENTITY_HANDLE: unique symbol = Symbol('entity handle');
|
||||
* What a graph sees of a running entity; the runtime's Entity satisfies this shape. Mutating
|
||||
* `x`/`y`/`props` is how actions take effect.
|
||||
*/
|
||||
/**
|
||||
* A sound, note or pattern started in time with a playing song or pattern (#338), a stinger: on the
|
||||
* spot `quantize` names of the playing numbered `playback`; 0 for none, which starts it at once.
|
||||
*/
|
||||
export interface MusicSync {
|
||||
playback: number;
|
||||
quantize: Quantize;
|
||||
}
|
||||
|
||||
export interface EntityHandle {
|
||||
readonly [ENTITY_HANDLE]: true;
|
||||
id: string;
|
||||
@@ -35,6 +48,8 @@ export interface EntityHandle {
|
||||
emits?: number;
|
||||
/** What `Play animation` started on this object (see `model/animation.ts`). */
|
||||
animations?: AnimationPlayer[];
|
||||
/** What `Play composition` started on a placed composition (see `model/composition.ts`). */
|
||||
composition?: CompositionPlayer;
|
||||
}
|
||||
|
||||
/** Position, rotation, scale and mirror of every ancestor applied, nearest last. */
|
||||
@@ -228,6 +243,13 @@ export interface NodeContext {
|
||||
/** Absent where nothing is simulated: the editor run, and a test double that does not ask for it. */
|
||||
physics?: PhysicsControl;
|
||||
log(message: string): void;
|
||||
/**
|
||||
* Plays, pauses or seeks the video the sprite `target` wears (#410). A free one gets a player of its
|
||||
* own from then on; a driven one, and the editor run, are left alone and say so in the log.
|
||||
*/
|
||||
steerVideo(target: EntityHandle, steer: VideoSteer): void;
|
||||
/** Where the video the sprite `target` wears stands (#410); null for none. */
|
||||
videoReading(target: EntityHandle): VideoReading | null;
|
||||
/**
|
||||
* Starts a sound asset, named by its id. `volume` multiplies the asset's own (1 leaves it alone);
|
||||
* `loop` is the caller's answer to whether it repeats, and `undefined` leaves that to the asset,
|
||||
@@ -242,7 +264,8 @@ export interface NodeContext {
|
||||
* names (#352), `NO_FX` none.
|
||||
* `fxParams` are values for its parameters; one not given is the default the FX declares.
|
||||
* `tempo` is what a Delay synced to beats counts by (#332). `bus` is the bus it is heard on
|
||||
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names.
|
||||
* (#325): a bus asset or `MASTER_BUS`, `''` for the one the sound asset names. `sync` starts it in
|
||||
* time with a playing (#338).
|
||||
* Returns the number of the voice for `tuneSound`, or 0 where nothing is played.
|
||||
*/
|
||||
playSound(
|
||||
@@ -254,6 +277,7 @@ export interface NodeContext {
|
||||
fxParams: FxParams,
|
||||
tempo: number,
|
||||
bus: string,
|
||||
sync: MusicSync,
|
||||
): number;
|
||||
/**
|
||||
* Plays one note of an instrument asset (#331): `note` as MIDI counts it (60 is C4), struck with
|
||||
@@ -262,7 +286,7 @@ export interface NodeContext {
|
||||
* values for the instrument's parameters, `tempo` as for `playSound`. `fx` sends the note through a
|
||||
* sound FX of its own (#346), `fxParams` with values for its parameters, apart from the instrument's;
|
||||
* `''` the one the instrument names, `NO_FX` none (#352).
|
||||
* `bus` as for `playSound`, `''` for the one the instrument names.
|
||||
* `bus` as for `playSound`, `''` for the one the instrument names, and `sync` too.
|
||||
* Returns the number of the voice, 0 where nothing plays.
|
||||
*/
|
||||
playNote(
|
||||
@@ -277,13 +301,14 @@ export interface NodeContext {
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
sync: MusicSync,
|
||||
): number;
|
||||
/**
|
||||
* Plays a pattern asset through its instrument, at its own tempo, once or round and round, every
|
||||
* note at `volume`. Owned like a `playSound` voice. `fx` is a sound FX all of it goes through as
|
||||
* one chain (#346), `''` the one the pattern names, `NO_FX` none (#352); `fxParams` values for its
|
||||
* parameters, `bus` as for `playSound`. Returns its
|
||||
* number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
|
||||
* parameters, `bus` as for `playSound`. Synced to a playing (#338), it starts on its spot and plays
|
||||
* at its tempo. Returns its number, for `stopVoice` and `tuneSound`; 0 where there is nothing to play.
|
||||
*/
|
||||
playPattern(
|
||||
patternId: string,
|
||||
@@ -293,6 +318,7 @@ export interface NodeContext {
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
sync: MusicSync,
|
||||
): number;
|
||||
/** Stops every playing of that pattern, `''` every pattern; `letRing` lets its notes fall away in their release. */
|
||||
stopPattern(patternId: string, letRing: boolean): void;
|
||||
@@ -300,8 +326,9 @@ export interface NodeContext {
|
||||
* Plays a song asset (#336) at its tempo, its tracks as they are set: from its start, then round
|
||||
* the loop of its ruler with `loop`, else once to its end. `volume` (0 to 1) is the level of the
|
||||
* whole of it, rising there from silence over `fadeIn` seconds. Owned like a `playSound` voice.
|
||||
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. Returns its number; 0 where
|
||||
* there is nothing to play.
|
||||
* `fx`, `fxParams` and `bus` as for `playPattern`, after its tracks. With `live` it plays its notes
|
||||
* even where it has a bake, so its tracks can be turned (#338). Returns its number; 0 where there is
|
||||
* nothing to play.
|
||||
*/
|
||||
playSong(
|
||||
songId: string,
|
||||
@@ -312,7 +339,33 @@ export interface NodeContext {
|
||||
fx: string,
|
||||
fxParams: FxParams,
|
||||
bus: string,
|
||||
live: boolean,
|
||||
): number;
|
||||
/**
|
||||
* Turns one track of a playing song (#338) to `volumeDb` on top of its own level, over `fade`
|
||||
* seconds from where `quantize` lands; a playing of its bake has no tracks, which is logged.
|
||||
*/
|
||||
setSongTrack(playback: number, track: string, volumeDb: number, fade: number, quantize: Quantize): void;
|
||||
/**
|
||||
* Holds the lane `lane` of one track of a playing song (#338) on `value`, in the lane's units,
|
||||
* reached over `fade` seconds from where `quantize` lands; its points are not heard until it is
|
||||
* let go. A track without such a lane, or a playing of its bake, is logged.
|
||||
*/
|
||||
setSongLane(
|
||||
playback: number,
|
||||
track: string,
|
||||
lane: string,
|
||||
value: number,
|
||||
fade: number,
|
||||
quantize: Quantize,
|
||||
): void;
|
||||
/** Lets such a held lane go (#338): back to its curve over `fade` seconds, as soon as can be heard. */
|
||||
releaseSongLane(playback: number, track: string, lane: string, fade: number): void;
|
||||
/**
|
||||
* Plays `songId` in place of a playing song (#338), `''` the same one, from the marker `marker`
|
||||
* (`''` its start), where `quantize` lands: over `fade` seconds, else in a cut. It keeps its number.
|
||||
*/
|
||||
switchSong(playback: number, songId: string, marker: string, quantize: Quantize, fade: number): void;
|
||||
/** Stops every playing of that song, `''` every song, falling silent over `fadeOut` seconds. */
|
||||
stopSong(songId: string, fadeOut: number): void;
|
||||
/** Stops one playing of a song or pattern by its number, falling silent over `fadeOut` seconds. */
|
||||
@@ -350,6 +403,16 @@ export interface NodeContext {
|
||||
setBusVolume(bus: string, volume: number, time: number, ease: EaseShare): void;
|
||||
/** The volume a bus is at, `''` the master: what a game set, else that of its asset; 0 for none. */
|
||||
busVolume(bus: string): number;
|
||||
/**
|
||||
* Where a playing song or pattern stands as it is heard (#338): the one numbered `playback`, else
|
||||
* the one started last of `song` or `pattern`, of every one where both are `''`; null for none.
|
||||
*/
|
||||
songPosition(playback: number | null, song: string, pattern: string): HeardPosition | null;
|
||||
/**
|
||||
* Whether the voice or playing numbered `id` is still to be heard (#373): waiting on its files,
|
||||
* playing, fading or ringing on; false for 0.
|
||||
*/
|
||||
playing(id: number): boolean;
|
||||
/**
|
||||
* Asks for another scene, by asset id. The swap happens once the frame is over – a graph must be
|
||||
* able to finish the flow it is in, and half a frame in the old world and half in the new one is
|
||||
@@ -358,7 +421,7 @@ export interface NodeContext {
|
||||
loadScene(sceneId: string): void;
|
||||
/**
|
||||
* The position is local to `parent` (world when none); null when the asset does not exist or
|
||||
* cannot be placed. A spawned behavior runs its `On start` on the next tick.
|
||||
* cannot be placed. A spawned behavior runs its `On start` at the end of this tick, before the frame is drawn.
|
||||
*/
|
||||
spawn(
|
||||
assetId: string,
|
||||
@@ -391,4 +454,10 @@ export interface NodeContext {
|
||||
isPaused(): boolean;
|
||||
/** From the next frame on, only behaviors that run while paused are ticked. */
|
||||
setPaused(paused: boolean): void;
|
||||
/** Starts recording the game view (#405); where it cannot, the log says why. */
|
||||
startRecording(settings: RecordingSettings): void;
|
||||
/** Ends the recording and saves it; without one, only the log hears of it. */
|
||||
stopRecording(): void;
|
||||
/** Pauses the editor's Play after this frame, as its pause button does; elsewhere nothing. */
|
||||
breakpoint(): void;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { heldTempo } from '../../../music/time';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asNumber, asString } from '../../values';
|
||||
import { detachedPort, exec, fxParamsPort, num, playedBusPort, soundFxPort, withDefault } from '../shared';
|
||||
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
|
||||
import { fxParamsOf, tempoPort } from './PlaySound';
|
||||
|
||||
export const PlayNote: NodeDef = {
|
||||
@@ -10,7 +11,8 @@ export const PlayNote: NodeDef = {
|
||||
label: 'Play note',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names.',
|
||||
'Plays one note of an instrument asset. "note" is its number as MIDI counts it: 60 is C4, 61 C#4, 72 C5. "velocity" is how hard it is struck, 0 to 1 – it makes the note quieter and is handed to what the instrument does with it. "duration" is how many seconds until the note is released; at 0 it is held until a Stop sound ends it. A note belongs to this object like a sound and stops when the object is destroyed, unless "detached" is on. "params" takes values for the parameters of the instrument from an Instrument parameters node. "fx" sends the note through a sound FX on top of what the instrument does; left empty, the one the instrument names, and None through none. "fx params" takes values for its parameters from a Sound FX parameters node – the two sets stay apart. "voice" is the number of this note, for Set sound FX parameters and Stop sound; 0 where the instrument plays nothing for it. "tempo" is what a Delay in the instrument synced to beats counts by, in quarter notes a minute. "bus" is the bus it is heard on; left empty, the one the instrument names. ' +
|
||||
SYNC,
|
||||
inputs: [
|
||||
exec(),
|
||||
{
|
||||
@@ -41,6 +43,8 @@ export const PlayNote: NodeDef = {
|
||||
{ ...soundFxPort(), key: 'soundFx' },
|
||||
fxParamsPort('soundFxParams'),
|
||||
playedBusPort(),
|
||||
syncPort(),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec(), num('voice')],
|
||||
params: [],
|
||||
@@ -59,6 +63,7 @@ export const PlayNote: NodeDef = {
|
||||
asString(inputs.soundFx),
|
||||
fxParamsOf(inputs.soundFxParams),
|
||||
asString(inputs.bus),
|
||||
syncOf(inputs),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { voice } };
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
soundFxPort,
|
||||
withDefault,
|
||||
} from '../shared';
|
||||
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
|
||||
import { fxParamsOf } from './PlaySound';
|
||||
|
||||
export const patternPort = (): PortDef => ({
|
||||
@@ -34,7 +35,8 @@ export const PlayPattern: NodeDef = {
|
||||
'none. "fx params" takes values for its parameters from a Sound ' +
|
||||
'FX parameters node. "playback" is the number of this playing, for Stop pattern and for Set sound ' +
|
||||
'FX parameters; 0 where the pattern has no instrument or no notes. "bus" is the bus all of it is ' +
|
||||
'heard on; left empty, the one the pattern names.',
|
||||
`heard on; left empty, the one the pattern names. ${SYNC} Synced, it plays at the tempo of that ` +
|
||||
'playing.',
|
||||
inputs: [
|
||||
exec(),
|
||||
patternPort(),
|
||||
@@ -44,6 +46,8 @@ export const PlayPattern: NodeDef = {
|
||||
soundFxPort(),
|
||||
fxParamsPort(),
|
||||
playedBusPort(),
|
||||
syncPort(),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec(), num('playback')],
|
||||
params: [],
|
||||
@@ -58,6 +62,7 @@ export const PlayPattern: NodeDef = {
|
||||
asString(inputs.fx),
|
||||
fxParamsOf(inputs.fxParams),
|
||||
asString(inputs.bus),
|
||||
syncOf(inputs),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
|
||||
@@ -33,8 +33,10 @@ export const PlaySong: NodeDef = {
|
||||
'is on. "fx" sends the whole song through one sound FX, after its tracks and before "volume"; ' +
|
||||
'left empty, the one the song names, and None is none. ' +
|
||||
'"fx params" takes values for its parameters from a Sound FX parameters node. "playback" is the ' +
|
||||
'number of this playing, for Stop song and for Set sound FX parameters; 0 where the song has ' +
|
||||
'nothing to play. "bus" is the bus all of it is heard on; left empty, the one the song names.',
|
||||
'number of this playing, for Stop song, Set song track, Switch song and Set sound FX parameters; ' +
|
||||
'0 where the song has nothing to play. "bus" is the bus all of it is heard on; left empty, the ' +
|
||||
'one the song names. A song with a bake plays the bake, one sound for all its tracks; with "live ' +
|
||||
'tracks" it plays its notes instead, as the Song tab does, so Set song track can turn its tracks.',
|
||||
inputs: [
|
||||
exec(),
|
||||
songPort(),
|
||||
@@ -45,6 +47,7 @@ export const PlaySong: NodeDef = {
|
||||
soundFxPort(),
|
||||
fxParamsPort(),
|
||||
playedBusPort(),
|
||||
checkPort('live', 'live tracks', 'Live tracks', false),
|
||||
],
|
||||
outputs: [exec(), num('playback')],
|
||||
params: [],
|
||||
@@ -60,6 +63,7 @@ export const PlaySong: NodeDef = {
|
||||
asString(inputs.fx),
|
||||
fxParamsOf(inputs.fxParams),
|
||||
asString(inputs.bus),
|
||||
asBoolean(inputs.live),
|
||||
)
|
||||
: 0;
|
||||
return { next: 'exec', outputs: { playback } };
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
soundPort,
|
||||
withDefault,
|
||||
} from '../shared';
|
||||
import { quantizePort, SYNC, syncOf, syncPort } from './musicSync';
|
||||
|
||||
/**
|
||||
* Three answers, not two: a checkbox cannot say "leave it to the asset", and without that a sound
|
||||
@@ -64,7 +65,7 @@ export const PlaySound: NodeDef = {
|
||||
'the sound has not been loaded yet. A pitch never changes it. "tail" is that length and the ' +
|
||||
'echoes of its FX after it, as they are when it starts – how long until the voice is quiet. ' +
|
||||
'"tempo" is what a Delay of its FX synced to beats counts by, in quarter notes a minute. "bus" is ' +
|
||||
'the bus it is heard on; left empty, the one the sound names.',
|
||||
`the bus it is heard on; left empty, the one the sound names. ${SYNC}`,
|
||||
inputs: [
|
||||
exec(),
|
||||
soundPort(),
|
||||
@@ -75,6 +76,8 @@ export const PlaySound: NodeDef = {
|
||||
fxParamsPort(),
|
||||
tempoPort(),
|
||||
playedBusPort(),
|
||||
syncPort(),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec(), num('voice'), num('length'), num('tail')],
|
||||
params: [],
|
||||
@@ -90,6 +93,7 @@ export const PlaySound: NodeDef = {
|
||||
fxParamsOf(inputs.fxParams),
|
||||
heldTempo(asNumber(inputs.tempo)),
|
||||
asString(inputs.bus),
|
||||
syncOf(inputs),
|
||||
)
|
||||
: 0;
|
||||
const length = voice ? ctx.soundLength(sound) : 0;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { playComposition, stopComposition } from '../../../model/composition';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asEntity } from '../../values';
|
||||
import { bool, exec, obj, withDefault } from '../shared';
|
||||
|
||||
export const PlayComposition: NodeDef = {
|
||||
type: 'PlayComposition',
|
||||
label: 'Play composition',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays a composition placed in the scene from its first frame: each item becomes an object below ' +
|
||||
'it when the item begins – its behaviors start then – and is destroyed when it ends. One already ' +
|
||||
'playing plays on; Restart starts it over. Without Loop it ends after its last item, and On ' +
|
||||
'composition finished hears that. The editor run plays none and says so in the log.',
|
||||
inputs: [exec(), obj('object'), withDefault(bool('restart'), false)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) playComposition(target, asBoolean(inputs.restart), ctx.self);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const StopComposition: NodeDef = {
|
||||
type: 'StopComposition',
|
||||
label: 'Stop composition',
|
||||
category: 'action',
|
||||
description:
|
||||
'Stops a playing composition: the objects of its items are destroyed on the next frame. It does ' +
|
||||
'not count as finished, and Play composition starts it from its first frame again.',
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (_ctx, inputs) => {
|
||||
const target = asEntity(inputs.object);
|
||||
if (target) stopComposition(target);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -65,6 +65,23 @@ export const ResumeGame: NodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Break: NodeDef = {
|
||||
type: 'Break',
|
||||
label: 'Break',
|
||||
category: 'action',
|
||||
description:
|
||||
"Pauses the editor's Play once this frame is done, as the pause button in the top bar does, to " +
|
||||
'look at the game the moment something happens. Next frame steps on from there, Ctrl+Enter ' +
|
||||
'resumes. A released game and the editor run go past it.',
|
||||
inputs: [exec()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx) => {
|
||||
ctx.breakpoint();
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const IsGamePaused: NodeDef = {
|
||||
type: 'IsGamePaused',
|
||||
label: 'Is game paused',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { heldQuantize, QUANTIZE_OPTIONS } from '../../../music/beats';
|
||||
import type { MusicSync } from '../../NodeContext';
|
||||
import type { PortDef } from '../../NodeRegistry';
|
||||
import { asNumber, type Value } from '../../values';
|
||||
import { num } from '../shared';
|
||||
|
||||
/** Where a change to the music lands (#338): at once, or on the next beat, bar or marker of the playing. */
|
||||
export const quantizePort = (): PortDef => ({
|
||||
key: 'quantize',
|
||||
label: 'quantize',
|
||||
type: 'string',
|
||||
default: 'bar',
|
||||
field: { key: 'quantize', label: 'Quantize', kind: 'enum', default: 'bar', options: [...QUANTIZE_OPTIONS] },
|
||||
});
|
||||
|
||||
/** The playing a sound, note or pattern starts in time with (#338), a stinger: not typed in either. */
|
||||
export const syncPort = (): PortDef => ({ ...num('sync'), inline: false });
|
||||
|
||||
/** What `syncPort` and `quantizePort` ask for. */
|
||||
export function syncOf(inputs: Record<string, Value>): MusicSync {
|
||||
return { playback: asNumber(inputs.sync), quantize: heldQuantize(inputs.quantize) };
|
||||
}
|
||||
|
||||
/** How the nodes that play a sound, note or pattern tell of `sync` (#338). */
|
||||
export const SYNC =
|
||||
'"sync" takes the playback number of a playing song or pattern to start in time with, as a ' +
|
||||
'stinger: on the next beat, bar or marker of it as it is heard, as "quantize" says, planned on ' +
|
||||
'the audio clock to the sample. Unconnected, or where that playing is not heard, it starts at once.';
|
||||
@@ -0,0 +1,84 @@
|
||||
import { RECORDING_FPS, RECORDING_RESOLUTIONS, recordingSettingsOf } from '../../../recording/recording';
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asBoolean, asString } from '../../values';
|
||||
import { checkPort, exec } from '../shared';
|
||||
|
||||
const choicePort = (
|
||||
key: string,
|
||||
label: string,
|
||||
options: readonly { value: string; label: string }[],
|
||||
): PortDef => ({
|
||||
key,
|
||||
label: key,
|
||||
type: 'string',
|
||||
default: options[0]!.value,
|
||||
field: { key, label, kind: 'enum', default: options[0]!.value, options: [...options] },
|
||||
});
|
||||
|
||||
export const StartRecording: NodeDef = {
|
||||
type: 'StartRecording',
|
||||
label: 'Start recording',
|
||||
category: 'action',
|
||||
description:
|
||||
'Records the game view as a video, with what the master plays: only the picture of the camera, ' +
|
||||
'none of what lies over it. "name" is what the recording is saved as; left empty, it is named ' +
|
||||
'after the moment it starts. "resolution" Game view records at the size the picture has on ' +
|
||||
'screen when it starts, 720p, 1080p, 1440p and 4K at that height with the width of the camera, ' +
|
||||
'both at most 3840 × 2160: a wider picture is scaled down to fit. "sound" off records the ' +
|
||||
'picture alone. "real time" off moves the game on by one frame of the recording at a time, ' +
|
||||
'however long the computer takes for it, so not a frame is missed even at 4K and 60 fps: the ' +
|
||||
'game view runs slower meanwhile, and the sound is made apart, in step with the picture. Such a ' +
|
||||
'recording holds at most ten minutes. The recording goes on through Load scene and Restart ' +
|
||||
'game, holds while the editor pauses the run, and ends with Stop recording or with the run. ' +
|
||||
'In the editor it is ' +
|
||||
'always uploaded: "convert" has the server make an MP4 of it, "import" makes that a video asset ' +
|
||||
'of the project once it is ready; without them the video picker has it. A released game ' +
|
||||
'downloads it as an MKV file and takes neither. A second start while one records is ignored and ' +
|
||||
'says so in the log.',
|
||||
inputs: [
|
||||
exec(),
|
||||
{
|
||||
key: 'name',
|
||||
label: 'name',
|
||||
type: 'string',
|
||||
field: { key: 'name', label: 'Name', kind: 'text', default: '' },
|
||||
},
|
||||
choicePort('resolution', 'Resolution', RECORDING_RESOLUTIONS),
|
||||
choicePort('fps', 'FPS', RECORDING_FPS),
|
||||
checkPort('sound', 'sound', 'Sound', true),
|
||||
checkPort('convert', 'convert', 'Convert', true),
|
||||
checkPort('adopt', 'import', 'Import after conversion', true),
|
||||
checkPort('realTime', 'real time', 'Real time', true),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
// Graphs from before these flags have no value for them: on, as they recorded then.
|
||||
const flag = (key: string) => (inputs[key] === undefined ? true : asBoolean(inputs[key]));
|
||||
ctx.startRecording(
|
||||
recordingSettingsOf(asString(inputs.name), asString(inputs.resolution), asString(inputs.fps), {
|
||||
sound: flag('sound'),
|
||||
convert: flag('convert'),
|
||||
adopt: flag('adopt'),
|
||||
realTime: flag('realTime'),
|
||||
}),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const StopRecording: NodeDef = {
|
||||
type: 'StopRecording',
|
||||
label: 'Stop recording',
|
||||
category: 'action',
|
||||
description:
|
||||
'Ends the recording Start recording began and saves it. Without one running it does nothing but ' +
|
||||
'say so in the log.',
|
||||
inputs: [exec()],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx) => {
|
||||
ctx.stopRecording();
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
import { heldQuantize } from '../../../music/beats';
|
||||
import { TRACK_MAX_DB, TRACK_MIN_DB } from '../../../music/level';
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { exec, num, withDefault } from '../shared';
|
||||
import { quantizePort } from './musicSync';
|
||||
import { songPort } from './PlaySong';
|
||||
|
||||
/** The playing a node changes: not typed in, as it is a number Play song hands out. */
|
||||
const playbackPort = (): PortDef => ({ ...num('playback'), inline: false });
|
||||
|
||||
const trackPort = (): PortDef => ({
|
||||
key: 'track',
|
||||
label: 'track',
|
||||
type: 'string',
|
||||
field: { key: 'track', label: 'Track', kind: 'asset', default: '', assetTypes: ['song'], tracks: true },
|
||||
});
|
||||
|
||||
const markerPort = (): PortDef => ({
|
||||
key: 'marker',
|
||||
label: 'marker',
|
||||
type: 'string',
|
||||
field: { key: 'marker', label: 'Marker', kind: 'asset', default: '', assetTypes: ['song'], markers: true },
|
||||
});
|
||||
|
||||
const QUANTIZE =
|
||||
'"quantize" says where it lands: Now, or on the next beat, bar or marker of the song as it is ' +
|
||||
'heard, planned on the audio clock to the sample; Next marker in a song without markers is the ' +
|
||||
'next bar.';
|
||||
|
||||
export const SetSongTrack: NodeDef = {
|
||||
type: 'SetSongTrack',
|
||||
label: 'Set song track',
|
||||
category: 'action',
|
||||
description:
|
||||
'Turns one track of a playing song up or down, by the "playback" number Play song handed out: ' +
|
||||
'"volume" in decibels on top of the level the song gives it, 0 as the song has it, -60 silent – ' +
|
||||
'what goes into the track, its sound FX and its sends alike. It gets there over "fade" seconds. ' +
|
||||
`${QUANTIZE} The track is picked as "Song / Track"; a track the playing song does not have is ` +
|
||||
'left alone. A song played from its bake has no tracks to turn: switch on "live tracks" in Play ' +
|
||||
'song, else the log says so. A Switch song to the same song keeps what was turned.',
|
||||
inputs: [
|
||||
exec(),
|
||||
playbackPort(),
|
||||
trackPort(),
|
||||
withDefault(
|
||||
{
|
||||
...num('volume', 'volume (dB)'),
|
||||
field: {
|
||||
key: 'volume',
|
||||
label: 'Volume (dB)',
|
||||
kind: 'number',
|
||||
default: 0,
|
||||
min: TRACK_MIN_DB,
|
||||
max: TRACK_MAX_DB,
|
||||
},
|
||||
},
|
||||
0,
|
||||
),
|
||||
withDefault(num('fade'), 1),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.setSongTrack(
|
||||
asNumber(inputs.playback),
|
||||
asString(inputs.track),
|
||||
asNumber(inputs.volume),
|
||||
asNumber(inputs.fade),
|
||||
heldQuantize(inputs.quantize),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
const lanePort = (): PortDef => ({
|
||||
key: 'lane',
|
||||
label: 'lane',
|
||||
type: 'string',
|
||||
default: 'volume',
|
||||
field: { key: 'lane', label: 'Lane', kind: 'text', default: 'volume' },
|
||||
});
|
||||
|
||||
const LANE =
|
||||
'The track is picked as "Song / Track"; "lane" names one of its automation lanes: "volume", ' +
|
||||
'"pan" or a parameter by its name. A track the playing song does not have is left alone; one ' +
|
||||
'without that lane is logged, and so is a song played from its bake ("live tracks" in Play song).';
|
||||
|
||||
export const SetSongLane: NodeDef = {
|
||||
type: 'SetSongLane',
|
||||
label: 'Set song lane',
|
||||
category: 'action',
|
||||
description:
|
||||
'Takes an automation lane of one track of a playing song over, by the "playback" number Play ' +
|
||||
'song handed out: from where the lane is, it goes to "value" over "fade" seconds and stays there, ' +
|
||||
'the points of the lane no longer heard, until Release song lane lets it go. "value" is in the ' +
|
||||
'units of the lane – decibels for the volume, -1 (left) to 1 (right) for the pan, a parameter in ' +
|
||||
`the range of its lane – and held to that range. ${QUANTIZE} ${LANE} A Switch song to the same ` +
|
||||
'song keeps what is held.',
|
||||
inputs: [
|
||||
exec(),
|
||||
playbackPort(),
|
||||
trackPort(),
|
||||
lanePort(),
|
||||
withDefault(num('value'), 0),
|
||||
withDefault(num('fade'), 1),
|
||||
quantizePort(),
|
||||
],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.setSongLane(
|
||||
asNumber(inputs.playback),
|
||||
asString(inputs.track),
|
||||
asString(inputs.lane),
|
||||
asNumber(inputs.value),
|
||||
asNumber(inputs.fade),
|
||||
heldQuantize(inputs.quantize),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const ReleaseSongLane: NodeDef = {
|
||||
type: 'ReleaseSongLane',
|
||||
label: 'Release song lane',
|
||||
category: 'action',
|
||||
description:
|
||||
'Lets an automation lane go that Set song lane took over, by the "playback" number Play song ' +
|
||||
'handed out: as soon as it can be heard, it goes back over "fade" seconds to where the curve of ' +
|
||||
'the lane is by then, and follows it from there. A lane that is not held stays as it is. ' +
|
||||
LANE,
|
||||
inputs: [exec(), playbackPort(), trackPort(), lanePort(), withDefault(num('fade'), 1)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.releaseSongLane(
|
||||
asNumber(inputs.playback),
|
||||
asString(inputs.track),
|
||||
asString(inputs.lane),
|
||||
asNumber(inputs.fade),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
|
||||
export const SwitchSong: NodeDef = {
|
||||
type: 'SwitchSong',
|
||||
label: 'Switch song',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays another song, or the same one from a marker, in place of a playing song, by the ' +
|
||||
'"playback" number Play song handed out; the playing keeps its number, so Stop song and Set ' +
|
||||
'song track go on reaching it. "song" left empty is the same song, "marker" left empty its ' +
|
||||
`start. ${QUANTIZE} With "fade" 0 it is a cut on that spot; else the old song falls silent over ` +
|
||||
'that many seconds as the new one rises from it. The new song plays with the volume, loop, ' +
|
||||
'bus, sound FX and "live tracks" of its Play song. A marker the song does not have is logged, ' +
|
||||
'and nothing changes.',
|
||||
inputs: [exec(), playbackPort(), songPort(), markerPort(), quantizePort(), withDefault(num('fade'), 0)],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: (ctx, inputs) => {
|
||||
ctx.switchSong(
|
||||
asNumber(inputs.playback),
|
||||
asString(inputs.song),
|
||||
asString(inputs.marker),
|
||||
heldQuantize(inputs.quantize),
|
||||
asNumber(inputs.fade),
|
||||
);
|
||||
return { next: 'exec' };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { VideoSteer } from '../../../model/videoClock';
|
||||
import type { EntityHandle, NodeContext } from '../../NodeContext';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asEntity, asNumber, type Value } from '../../values';
|
||||
import { exec, num, obj } from '../shared';
|
||||
|
||||
const STEERS =
|
||||
'A free video gets a player of its own from then on, so other sprites wearing the same clip play ' +
|
||||
'on. A driven one and the editor run are left alone and say so in the log. Unconnected, object is the owner.';
|
||||
|
||||
/** The sprite a video node works on: the connected object, or the owner. */
|
||||
export function videoTarget(ctx: NodeContext, value: Value | undefined): EntityHandle | null {
|
||||
return asEntity(value) ?? ctx.owner;
|
||||
}
|
||||
|
||||
function steering(steer: (inputs: Record<string, Value>) => VideoSteer): NodeDef['run'] {
|
||||
return (ctx, inputs) => {
|
||||
const target = videoTarget(ctx, inputs.object);
|
||||
if (target) ctx.steerVideo(target, steer(inputs));
|
||||
return { next: 'exec' };
|
||||
};
|
||||
}
|
||||
|
||||
export const PlayVideo: NodeDef = {
|
||||
type: 'PlayVideo',
|
||||
label: 'Play video',
|
||||
category: 'action',
|
||||
description:
|
||||
'Plays on the video a sprite wears from where it stands, heard as before. At the end of a clip ' +
|
||||
`without Loop it starts over. ${STEERS}`,
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: steering(() => ({ kind: 'play' })),
|
||||
};
|
||||
|
||||
export const PauseVideo: NodeDef = {
|
||||
type: 'PauseVideo',
|
||||
label: 'Pause video',
|
||||
category: 'action',
|
||||
description: `Holds the video a sprite wears at the picture it shows, without sound. ${STEERS}`,
|
||||
inputs: [exec(), obj('object')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: steering(() => ({ kind: 'pause' })),
|
||||
};
|
||||
|
||||
export const SeekVideo: NodeDef = {
|
||||
type: 'SeekVideo',
|
||||
label: 'Seek video',
|
||||
category: 'action',
|
||||
description:
|
||||
'Sends the video a sprite wears to time, in seconds from the start of its clip; playing or held, ' +
|
||||
'it stays so. Past the end it goes round with Loop and holds the last picture without, as Video ' +
|
||||
`time does. ${STEERS}`,
|
||||
inputs: [exec(), obj('object'), num('time', 'time (s)')],
|
||||
outputs: [exec()],
|
||||
params: [],
|
||||
run: steering((inputs) => ({ kind: 'seek', time: asNumber(inputs.time) })),
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { PLAYBACK_INPUT, playbackPort } from '../events/musicEvents';
|
||||
import { bool, num } from '../shared';
|
||||
|
||||
export const MusicPlaying: NodeDef = {
|
||||
type: 'MusicPlaying',
|
||||
label: 'Music playing',
|
||||
category: 'data',
|
||||
description:
|
||||
'Whether the song or pattern numbered "playback" is still to be heard: from Play song or Play ' +
|
||||
'pattern on, while its files load, while it plays and fades, until its last echoes are gone – ' +
|
||||
'the moment On music finished runs. Unlike "playing" of Song position it is on before the first ' +
|
||||
'note is heard. Off for 0 and for a number nothing plays under.',
|
||||
inputs: [playbackPort()],
|
||||
outputs: [bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input(PLAYBACK_INPUT))) }),
|
||||
};
|
||||
|
||||
export const SoundPlaying: NodeDef = {
|
||||
type: 'SoundPlaying',
|
||||
label: 'Sound playing',
|
||||
category: 'data',
|
||||
description:
|
||||
'Whether the voice numbered "voice", the number Play sound handed out, is still to be heard: ' +
|
||||
'while its file loads, while it plays, until the echoes of its FX are gone – the moment On sound ' +
|
||||
'finished runs. Off for 0 and for a voice that has ended.',
|
||||
inputs: [{ ...num('voice'), inline: false }],
|
||||
outputs: [bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => ({ playing: ctx.playing(asNumber(input('voice'))) }),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber, asString } from '../../values';
|
||||
import { patternPort } from '../action/PlayPattern';
|
||||
import { songPort } from '../action/PlaySong';
|
||||
import { PLAYBACK_INPUT, playbackPort } from '../events/musicEvents';
|
||||
import { bool, num } from '../shared';
|
||||
|
||||
export const SongPosition: NodeDef = {
|
||||
type: 'SongPosition',
|
||||
label: 'Song position',
|
||||
category: 'data',
|
||||
description:
|
||||
'Where a playing song or pattern stands, as it is heard: the output latency of the device taken ' +
|
||||
'off, a baked song too, so what is drawn by it keeps to the sound. "bar" and "beat" count from 1 ' +
|
||||
'as the ruler does, "fraction" is how far into the beat (0 to below 1), "seconds" how far from ' +
|
||||
'the start of the song, back with the loop. Connect "playback" for that playing; else it is the ' +
|
||||
'one started last of the Song or the Pattern picked (both picked, either), of every song and ' +
|
||||
'pattern where none is. "playing" is off before its first note is heard and after its end; with ' +
|
||||
'no such playing everything is 0.',
|
||||
inputs: [songPort(), patternPort(), playbackPort()],
|
||||
outputs: [num('bar'), num('beat'), num('fraction'), num('seconds'), bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => {
|
||||
const playback = input(PLAYBACK_INPUT);
|
||||
const at = ctx.songPosition(
|
||||
playback === null ? null : asNumber(playback),
|
||||
asString(input('song')),
|
||||
asString(input('pattern')),
|
||||
);
|
||||
return at
|
||||
? { bar: at.bar, beat: at.beat, fraction: at.fraction, seconds: at.seconds, playing: at.playing }
|
||||
: { bar: 0, beat: 0, fraction: 0, seconds: 0, playing: false };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { videoTarget } from '../action/videoNodes';
|
||||
import { bool, num, obj } from '../shared';
|
||||
|
||||
export const VideoTime: NodeDef = {
|
||||
type: 'VideoTime',
|
||||
label: 'Video time',
|
||||
category: 'data',
|
||||
description:
|
||||
'Where the video a sprite wears stands: "time" in seconds from the start of its clip, "length" ' +
|
||||
'how long the clip lasts, "playing" whether it moves. A free video counts from when the sprite ' +
|
||||
'appeared, while it is switched on; a driven one stands where its Video time says. All 0 for a ' +
|
||||
'sprite without a video. Unconnected, object is the owner.',
|
||||
inputs: [obj('object')],
|
||||
outputs: [num('time', 'time (s)'), num('length', 'length (s)'), bool('playing')],
|
||||
params: [],
|
||||
read: (ctx, _params, input) => {
|
||||
const target = videoTarget(ctx, input('object'));
|
||||
const reading = target ? ctx.videoReading(target) : null;
|
||||
return reading ? { ...reading } : { time: 0, length: 0, playing: false };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, obj } from '../shared';
|
||||
|
||||
export const OnCompositionFinished: NodeDef = {
|
||||
type: 'OnCompositionFinished',
|
||||
label: 'On composition finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a composition placed at the owner, or below it, plays past its last item – and when ' +
|
||||
'one this behavior played itself does, wherever it stands. A looping one never finishes, and Stop ' +
|
||||
'composition does not count. Leave Composition at None to hear every composition.',
|
||||
inputs: [],
|
||||
outputs: [exec(), obj('object')],
|
||||
params: [
|
||||
{ key: 'composition', label: 'Composition', kind: 'asset', default: '', assetTypes: ['composition'] },
|
||||
orderParam,
|
||||
],
|
||||
hiddenParams: (params) => [
|
||||
...(textOf(params.composition ?? '') ? [] : ['composition']),
|
||||
...hiddenOrder(params),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { bool, exec, num } from '../shared';
|
||||
|
||||
/** The input that picks one play (#373): not typed in, so unconnected it is null and the Sound decides. */
|
||||
export const VOICE_INPUT = 'voice';
|
||||
|
||||
export const OnSoundFinished: NodeDef = {
|
||||
type: 'OnSoundFinished',
|
||||
label: 'On sound finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a sound a Play sound of this game started is over: once nothing of it is heard ' +
|
||||
'any more, the echoes of its FX included. "stopped" is on where it did not play to its end but ' +
|
||||
'was stopped – by Stop sound, by the end of the behavior that owns it, or to make room for ' +
|
||||
'newer voices – and where it never started because its file would not load. A looping sound ' +
|
||||
'ends only so. Pick a Sound to hear only that one, or connect "voice" to hear only that play, ' +
|
||||
'the number Play sound handed out. A note of an instrument is none of them. It comes in the ' +
|
||||
'first frame after the end, so up to a frame late.',
|
||||
inputs: [{ ...num(VOICE_INPUT), inline: false }],
|
||||
outputs: [exec(), num('voice'), bool('stopped')],
|
||||
params: [{ key: 'sound', label: 'Sound', kind: 'sound', default: '' }, orderParam],
|
||||
hiddenParams: (params) => [...(textOf(params.sound ?? '') ? [] : ['sound']), ...hiddenOrder(params)],
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { exec, obj } from '../shared';
|
||||
|
||||
export const OnVideoFinished: NodeDef = {
|
||||
type: 'OnVideoFinished',
|
||||
label: 'On video finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a video without Loop reaches its last picture on the owner, or on a sprite below ' +
|
||||
'it – and when one this behavior played, paused or sought does, on whatever sprite. A free video ' +
|
||||
'counts from when its sprite appeared, while it is switched on; a driven one ends when its Video ' +
|
||||
'time passes the end. Leave Video at None to hear every video; a video hears its clips as well.',
|
||||
inputs: [],
|
||||
outputs: [exec(), obj('object')],
|
||||
params: [{ key: 'video', label: 'Video', kind: 'asset', default: '', assetTypes: ['video'] }, orderParam],
|
||||
hiddenParams: (params) => [...(textOf(params.video ?? '') ? [] : ['video']), ...hiddenOrder(params)],
|
||||
};
|
||||
@@ -0,0 +1,112 @@
|
||||
import { textOf } from '../../../model/Properties';
|
||||
import type { PropertyDef } from '../../../properties/PropertySchema';
|
||||
import type { NodeDef, PortDef } from '../../NodeRegistry';
|
||||
import { hiddenOrder, orderParam } from '../../eventOrder';
|
||||
import { bool, exec, num, str } from '../shared';
|
||||
|
||||
/** The input that picks one playing (#338): not typed in, so unconnected it is null and hears every one. */
|
||||
export const PLAYBACK_INPUT = 'playback';
|
||||
export const playbackPort = (): PortDef => ({ ...num(PLAYBACK_INPUT), inline: false });
|
||||
|
||||
const songParam: PropertyDef = {
|
||||
key: 'song',
|
||||
label: 'Song',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['song'],
|
||||
};
|
||||
const patternParam: PropertyDef = {
|
||||
key: 'pattern',
|
||||
label: 'Pattern',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['pattern'],
|
||||
};
|
||||
const markerParam: PropertyDef = {
|
||||
key: 'marker',
|
||||
label: 'Marker',
|
||||
kind: 'asset',
|
||||
default: '',
|
||||
assetTypes: ['song'],
|
||||
markers: true,
|
||||
};
|
||||
|
||||
/** A pick left at None is not shown on the node: it hears every one. */
|
||||
const hiddenEmpty =
|
||||
(...keys: string[]) =>
|
||||
(params: Parameters<NonNullable<NodeDef['hiddenParams']>>[0]) => [
|
||||
...keys.filter((key) => textOf(params[key] ?? '') === ''),
|
||||
...hiddenOrder(params),
|
||||
];
|
||||
|
||||
const HEARS =
|
||||
'It hears every song a Play song plays and every pattern a Play pattern plays, wherever it was ' +
|
||||
'played; pick a Song or a Pattern to hear only that one (both picked, either), or connect ' +
|
||||
'"playback" to hear only that playing. A composition plays none of them. It goes by what is heard, ' +
|
||||
'the output latency of the device taken off, a baked song too. A frame comes now and then, so ' +
|
||||
'"late" says how many seconds ago it was heard; one more than half a beat (at most 0.15 s) ago is ' +
|
||||
'not told, and of several passed in one frame only the last.';
|
||||
|
||||
export const OnBeat: NodeDef = {
|
||||
type: 'OnBeat',
|
||||
label: 'On beat',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs on every beat of a playing song or pattern, the first beat of a bar too. A beat is a note ' +
|
||||
'of the value under the line of the time signature: a quarter in 4/4, an eighth in 6/8. "bar" ' +
|
||||
'and "beat" count from 1, as the ruler does, and start over with the loop. ' +
|
||||
HEARS,
|
||||
inputs: [playbackPort()],
|
||||
outputs: [exec(), num('playback'), num('bar'), num('beat'), num('late')],
|
||||
params: [songParam, patternParam, orderParam],
|
||||
hiddenParams: hiddenEmpty('song', 'pattern'),
|
||||
};
|
||||
|
||||
export const OnBar: NodeDef = {
|
||||
type: 'OnBar',
|
||||
label: 'On bar',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs at the start of every bar of a playing song or pattern, "bar" counting from 1 as the ruler ' +
|
||||
'does; with the loop it starts over at the bar the loop starts in. ' +
|
||||
HEARS,
|
||||
inputs: [playbackPort()],
|
||||
outputs: [exec(), num('playback'), num('bar'), num('late')],
|
||||
params: [songParam, patternParam, orderParam],
|
||||
hiddenParams: hiddenEmpty('song', 'pattern'),
|
||||
};
|
||||
|
||||
export const OnMusicFinished: NodeDef = {
|
||||
type: 'OnMusicFinished',
|
||||
label: 'On music finished',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs once when a song or pattern a Play song or Play pattern of this game started is over: once ' +
|
||||
'nothing of it is heard any more, its last echoes and a fade out included. "stopped" is on where ' +
|
||||
'it did not play to its end but was stopped – by Stop song, Stop pattern or Stop sound, or by ' +
|
||||
'the end of the behavior that owns it. One with a loop ends only so. After a Switch song it ' +
|
||||
'counts as the song heard last. Pick a Song or a Pattern to hear only that one (both picked, ' +
|
||||
'either), or connect "playback" to hear only that playing. It comes in the first frame after ' +
|
||||
'the end, so up to a frame late.',
|
||||
inputs: [playbackPort()],
|
||||
outputs: [exec(), num('playback'), bool('stopped')],
|
||||
params: [songParam, patternParam, orderParam],
|
||||
hiddenParams: hiddenEmpty('song', 'pattern'),
|
||||
};
|
||||
|
||||
export const OnSongMarker: NodeDef = {
|
||||
type: 'OnSongMarker',
|
||||
label: 'On song marker',
|
||||
category: 'event',
|
||||
description:
|
||||
'Runs when a playing song passes a marker of its ruler, every time round the loop. Pick a Marker ' +
|
||||
'to hear only that one, or a Song to hear only its markers; markers on one tick each run it. ' +
|
||||
'"marker" is the id a marker is known by, "name" what it is called. Connect "playback" to hear ' +
|
||||
'only that playing. It goes by what is heard, a baked song too. "late" says how many seconds ago ' +
|
||||
'it was heard; one more than half a beat (at most 0.15 s) ago is not told, and of several passed ' +
|
||||
'in one frame only the last.',
|
||||
inputs: [playbackPort()],
|
||||
outputs: [exec(), num('playback'), str('marker'), str('name'), num('late')],
|
||||
params: [songParam, markerParam, orderParam],
|
||||
hiddenParams: hiddenEmpty('song', 'marker'),
|
||||
};
|
||||
@@ -2,8 +2,9 @@ import { type NodeDef, registerNode } from '../NodeRegistry';
|
||||
import { AddJoint } from './action/AddJoint';
|
||||
import { Destroy } from './action/Destroy';
|
||||
import { PauseAnimation, PlayAnimation, StopAnimation } from './action/animationNodes';
|
||||
import { PlayComposition, StopComposition } from './action/compositionNodes';
|
||||
import { Emit } from './action/Emit';
|
||||
import { IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
|
||||
import { Break, IsGamePaused, PauseGame, ResumeGame, SetTimeScale, TimeScale } from './action/gameTimeNodes';
|
||||
import { LoadScene } from './action/LoadScene';
|
||||
import { Log } from './action/Log';
|
||||
import { IgnoreOneWay } from './action/IgnoreOneWay';
|
||||
@@ -30,9 +31,12 @@ import { PlayPattern } from './action/PlayPattern';
|
||||
import { StopPattern } from './action/StopPattern';
|
||||
import { PlaySong } from './action/PlaySong';
|
||||
import { StopSong } from './action/StopSong';
|
||||
import { ReleaseSongLane, SetSongLane, SetSongTrack, SwitchSong } from './action/songSync';
|
||||
import { SetZ } from './action/SetZ';
|
||||
import { Spawn } from './action/Spawn';
|
||||
import { StopGame } from './action/StopGame';
|
||||
import { StartRecording, StopRecording } from './action/recordingNodes';
|
||||
import { PauseVideo, PlayVideo, SeekVideo } from './action/videoNodes';
|
||||
import { StopSound } from './action/StopSound';
|
||||
import { CellToWorld, SetTile, TileAt, TileLiteral, WorldToCell } from './action/tileNodes';
|
||||
import { Abs } from './data/Abs';
|
||||
@@ -122,6 +126,13 @@ import { SetItem } from './data/SetItem';
|
||||
import { SubList } from './data/SubList';
|
||||
import { TextureSize } from './data/TextureSize';
|
||||
import { OnAnimationFinished } from './events/OnAnimationFinished';
|
||||
import { OnCompositionFinished } from './events/OnCompositionFinished';
|
||||
import { OnVideoFinished } from './events/OnVideoFinished';
|
||||
import { OnBar, OnBeat, OnMusicFinished, OnSongMarker } from './events/musicEvents';
|
||||
import { OnSoundFinished } from './events/OnSoundFinished';
|
||||
import { MusicPlaying, SoundPlaying } from './data/MusicPlaying';
|
||||
import { SongPosition } from './data/SongPosition';
|
||||
import { VideoTime } from './data/VideoTime';
|
||||
import { OnBehavior } from './events/OnBehavior';
|
||||
import { OnClick } from './events/OnClick';
|
||||
import { OnMouseWheel } from './events/OnMouseWheel';
|
||||
@@ -182,6 +193,13 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
OnClick,
|
||||
OnMouseWheel,
|
||||
OnAnimationFinished,
|
||||
OnCompositionFinished,
|
||||
OnVideoFinished,
|
||||
OnBeat,
|
||||
OnBar,
|
||||
OnSongMarker,
|
||||
OnMusicFinished,
|
||||
OnSoundFinished,
|
||||
OnBehavior,
|
||||
OnStateEntered,
|
||||
OnStateExited,
|
||||
@@ -307,6 +325,11 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
PlayAnimation,
|
||||
PauseAnimation,
|
||||
StopAnimation,
|
||||
PlayComposition,
|
||||
StopComposition,
|
||||
PlayVideo,
|
||||
PauseVideo,
|
||||
SeekVideo,
|
||||
PlaySound,
|
||||
StopSound,
|
||||
SetSoundFxParameters,
|
||||
@@ -317,15 +340,26 @@ export const BUILT_IN_NODES: readonly NodeDef[] = [
|
||||
StopPattern,
|
||||
PlaySong,
|
||||
StopSong,
|
||||
SetSongTrack,
|
||||
SetSongLane,
|
||||
ReleaseSongLane,
|
||||
SwitchSong,
|
||||
SongPosition,
|
||||
MusicPlaying,
|
||||
SoundPlaying,
|
||||
VideoTime,
|
||||
SetBusVolume,
|
||||
BusVolume,
|
||||
LoadScene,
|
||||
Log,
|
||||
StopGame,
|
||||
RestartGame,
|
||||
StartRecording,
|
||||
StopRecording,
|
||||
PauseGame,
|
||||
ResumeGame,
|
||||
IsGamePaused,
|
||||
Break,
|
||||
SetTimeScale,
|
||||
TimeScale,
|
||||
];
|
||||
|
||||
@@ -21,8 +21,10 @@ import {
|
||||
SoundFxPitch,
|
||||
} from '../soundfx/nodes';
|
||||
import {
|
||||
ACID_ROWS,
|
||||
CHIP_ROWS,
|
||||
ENVELOPE_ROWS,
|
||||
INSTRUMENT_ACID,
|
||||
INSTRUMENT_CHIP,
|
||||
INSTRUMENT_ENVELOPE,
|
||||
INSTRUMENT_NOTES,
|
||||
@@ -58,6 +60,7 @@ export type TonePreset =
|
||||
| 'pluck'
|
||||
| 'triangleBass'
|
||||
| 'pluckBass'
|
||||
| 'acidBass'
|
||||
| 'drumKit';
|
||||
export type ChipPreset = EffectPreset | TonePreset;
|
||||
|
||||
@@ -76,6 +79,7 @@ export const TONE_PRESETS: readonly { value: TonePreset; label: string }[] = [
|
||||
{ value: 'pluck', label: 'Pluck' },
|
||||
{ value: 'triangleBass', label: 'Triangle Bass' },
|
||||
{ value: 'pluckBass', label: 'Pluck Bass' },
|
||||
{ value: 'acidBass', label: 'Acid Bass' },
|
||||
{ value: 'drumKit', label: 'Drum Kit' },
|
||||
];
|
||||
|
||||
@@ -395,9 +399,21 @@ const COLUMN = 240;
|
||||
const ROW = 340;
|
||||
/**
|
||||
* Columns a node takes that is drawn as its editor in the graph (#385): a Filter's curve is wide,
|
||||
* a Chip's device (#386) wider.
|
||||
* a Chip's device (#386) wider, an Envelope's (#388) a little less, a Pitch's and a Mix's (#390) two,
|
||||
* a Delay's (#393), a Phaser's, a Flanger's (#398) and a Bitcrusher's (#399) four, an Acid's (#421) three.
|
||||
*/
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = { [SoundFxFilter.type]: 4, [INSTRUMENT_CHIP]: 5 };
|
||||
const COLUMNS_OF: Readonly<Record<string, number>> = {
|
||||
[SoundFxFilter.type]: 4,
|
||||
[SoundFxBitcrusher.type]: 4,
|
||||
[SoundFxDelay.type]: 4,
|
||||
[SoundFxPhaser.type]: 4,
|
||||
[SoundFxFlanger.type]: 4,
|
||||
[SoundFxPitch.type]: 2,
|
||||
[SoundFxMix.type]: 2,
|
||||
[INSTRUMENT_CHIP]: 5,
|
||||
[INSTRUMENT_ENVELOPE]: 3,
|
||||
[INSTRUMENT_ACID]: 3,
|
||||
};
|
||||
|
||||
/** The column right of a node, past all it takes. */
|
||||
const columnAfter = (node: GraphNode): number => node.x / COLUMN + (COLUMNS_OF[node.type] ?? 1);
|
||||
@@ -700,6 +716,15 @@ const TONES: Record<Exclude<TonePreset, 'drumKit'>, (s: Sketch, notes: GraphNode
|
||||
sustain: 0.15,
|
||||
release: 0.08,
|
||||
}),
|
||||
// nodeseq's acid (#421): a saw through a filter that rings and snaps shut, with a short echo.
|
||||
acidBass: (s, notes) => {
|
||||
const acid = s.add(INSTRUMENT_ACID, 1, 0, {
|
||||
wave: 'saw',
|
||||
...held({ cutoff: 250, resonance: 0.78, envMod: 0.62 }, ACID_ROWS),
|
||||
});
|
||||
s.wire(notes, 'any', acid, 'trigger');
|
||||
return s.line(acid, echo(0.75, 0.25, 0.1));
|
||||
},
|
||||
};
|
||||
|
||||
/** An instrument preset: every note at its own pitch, the same graph each time it is picked. */
|
||||
|
||||
@@ -21,6 +21,7 @@ import { resolveAssetProps } from '../properties/resolve';
|
||||
import { volumeOf } from '../properties/sounds';
|
||||
import { soundFxBuildFor, soundFxKey } from '../soundfx/compile';
|
||||
import {
|
||||
type FxAcidPlan,
|
||||
type FxAmount,
|
||||
type FxChipPlan,
|
||||
type FxEnvelopePlan,
|
||||
@@ -41,9 +42,12 @@ import {
|
||||
withTaps,
|
||||
} from '../soundfx/nodes';
|
||||
import {
|
||||
ACID_ROWS,
|
||||
acidWave,
|
||||
CHIP_ROWS,
|
||||
chipWave,
|
||||
ENVELOPE_ROWS,
|
||||
INSTRUMENT_ACID,
|
||||
INSTRUMENT_CHIP,
|
||||
INSTRUMENT_ENVELOPE,
|
||||
INSTRUMENT_NOTES,
|
||||
@@ -210,6 +214,11 @@ export function compileInstrument(
|
||||
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
|
||||
return { out: { plans: [{ pitch: [], stages: [], chip: chipOf(node, note, amounts) }] } };
|
||||
}
|
||||
case INSTRUMENT_ACID: {
|
||||
const trigger = inputs.trigger;
|
||||
if (!trigger || !('on' in trigger) || !trigger.on) return { out: SILENT };
|
||||
return { out: { plans: [{ pitch: [], stages: [], acid: acidOf(node, note, amounts) }] } };
|
||||
}
|
||||
case INSTRUMENT_SOUND_FX: {
|
||||
const fx = sources.soundFx(textOf(node.params.fx ?? ''));
|
||||
const arriving = plansAt('in');
|
||||
@@ -293,6 +302,25 @@ export function compileInstrument(
|
||||
};
|
||||
}
|
||||
|
||||
/** An Acid's numbers, held to its rows (#421): it always plays the note, as loud as its velocity. */
|
||||
function acidOf(node: GraphNode, played: number, amounts: Readonly<Record<string, FxAmount>>): FxAcidPlan {
|
||||
const rows = ACID_ROWS;
|
||||
return {
|
||||
id: node.id,
|
||||
wave: acidWave(node.params),
|
||||
frequency: { value: noteFrequency(played) },
|
||||
velocity: { value: 1, param: VELOCITY_PARAM, min: 0, max: 1 },
|
||||
cutoff: held(amounts.cutoff, rows.cutoff),
|
||||
resonance: held(amounts.resonance, rows.resonance),
|
||||
envMod: held(amounts.envMod, rows.envMod),
|
||||
attack: held(amounts.attack, rows.attack),
|
||||
decay: held(amounts.decay, rows.decay),
|
||||
sustain: held(amounts.sustain, rows.sustain),
|
||||
release: held(amounts.release, rows.release),
|
||||
glide: held(amounts.glide, rows.glide),
|
||||
};
|
||||
}
|
||||
|
||||
const output = graph.nodes.find((n) => n.type === INSTRUMENT_OUTPUT);
|
||||
if (!output) return { layers: [], problems: walk.problems, reason: 'No Output node' };
|
||||
// Saved while the Output still took any number of sounds: only the first would be heard.
|
||||
@@ -449,6 +477,15 @@ export function playedNotes(graph: Graph): Set<number> {
|
||||
return notes;
|
||||
}
|
||||
|
||||
/** Which notes are heard: inside a Notes node's range, and where they lead to something that sounds. */
|
||||
export function soundingNotes(asset: InstrumentAsset, sources: InstrumentSources): Set<number> {
|
||||
return new Set(
|
||||
[...playedNotes(asset.graph)].filter(
|
||||
(note) => instrumentBuildFor(asset, note, sources).layers.length > 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** The notes a Notes node with single notes has given a name of their own (a drum kit's). */
|
||||
export function namedNotes(graph: Graph): Map<number, string> {
|
||||
const names = new Map<number, string>();
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { NodeDef, PortDef } from '../graph/NodeRegistry';
|
||||
import { asNumber } from '../graph/values';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import type { FlatDef, PropertyDef } from '../properties/PropertySchema';
|
||||
import type { AcidWave } from '../soundfx/acid';
|
||||
import type { ChipWave } from '../soundfx/chip';
|
||||
import { type FxChipPlan, soundFxParamPort } from '../soundfx/nodes';
|
||||
import { HIGHEST_NOTE, heldNote, LOWEST_NOTE, MIDDLE_C, NOTE_OPTIONS, noteName } from './notes';
|
||||
@@ -120,7 +121,7 @@ const ROOT = noteRow('root', 'Root', MIDDLE_C);
|
||||
const FOLLOW = { key: 'followNote', label: 'Follow note', kind: 'boolean' as const, default: true };
|
||||
/** How a Sample plays while its note is held and once it is let go. */
|
||||
export type SamplePlay = 'gate' | 'oneshot' | 'loop' | 'release';
|
||||
const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [
|
||||
export const SAMPLE_PLAYS: { value: SamplePlay; label: string }[] = [
|
||||
{ value: 'gate', label: 'Gate' },
|
||||
{ value: 'oneshot', label: 'One-shot' },
|
||||
{ value: 'loop', label: 'Loop' },
|
||||
@@ -154,7 +155,7 @@ export function withSamplePlay(params: PropertyBag): PropertyBag {
|
||||
const { mode: _mode, loop: _loop, ...rest } = params;
|
||||
return { ...rest, [PLAY.key]: samplePlay(params) };
|
||||
}
|
||||
const LOOP_START: PropertyDef = {
|
||||
const LOOP_START: FlatDef<'number'> = {
|
||||
key: 'loopStart',
|
||||
label: 'Loop start (s)',
|
||||
kind: 'number',
|
||||
@@ -162,7 +163,7 @@ const LOOP_START: PropertyDef = {
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
};
|
||||
const LOOP_END: PropertyDef = {
|
||||
const LOOP_END: FlatDef<'number'> = {
|
||||
key: 'loopEnd',
|
||||
label: 'Loop end (s)',
|
||||
kind: 'number',
|
||||
@@ -170,6 +171,8 @@ const LOOP_END: PropertyDef = {
|
||||
min: 0,
|
||||
step: 0.01,
|
||||
};
|
||||
/** Where a Sample loops, in seconds; its device (#400) types them in. */
|
||||
export const SAMPLE_LOOP_ROWS = { loopStart: LOOP_START, loopEnd: LOOP_END };
|
||||
|
||||
export const InstrumentSample: InstrumentNodeDef = {
|
||||
type: INSTRUMENT_SAMPLE,
|
||||
@@ -342,6 +345,61 @@ export const InstrumentChip: InstrumentNodeDef = {
|
||||
},
|
||||
};
|
||||
|
||||
export const INSTRUMENT_ACID = 'InstrumentAcid';
|
||||
|
||||
export const ACID_WAVES: { value: AcidWave; label: string }[] = [
|
||||
{ value: 'saw', label: 'Saw' },
|
||||
{ value: 'square', label: 'Square' },
|
||||
];
|
||||
const ACID_WAVE: PropertyDef = {
|
||||
key: 'wave',
|
||||
label: 'Wave',
|
||||
kind: 'enum',
|
||||
default: 'saw',
|
||||
options: ACID_WAVES,
|
||||
};
|
||||
|
||||
export function acidWave(params: PropertyBag): AcidWave {
|
||||
return ACID_WAVES.find((option) => option.value === params[ACID_WAVE.key])?.value ?? 'saw';
|
||||
}
|
||||
|
||||
/** The defaults are nodeseq's acid bass: Cutoff 30, Reso 78, Env 62 of its 100, its ADSR and its glide. */
|
||||
export const ACID_ROWS = {
|
||||
cutoff: chipRow('cutoff', 'Cutoff (Hz)', 40, 18000, 1, 250),
|
||||
resonance: chipRow('resonance', 'Resonance', 0, 1, 0.01, 0.78),
|
||||
envMod: chipRow('envMod', 'Env mod', 0, 1, 0.01, 0.62),
|
||||
attack: chipRow('attack', 'Attack (s)', 0.001, 2, 0.001, 0.003),
|
||||
decay: chipRow('decay', 'Decay (s)', 0.02, 3, 0.01, 0.16),
|
||||
sustain: chipRow('sustain', 'Sustain', 0, 1, 0.01, 0.15),
|
||||
release: chipRow('release', 'Release (s)', 0.02, 4, 0.01, 0.08),
|
||||
glide: chipRow('glide', 'Glide (s)', 0, 0.5, 0.001, 0.05),
|
||||
} satisfies Record<string, FlatDef<'number'>>;
|
||||
|
||||
export const InstrumentAcid: InstrumentNodeDef = {
|
||||
type: INSTRUMENT_ACID,
|
||||
label: 'Acid',
|
||||
category: 'sound',
|
||||
instrument: true,
|
||||
description:
|
||||
'An acid bass, one note at a time, as nodeseq.com plays it; its defaults are nodeseq\'s acid. "Wave" is a Saw or a Square at the pitch of the note, through a low-pass with a sharp resonance. "cutoff" is where the filter rests, "resonance" how sharply it rings there. One ADSR shapes the volume and the filter alike: in "attack" seconds the filter opens by up to five octaves with "env mod", in "decay" it falls back to where "sustain" holds it, and when the note ends it closes in "release". Every note strikes anew and glides from the pitch of the note before in "glide" seconds; a note that starts while the one before still sounds cuts it short. Notes that start together all play. The velocity makes it louder or softer. The Acid shapes its own volume and plays out its release, so it needs no Envelope behind it. An Envelope can move "cutoff", a Pitch moves its frequency. In the graph the node is a device: it draws the filter where it rests, at the top of its sweep and where the sustain holds it.',
|
||||
inputs: [
|
||||
triggerIn,
|
||||
...Object.values(ACID_ROWS).map((row): PortDef =>
|
||||
row.key === ACID_ROWS.cutoff.key ? { ...numberIn(row), followsEnvelope: true } : numberIn(row),
|
||||
),
|
||||
],
|
||||
outputs: [audioOut],
|
||||
params: [ACID_WAVE],
|
||||
// At full sustain and without a sweep nothing falls: the decay has nothing to do.
|
||||
idleParams: (params, fed = () => false) =>
|
||||
!fed('sustain') &&
|
||||
!fed('envMod') &&
|
||||
asNumber(params.sustain ?? ACID_ROWS.sustain.default) >= 1 &&
|
||||
asNumber(params.envMod ?? ACID_ROWS.envMod.default) <= 0
|
||||
? [ACID_ROWS.decay.key]
|
||||
: [],
|
||||
};
|
||||
|
||||
export const InstrumentOutput: InstrumentNodeDef = {
|
||||
type: INSTRUMENT_OUTPUT,
|
||||
label: 'Output',
|
||||
@@ -358,6 +416,7 @@ export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [
|
||||
InstrumentNotes,
|
||||
InstrumentSample,
|
||||
InstrumentChip,
|
||||
InstrumentAcid,
|
||||
InstrumentParameters,
|
||||
InstrumentSoundFx,
|
||||
InstrumentEnvelope,
|
||||
@@ -366,7 +425,7 @@ export const INSTRUMENT_NODES: readonly InstrumentNodeDef[] = [
|
||||
|
||||
/**
|
||||
* What a new instrument starts as: every note of an octave from C4 plays one Sample, which waits for
|
||||
* its sound to be picked.
|
||||
* its sound to be picked. The Output sits past the Sample's device (#400), some 580 wide.
|
||||
*/
|
||||
export function starterInstrumentGraph(): Graph {
|
||||
const notes = newId();
|
||||
@@ -376,7 +435,7 @@ export function starterInstrumentGraph(): Graph {
|
||||
nodes: [
|
||||
{ id: notes, type: INSTRUMENT_NOTES, x: 0, y: 0, params: {} },
|
||||
{ id: sample, type: INSTRUMENT_SAMPLE, x: 220, y: 0, params: {} },
|
||||
{ id: output, type: INSTRUMENT_OUTPUT, x: 460, y: 0, params: {} },
|
||||
{ id: output, type: INSTRUMENT_OUTPUT, x: 840, y: 0, params: {} },
|
||||
],
|
||||
edges: [
|
||||
{ id: newId(), from: { node: notes, port: 'any' }, to: { node: sample, port: 'trigger' } },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Graph } from '../graph/Graph';
|
||||
import type { Bake } from '../music/bake';
|
||||
import type { SongMarker } from '../music/markers';
|
||||
import type { PatternNote } from '../music/pattern';
|
||||
import type { SongItem, SongTrack } from '../music/song';
|
||||
import type { LoopRange } from '../music/sequence';
|
||||
@@ -8,13 +9,17 @@ import type { AssetType } from './AssetTypes';
|
||||
import type { PropertyBag, PropertyValue, Vec2 } from './Properties';
|
||||
import type { Scope } from './Scope';
|
||||
import type { ObjectGroup, SceneObject } from './SceneObject';
|
||||
import type { FadeCurve } from './sound';
|
||||
|
||||
export interface AssetBase {
|
||||
id: string;
|
||||
type: AssetType;
|
||||
name: string;
|
||||
props: PropertyBag;
|
||||
/** The song a sound, pattern or sound FX made in it belongs to (#355); absent on a project asset. */
|
||||
/**
|
||||
* The song a sound, pattern or sound FX made in it belongs to (#355), or the composition a sprite
|
||||
* worn by its items does (#419); absent on a project asset.
|
||||
*/
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
@@ -260,6 +265,78 @@ export interface AnimationAsset extends AssetBase {
|
||||
tracks: AnimationTrack[];
|
||||
}
|
||||
|
||||
/** A lane of a composition (#417); the first one lies in front. */
|
||||
export interface CompositionTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One stretch on a track: the object of the same id lives from `start` for `length` frames.
|
||||
* `offset` is how many seconds into its video the item begins, which a trim of the left edge moves;
|
||||
* below 0 the item begins before the video and shows its first picture until then. `fitMedia` says
|
||||
* its length still follows that video, or the default while there is none. Items of a track may
|
||||
* overlap. An item with `audio` spawns no object: it plays a sound, a pattern or a song (#418), and
|
||||
* `offset` is how far into that it begins. `fadeIn` and `fadeOut` are the frames its sound – what it
|
||||
* plays, or its video's – rises from silence at its start and falls back at its end, shaped by
|
||||
* `curveIn` and `curveOut`; absent for none.
|
||||
*/
|
||||
export interface CompositionItem {
|
||||
id: string;
|
||||
track: string;
|
||||
start: number;
|
||||
length: number;
|
||||
offset: number;
|
||||
fitMedia?: boolean;
|
||||
audio?: CompositionAudio;
|
||||
fadeIn?: number;
|
||||
fadeOut?: number;
|
||||
curveIn?: FadeCurve;
|
||||
curveOut?: FadeCurve;
|
||||
}
|
||||
|
||||
export type CompositionAudioKind = 'sound' | 'pattern' | 'song';
|
||||
|
||||
/**
|
||||
* What an audio item plays (#418), round and round for as long as it lasts – a song round its loop.
|
||||
* `asset` is a sound, a clip of one, a pattern or a song; `volume` is in decibels. `bus` is the bus
|
||||
* of the mix it is heard on: `''` where the routing sends its asset, else a bus or the master.
|
||||
*/
|
||||
export interface CompositionAudio {
|
||||
kind: CompositionAudioKind;
|
||||
asset: string;
|
||||
volume: number;
|
||||
/** '' along the routing of its asset, `TO_COMPOSITION_BUS` into the composition's bus, else as a Play node picks. */
|
||||
bus: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An animation laid on a track from `start`, one pass long. It plays on every item of its track it
|
||||
* overlaps; its rows find their targets there as `Play animation` finds them.
|
||||
*/
|
||||
export interface CompositionClip {
|
||||
id: string;
|
||||
track: string;
|
||||
animation: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Objects along a time axis (#417), for a cut or a cutscene: each item spawns its object for as long
|
||||
* as it lasts, and animation clips play on the items they overlap. The objects are stored like a
|
||||
* scene's, overrides and container edits included, one per item and all at the top level. Time is
|
||||
* counted in frames of its own `fps` (see `model/composition.ts`).
|
||||
*/
|
||||
export interface CompositionAsset extends AssetBase {
|
||||
type: 'composition';
|
||||
objects: SceneObject[];
|
||||
/** Hierarchy folders, as a scene's (#241); absent when there are none. */
|
||||
groups?: ObjectGroup[];
|
||||
tracks: CompositionTrack[];
|
||||
items: CompositionItem[];
|
||||
clips: CompositionClip[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The graph holds one `State` node per state and an edge per transition; a behavior reads the
|
||||
* machine's current state through `State branch` and moves it through `Set state` or `State transition`.
|
||||
@@ -348,6 +425,31 @@ export interface SoundClip {
|
||||
stream?: SoundStream;
|
||||
}
|
||||
|
||||
/**
|
||||
* `file` names the video in `data/videos/` (#402): `{folder}/{sha256}.mp4`, the MP4 the worker made
|
||||
* from the upload, with a poster `.jpg` and its details beside it. The size, length, frame rate and
|
||||
* whether it has sound are copied in when it is picked, so a sprite can be measured before the file
|
||||
* loads. A sprite wears it through `texture`; its clips are stretches of it, as a sound's are.
|
||||
*/
|
||||
export interface VideoAsset extends AssetBase {
|
||||
type: 'video';
|
||||
file: string;
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
audio: boolean;
|
||||
/** Absent for a video without clips. */
|
||||
clips?: VideoClip[];
|
||||
}
|
||||
|
||||
/** A stretch of a video with a name, `props` its start and end (see `properties/videos.ts`). */
|
||||
export interface VideoClip {
|
||||
id: string;
|
||||
name: string;
|
||||
props: PropertyBag;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a sound is played through (#311): the graph runs from its `Input` over effect nodes to its
|
||||
* `Output`, and a `Play sound` naming it sends the voice along that line (see `core/soundfx`).
|
||||
@@ -389,6 +491,8 @@ export interface SongAsset extends AssetBase {
|
||||
tracks: SongTrack[];
|
||||
items: SongItem[];
|
||||
loop?: LoopRange;
|
||||
/** Named spots on its time (#338); left out without one. */
|
||||
markers?: SongMarker[];
|
||||
graph: Graph;
|
||||
bake?: Bake;
|
||||
}
|
||||
@@ -645,6 +749,7 @@ export type Asset =
|
||||
| PixelArtAsset
|
||||
| PaletteAsset
|
||||
| AnimationAsset
|
||||
| CompositionAsset
|
||||
| CurveAsset
|
||||
| TileAsset
|
||||
| TilemapAsset
|
||||
@@ -658,7 +763,8 @@ export type Asset =
|
||||
| PatternAsset
|
||||
| SongAsset
|
||||
| BusAsset
|
||||
| ParticlesAsset;
|
||||
| ParticlesAsset
|
||||
| VideoAsset;
|
||||
|
||||
/** The assets whose `variables` are declared by the user – see `requireVariableOwner`. */
|
||||
export type VariableOwnerAsset = BehaviorAsset | VariablesAsset | StructAsset;
|
||||
|
||||
@@ -30,6 +30,8 @@ export const ASSET_TYPES = [
|
||||
'song',
|
||||
'bus',
|
||||
'particles',
|
||||
'video',
|
||||
'composition',
|
||||
] as const;
|
||||
|
||||
export type AssetType = (typeof ASSET_TYPES)[number];
|
||||
@@ -66,6 +68,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
song: 'Song',
|
||||
bus: 'Bus',
|
||||
particles: 'Particles',
|
||||
video: 'Video',
|
||||
composition: 'Composition',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -79,7 +83,8 @@ export const ASSET_TYPE_LABELS: Record<AssetType, string> = {
|
||||
* A shader function is part of other shaders (#303), worn by nothing itself. A sound FX is what a
|
||||
* sound is played through (#311), heard but never seen, an instrument what a note is played
|
||||
* with (#331), a pattern the notes it plays (#333) and a song what lays them out (#334). A bus is
|
||||
* where sounds are mixed on their way out (#325).
|
||||
* where sounds are mixed on their way out (#325). A video is a moving picture a sprite wears through
|
||||
* `texture` (#402), as it wears an image.
|
||||
*/
|
||||
export type PlaceableAssetType = Exclude<
|
||||
AssetType,
|
||||
@@ -103,6 +108,7 @@ export type PlaceableAssetType = Exclude<
|
||||
| 'bus'
|
||||
| 'variables'
|
||||
| 'struct'
|
||||
| 'video'
|
||||
>;
|
||||
|
||||
const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
@@ -126,6 +132,7 @@ const NOT_PLACEABLE: ReadonlySet<AssetType> = new Set([
|
||||
'bus',
|
||||
'variables',
|
||||
'struct',
|
||||
'video',
|
||||
]);
|
||||
|
||||
export function isPlaceableType(type: AssetType): type is PlaceableAssetType {
|
||||
@@ -145,6 +152,7 @@ export function isSymbolType(type: AssetType): boolean {
|
||||
type === 'rigidbody' ||
|
||||
type === 'joint' ||
|
||||
type === 'container' ||
|
||||
type === 'composition' ||
|
||||
type === 'clip'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type Asset,
|
||||
type CompositionAsset,
|
||||
type ContainerAsset,
|
||||
type GraphAsset,
|
||||
isGraphAsset,
|
||||
@@ -151,13 +152,14 @@ export function findGraphAsset(project: Project, assetId: string): GraphHolder |
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything that holds placed objects. A container's `objects` are a template, so anything
|
||||
* walking "all placed objects" must cover both or it silently skips the templates.
|
||||
* Everything that holds placed objects. A container's `objects` are a template, and a composition's
|
||||
* live only while their items run, so anything walking "all placed objects" must cover all three or
|
||||
* it silently skips some.
|
||||
*/
|
||||
export type ObjectHolder = SceneAsset | ContainerAsset;
|
||||
export type ObjectHolder = SceneAsset | ContainerAsset | CompositionAsset;
|
||||
|
||||
function isObjectHolder(asset: Asset): asset is ObjectHolder {
|
||||
return asset.type === 'scene' || asset.type === 'container';
|
||||
return asset.type === 'scene' || asset.type === 'container' || asset.type === 'composition';
|
||||
}
|
||||
|
||||
export function objectHolders(project: Project): ObjectHolder[] {
|
||||
|
||||
@@ -23,6 +23,7 @@ import { instanceChildId, resolveObjects } from './instance';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { colorOf, type PropertyBag, type PropertyValue, textOf } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
import { findVideo, type VideoRef } from './video';
|
||||
import { mixColor } from '../util/color';
|
||||
|
||||
/** `once` holds the last value when it ends, `once-reset` puts back what was there before it started. */
|
||||
@@ -427,11 +428,12 @@ export function placedRoot(root: Asset): SceneObject {
|
||||
};
|
||||
}
|
||||
|
||||
/** One object a row may aim at below the root, under the id the row stores. */
|
||||
/** One object a row may aim at below the root, under the id the row stores; `parent` is the one above it, '' at the top. */
|
||||
export interface AnimationTarget {
|
||||
id: string;
|
||||
object: SceneObject;
|
||||
depth: number;
|
||||
parent: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -446,12 +448,18 @@ export function animationTargets(project: Project, root: Asset | undefined): Ani
|
||||
id: object.id,
|
||||
object,
|
||||
depth,
|
||||
parent: depth > 0 ? object.parentId : '',
|
||||
}));
|
||||
if (root?.type !== 'container') return [];
|
||||
const prefix = instanceChildId(PLACED_ROOT_ID, '');
|
||||
return treeOrder({ objects: resolveObjects(project, { objects: [placedRoot(root)] }) })
|
||||
.filter(({ object }) => object.id.startsWith(prefix))
|
||||
.map(({ object, depth }) => ({ id: object.id.slice(prefix.length), object, depth: depth - 1 }));
|
||||
.map(({ object, depth }) => ({
|
||||
id: object.id.slice(prefix.length),
|
||||
object,
|
||||
depth: depth - 1,
|
||||
parent: object.parentId.startsWith(prefix) ? object.parentId.slice(prefix.length) : '',
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -522,6 +530,26 @@ export function trackDef(
|
||||
return targetDefs(project, animation, track.target)?.find((d) => d.key === track.property);
|
||||
}
|
||||
|
||||
/**
|
||||
* The video the object a row aims at wears at `frame`, for a row shown only with one (#407): what
|
||||
* the texture row the def names holds there – keyed in this animation, or else on the object.
|
||||
*/
|
||||
export function rowVideoAt(
|
||||
project: Project,
|
||||
animation: AnimationAsset,
|
||||
track: AnimationTrack,
|
||||
frame: number,
|
||||
): VideoRef | null {
|
||||
const aimed = aimedAt(project, animation, track.target);
|
||||
const def =
|
||||
aimed && animatableDefsOf(project, aimed.asset, aimed.bag).find((d) => d.key === track.property);
|
||||
const textureKey = def && 'shownForVideo' in def ? def.shownForVideo : undefined;
|
||||
if (!aimed || !textureKey) return null;
|
||||
const keyed = animation.tracks.find((t) => t.target === track.target && t.property === textureKey);
|
||||
const value = (keyed && trackValueAt(keyed.keys, 'texture', frame)) ?? aimed.bag[textureKey];
|
||||
return findVideo(project, textOf(value ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* The rows the dialog offers for the object `target` names: what it can animate, less a behavior's
|
||||
* private variables (#270) and what another row animates already. A row on a private variable keeps
|
||||
@@ -556,6 +584,98 @@ export function trackLabel(project: Project, animation: AnimationAsset, track: A
|
||||
return `${who} · ${def ? def.label : aimed ? `${track.property} (missing)` : track.property}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A line of the timeline's header column (#408): the head of an object's group, or a row under it,
|
||||
* named by its property alone. `depth` is the object's in the hierarchy, `above` the objects it sits
|
||||
* under, the nearest last – the root not among them: its head stands level with its children's (#414).
|
||||
* A row carries its property (none once that is gone) and `held`, what it shows before it has keys:
|
||||
* it writes nothing then, so that is what the object holds – or no offset at all for a relative row.
|
||||
*/
|
||||
export type TimelineLine =
|
||||
| { kind: 'group'; target: string; name: string; depth: number; above: readonly string[] }
|
||||
| {
|
||||
kind: 'row';
|
||||
track: AnimationTrack;
|
||||
label: string;
|
||||
def: PropertyDef | undefined;
|
||||
held: PropertyValue;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The rows grouped by the object they aim at, in the order the objects stand in the hierarchy – the
|
||||
* root first – and under each object in the order it lists its properties. Rows aiming at something
|
||||
* gone come last, as they were added. Only the view is sorted: the asset keeps its own order.
|
||||
*/
|
||||
export function timelineLines(project: Project, animation: AnimationAsset): TimelineLine[] {
|
||||
const placed = new Map<string, { at: number; depth: number; parent: string }>([
|
||||
['', { at: 0, depth: 0, parent: '' }],
|
||||
]);
|
||||
animationTargets(project, findAsset(project, animation.root)).forEach((t, i) =>
|
||||
placed.set(t.id, { at: i + 1, depth: t.depth, parent: t.parent }),
|
||||
);
|
||||
const aboveOf = (target: string): string[] => {
|
||||
const above: string[] = [];
|
||||
let at = placed.get(target)?.parent ?? '';
|
||||
while (at !== '' && !above.includes(at)) {
|
||||
above.unshift(at);
|
||||
at = placed.get(at)?.parent ?? '';
|
||||
}
|
||||
return above;
|
||||
};
|
||||
const targets = [...new Set(animation.tracks.map((t) => t.target))];
|
||||
const rank = (target: string) => placed.get(target)?.at ?? placed.size + targets.indexOf(target);
|
||||
const lines: TimelineLine[] = [];
|
||||
for (const target of targets.sort((a, b) => rank(a) - rank(b))) {
|
||||
const aimed = aimedAt(project, animation, target);
|
||||
const defs = aimed ? animatableDefsOf(project, aimed.asset, aimed.bag) : [];
|
||||
const depth = placed.get(target)?.depth ?? 0;
|
||||
const name = !aimed
|
||||
? '(missing)'
|
||||
: aimed.object
|
||||
? objectName(aimed.object, aimed.asset)
|
||||
: aimed.asset.name;
|
||||
lines.push({ kind: 'group', target, name, depth, above: aboveOf(target) });
|
||||
const order = (track: AnimationTrack) => {
|
||||
const i = defs.findIndex((d) => d.key === track.property);
|
||||
return i < 0 ? defs.length : i;
|
||||
};
|
||||
const rows = animation.tracks.filter((t) => t.target === target).sort((a, b) => order(a) - order(b));
|
||||
for (const track of rows) {
|
||||
const def = defs.find((d) => d.key === track.property);
|
||||
const label = def ? def.label : `${track.property} (missing)`;
|
||||
lines.push({ kind: 'row', track, label, def, held: heldBy(aimed, def, track), depth });
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function heldBy(
|
||||
aimed: Aimed | undefined,
|
||||
def: PropertyDef | undefined,
|
||||
track: AnimationTrack,
|
||||
): PropertyValue {
|
||||
if (!aimed || !def || track.relative) return 0;
|
||||
if (isPositionRow(aimed.asset.type, def.key)) return aimed.object?.position[def.key] ?? 0;
|
||||
return aimed.bag[def.key] ?? def.default;
|
||||
}
|
||||
|
||||
/** What a row holds without keys (#412): the value its object has, no offset for a relative row. */
|
||||
export function trackHeld(project: Project, animation: AnimationAsset, track: AnimationTrack): PropertyValue {
|
||||
return heldBy(aimedAt(project, animation, track.target), trackDef(project, animation, track), track);
|
||||
}
|
||||
|
||||
/** What a row's header shows at `frame` (#408): the row's value there, else what it holds without keys. */
|
||||
export function lineValueAt(
|
||||
line: TimelineLine & { kind: 'row' },
|
||||
frame: number,
|
||||
reverse = false,
|
||||
curves: CurveLookup = NO_CURVES,
|
||||
): PropertyValue | undefined {
|
||||
if (!line.def) return undefined;
|
||||
return trackValueAt(line.track.keys, line.def.kind, frame, reverse, curves) ?? line.held;
|
||||
}
|
||||
|
||||
/** Whether a value names something in the project – what deleting that something has to count. */
|
||||
export function referencesAny(
|
||||
track: AnimationTrack,
|
||||
|
||||
@@ -41,7 +41,7 @@ export function previewHolder(
|
||||
const at: ReadAt = { frame, reverse, curves: curveLookup(project) };
|
||||
for (const track of reset ? [] : animation.tracks) {
|
||||
if (root.type === 'scene' && track.target === '') writeScene(holder, track, at);
|
||||
else writeObject(project, holder, previewObjectId(root, track.target), track, at);
|
||||
else writeObjectRow(project, holder, previewObjectId(root, track.target), track, at);
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
@@ -71,7 +71,7 @@ function placedHolder(root: Asset): ObjectHolder {
|
||||
}
|
||||
|
||||
/** Where on the timeline a row is read, and the Curve assets its keys may blend along. */
|
||||
interface ReadAt {
|
||||
export interface ReadAt {
|
||||
frame: number;
|
||||
reverse: boolean;
|
||||
curves: CurveLookup;
|
||||
@@ -86,7 +86,8 @@ function writeScene(holder: ObjectHolder, track: AnimationTrack, at: ReadAt): vo
|
||||
if (value !== undefined) holder.props[track.property] = value;
|
||||
}
|
||||
|
||||
function writeObject(
|
||||
/** A row on the object `id` of the holder, stored there or resolved from a container – also a composition's (#417). */
|
||||
export function writeObjectRow(
|
||||
project: Project,
|
||||
holder: ObjectHolder,
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Asset } from './Asset';
|
||||
import { frameLabel } from './pixelart';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { clipsOf, hasClips } from './sound';
|
||||
|
||||
/** A named piece of an asset with an id of its own: a clip of a sound or video, a frame of a picture. */
|
||||
export interface AssetPart {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the asset panel lists under an asset (#425, #430): the clips of a sound or a video, the frames
|
||||
* of a texture, and those of pixel art once it has more than one – the frames a texture field offers.
|
||||
*/
|
||||
export function partsOf(asset: Asset): AssetPart[] {
|
||||
if (hasClips(asset)) return clipsOf<AssetPart>(asset).map(({ id, name }) => ({ id, name }));
|
||||
if (asset.type === 'texture') return asset.frames.map(({ id, name }) => ({ id, name }));
|
||||
if (asset.type === 'pixelart' && asset.frames.length > 1)
|
||||
return asset.frames.map((frame, index) => ({ id: frame.id, name: frameLabel(index) }));
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The asset `id` is a listed part of; undefined for an asset's own id or anything else. */
|
||||
export function partOwner(project: Project, id: string): Asset | undefined {
|
||||
if (id === '' || findAsset(project, id)) return undefined;
|
||||
return project.assets.find((asset) => partsOf(asset).some((part) => part.id === id));
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
import { editorTransform, parentTransform, positionOf } from '../properties/transform';
|
||||
import { findObject, subtreeIds, subtreeRoots, treeOrder } from './hierarchy';
|
||||
import { isLiveInstance, isTemplateContent, resolvedHolder, splitInstanceId } from './instance';
|
||||
import type { CompositionClip, CompositionItem, SpriteAsset } from './Asset';
|
||||
import { isCompositionSprite } from './composition';
|
||||
import { carriedClips } from './compositionEdit';
|
||||
import type { Mat } from './matrix';
|
||||
import type { Position } from './Properties';
|
||||
import type { ObjectHolder, Project } from './Project';
|
||||
@@ -34,6 +37,14 @@ export interface ObjectClip {
|
||||
/** Parents before children; a root's `parentId` is the one it had where it was copied. */
|
||||
objects: SceneObject[];
|
||||
roots: ClipRoot[];
|
||||
/** Copied out of a composition (#417): the items of the roots that are items, for a paste there. */
|
||||
items?: CompositionItem[];
|
||||
/** The animation clips that belong to those items, to go with them. */
|
||||
clips?: CompositionClip[];
|
||||
/** The sprites of a composition's own the objects wear (#419), back for a paste after a cut. */
|
||||
sprites?: SpriteAsset[];
|
||||
/** Audio items copied with them (#418): they have no object. */
|
||||
audio?: CompositionItem[];
|
||||
}
|
||||
|
||||
export interface GraphClip {
|
||||
@@ -44,7 +55,14 @@ export interface GraphClip {
|
||||
annotations: { kind: AnnotationKind; annotation: GraphAnnotation }[];
|
||||
}
|
||||
|
||||
export type Clip = ObjectClip | GraphClip;
|
||||
/** Animation clips or audio items of a composition (#417, #418), for a paste into the same one or another. */
|
||||
export interface CompositionClipsClip {
|
||||
kind: 'compositionClips';
|
||||
clips: CompositionClip[];
|
||||
items?: CompositionItem[];
|
||||
}
|
||||
|
||||
export type Clip = ObjectClip | GraphClip | CompositionClipsClip;
|
||||
|
||||
/**
|
||||
* The selected objects with everything below them. A placed container keeps showing its template,
|
||||
@@ -76,9 +94,18 @@ export function copyObjects(
|
||||
.map((entry) => entry.object)
|
||||
.filter((o) => members.has(o.id) && !shownByInstance(o.id))
|
||||
.map((o) => ({ ...structuredClone(o), editorOffset: { x: 0, y: 0 } }));
|
||||
const items = holder.type === 'composition' ? holder.items.filter((i) => roots.includes(i.id)) : [];
|
||||
const carried =
|
||||
holder.type === 'composition' ? carriedClips(holder.items, holder.clips, new Set(roots)) : new Set();
|
||||
const clips = holder.type === 'composition' ? holder.clips.filter((c) => carried.has(c.id)) : [];
|
||||
const worn = new Set(objects.map((o) => o.assetId));
|
||||
const sprites = project.assets.filter(isCompositionSprite).filter((a) => worn.has(a.id));
|
||||
return {
|
||||
kind: 'objects',
|
||||
objects,
|
||||
...(items.length > 0 && { items: structuredClone(items) }),
|
||||
...(clips.length > 0 && { clips: structuredClone(clips) }),
|
||||
...(sprites.length > 0 && { sprites: structuredClone(sprites) }),
|
||||
roots: roots.map((id) => {
|
||||
const object = findObject(view, id)!;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* Compositions (#417): objects along a time axis, counted in frames of the composition's own `fps`.
|
||||
* What runs at a frame is one pure function, `compositionAt`, so the tab's preview and the running
|
||||
* game read the same cut; `compositionHolder` writes it out as objects for a view to draw.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
import { patternLength, patternTempo } from '../music/pattern';
|
||||
import { ITEM_MIN_DB, songLoop, songTempo, SOUND_ITEM_MAX_DB } from '../music/song';
|
||||
import { secondsOfTicks } from '../music/time';
|
||||
import {
|
||||
COMPOSITION_FPS_KEY,
|
||||
COMPOSITION_LOOP_KEY,
|
||||
DEFAULT_COMPOSITION_FPS,
|
||||
} from '../properties/compositions';
|
||||
import { playedSoundById } from '../properties/playedSound';
|
||||
import { resolveSceneObjectProps } from '../properties/resolve';
|
||||
import { DEFAULT_FADE_CURVE } from '../properties/sounds';
|
||||
import {
|
||||
VIDEO_CLOCK_KEY,
|
||||
VIDEO_LEVEL_KEY,
|
||||
VIDEO_TIME_KEY,
|
||||
videoDrivenOf,
|
||||
videoLevelOf,
|
||||
} from '../properties/videos';
|
||||
import { animationDuration, fpsOf, frameCount, targetNamePath } from './animation';
|
||||
import { type ReadAt, writeObjectRow } from './animationPreview';
|
||||
import type {
|
||||
AnimationAsset,
|
||||
AnimationTrack,
|
||||
Asset,
|
||||
CompositionAsset,
|
||||
CompositionAudio,
|
||||
CompositionAudioKind,
|
||||
CompositionClip,
|
||||
CompositionItem,
|
||||
CompositionTrack,
|
||||
SpriteAsset,
|
||||
} from './Asset';
|
||||
import { curveLookup } from './curve';
|
||||
import {
|
||||
contentId,
|
||||
instanceChildId,
|
||||
objectRef,
|
||||
placedWithContent,
|
||||
resolveObjects,
|
||||
splitInstanceId,
|
||||
} from './instance';
|
||||
import { findAsset, findAssetOf, type Project } from './Project';
|
||||
import { booleanOf, type PropertyBag } from './Properties';
|
||||
import { objectName, type SceneObject } from './SceneObject';
|
||||
import { type FadeCurve, fadeGain, isFadeCurve } from './sound';
|
||||
import { findVideo, videoLengthOf, type VideoRef } from './video';
|
||||
|
||||
/** How long an item lasts while it has no video to take its length from. */
|
||||
export const DEFAULT_ITEM_SECONDS = 3;
|
||||
|
||||
export function compositionFps(comp: CompositionAsset): number {
|
||||
const fps = Number(comp.props[COMPOSITION_FPS_KEY]);
|
||||
return Number.isFinite(fps) && fps > 0 ? fps : DEFAULT_COMPOSITION_FPS;
|
||||
}
|
||||
|
||||
export function compositionLoops(comp: CompositionAsset): boolean {
|
||||
return booleanOf(comp.props[COMPOSITION_LOOP_KEY]);
|
||||
}
|
||||
|
||||
/** A sprite a composition keeps to itself (#419), worn by the items a texture or video was laid as. */
|
||||
export function isCompositionSprite(asset: Asset): asset is SpriteAsset {
|
||||
return asset.type === 'sprite' && (asset.owner ?? '') !== '';
|
||||
}
|
||||
|
||||
export function newCompositionTrack(index: number): CompositionTrack {
|
||||
return { id: newId(), name: `Track ${index}` };
|
||||
}
|
||||
|
||||
export function itemEnd(item: CompositionItem): number {
|
||||
return item.start + item.length;
|
||||
}
|
||||
|
||||
/** The object an item spawns; undefined once it is gone, and then the item does nothing – or for an audio item. */
|
||||
export function itemObject(comp: CompositionAsset, item: CompositionItem): SceneObject | undefined {
|
||||
return item.audio ? undefined : comp.objects.find((o) => o.id === item.id);
|
||||
}
|
||||
|
||||
/** Whether an item stands on its track: an audio item always (#418), any other while it has its object. */
|
||||
export function itemStands(comp: CompositionAsset, item: CompositionItem): boolean {
|
||||
return item.audio !== undefined || itemObject(comp, item) !== undefined;
|
||||
}
|
||||
|
||||
/** Seconds a sound file lasts once its bytes are there, as the editor's sound library knows it (#418). */
|
||||
let soundSeconds: (file: string) => number | null = () => null;
|
||||
|
||||
export function setSoundMeasure(measure: (file: string) => number | null): void {
|
||||
soundSeconds = measure;
|
||||
}
|
||||
|
||||
/** Whether an item has a sound its fades shape: what an audio item plays, or the video its object wears. */
|
||||
export function itemSounds(project: Project, comp: CompositionAsset, item: CompositionItem): boolean {
|
||||
return item.audio !== undefined || itemVideo(project, comp, item) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The fades of an item in whole frames as they work: the fade in no longer than the item, the fade
|
||||
* out no longer than the rest.
|
||||
*/
|
||||
export interface ItemFades {
|
||||
fadeIn: number;
|
||||
fadeOut: number;
|
||||
curveIn: FadeCurve;
|
||||
curveOut: FadeCurve;
|
||||
}
|
||||
|
||||
export function itemFades(item: CompositionItem): ItemFades {
|
||||
const frames = (value: number | undefined, most: number) =>
|
||||
Math.max(0, Math.min(most, Math.round(Number.isFinite(value) ? value! : 0)));
|
||||
const fadeIn = frames(item.fadeIn, item.length);
|
||||
return {
|
||||
fadeIn,
|
||||
fadeOut: frames(item.fadeOut, item.length - fadeIn),
|
||||
curveIn: item.curveIn ?? DEFAULT_FADE_CURVE,
|
||||
curveOut: item.curveOut ?? DEFAULT_FADE_CURVE,
|
||||
};
|
||||
}
|
||||
|
||||
/** How loud an item's sound is `at` frames after its start, its fades considered: 0 to 1. */
|
||||
export function fadeLevel(item: CompositionItem, at: number): number {
|
||||
const { fadeIn, fadeOut, curveIn, curveOut } = itemFades(item);
|
||||
const rise = fadeIn > 0 && at < fadeIn ? fadeGain(curveIn, at / fadeIn) : 1;
|
||||
const left = item.length - at;
|
||||
const fall = fadeOut > 0 && left < fadeOut ? fadeGain(curveOut, left / fadeOut) : 1;
|
||||
return rise * fall;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seconds the first pass of what an audio item plays lasts: its sound once loaded, its pattern, its
|
||||
* song to the end of its loop – it goes round there, what lies behind is never heard –; 0 while that
|
||||
* is not known or the asset is gone.
|
||||
*/
|
||||
export function audioSeconds(project: Project, audio: CompositionAudio): number {
|
||||
if (audio.kind === 'sound') {
|
||||
const file = playedSoundById(project, audio.asset);
|
||||
return (file && soundSeconds(file)) || 0;
|
||||
}
|
||||
if (audio.kind === 'pattern') {
|
||||
const pattern = findAssetOf(project, audio.asset, 'pattern');
|
||||
return pattern ? secondsOfTicks(patternLength(pattern), patternTempo(pattern.props)) : 0;
|
||||
}
|
||||
const song = findAssetOf(project, audio.asset, 'song');
|
||||
return song ? secondsOfTicks(songLoop(song).end, songTempo(song.props)) : 0;
|
||||
}
|
||||
|
||||
/** Seconds every pass after the first lasts: a song's loop, else the same as the first. */
|
||||
export function audioRoundSeconds(project: Project, audio: CompositionAudio): number {
|
||||
const song = audio.kind === 'song' ? findAssetOf(project, audio.asset, 'song') : undefined;
|
||||
if (!song) return audioSeconds(project, audio);
|
||||
const loop = songLoop(song);
|
||||
return secondsOfTicks(loop.end - loop.start, songTempo(song.props));
|
||||
}
|
||||
|
||||
/**
|
||||
* The animation a clip plays, unless it is gone or built over a scene: a scene's rows aim at that
|
||||
* scene's objects, nothing an item holds.
|
||||
*/
|
||||
function clipAnimation(project: Project, clip: CompositionClip): AnimationAsset | undefined {
|
||||
const animation = findAssetOf(project, clip.animation, 'animation');
|
||||
return animation && findAsset(project, animation.root)?.type !== 'scene' ? animation : undefined;
|
||||
}
|
||||
|
||||
/** Frames one pass of the clip's animation takes in the composition; 0 when it plays nothing. */
|
||||
export function clipLength(project: Project, comp: CompositionAsset, clip: CompositionClip): number {
|
||||
const animation = clipAnimation(project, clip);
|
||||
const seconds = animation ? animationDuration(animation) : 0;
|
||||
return seconds > 0 ? Math.max(1, Math.ceil(seconds * compositionFps(comp) - 1e-6)) : 0;
|
||||
}
|
||||
|
||||
/** The frame after the last item or clip ends: how long one run of the composition is. */
|
||||
export function compositionLength(project: Project, comp: CompositionAsset): number {
|
||||
let end = 0;
|
||||
for (const item of comp.items) if (itemStands(comp, item)) end = Math.max(end, itemEnd(item));
|
||||
for (const clip of comp.clips) end = Math.max(end, clip.start + clipLength(project, comp, clip));
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* The video the item's own object wears through its texture – the override's, else its sprite's. A
|
||||
* video worn further down a container item runs with the item but gives it no length.
|
||||
*/
|
||||
export function itemVideo(project: Project, comp: CompositionAsset, item: CompositionItem): VideoRef | null {
|
||||
const object = itemObject(comp, item);
|
||||
const asset = object && findAsset(project, object.assetId);
|
||||
if (!object || asset?.type !== 'sprite') return null;
|
||||
return videoWorn(resolveSceneObjectProps(project, asset, object), project);
|
||||
}
|
||||
|
||||
function videoWorn(props: PropertyBag, project: Project): VideoRef | null {
|
||||
const texture = props.texture;
|
||||
return typeof texture === 'string' ? findVideo(project, texture) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The length an item with `fitMedia` takes: from its start to the end of its video, or of what an
|
||||
* audio item plays; the default while it has none or its length is not known yet.
|
||||
*/
|
||||
export function fittedLength(project: Project, comp: CompositionAsset, item: CompositionItem): number {
|
||||
const fps = compositionFps(comp);
|
||||
const video = item.audio ? null : itemVideo(project, comp, item);
|
||||
const seconds = item.audio
|
||||
? audioSeconds(project, item.audio) - item.offset
|
||||
: video
|
||||
? videoLengthOf(video) - item.offset
|
||||
: 0;
|
||||
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
|
||||
}
|
||||
|
||||
/**
|
||||
* A composition placed in a running game that `Play composition` started; kept on its entity, out of
|
||||
* `props`. Its items exist only while it plays.
|
||||
*/
|
||||
export interface CompositionPlayer {
|
||||
/** Tells a start apart from the one before, so a restart builds every item anew. */
|
||||
run: number;
|
||||
/** Seconds since its first frame. */
|
||||
time: number;
|
||||
/** Whether the first frame was shown: the clock runs from the tick after the start. */
|
||||
shown: boolean;
|
||||
/** Set by `Stop composition`: the next tick takes its items away. */
|
||||
stopped: boolean;
|
||||
/** The behaviors that played it; they hear `On composition finished` wherever it stands. */
|
||||
startedBy: object[];
|
||||
}
|
||||
|
||||
let runCount = 0;
|
||||
|
||||
/**
|
||||
* Starts the composition placed as `holder` from its first frame. One already playing plays on, unless
|
||||
* `restart` – or a `Stop composition` this tick – asks to start over.
|
||||
*/
|
||||
export function playComposition(
|
||||
holder: { composition?: CompositionPlayer },
|
||||
restart = false,
|
||||
startedBy?: object,
|
||||
): void {
|
||||
const playing = holder.composition;
|
||||
if (playing && !playing.stopped && !restart) {
|
||||
if (startedBy && !playing.startedBy.includes(startedBy)) playing.startedBy.push(startedBy);
|
||||
return;
|
||||
}
|
||||
holder.composition = {
|
||||
run: ++runCount,
|
||||
time: 0,
|
||||
shown: false,
|
||||
stopped: false,
|
||||
startedBy: startedBy ? [startedBy] : [],
|
||||
};
|
||||
}
|
||||
|
||||
export function stopComposition(holder: { composition?: CompositionPlayer }): void {
|
||||
if (holder.composition) holder.composition.stopped = true;
|
||||
}
|
||||
|
||||
/** The frame a player's time stands on; the small guard keeps a time of exactly `n / fps` on frame n. */
|
||||
export function compositionFrameAt(comp: CompositionAsset, time: number): number {
|
||||
return Math.floor(time * compositionFps(comp) + 1e-6);
|
||||
}
|
||||
|
||||
/** A clip playing on an item, at `frame` of its animation – held on the last one once it is through. */
|
||||
export interface ClipAt {
|
||||
clip: CompositionClip;
|
||||
animation: AnimationAsset;
|
||||
frame: number;
|
||||
}
|
||||
|
||||
/** An item alive at a frame of the composition. */
|
||||
export interface LiveItem {
|
||||
item: CompositionItem;
|
||||
object: SceneObject;
|
||||
/** The index of its track, 0 the first. */
|
||||
track: number;
|
||||
/** Seconds since the item began. */
|
||||
time: number;
|
||||
/** Seconds into what its videos play: `time` past the item's offset. */
|
||||
videoTime: number;
|
||||
/** How loud its videos are heard, 0 to 1: its fades at this frame. */
|
||||
level: number;
|
||||
/** In the order they started; a later one writes over an earlier one. */
|
||||
clips: ClipAt[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything alive at `frame`, in drawing order: the last track first, so the first lies in front –
|
||||
* the order the items take below the composition, which decides between equal `z`. An item lives
|
||||
* from its start up to, not including, its end; one on a track that is gone, or whose object is gone,
|
||||
* does not. Items of a track that overlap both live, the later one in front. A clip plays on every
|
||||
* item of its track it overlaps once it has started, and holds its last frame for as long as that
|
||||
* item lasts.
|
||||
*/
|
||||
export function compositionAt(project: Project, comp: CompositionAsset, frame: number): LiveItem[] {
|
||||
const fps = compositionFps(comp);
|
||||
const tracks = new Map(comp.tracks.map((t, index) => [t.id, index]));
|
||||
const clips = comp.clips
|
||||
.map((clip, order) => ({ clip, order, animation: clipAnimation(project, clip) }))
|
||||
.filter((c) => c.animation !== undefined && c.clip.start <= frame)
|
||||
.sort((a, b) => a.clip.start - b.clip.start || a.order - b.order);
|
||||
const live: { item: LiveItem; order: number }[] = [];
|
||||
comp.items.forEach((item, order) => {
|
||||
const track = tracks.get(item.track);
|
||||
const object = itemObject(comp, item);
|
||||
if (track === undefined || !object || frame < item.start || frame >= itemEnd(item)) return;
|
||||
const time = (frame - item.start) / fps;
|
||||
const playing: ClipAt[] = [];
|
||||
for (const { clip, animation } of clips) {
|
||||
const end = clip.start + clipLength(project, comp, clip);
|
||||
if (clip.track !== item.track || clip.start >= itemEnd(item) || end <= item.start) continue;
|
||||
const at = ((frame - clip.start) / fps) * fpsOf(animation!);
|
||||
playing.push({ clip, animation: animation!, frame: Math.min(at, frameCount(animation!) - 1) });
|
||||
}
|
||||
const level = fadeLevel(item, frame - item.start);
|
||||
live.push({
|
||||
item: { item, object, track, time, videoTime: item.offset + time, level, clips: playing },
|
||||
order,
|
||||
});
|
||||
});
|
||||
live.sort(
|
||||
(a, b) => b.item.track - a.item.track || a.item.item.start - b.item.item.start || a.order - b.order,
|
||||
);
|
||||
return live.map((l) => l.item);
|
||||
}
|
||||
|
||||
/**
|
||||
* The composition at `frame` as objects a view draws: a copy holding only what is alive, in drawing
|
||||
* order, each with its clips' rows and its video time written over it. Every sprite of an item that
|
||||
* wears a video and is not driven by hand is driven at the item's video time. Nothing here touches
|
||||
* the document.
|
||||
*/
|
||||
export function compositionHolder(project: Project, comp: CompositionAsset, frame: number): CompositionAsset {
|
||||
return holderOf(project, comp, compositionAt(project, comp, frame));
|
||||
}
|
||||
|
||||
/** How far ahead of where a composition stands the items about to begin are written out, in seconds. */
|
||||
const AHEAD_SECONDS = 1;
|
||||
|
||||
/**
|
||||
* The items that begin within `seconds` after `frame` – once it loops, those at the start too when
|
||||
* the end is that close – each written out as at its first frame, like `compositionHolder` does. A
|
||||
* view opens their videos ahead of the cut: opened when its item began, a video had no picture yet,
|
||||
* and the cut showed nothing until it loaded. One alive at `frame` is left out, it wears its own time.
|
||||
*/
|
||||
export function compositionAhead(
|
||||
project: Project,
|
||||
comp: CompositionAsset,
|
||||
frame: number,
|
||||
seconds = AHEAD_SECONDS,
|
||||
): CompositionAsset {
|
||||
const until = frame + Math.ceil(seconds * compositionFps(comp));
|
||||
const length = compositionLength(project, comp);
|
||||
const wrapped = compositionLoops(comp) && until >= length ? until - length : -1;
|
||||
const coming = comp.items.filter(
|
||||
(item) => (item.start > frame && item.start <= until) || item.start <= wrapped,
|
||||
);
|
||||
if (coming.length === 0) return holderOf(project, comp, []);
|
||||
const alive = new Set(compositionAt(project, comp, frame).map((l) => l.item.id));
|
||||
const live = coming
|
||||
.filter((item) => !alive.has(item.id))
|
||||
.flatMap((item) => compositionAt(project, comp, item.start).filter((l) => l.item.id === item.id));
|
||||
return holderOf(project, comp, live);
|
||||
}
|
||||
|
||||
function holderOf(project: Project, comp: CompositionAsset, live: LiveItem[]): CompositionAsset {
|
||||
const holder: CompositionAsset = {
|
||||
...comp,
|
||||
props: { ...comp.props },
|
||||
objects: live.map((l) => copyOf(l.object)),
|
||||
};
|
||||
const curves = curveLookup(project);
|
||||
for (const item of live) {
|
||||
driveVideos(project, holder, item);
|
||||
for (const clip of item.clips) {
|
||||
const at: ReadAt = { frame: clip.frame, reverse: false, curves };
|
||||
for (const track of clip.animation.tracks) {
|
||||
const id = rowTarget(project, holder, item.object.id, clip.animation, track);
|
||||
if (id) writeObjectRow(project, holder, id, track, at);
|
||||
}
|
||||
}
|
||||
}
|
||||
return holder;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a composition placed as `placementId` shows in the editor: its first frame, as objects below
|
||||
* the placement with the ids its items take in a run. Stored nowhere – a placement is sealed, and
|
||||
* its items are edited in the composition only.
|
||||
*/
|
||||
export function placedPicture(project: Project, comp: CompositionAsset, placementId: string): SceneObject[] {
|
||||
return compositionHolder(project, comp, 0).objects.flatMap((o) =>
|
||||
placedWithContent(project, o, contentId(placementId, o.id), placementId),
|
||||
);
|
||||
}
|
||||
|
||||
const record = (value: unknown): Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {};
|
||||
const text = (value: unknown) => (typeof value === 'string' ? value : '');
|
||||
const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
|
||||
const list = (raw: unknown): Record<string, unknown>[] => (Array.isArray(raw) ? raw.map(record) : []);
|
||||
|
||||
/** Held to what a track may be: ids no other has, a name, and at least one track. */
|
||||
export function heldCompositionTracks(raw: unknown): CompositionTrack[] {
|
||||
const tracks: CompositionTrack[] = [];
|
||||
for (const t of list(raw)) {
|
||||
const id = text(t.id);
|
||||
if (!id || tracks.some((other) => other.id === id)) continue;
|
||||
tracks.push({ id, name: text(t.name) || `Track ${tracks.length + 1}` });
|
||||
}
|
||||
return tracks.length > 0 ? tracks : [newCompositionTrack(1)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Held to what an item may be: the id of an object it spawns and no other item does – an audio item
|
||||
* an id of its own and what it plays –, a track of the composition, a start in whole frames from 0,
|
||||
* at least a frame long, a finite offset, fades in whole frames above 0 and curves there are.
|
||||
*/
|
||||
export function heldCompositionItems(
|
||||
raw: unknown,
|
||||
tracks: readonly CompositionTrack[],
|
||||
objects: readonly SceneObject[],
|
||||
): CompositionItem[] {
|
||||
const known = new Set(tracks.map((t) => t.id));
|
||||
const placed = new Set(objects.map((o) => o.id));
|
||||
const items: CompositionItem[] = [];
|
||||
for (const i of list(raw)) {
|
||||
const id = text(i.id);
|
||||
const start = Math.round(finite(i.start));
|
||||
const length = Math.round(finite(i.length));
|
||||
const audio = heldAudio(i.audio);
|
||||
if (!id || (audio ? placed.has(id) : !placed.has(id))) continue;
|
||||
if (items.some((other) => other.id === id) || !known.has(text(i.track))) continue;
|
||||
if (!(start >= 0) || !(length >= 1)) continue;
|
||||
const offset = finite(i.offset);
|
||||
const item: CompositionItem = {
|
||||
id,
|
||||
track: text(i.track),
|
||||
start,
|
||||
length,
|
||||
offset: Number.isFinite(offset) ? offset : 0,
|
||||
};
|
||||
if (i.fitMedia === true) item.fitMedia = true;
|
||||
if (audio) item.audio = audio;
|
||||
const fadeIn = Math.round(finite(i.fadeIn));
|
||||
const fadeOut = Math.round(finite(i.fadeOut));
|
||||
if (fadeIn > 0) item.fadeIn = fadeIn;
|
||||
if (fadeOut > 0) item.fadeOut = fadeOut;
|
||||
if (isFadeCurve(i.curveIn)) item.curveIn = i.curveIn;
|
||||
if (isFadeCurve(i.curveOut)) item.curveOut = i.curveOut;
|
||||
items.push(item);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
const AUDIO_KINDS: readonly CompositionAudioKind[] = ['sound', 'pattern', 'song'];
|
||||
|
||||
/** Held to what an audio item plays: a kind, an asset id kept even once the asset is gone, a volume. */
|
||||
function heldAudio(raw: unknown): CompositionAudio | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null;
|
||||
const a = raw as Record<string, unknown>;
|
||||
const kind = AUDIO_KINDS.find((k) => k === a.kind);
|
||||
if (!kind || !text(a.asset)) return null;
|
||||
const volume = finite(a.volume);
|
||||
return {
|
||||
kind,
|
||||
asset: text(a.asset),
|
||||
volume: Number.isFinite(volume) ? Math.max(ITEM_MIN_DB, Math.min(SOUND_ITEM_MAX_DB, volume)) : 0,
|
||||
bus: text(a.bus),
|
||||
};
|
||||
}
|
||||
|
||||
/** Held like an item; an animation that is gone is kept, as an animation keeps a root that is gone. */
|
||||
export function heldCompositionClips(raw: unknown, tracks: readonly CompositionTrack[]): CompositionClip[] {
|
||||
const known = new Set(tracks.map((t) => t.id));
|
||||
const clips: CompositionClip[] = [];
|
||||
for (const c of list(raw)) {
|
||||
const id = text(c.id);
|
||||
const start = Math.round(finite(c.start));
|
||||
if (!id || clips.some((other) => other.id === id) || !known.has(text(c.track)) || !(start >= 0)) continue;
|
||||
clips.push({ id, track: text(c.track), animation: text(c.animation), start });
|
||||
}
|
||||
return clips;
|
||||
}
|
||||
|
||||
function copyOf(object: SceneObject): SceneObject {
|
||||
return {
|
||||
...object,
|
||||
position: { ...object.position },
|
||||
overrides: { ...object.overrides },
|
||||
instanceEdits: structuredClone(object.instanceEdits),
|
||||
};
|
||||
}
|
||||
|
||||
function driveVideos(project: Project, holder: CompositionAsset, live: LiveItem): void {
|
||||
const below = resolveObjects(project, holder).filter(
|
||||
(o) => splitInstanceId(o.id)?.instanceId === live.object.id,
|
||||
);
|
||||
for (const object of [holder.objects.find((o) => o.id === live.object.id)!, ...below]) {
|
||||
const asset = findAsset(project, object.assetId);
|
||||
if (asset?.type !== 'sprite') continue;
|
||||
const props = resolveSceneObjectProps(project, asset, object);
|
||||
const own = object.id === live.object.id;
|
||||
if (!videoWorn(props, project) || (!own && videoDrivenOf(props))) continue;
|
||||
const ref = objectRef(project, holder, object.id);
|
||||
ref?.setOverride(VIDEO_CLOCK_KEY, 'driven');
|
||||
ref?.setOverride(VIDEO_TIME_KEY, live.videoTime);
|
||||
if (live.level < 1) ref?.setOverride(VIDEO_LEVEL_KEY, videoLevelOf(props) * live.level);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The object of the holder a row of a clip aims at on the item `rootId`: the item itself, the object
|
||||
* at the row's template path below it, or else the one its names lead to – the way `Play animation`
|
||||
* finds a target on an object built from another container.
|
||||
*/
|
||||
function rowTarget(
|
||||
project: Project,
|
||||
holder: CompositionAsset,
|
||||
rootId: string,
|
||||
animation: AnimationAsset,
|
||||
track: AnimationTrack,
|
||||
): string | undefined {
|
||||
if (track.target === '') return rootId;
|
||||
const objects = resolveObjects(project, holder);
|
||||
const byPath = instanceChildId(rootId, track.target);
|
||||
if (objects.some((o) => o.id === byPath)) return byPath;
|
||||
const names = targetNamePath(project, findAsset(project, animation.root), track.target);
|
||||
let at: string | undefined = rootId;
|
||||
for (const name of names ?? []) {
|
||||
const found = objects.filter(
|
||||
(o) => o.parentId === at && objectName(o, findAsset(project, o.assetId)) === name,
|
||||
);
|
||||
at = found.length === 1 ? found[0]!.id : undefined;
|
||||
if (!at) return undefined;
|
||||
}
|
||||
return names ? at : undefined;
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* What the Composition tab does to items and clips (#417), as plain functions on lists so a test can
|
||||
* check them. Items of a track may overlap – both live there, as on two tracks –, clips of a track
|
||||
* do not: a clip's edit that would make them is held short, a dropped clip lands in the gap it falls
|
||||
* into.
|
||||
*/
|
||||
import { videoLoopsOf } from '../properties/videos';
|
||||
import type { CompositionAsset, CompositionAudioKind, CompositionClip, CompositionItem } from './Asset';
|
||||
import {
|
||||
audioRoundSeconds,
|
||||
audioSeconds,
|
||||
compositionFps,
|
||||
DEFAULT_ITEM_SECONDS,
|
||||
fittedLength,
|
||||
itemEnd,
|
||||
itemFades,
|
||||
itemVideo,
|
||||
} from './composition';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { findSound } from './sound';
|
||||
import { findVideo, videoLengthOf } from './video';
|
||||
|
||||
/** A stretch of a track, in frames. */
|
||||
export interface Span {
|
||||
start: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
interface Placed {
|
||||
id: string;
|
||||
track: string;
|
||||
start: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where something `length` frames long lands when dropped at `at`: there, or right after whatever
|
||||
* of `taken` covers `at`, and cut short where the next begins. Null where not a frame is free.
|
||||
*/
|
||||
export function droppedSpan(taken: readonly Span[], at: number, length: number): Span | null {
|
||||
let start = Math.max(0, Math.round(at));
|
||||
for (const span of [...taken].sort((a, b) => a.start - b.start))
|
||||
if (span.start <= start && start < span.start + span.length) start = span.start + span.length;
|
||||
const next = Math.min(...taken.filter((s) => s.start >= start).map((s) => s.start));
|
||||
const room = Number.isFinite(next) ? next - start : Infinity;
|
||||
return room >= 1 ? { start, length: Math.min(Math.max(1, length), room) } : null;
|
||||
}
|
||||
|
||||
/** The stretches the clips of a track cover, all but `except`. */
|
||||
export function clipSpans(
|
||||
clips: readonly CompositionClip[],
|
||||
track: string,
|
||||
lengthOf: (clip: CompositionClip) => number,
|
||||
except: ReadonlySet<string> = new Set(),
|
||||
): Span[] {
|
||||
return clips
|
||||
.filter((c) => c.track === track && !except.has(c.id))
|
||||
.map((c) => ({ start: c.start, length: Math.max(1, lengthOf(c)) }));
|
||||
}
|
||||
|
||||
/** Whether no two of `spans` on the same track share a frame. */
|
||||
function apart<T extends Placed>(list: readonly T[], lengthOf: (t: T) => number): boolean {
|
||||
const byTrack = new Map<string, T[]>();
|
||||
for (const t of list) byTrack.set(t.track, [...(byTrack.get(t.track) ?? []), t]);
|
||||
for (const on of byTrack.values()) {
|
||||
const sorted = [...on].sort((a, b) => a.start - b.start);
|
||||
for (let i = 1; i < sorted.length; i++)
|
||||
if (sorted[i]!.start < sorted[i - 1]!.start + Math.max(1, lengthOf(sorted[i - 1]!))) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* A move of the picked ones held so that none starts before 0 or leaves the tracks there are: what
|
||||
* the pointer asks for, as far as it goes.
|
||||
*/
|
||||
export function clampedMove(
|
||||
list: readonly Placed[],
|
||||
picked: ReadonlySet<string>,
|
||||
trackIds: readonly string[],
|
||||
frames: number,
|
||||
tracks: number,
|
||||
): { frames: number; tracks: number } {
|
||||
const chosen = list.filter((t) => picked.has(t.id));
|
||||
if (chosen.length === 0) return { frames: 0, tracks: 0 };
|
||||
const rows = chosen.map((t) => trackIds.indexOf(t.track));
|
||||
return {
|
||||
// `|| 0`: no -0 for one at frame 0.
|
||||
frames: Math.max(frames, -Math.min(...chosen.map((t) => t.start))) || 0,
|
||||
tracks: Math.max(-Math.min(...rows), Math.min(trackIds.length - 1 - Math.max(...rows), tracks)) || 0,
|
||||
};
|
||||
}
|
||||
|
||||
function moved<T extends Placed>(
|
||||
list: readonly T[],
|
||||
picked: ReadonlySet<string>,
|
||||
trackIds: readonly string[],
|
||||
frames: number,
|
||||
tracks: number,
|
||||
): T[] | null {
|
||||
const out: T[] = [];
|
||||
for (const t of list) {
|
||||
if (!picked.has(t.id)) {
|
||||
out.push(t);
|
||||
continue;
|
||||
}
|
||||
const track = trackIds[trackIds.indexOf(t.track) + tracks];
|
||||
if (track === undefined || t.start + frames < 0) return null;
|
||||
out.push({ ...t, start: t.start + frames, track });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The picked items moved by `frames` and `tracks`; null where one would start before 0 or leave the tracks. */
|
||||
export function movedItems(
|
||||
items: readonly CompositionItem[],
|
||||
picked: ReadonlySet<string>,
|
||||
trackIds: readonly string[],
|
||||
frames: number,
|
||||
tracks: number,
|
||||
): CompositionItem[] | null {
|
||||
return moved(items, picked, trackIds, frames, tracks);
|
||||
}
|
||||
|
||||
/**
|
||||
* The clips that belong to the picked items: a clip belongs to the item lying on its track at its
|
||||
* first frame, of two there the one in front – the later start, at the same start the later in the
|
||||
* list. A clip that begins before an item and only reaches into it belongs to the one before. An
|
||||
* audio item (#418) has nothing a clip could move, and so has no clips.
|
||||
*/
|
||||
export function carriedClips(
|
||||
items: readonly CompositionItem[],
|
||||
clips: readonly CompositionClip[],
|
||||
picked: ReadonlySet<string>,
|
||||
): Set<string> {
|
||||
const carried = new Set<string>();
|
||||
if (picked.size === 0) return carried;
|
||||
for (const clip of clips) {
|
||||
let owner: CompositionItem | undefined;
|
||||
for (const item of items)
|
||||
if (!item.audio && item.track === clip.track && item.start <= clip.start && clip.start < itemEnd(item))
|
||||
if (!owner || item.start >= owner.start) owner = item;
|
||||
if (owner && picked.has(owner.id)) carried.add(clip.id);
|
||||
}
|
||||
return carried;
|
||||
}
|
||||
|
||||
/**
|
||||
* The picked items moved, their clips with them unless `alone`; null where an item would start
|
||||
* before 0 or leave the tracks, or a clip would come to lie over another.
|
||||
*/
|
||||
export function movedWithClips(
|
||||
items: readonly CompositionItem[],
|
||||
clips: readonly CompositionClip[],
|
||||
picked: ReadonlySet<string>,
|
||||
trackIds: readonly string[],
|
||||
frames: number,
|
||||
tracks: number,
|
||||
lengthOf: (clip: CompositionClip) => number,
|
||||
alone = false,
|
||||
): { items: CompositionItem[]; clips: CompositionClip[] } | null {
|
||||
const outItems = movedItems(items, picked, trackIds, frames, tracks);
|
||||
const carried = alone ? new Set<string>() : carriedClips(items, clips, picked);
|
||||
if (!outItems) return null;
|
||||
if (carried.size === 0) return { items: outItems, clips: [...clips] };
|
||||
const outClips = movedClips(clips, carried, trackIds, frames, tracks, lengthOf);
|
||||
return outClips && { items: outItems, clips: outClips };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a move asked to go `wanted` lands when only places that `fit` will do: on the way back to
|
||||
* `last`, on the track asked for, else on the one it was on; `last` itself when nothing fits.
|
||||
*/
|
||||
export function heldMove(
|
||||
wanted: { frames: number; tracks: number },
|
||||
last: { frames: number; tracks: number },
|
||||
fits: (frames: number, tracks: number) => boolean,
|
||||
): { frames: number; tracks: number } {
|
||||
for (const on of [wanted.tracks, last.tracks]) {
|
||||
const step = Math.sign(last.frames - wanted.frames);
|
||||
for (let at = wanted.frames; ; at += step) {
|
||||
if (fits(at, on)) return { frames: at, tracks: on };
|
||||
if (at === last.frames) break;
|
||||
}
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/** The picked clips moved like items; null, too, where one would come to lie over another. */
|
||||
export function movedClips(
|
||||
clips: readonly CompositionClip[],
|
||||
picked: ReadonlySet<string>,
|
||||
trackIds: readonly string[],
|
||||
frames: number,
|
||||
tracks: number,
|
||||
lengthOf: (clip: CompositionClip) => number,
|
||||
): CompositionClip[] | null {
|
||||
const out = moved(clips, picked, trackIds, frames, tracks);
|
||||
return out && apart(out, lengthOf) ? out : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied clips placed as `placing` places what is pasted, then moved on frame by frame as long as
|
||||
* one would lie over a clip of `clips` or over another of the copy – clips of a track never overlap.
|
||||
*/
|
||||
export function pastedClips(
|
||||
copied: readonly CompositionClip[],
|
||||
clips: readonly CompositionClip[],
|
||||
ids: ReadonlyMap<string, string>,
|
||||
at: number,
|
||||
trackIds: readonly string[],
|
||||
lengthOf: (clip: CompositionClip) => number,
|
||||
row?: number,
|
||||
): CompositionClip[] {
|
||||
const place = placing(copied, at, trackIds, row);
|
||||
const placed = copied.map((c) => place(c, ids));
|
||||
const by = framesToRoom(placed, clips, lengthOf);
|
||||
return placed.map((c) => ({ ...c, start: c.start + by }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Copied items placed as `placing` places them, the copied clips that came with them moved by as
|
||||
* much; then both moved on frame by frame as long as one of the clips would lie over a clip of
|
||||
* `clips` – clips of a track never overlap.
|
||||
*/
|
||||
export function pastedItemsWithClips(
|
||||
items: readonly CompositionItem[],
|
||||
copied: readonly CompositionClip[],
|
||||
clips: readonly CompositionClip[],
|
||||
ids: ReadonlyMap<string, string>,
|
||||
at: number,
|
||||
trackIds: readonly string[],
|
||||
lengthOf: (clip: CompositionClip) => number,
|
||||
row?: number,
|
||||
): { items: CompositionItem[]; clips: CompositionClip[] } {
|
||||
const place = placing(items, at, trackIds, row);
|
||||
const placedItems = items.map((i) => place(i, ids));
|
||||
const placedClips = copied.map((c) => place(c, ids));
|
||||
const by = framesToRoom(placedClips, clips, lengthOf);
|
||||
return {
|
||||
items: placedItems.map((i) => ({ ...i, start: i.start + by })),
|
||||
clips: placedClips.map((c) => ({ ...c, start: c.start + by })),
|
||||
};
|
||||
}
|
||||
|
||||
/** The frames `placed` moves on until none of it lies over a clip of `clips` or over another of it. */
|
||||
function framesToRoom(
|
||||
placed: readonly CompositionClip[],
|
||||
clips: readonly CompositionClip[],
|
||||
lengthOf: (clip: CompositionClip) => number,
|
||||
): number {
|
||||
// Past the end of every clip there is room, so the search ends there at the latest.
|
||||
const last = Math.max(0, ...clips.map((c) => c.start + Math.max(1, lengthOf(c))));
|
||||
for (let by = 0; ; by++) {
|
||||
const moved = placed.map((c) => ({ ...c, start: c.start + by }));
|
||||
if (apart([...clips, ...moved], lengthOf) || moved.every((c) => c.start >= last)) return by;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a paste moves what was copied, under the ids `ids` gives it: the earliest of `items` to `at`,
|
||||
* on their tracks, or, given `row`, the topmost on that track and the others as far below as they
|
||||
* were, as far as there are tracks – a song's paste. One whose track is gone counts as on the first.
|
||||
*/
|
||||
function placing(
|
||||
items: readonly Placed[],
|
||||
at: number,
|
||||
trackIds: readonly string[],
|
||||
row?: number,
|
||||
): <T extends Placed>(t: T, ids: ReadonlyMap<string, string>) => T {
|
||||
if (items.length === 0) return (t) => t;
|
||||
const first = Math.min(...items.map((i) => i.start));
|
||||
const index = new Map(trackIds.map((id, i) => [id, i]));
|
||||
const rows = items.map((i) => index.get(i.track) ?? 0);
|
||||
const top = Math.min(...rows);
|
||||
const bottom = Math.max(...rows);
|
||||
const shift = row === undefined ? 0 : Math.max(-top, Math.min(trackIds.length - 1 - bottom, row - top));
|
||||
return (t, ids) => ({
|
||||
...t,
|
||||
id: ids.get(t.id) ?? t.id,
|
||||
start: Math.max(0, Math.round(at)) + (t.start - first),
|
||||
track: trackIds[(index.get(t.track) ?? 0) + shift] ?? t.track,
|
||||
});
|
||||
}
|
||||
|
||||
/** The item with its end edge dragged to make it `wanted` frames long, at least one; its length is its own from then on. */
|
||||
export function resizedItem(item: CompositionItem, wanted: number): CompositionItem {
|
||||
const { fitMedia: _, ...rest } = item;
|
||||
return { ...rest, length: Math.max(1, Math.round(wanted)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The item with its start edge dragged to `wanted`: never onto its end or before 0. What it shows
|
||||
* stays where it was in time – the video starts that much later or sooner –, and pulled out before
|
||||
* the video's beginning the item shows its first picture until the video starts.
|
||||
*/
|
||||
export function trimmedItem(
|
||||
item: CompositionItem,
|
||||
wanted: number,
|
||||
fps: number,
|
||||
video: boolean,
|
||||
): CompositionItem {
|
||||
const start = Math.max(0, Math.min(Math.round(wanted), itemEnd(item) - 1));
|
||||
const by = start - item.start;
|
||||
const { fitMedia: _, ...rest } = item;
|
||||
const offset = video ? Math.round((item.offset + by / fps) * 1e6) / 1e6 || 0 : item.offset;
|
||||
return { ...rest, start, length: item.length - by, offset };
|
||||
}
|
||||
|
||||
/**
|
||||
* The item with the handle of its fade in or out dragged to `frame`: the fade from its edge to there,
|
||||
* none where that is at or past the edge, no further than the other fade lets it. A fade of 0 is left
|
||||
* out of the item.
|
||||
*/
|
||||
export function fadedItem(item: CompositionItem, end: 'in' | 'out', frame: number): CompositionItem {
|
||||
const fades = itemFades(item);
|
||||
const asked = end === 'in' ? frame - item.start : itemEnd(item) - frame;
|
||||
const room = item.length - (end === 'in' ? fades.fadeOut : fades.fadeIn);
|
||||
const frames = Math.max(0, Math.min(room, Math.round(asked)));
|
||||
const key = end === 'in' ? 'fadeIn' : 'fadeOut';
|
||||
const { [key]: _, ...rest } = item;
|
||||
return frames > 0 ? { ...rest, [key]: frames } : rest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an item's video lies in the composition, in frames: where it begins, how long its first pass
|
||||
* is, whether it goes round, and how long every pass after the first is where that differs – a song
|
||||
* goes round its loop only.
|
||||
*/
|
||||
export interface ItemMedia {
|
||||
begins: number;
|
||||
length: number;
|
||||
loops: boolean;
|
||||
round?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The video of an item as it lies on its track, or what an audio item plays (#418) – a sound and a
|
||||
* pattern go round, a song to the end of its loop and then round it –; null without one, or while
|
||||
* its length is not known.
|
||||
*/
|
||||
export function itemMedia(project: Project, comp: CompositionAsset, item: CompositionItem): ItemMedia | null {
|
||||
const video = item.audio ? null : itemVideo(project, comp, item);
|
||||
const seconds = item.audio ? audioSeconds(project, item.audio) : video ? videoLengthOf(video) : 0;
|
||||
if (!(seconds > 0)) return null;
|
||||
const fps = compositionFps(comp);
|
||||
const media = {
|
||||
begins: item.start - item.offset * fps,
|
||||
length: seconds * fps,
|
||||
loops: item.audio ? true : videoLoopsOf(video!.asset.props),
|
||||
};
|
||||
const round = item.audio?.kind === 'song' ? audioRoundSeconds(project, item.audio) * fps : 0;
|
||||
return round > 0 ? { ...media, round } : media;
|
||||
}
|
||||
|
||||
/** The frames from `from` to `to` where the video begins or a pass of it ends. */
|
||||
export function mediaEdges(media: ItemMedia, from: number, to: number): number[] {
|
||||
const edges = media.begins >= from && media.begins <= to ? [media.begins] : [];
|
||||
const round = media.round ?? media.length;
|
||||
const first = media.begins + media.length;
|
||||
for (let pass = Math.max(0, Math.ceil((from - first) / round)); pass === 0 || media.loops; pass++) {
|
||||
const end = first + pass * round;
|
||||
if (end > to) break;
|
||||
if (end >= from) edges.push(end);
|
||||
}
|
||||
return edges;
|
||||
}
|
||||
|
||||
/** Half a frame: closer than this an edge counts as on the video's. */
|
||||
const ON_EDGE = 0.5;
|
||||
|
||||
/**
|
||||
* What the timeline marks on an item wearing a video: where inside it the video begins or a pass
|
||||
* ends (`seams`), and which of its ends cut into the video – starting after its beginning, ending
|
||||
* before its end.
|
||||
*/
|
||||
export function mediaMarks(
|
||||
item: CompositionItem,
|
||||
media: ItemMedia,
|
||||
): { seams: number[]; cutStart: boolean; cutEnd: boolean } {
|
||||
return {
|
||||
seams: mediaEdges(media, item.start + ON_EDGE, itemEnd(item) - ON_EDGE),
|
||||
cutStart: item.start > media.begins + ON_EDGE,
|
||||
cutEnd: itemEnd(item) < media.begins + media.length - ON_EDGE,
|
||||
};
|
||||
}
|
||||
|
||||
/** A frame pulled onto the nearest edge of the video within `reach` frames; else as it is. */
|
||||
export function snappedToMedia(frame: number, media: ItemMedia | null, reach: number): number {
|
||||
const edges = media ? mediaEdges(media, frame - reach, frame + reach) : [];
|
||||
return edges.reduce(
|
||||
(best, edge) => (Math.abs(edge - frame) < Math.abs(best - frame) ? edge : best),
|
||||
edges[0] ?? frame,
|
||||
);
|
||||
}
|
||||
|
||||
/** What an asset is laid on a track as when it is to be heard (#418): a sound, a clip of one, a pattern or a song. */
|
||||
export function audioKindOf(project: Project, assetId: string): CompositionAudioKind | null {
|
||||
const asset = findAsset(project, assetId);
|
||||
if (asset?.type === 'pattern' || asset?.type === 'song') return asset.type;
|
||||
return findSound(project, assetId) ? 'sound' : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long an item of the asset `id` names is when it is dropped in: as long as the video or clip of
|
||||
* one it is (#419, #425) or its sprite wears, or what it plays as an audio item (#418); else 3 s.
|
||||
*/
|
||||
export function droppedLength(project: Project, comp: CompositionAsset, id: string): number {
|
||||
const kind = audioKindOf(project, id);
|
||||
if (kind)
|
||||
return fittedLength(project, comp, {
|
||||
id: '',
|
||||
track: '',
|
||||
start: 0,
|
||||
length: 1,
|
||||
offset: 0,
|
||||
audio: { kind, asset: id, volume: 0, bus: '' },
|
||||
});
|
||||
const asset = findAsset(project, id);
|
||||
const texture = asset?.type === 'sprite' ? asset.props.texture : id;
|
||||
const video = typeof texture === 'string' ? findVideo(project, texture) : null;
|
||||
const fps = compositionFps(comp);
|
||||
const seconds = video ? videoLengthOf(video) : 0;
|
||||
return seconds > 0 ? Math.max(1, Math.round(seconds * fps)) : Math.round(DEFAULT_ITEM_SECONDS * fps);
|
||||
}
|
||||
|
||||
/** What an item wears that its length follows: the video or clip's id, what an audio item plays, '' for none. */
|
||||
export function mediaKey(project: Project, comp: CompositionAsset, item: CompositionItem): string {
|
||||
if (item.audio) return item.audio.asset;
|
||||
const video = itemVideo(project, comp, item);
|
||||
return video ? (video.clip?.id ?? video.asset.id) : '';
|
||||
}
|
||||
@@ -436,6 +436,40 @@ export function materializeInstance(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The id the object at `path` below `ownerId` has – one level deeper when the owner is content
|
||||
* itself, so the content of `q~a` is `q~a/b`, the id a template path through nested containers gives.
|
||||
*/
|
||||
export function contentId(ownerId: string, path: string): string {
|
||||
const split = splitInstanceId(ownerId);
|
||||
return split
|
||||
? instanceChildId(split.instanceId, `${split.path}${PATH_SEP}${path}`)
|
||||
: instanceChildId(ownerId, path);
|
||||
}
|
||||
|
||||
/**
|
||||
* `object` as `id` below `parentId`, followed by its container content with the ids below that one –
|
||||
* how a composition puts its objects into a scene (#417). Copies, free to be stored and written to.
|
||||
*/
|
||||
export function placedWithContent(
|
||||
project: Project,
|
||||
object: SceneObject,
|
||||
id: string,
|
||||
parentId: string,
|
||||
): SceneObject[] {
|
||||
const rename = (at: string) => (at === object.id ? id : contentId(id, at.slice(object.id.length + 1)));
|
||||
const { groupId: _, ...top } = object;
|
||||
return resolveObjects(project, { objects: [{ ...top, parentId: '' }] }).map((o) => ({
|
||||
...o,
|
||||
id: rename(o.id),
|
||||
parentId: o.id === object.id ? parentId : rename(o.parentId),
|
||||
position: { ...o.position },
|
||||
editorOffset: { ...o.editorOffset },
|
||||
overrides: { ...o.overrides },
|
||||
instanceEdits: {},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Spells out every live instance of the holder. The runtime needs it: entities are built from
|
||||
* stored objects, and a behavior may destroy or move one of them – which a resolved copy could
|
||||
|
||||
@@ -14,5 +14,7 @@ export function placementRejection(project: Project, holder: ObjectHolder, asset
|
||||
return `A ${ASSET_TYPE_LABELS[asset.type].toLowerCase()} cannot be placed into a scene`;
|
||||
if (asset.type === 'container' && containerReaches(project, asset, holder.id))
|
||||
return `${asset.name} cannot contain ${holder.name}: that nests forever`;
|
||||
if (asset.type === 'composition' && holder.type === 'composition')
|
||||
return 'A composition cannot be placed into a composition';
|
||||
return '';
|
||||
}
|
||||
@@ -64,7 +64,7 @@ const UNNAMING: ReadonlySet<AssetType> = new Set(['sound', 'texture', 'pixelart'
|
||||
* Every text the project holds outside the assets that cannot name a sound. Where a sound is played
|
||||
* whole – a sound field, an item of a song, a Sample, a Reverb's impulse – its id is one of them; a
|
||||
* clip has an id of its own. Looked at as data, not field by field, so no place is missed: one that
|
||||
* only looks like a reference loads the file as before.
|
||||
* only looks like a reference just loads a file nothing plays.
|
||||
*/
|
||||
function namedStrings(project: Project): Set<string> {
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -17,6 +17,7 @@ export const VISIBILITY_TYPES: readonly PlaceableAssetType[] = [
|
||||
'camera',
|
||||
'clip',
|
||||
'collider',
|
||||
'composition',
|
||||
'container',
|
||||
'joint',
|
||||
'particles',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { nextFreeName, nextNumberedName } from '../commands/naming';
|
||||
import { newId } from '../ids';
|
||||
import type { Asset, SoundAsset, SoundClip } from './Asset';
|
||||
import type { Asset, SoundAsset, SoundClip, VideoAsset } from './Asset';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { storedFilePattern } from './storedFile';
|
||||
|
||||
@@ -13,10 +13,18 @@ export function isSoundFile(name: string): boolean {
|
||||
const CLIP_SEPARATOR = ' / ';
|
||||
const DEFAULT_CLIP_NAME = 'Clip';
|
||||
|
||||
export function clipsOf(asset: SoundAsset): readonly SoundClip[] {
|
||||
/** The clips of a sound (#357) or a video (#402). */
|
||||
export function clipsOf<C>(asset: { clips?: C[] }): readonly C[] {
|
||||
return asset.clips ?? [];
|
||||
}
|
||||
|
||||
/** What has clips: a sound and a video, each a file with named stretches of it. */
|
||||
export type ClipOwner = SoundAsset | VideoAsset;
|
||||
|
||||
export function hasClips(asset: Asset): asset is ClipOwner {
|
||||
return asset.type === 'sound' || asset.type === 'video';
|
||||
}
|
||||
|
||||
/** What a sound field names (#357): a sound, the whole file, or one of its clips. */
|
||||
export interface SoundRef {
|
||||
asset: SoundAsset;
|
||||
@@ -39,10 +47,12 @@ export function findSound(project: Project, id: string): SoundRef | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The asset among `assets` an id names: the asset itself, or the sound one of whose clips it is. */
|
||||
/** The asset among `assets` an id names: the asset itself, or the one one of whose clips it is. */
|
||||
export function assetNamedBy(assets: readonly Asset[], id: string): Asset | undefined {
|
||||
if (id === '') return undefined;
|
||||
return assets.find((a) => a.id === id || (a.type === 'sound' && clipsOf(a).some((c) => c.id === id)));
|
||||
return assets.find(
|
||||
(a) => a.id === id || (hasClips(a) && clipsOf<{ id: string }>(a).some((c) => c.id === id)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,8 +60,8 @@ export function assetNamedBy(assets: readonly Asset[], id: string): Asset | unde
|
||||
* each old id with its new one in `ids` so the places that named a clip can follow.
|
||||
*/
|
||||
export function renewClipIds(copy: Asset, ids: Map<string, string>): void {
|
||||
if (copy.type !== 'sound') return;
|
||||
for (const clip of clipsOf(copy)) {
|
||||
if (!hasClips(copy)) return;
|
||||
for (const clip of clipsOf<{ id: string }>(copy)) {
|
||||
const id = newId();
|
||||
ids.set(clip.id, id);
|
||||
clip.id = id;
|
||||
@@ -60,21 +70,26 @@ export function renewClipIds(copy: Asset, ids: Map<string, string>): void {
|
||||
|
||||
/** "Kick" for the sound, "Kick / Clip 1" for a clip of it. */
|
||||
export function soundRefName(ref: SoundRef, soundName = ref.asset.name): string {
|
||||
return ref.clip ? `${soundName}${CLIP_SEPARATOR}${ref.clip.name}` : soundName;
|
||||
return ref.clip ? clipRefName(soundName, ref.clip.name) : soundName;
|
||||
}
|
||||
|
||||
/** "Clip 1", 2, 3 …: the first number none of the sound's clips has. */
|
||||
export function nextClipName(asset: SoundAsset): string {
|
||||
/** "Kick / Clip 1": a clip of a sound or a video by name. */
|
||||
export function clipRefName(assetName: string, clipName: string): string {
|
||||
return `${assetName}${CLIP_SEPARATOR}${clipName}`;
|
||||
}
|
||||
|
||||
/** "Clip 1", 2, 3 …: the first number none of the asset's clips has. */
|
||||
export function nextClipName(asset: ClipOwner): string {
|
||||
return nextNumberedName(
|
||||
clipsOf(asset).map((c) => c.name),
|
||||
clipsOf<{ name: string }>(asset).map((c) => c.name),
|
||||
DEFAULT_CLIP_NAME,
|
||||
);
|
||||
}
|
||||
|
||||
/** A name free among the sound's clips: `name`, else "name 2", "name 3" … */
|
||||
export function uniqueClipName(asset: SoundAsset, name: string, ignoreId = ''): string {
|
||||
/** A name free among the asset's clips: `name`, else "name 2", "name 3" … */
|
||||
export function uniqueClipName(asset: ClipOwner, name: string, ignoreId = ''): string {
|
||||
return nextFreeName(
|
||||
clipsOf(asset)
|
||||
clipsOf<{ id: string; name: string }>(asset)
|
||||
.filter((c) => c.id !== ignoreId)
|
||||
.map((c) => c.name),
|
||||
name,
|
||||
|
||||
@@ -4,6 +4,17 @@ import { findPixelFrame, pixelFrameRect } from './pixelart';
|
||||
import { type PropertyBag, textOf, type Vec2 } from './Properties';
|
||||
import { findAsset, type Project } from './Project';
|
||||
import { storedFilePattern } from './storedFile';
|
||||
import { videoBusOf } from '../properties/videos';
|
||||
import {
|
||||
findVideo,
|
||||
isVideoFile,
|
||||
isVideoKey,
|
||||
routedVideoKey,
|
||||
videoKey,
|
||||
videoMixBus,
|
||||
videoPlayOf,
|
||||
type VideoRef,
|
||||
} from './video';
|
||||
|
||||
const FILE_PATTERN = storedFilePattern('png|jpg|webp|gif');
|
||||
|
||||
@@ -92,16 +103,23 @@ export function textureRefIds(asset: TextureAsset): string[] {
|
||||
|
||||
/** What a sprite wearing `props.texture` shows. */
|
||||
export function resolveTexture(project: Project, props: PropertyBag): TextureRef | null {
|
||||
return resolvePicture(project, textOf(props.texture ?? ''));
|
||||
const ref = resolvePicture(project, textOf(props.texture ?? ''));
|
||||
// A video heard on a bus the sprite picked plays in a player of its own (#418).
|
||||
const bus = ref && isVideoKey(ref.file) ? videoMixBus(project, videoBusOf(props)) : null;
|
||||
return bus ? { ...ref!, file: routedVideoKey(ref!.file, bus) } : ref;
|
||||
}
|
||||
|
||||
/** A texture, a frame or a generating asset; any other asset's id is no picture. */
|
||||
/** A texture, a frame, a video or a clip of one, or a generating asset; any other asset's id is no picture. */
|
||||
export function resolvePicture(project: Project, id: string): TextureRef | null {
|
||||
if (!id) return null;
|
||||
const asset = findAsset(project, id);
|
||||
if (asset) return asset.type === 'texture' ? refFor(asset, null) : generatedRef(asset, project);
|
||||
if (asset?.type === 'texture') return refFor(asset, null);
|
||||
if (asset?.type === 'video') return videoRef({ asset, clip: null });
|
||||
if (asset) return generatedRef(asset, project);
|
||||
const found = findFrame(project, id);
|
||||
if (found) return refFor(found.asset, found.frame);
|
||||
const video = findVideo(project, id);
|
||||
if (video) return videoRef(video);
|
||||
// A frame of pixel art (#130) is a cut of its generated sheet, as a texture frame is of its image.
|
||||
const pixel = findPixelFrame(project, id);
|
||||
return pixel ? generatedRef(pixel.asset, project, pixelFrameRect(pixel.asset, pixel.index)) : null;
|
||||
@@ -128,6 +146,19 @@ function generatedRef(asset: Asset, project: Project, rect: FrameRect | null = n
|
||||
};
|
||||
}
|
||||
|
||||
/** A playing video (#402) reads like a texture of its size; the key says what plays, see `videoKey`. */
|
||||
function videoRef(ref: VideoRef): TextureRef | null {
|
||||
if (!isVideoFile(ref.asset.file)) return null;
|
||||
return {
|
||||
file: videoKey(videoPlayOf(ref)),
|
||||
smooth: true,
|
||||
rect: null,
|
||||
sourceWidth: ref.asset.width,
|
||||
sourceHeight: ref.asset.height,
|
||||
slice: null,
|
||||
};
|
||||
}
|
||||
|
||||
function refFor(asset: TextureAsset, frame: TextureFrame | null): TextureRef | null {
|
||||
if (!isTextureFile(asset.file)) return null;
|
||||
return {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { type Neighbours, type TileQuadrants, tileQuadrants } from './autotile';
|
||||
import { type TileKind, tileKind } from './tile';
|
||||
import type { PropertyBag } from './Properties';
|
||||
import { findAssetOf, type Project } from './Project';
|
||||
import { cellsTileIds, cellsVersion, KeyedMemo } from './tilemapMemo';
|
||||
import { cellsChangedSince, cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo';
|
||||
|
||||
export const TILEMAP_COLUMNS_KEY = 'columns';
|
||||
export const TILEMAP_ROWS_KEY = 'rows';
|
||||
@@ -145,33 +145,105 @@ function joinedBy(same: (dx: number, dy: number) => boolean): Neighbours {
|
||||
export interface LayerCells {
|
||||
columns: number;
|
||||
rows: number;
|
||||
list: readonly { x: number; y: number; tileId: string }[];
|
||||
readonly list: readonly { x: number; y: number; tileId: string }[];
|
||||
/** Tile id by `y * columns + x`. */
|
||||
at: ReadonlyMap<number, string>;
|
||||
}
|
||||
|
||||
const indexed = new WeakMap<Record<string, string>, LayerCells & { version: number }>();
|
||||
/**
|
||||
* Patched in place when cells are painted (#348), so it holds for the cells as they are now: whoever
|
||||
* asks works from it at once and keeps it no longer.
|
||||
*/
|
||||
class CellIndex implements LayerCells {
|
||||
readonly at = new Map<number, string>();
|
||||
private ordered: LayerCells['list'] | null = null;
|
||||
|
||||
constructor(
|
||||
readonly columns: number,
|
||||
readonly rows: number,
|
||||
public version: number,
|
||||
) {}
|
||||
|
||||
get list(): LayerCells['list'] {
|
||||
const { at, columns } = this;
|
||||
this.ordered ??= [...at.keys()]
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => ({ x: i % columns, y: Math.floor(i / columns), tileId: at.get(i)! }));
|
||||
return this.ordered;
|
||||
}
|
||||
|
||||
read(key: string, tileId: string | undefined): void {
|
||||
const cell = parseCellKey(key);
|
||||
if (!cell || cell.x < 0 || cell.y < 0 || cell.x >= this.columns || cell.y >= this.rows) return;
|
||||
const index = cell.y * this.columns + cell.x;
|
||||
if (tileId) this.at.set(index, tileId);
|
||||
else this.at.delete(index);
|
||||
this.ordered = null;
|
||||
}
|
||||
}
|
||||
|
||||
const indexed = new WeakMap<Record<string, string>, CellIndex>();
|
||||
|
||||
export function layerCells(props: PropertyBag, layer: TilemapLayer): LayerCells {
|
||||
const columns = tilemapColumns(props);
|
||||
const rows = tilemapRows(props);
|
||||
const version = cellsVersion(layer.cells);
|
||||
const held = indexed.get(layer.cells);
|
||||
if (held && held.version === version && held.columns === columns && held.rows === rows) return held;
|
||||
const at = new Map<number, string>();
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) {
|
||||
const cell = parseCellKey(key);
|
||||
if (!tileId || !cell || cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows) continue;
|
||||
at.set(cell.y * columns + cell.x, tileId);
|
||||
if (held && held.columns === columns && held.rows === rows) {
|
||||
if (held.version === version) return held;
|
||||
const changed = cellsChangedSince(layer.cells, held.version);
|
||||
if (changed) {
|
||||
for (const key of changed) held.read(key, layer.cells[key]);
|
||||
held.version = version;
|
||||
return held;
|
||||
}
|
||||
}
|
||||
const list = [...at.keys()]
|
||||
.sort((a, b) => a - b)
|
||||
.map((i) => ({ x: i % columns, y: Math.floor(i / columns), tileId: at.get(i)! }));
|
||||
const fresh = { columns, rows, list, at, version };
|
||||
const fresh = new CellIndex(columns, rows, version);
|
||||
for (const [key, tileId] of Object.entries(layer.cells)) fresh.read(key, tileId);
|
||||
indexed.set(layer.cells, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* The cells that may show or block otherwise once `keys` are painted: those cells and their eight
|
||||
* neighbours inside the grid, by `y * columns + x`, ascending. Further off nothing changes – a
|
||||
* cell's quarters and its blocking shape follow its neighbours alone.
|
||||
*/
|
||||
export function cellsAround(keys: readonly string[], columns: number, rows: number): number[] {
|
||||
const near = new Set<number>();
|
||||
for (const key of keys) {
|
||||
const cell = parseCellKey(key);
|
||||
if (!cell) continue;
|
||||
for (let y = Math.max(0, cell.y - 1); y <= Math.min(rows - 1, cell.y + 1); y++)
|
||||
for (let x = Math.max(0, cell.x - 1); x <= Math.min(columns - 1, cell.x + 1); x++)
|
||||
near.add(y * columns + x);
|
||||
}
|
||||
return [...near].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
/**
|
||||
* A list in cell order with the entries at `indices` (ascending) worked out again: `make` gives a
|
||||
* cell's new entries, none or several, from the ones it had. A new list – the old one may still be
|
||||
* held by identity.
|
||||
*/
|
||||
export function patchedInOrder<T>(
|
||||
list: readonly T[],
|
||||
indexOf: (entry: T) => number,
|
||||
indices: readonly number[],
|
||||
make: (index: number, before: readonly T[]) => readonly T[],
|
||||
): T[] {
|
||||
const out: T[] = [];
|
||||
let i = 0;
|
||||
for (const index of indices) {
|
||||
while (i < list.length && indexOf(list[i]!) < index) out.push(list[i++]!);
|
||||
const start = i;
|
||||
while (i < list.length && indexOf(list[i]!) === index) i++;
|
||||
for (const entry of make(index, list.slice(start, i))) out.push(entry);
|
||||
}
|
||||
while (i < list.length) out.push(list[i++]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `neighboursOf`, read from the index. */
|
||||
export function neighboursIn(cells: LayerCells, x: number, y: number, tileId: string): Neighbours {
|
||||
const { columns, rows, at } = cells;
|
||||
@@ -202,16 +274,36 @@ export function layerTiles(project: Project, props: PropertyBag, layer: TilemapL
|
||||
const tile: TileAsset | undefined = findAssetOf(project, id, 'tile');
|
||||
if (tile) kinds.set(id, tileKind(tile));
|
||||
}
|
||||
const key = [
|
||||
const kept = [
|
||||
tilemapColumns(props),
|
||||
tilemapRows(props),
|
||||
cellsVersion(layer.cells),
|
||||
[...kinds].map(([id, kind]) => `${id}:${kind}`).join(','),
|
||||
].join('|');
|
||||
return drawn.get(key, () => workOutLayerTiles(props, layer, kinds));
|
||||
const version = cellsVersion(layer.cells);
|
||||
return drawn.get(`${kept}|${version}`, () => {
|
||||
// The last list for these cells and tiles, patched where they were painted since (#348).
|
||||
const last = lastDrawn.get(`${kept}|${identityToken(layer.cells)}`, () => ({ version: 0, tiles: null }));
|
||||
const changed = last.tiles && cellsChangedSince(layer.cells, last.version);
|
||||
const tiles = changed
|
||||
? patchedLayerTiles(props, layer, kinds, last.tiles!, changed)
|
||||
: workOutLayerTiles(props, layer, kinds);
|
||||
last.version = version;
|
||||
last.tiles = tiles;
|
||||
return tiles;
|
||||
});
|
||||
}
|
||||
|
||||
const drawn = new KeyedMemo<readonly TileDraw[]>();
|
||||
const lastDrawn = new KeyedMemo<{ version: number; tiles: readonly TileDraw[] | null }>();
|
||||
|
||||
function tileDrawAt(cells: LayerCells, kinds: Map<string, TileKind>, index: number): TileDraw | null {
|
||||
const tileId = cells.at.get(index);
|
||||
const kind = tileId && kinds.get(tileId);
|
||||
if (!kind) return null;
|
||||
const x = index % cells.columns;
|
||||
const y = Math.floor(index / cells.columns);
|
||||
return { x, y, tileId, quadrants: tileQuadrants(kind, neighboursIn(cells, x, y, tileId)) };
|
||||
}
|
||||
|
||||
function workOutLayerTiles(
|
||||
props: PropertyBag,
|
||||
@@ -220,14 +312,124 @@ function workOutLayerTiles(
|
||||
): TileDraw[] {
|
||||
const cells = layerCells(props, layer);
|
||||
const tiles: TileDraw[] = [];
|
||||
for (const { x, y, tileId } of cells.list) {
|
||||
const kind = kinds.get(tileId);
|
||||
if (!kind) continue;
|
||||
tiles.push({ x, y, tileId, quadrants: tileQuadrants(kind, neighboursIn(cells, x, y, tileId)) });
|
||||
for (const { x, y } of cells.list) {
|
||||
const drawn = tileDrawAt(cells, kinds, y * cells.columns + x);
|
||||
if (drawn) tiles.push(drawn);
|
||||
}
|
||||
return tiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* A cell around the painted ones that still looks the same keeps its entry, so only what is drawn
|
||||
* otherwise counts as changed (#424). Nothing drawn otherwise hands back the very same list.
|
||||
*/
|
||||
function patchedLayerTiles(
|
||||
props: PropertyBag,
|
||||
layer: TilemapLayer,
|
||||
kinds: Map<string, TileKind>,
|
||||
tiles: readonly TileDraw[],
|
||||
changed: readonly string[],
|
||||
): readonly TileDraw[] {
|
||||
const cells = layerCells(props, layer);
|
||||
const removed: TileDraw[] = [];
|
||||
const added: TileDraw[] = [];
|
||||
const patched = patchedInOrder(
|
||||
tiles,
|
||||
(tile) => tile.y * cells.columns + tile.x,
|
||||
cellsAround(changed, cells.columns, cells.rows),
|
||||
(index, [held]) => {
|
||||
const drawn = tileDrawAt(cells, kinds, index);
|
||||
if (held && drawn && sameTileDraw(held, drawn)) return [held];
|
||||
if (held) removed.push(held);
|
||||
if (drawn) added.push(drawn);
|
||||
return drawn ? [drawn] : [];
|
||||
},
|
||||
);
|
||||
if (removed.length === 0 && added.length === 0) return tiles;
|
||||
noteTilesPatch(patched, { from: tiles, removed, added });
|
||||
return patched;
|
||||
}
|
||||
|
||||
function sameTileDraw(a: TileDraw, b: TileDraw): boolean {
|
||||
return (
|
||||
a.x === b.x &&
|
||||
a.y === b.y &&
|
||||
a.tileId === b.tileId &&
|
||||
a.quadrants.every((q, i) => q.x === b.quadrants[i]!.x && q.y === b.quadrants[i]!.y)
|
||||
);
|
||||
}
|
||||
|
||||
/** How a list of tiles came out of the one before it (#424): the entries gone and the ones new. */
|
||||
export interface TilesPatch {
|
||||
from: readonly TileDraw[];
|
||||
removed: readonly TileDraw[];
|
||||
added: readonly TileDraw[];
|
||||
}
|
||||
|
||||
const patches = new WeakMap<readonly TileDraw[], TilesPatch>();
|
||||
|
||||
/** Null for a list worked out whole, or one whose patch was passed on to a newer list. */
|
||||
export function tilesPatch(tiles: readonly TileDraw[]): TilesPatch | null {
|
||||
return patches.get(tiles) ?? null;
|
||||
}
|
||||
|
||||
/** One step only: a chain of patched lists would otherwise keep every list before alive. */
|
||||
function noteTilesPatch(tiles: readonly TileDraw[], patch: TilesPatch): void {
|
||||
patches.delete(patch.from);
|
||||
patches.set(tiles, patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* `groups` of the list `patch` came from, grouped for the list it made: a group no changed entry
|
||||
* falls into stays the very same array, the others are patched in cell order and note their own
|
||||
* patch. In the order `order` gives, by default the one a fresh grouping has – by first cell.
|
||||
*/
|
||||
export function patchedGroups<K>(
|
||||
groups: ReadonlyMap<K, readonly TileDraw[]>,
|
||||
patch: TilesPatch,
|
||||
keyOf: (tile: TileDraw) => K,
|
||||
order: (a: [K, readonly TileDraw[]], b: [K, readonly TileDraw[]]) => number = ([, a], [, b]) =>
|
||||
byCell(a[0]!, b[0]!),
|
||||
): Map<K, readonly TileDraw[]> {
|
||||
const touched = new Map<K, { removed: TileDraw[]; added: TileDraw[] }>();
|
||||
const changeOf = (key: K) => {
|
||||
let change = touched.get(key);
|
||||
if (!change) touched.set(key, (change = { removed: [], added: [] }));
|
||||
return change;
|
||||
};
|
||||
for (const tile of patch.removed) changeOf(keyOf(tile)).removed.push(tile);
|
||||
for (const tile of patch.added) changeOf(keyOf(tile)).added.push(tile);
|
||||
const out = new Map(groups);
|
||||
for (const [key, { removed, added }] of touched) {
|
||||
const from = groups.get(key) ?? [];
|
||||
const gone = new Set(removed);
|
||||
const merged = mergedInCellOrder(
|
||||
from.filter((tile) => !gone.has(tile)),
|
||||
[...added].sort(byCell),
|
||||
);
|
||||
if (merged.length === 0) out.delete(key);
|
||||
else {
|
||||
out.set(key, merged);
|
||||
noteTilesPatch(merged, { from, removed, added });
|
||||
}
|
||||
}
|
||||
return new Map([...out].sort(order));
|
||||
}
|
||||
|
||||
function byCell(a: TileDraw, b: TileDraw): number {
|
||||
return a.y - b.y || a.x - b.x;
|
||||
}
|
||||
|
||||
function mergedInCellOrder(a: readonly TileDraw[], b: readonly TileDraw[]): TileDraw[] {
|
||||
const out: TileDraw[] = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) out.push(byCell(a[i]!, b[j]!) <= 0 ? a[i++]! : b[j++]!);
|
||||
while (i < a.length) out.push(a[i++]!);
|
||||
while (j < b.length) out.push(b[j++]!);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The layer names of every tilemap in the project, each once, in asset and layer order. A node
|
||||
* picks a layer by name before it knows which map it will be given (#135), so it is offered them all.
|
||||
|
||||
@@ -45,14 +45,17 @@ import {
|
||||
} from './tile';
|
||||
import {
|
||||
cellOrigin,
|
||||
cellsAround,
|
||||
layerCells,
|
||||
type LayerCells,
|
||||
neighboursIn,
|
||||
patchedInOrder,
|
||||
tilemapColumns,
|
||||
tilemapRows,
|
||||
tilemapSize,
|
||||
tilemapTileSize,
|
||||
} from './tilemap';
|
||||
import { cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo';
|
||||
import { cellsChangedSince, cellsTileIds, cellsVersion, identityToken, KeyedMemo } from './tilemapMemo';
|
||||
import { type ColliderShape, oneWayAngle, type ShapeBounds } from '../properties/collider';
|
||||
import { colliderMaterial } from '../properties/rigidbody';
|
||||
|
||||
@@ -258,7 +261,7 @@ function picks(tile: TileAsset, pick: TilePick): boolean {
|
||||
return isTileSolid(tile) && passesFilter(tile, pick.oneWay, pick.material);
|
||||
}
|
||||
|
||||
/** The solid tiles painted on the map, each once, in the order the layers first name them. */
|
||||
/** The solid tiles painted on the map, each once: layer by layer, by id within one. */
|
||||
function solidTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||||
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||||
return [...ids].flatMap((id) => {
|
||||
@@ -314,61 +317,123 @@ function blockingCells(
|
||||
: null,
|
||||
);
|
||||
}
|
||||
const key = [
|
||||
const kept = [
|
||||
tilemapColumns(props),
|
||||
tilemapRows(props),
|
||||
'trigger' in pick
|
||||
? `trigger:${pick.trigger ?? ''}`
|
||||
: `${pick.oneWay}|${pick.material ? `${pick.material.friction}/${pick.material.bounciness}` : ''}`,
|
||||
asset.layers.map((layer) => cellsVersion(layer.cells)).join(','),
|
||||
[...tiles]
|
||||
.map(([id, t]) => (t ? `${id}:${t.scale}:${t.shape ? identityToken(t.shape) : 'box'}` : id))
|
||||
.join(','),
|
||||
].join('|');
|
||||
return blocking.get(key, () => workOutBlockingCells(props, asset, tiles));
|
||||
const versions = asset.layers.map((layer) => cellsVersion(layer.cells));
|
||||
return blocking.get(`${kept}|${versions.join(',')}`, () => {
|
||||
// The last cells for these layers and tiles, patched where they were painted since (#348).
|
||||
const layersKey = asset.layers.map((layer) => identityToken(layer.cells)).join(',');
|
||||
const last = lastBlocking.get(`${kept}|${layersKey}`, () => ({ versions: [], cells: null }));
|
||||
const changed = last.cells
|
||||
? asset.layers.map((layer, i) => cellsChangedSince(layer.cells, last.versions[i]!))
|
||||
: [];
|
||||
const cells =
|
||||
last.cells && changed.every((keys) => keys !== null)
|
||||
? patchedBlockingCells(props, asset, tiles, last.cells, changed.flat())
|
||||
: workOutBlockingCells(props, asset, tiles);
|
||||
last.versions = versions;
|
||||
last.cells = cells;
|
||||
return cells;
|
||||
});
|
||||
}
|
||||
|
||||
const lastBlocking = new KeyedMemo<{ versions: number[]; cells: readonly CellBlock[] | null }>();
|
||||
|
||||
/** What blocks in one cell, from what each layer holds there – null when nothing does. */
|
||||
function blockAt(
|
||||
layers: readonly LayerCells[],
|
||||
tiles: Map<string, BlockingTile>,
|
||||
index: number,
|
||||
): CellBlock | null {
|
||||
let cell: CellBlock | null = null;
|
||||
for (const cells of layers) {
|
||||
const tileId = cells.at.get(index);
|
||||
const tile = tileId && tiles.get(tileId);
|
||||
if (!tile) continue;
|
||||
cell ??= { x: index % cells.columns, y: Math.floor(index / cells.columns), box: null, polygons: [] };
|
||||
const { shape, scale } = tile;
|
||||
if (!shape) {
|
||||
cell.box = Math.max(cell.box ?? 0, scale);
|
||||
} else if (shape.kind === 'plain') {
|
||||
// The collision scale draws the outline in or out about the cell's centre (#140).
|
||||
const at = 0.5 - scale / 2;
|
||||
for (const outline of shape.outlines)
|
||||
cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale));
|
||||
} else {
|
||||
const around = neighboursIn(cells, cell.x, cell.y, tileId);
|
||||
const quarters = tileQuadrants(shape.tileKind, around).map(
|
||||
(q) => shape.quarters.get(`${q.x},${q.y}`) ?? [],
|
||||
);
|
||||
if (quarters.every((q) => q === 'full')) {
|
||||
cell.box = Math.max(cell.box ?? 0, 1);
|
||||
continue;
|
||||
}
|
||||
for (const [corner, quarter] of quarters.entries()) {
|
||||
const x = cell.x + (corner % 2) / 2;
|
||||
const y = cell.y + Math.floor(corner / 2) / 2;
|
||||
for (const outline of quarter === 'full' ? [SQUARE] : quarter)
|
||||
cell.polygons.push(placed(outline, x, y, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
/** In cell order, whichever layers block there. */
|
||||
function workOutBlockingCells(
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
tiles: Map<string, BlockingTile>,
|
||||
): CellBlock[] {
|
||||
const found = new Map<number, CellBlock>();
|
||||
for (const layer of asset.layers) {
|
||||
const cells = layerCells(props, layer);
|
||||
for (const { x, y, tileId } of cells.list) {
|
||||
const tile = tiles.get(tileId);
|
||||
if (!tile) continue;
|
||||
const index = y * cells.columns + x;
|
||||
let cell = found.get(index);
|
||||
if (!cell) found.set(index, (cell = { x, y, box: null, polygons: [] }));
|
||||
const { shape, scale } = tile;
|
||||
if (!shape) {
|
||||
cell.box = Math.max(cell.box ?? 0, scale);
|
||||
} else if (shape.kind === 'plain') {
|
||||
// The collision scale draws the outline in or out about the cell's centre (#140).
|
||||
const at = 0.5 - scale / 2;
|
||||
for (const outline of shape.outlines)
|
||||
cell.polygons.push(placed(outline, cell.x + at, cell.y + at, scale));
|
||||
} else {
|
||||
const around = neighboursIn(cells, cell.x, cell.y, tileId);
|
||||
const quarters = tileQuadrants(shape.tileKind, around).map(
|
||||
(q) => shape.quarters.get(`${q.x},${q.y}`) ?? [],
|
||||
);
|
||||
if (quarters.every((q) => q === 'full')) {
|
||||
cell.box = Math.max(cell.box ?? 0, 1);
|
||||
continue;
|
||||
}
|
||||
for (const [corner, quarter] of quarters.entries()) {
|
||||
const x = cell.x + (corner % 2) / 2;
|
||||
const y = cell.y + Math.floor(corner / 2) / 2;
|
||||
for (const outline of quarter === 'full' ? [SQUARE] : quarter)
|
||||
cell.polygons.push(placed(outline, x, y, 0.5));
|
||||
}
|
||||
}
|
||||
}
|
||||
const layers = asset.layers.map((layer) => layerCells(props, layer));
|
||||
const indices = new Set<number>();
|
||||
for (const cells of layers) for (const index of cells.at.keys()) indices.add(index);
|
||||
const found: CellBlock[] = [];
|
||||
for (const index of [...indices].sort((a, b) => a - b)) {
|
||||
const cell = blockAt(layers, tiles, index);
|
||||
if (cell) found.push(cell);
|
||||
}
|
||||
return [...found.values()];
|
||||
return found;
|
||||
}
|
||||
|
||||
function patchedBlockingCells(
|
||||
props: PropertyBag,
|
||||
asset: TilemapAsset,
|
||||
tiles: Map<string, BlockingTile>,
|
||||
blocks: readonly CellBlock[],
|
||||
changed: readonly string[],
|
||||
): CellBlock[] {
|
||||
const layers = asset.layers.map((layer) => layerCells(props, layer));
|
||||
const columns = tilemapColumns(props);
|
||||
const indices = cellsAround(changed, columns, tilemapRows(props));
|
||||
const made = new Map<number, CellBlock>();
|
||||
const patched = patchedInOrder(
|
||||
blocks,
|
||||
(cell) => cell.y * columns + cell.x,
|
||||
indices,
|
||||
(index) => {
|
||||
const cell = blockAt(layers, tiles, index);
|
||||
if (!cell) return [];
|
||||
made.set(index, cell);
|
||||
return [cell];
|
||||
},
|
||||
);
|
||||
// A block worked out again may block otherwise, which cuts its neighbours' pieces otherwise too.
|
||||
const rows = new Set<number>();
|
||||
for (const index of indices) {
|
||||
const y = Math.floor(index / columns);
|
||||
for (let near = y - 1; near <= y + 1; near++) rows.add(near);
|
||||
}
|
||||
patchOf.set(patched, { from: blocks, rows: [...rows], indices, columns, made });
|
||||
return patched;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -407,18 +472,33 @@ export function solidPolygons(
|
||||
return polygonCells(blockingCells(project, props, asset, { oneWay, material }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks come in cell order, so the polygons do too: top-down, then left to right. A block a
|
||||
* patch kept is the same object, and so are its polygons (#348).
|
||||
*/
|
||||
function polygonCells(blocks: readonly CellBlock[]): readonly CellPolygon[] {
|
||||
return derived(polygonsOf, blocks, (cells) => {
|
||||
const polygons: CellPolygon[] = [];
|
||||
for (const cell of cells) {
|
||||
if (cell.box !== null) continue;
|
||||
for (const { outline, pieces } of cell.polygons)
|
||||
polygons.push({ x: cell.x, y: cell.y, outline, pieces });
|
||||
}
|
||||
return polygons.sort((a, b) => a.y - b.y || a.x - b.x);
|
||||
const patch = patchOf.get(cells);
|
||||
const before = patch && polygonsOf.get(patch.from);
|
||||
if (before)
|
||||
return patchedInOrder(
|
||||
before,
|
||||
(polygon) => polygon.y * patch.columns + polygon.x,
|
||||
patch.indices,
|
||||
(index) => {
|
||||
const cell = patch.made.get(index);
|
||||
return cell ? polygonsOfBlock(cell) : [];
|
||||
},
|
||||
);
|
||||
return cells.flatMap(polygonsOfBlock);
|
||||
});
|
||||
}
|
||||
|
||||
function polygonsOfBlock(cell: CellBlock): readonly CellPolygon[] {
|
||||
if (cell.box !== null) return [];
|
||||
return cell.polygons.map(({ outline, pieces }) => ({ x: cell.x, y: cell.y, outline, pieces }));
|
||||
}
|
||||
|
||||
/**
|
||||
* The same cells hand out the same rectangles and polygons, the very same arrays – so whoever builds
|
||||
* on them can tell by identity that nothing changed (the physics does, every tick).
|
||||
@@ -433,7 +513,6 @@ function derived<T>(
|
||||
}
|
||||
|
||||
const polygonsOf = new WeakMap<readonly CellBlock[], readonly CellPolygon[]>();
|
||||
const rectsOf = new WeakMap<readonly CellBlock[], readonly CellRect[]>();
|
||||
|
||||
/** A rectangle by its four sides, which is what the merge compares. */
|
||||
interface Piece {
|
||||
@@ -496,14 +575,17 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
const unique = new Map<string, SolidCell>();
|
||||
for (const cell of cells) unique.set(`${cell.x},${cell.y}`, cell);
|
||||
const blocks = (x: number, y: number) => unique.has(`${x},${y}`);
|
||||
return stacked(joinedStrips([...unique.values()].flatMap((cell) => cellPieces(cell, blocks))));
|
||||
}
|
||||
|
||||
/** Pieces on the same strip – same top and bottom – that touch or overlap, joined into runs. */
|
||||
function joinedStrips(pieces: readonly Piece[]): Piece[] {
|
||||
const strips = new Map<string, Piece[]>();
|
||||
for (const cell of unique.values()) {
|
||||
for (const piece of cellPieces(cell, blocks)) {
|
||||
const key = `${piece.y0},${piece.y1}`;
|
||||
const strip = strips.get(key);
|
||||
if (strip) strip.push(piece);
|
||||
else strips.set(key, [piece]);
|
||||
}
|
||||
for (const piece of pieces) {
|
||||
const key = `${piece.y0},${piece.y1}`;
|
||||
const strip = strips.get(key);
|
||||
if (strip) strip.push(piece);
|
||||
else strips.set(key, [piece]);
|
||||
}
|
||||
const runs: Piece[] = [];
|
||||
for (const strip of strips.values()) {
|
||||
@@ -514,11 +596,15 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
else runs.push((run = { ...piece }));
|
||||
}
|
||||
}
|
||||
runs.sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0);
|
||||
return runs;
|
||||
}
|
||||
|
||||
/** Runs of exactly the same left and right that meet top to bottom, joined into taller rectangles. */
|
||||
function stacked(runs: readonly Piece[]): CellRect[] {
|
||||
const done: Piece[] = [];
|
||||
// Rectangles still open at the bottom, by the left and right they span.
|
||||
const open = new Map<string, Piece>();
|
||||
for (const run of runs) {
|
||||
for (const run of [...runs].sort((a, b) => a.y0 - b.y0 || a.x0 - b.x0)) {
|
||||
const key = `${run.x0},${run.x1}`;
|
||||
const above = open.get(key);
|
||||
if (above && above.y1 === run.y0) {
|
||||
@@ -526,7 +612,7 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
continue;
|
||||
}
|
||||
if (above) done.push(above);
|
||||
open.set(key, run);
|
||||
open.set(key, { ...run });
|
||||
}
|
||||
done.push(...open.values());
|
||||
return done
|
||||
@@ -534,6 +620,57 @@ export function mergeCells(cells: readonly SolidCell[]): CellRect[] {
|
||||
.map((p) => ({ x: p.x0, y: p.y0, w: p.x1 - p.x0, h: p.y1 - p.y0 }));
|
||||
}
|
||||
|
||||
/**
|
||||
* A map's rectangles, merged row by row: a strip never spans two rows – its top and bottom name the
|
||||
* row – so the runs of a row follow from that row and the two around it alone. A patched list of
|
||||
* blocks (#348) joins only the rows near its patch again and stacks the rest as they were.
|
||||
*/
|
||||
interface MergedRows {
|
||||
runs: ReadonlyMap<number, readonly Piece[]>;
|
||||
rects: readonly CellRect[];
|
||||
}
|
||||
|
||||
const mergedOf = new WeakMap<readonly CellBlock[], MergedRows>();
|
||||
|
||||
/**
|
||||
* How a patched list of blocks came from the one before: the cells worked out again, what blocks
|
||||
* there now, and the rows whose runs that may change.
|
||||
*/
|
||||
interface BlockPatch {
|
||||
from: readonly CellBlock[];
|
||||
rows: readonly number[];
|
||||
indices: readonly number[];
|
||||
columns: number;
|
||||
made: ReadonlyMap<number, CellBlock>;
|
||||
}
|
||||
|
||||
const patchOf = new WeakMap<readonly CellBlock[], BlockPatch>();
|
||||
|
||||
/** The index of the first block in row `y` or below; the blocks are in cell order. */
|
||||
function firstInRow(blocks: readonly CellBlock[], y: number): number {
|
||||
let low = 0;
|
||||
let high = blocks.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (blocks[mid]!.y < y) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
function rowRuns(blocks: readonly CellBlock[], y: number): Piece[] {
|
||||
const near = [new Set<number>(), new Set<number>(), new Set<number>()];
|
||||
const row: Required<SolidCell>[] = [];
|
||||
for (let i = firstInRow(blocks, y - 1); i < blocks.length && blocks[i]!.y <= y + 1; i++) {
|
||||
const { x, y: at, box } = blocks[i]!;
|
||||
if (box === null) continue;
|
||||
near[at - y + 1]!.add(x);
|
||||
if (at === y) row.push({ x, y, scale: box });
|
||||
}
|
||||
const blocksAt = (x: number, at: number) => near[at - y + 1]?.has(x) ?? false;
|
||||
return joinedStrips(row.flatMap((cell) => cellPieces(cell, blocksAt)));
|
||||
}
|
||||
|
||||
/** The blocking rectangles of a map, in cells. */
|
||||
export function solidRects(
|
||||
project: Project,
|
||||
@@ -546,10 +683,27 @@ export function solidRects(
|
||||
}
|
||||
|
||||
function rectCells(blocks: readonly CellBlock[]): readonly CellRect[] {
|
||||
return derived(rectsOf, blocks, (cells) => mergeCells(boxCells(cells)));
|
||||
let merged = mergedOf.get(blocks);
|
||||
if (!merged) {
|
||||
const patch = patchOf.get(blocks);
|
||||
const before = patch && mergedOf.get(patch.from);
|
||||
const runs = new Map(before?.runs);
|
||||
const rows = before
|
||||
? patch.rows
|
||||
: new Set(blocks.filter((cell) => cell.box !== null).map((cell) => cell.y));
|
||||
for (const y of rows) {
|
||||
const found = rowRuns(blocks, y);
|
||||
if (found.length > 0) runs.set(y, found);
|
||||
else runs.delete(y);
|
||||
}
|
||||
const order = [...runs.keys()].sort((a, b) => a - b);
|
||||
merged = { runs, rects: stacked(order.flatMap((y) => runs.get(y)!)) };
|
||||
mergedOf.set(blocks, merged);
|
||||
}
|
||||
return merged.rects;
|
||||
}
|
||||
|
||||
/** The trigger tiles painted on the map (#160), each once, in the order the layers first name them. */
|
||||
/** The trigger tiles painted on the map (#160), each once: layer by layer, by id within one. */
|
||||
function triggerTiles(project: Project, asset: TilemapAsset): TileAsset[] {
|
||||
const ids = new Set(asset.layers.flatMap((layer) => cellsTileIds(layer.cells)));
|
||||
return [...ids].flatMap((id) => {
|
||||
|
||||
@@ -8,52 +8,115 @@
|
||||
* with what it saw last and hands out a new number only when it differs – a number that stands for
|
||||
* exactly that content in a cache key. Without that word nothing is compared: a map is asked some
|
||||
* sixty times a frame, and walking every cell each time was the largest single cost of a run (#284).
|
||||
*
|
||||
* Each new version also keeps which cells it changed, so whoever worked out the one before can
|
||||
* patch those cells instead of the whole map (#348, `cellsChangedSince`).
|
||||
*/
|
||||
|
||||
interface Seen {
|
||||
keys: string[];
|
||||
values: string[];
|
||||
/** The content as it was last looked at. */
|
||||
held: Map<string, string>;
|
||||
/** How many cells name each tile. */
|
||||
counts: Map<string, number>;
|
||||
version: number;
|
||||
tileIds: string[];
|
||||
/** Written to since it was looked at. */
|
||||
stale: boolean;
|
||||
/** Written to since it was looked at: the keys the painter named, or every key when it named none. */
|
||||
stale: Set<string> | 'all' | null;
|
||||
/** The cells each of the last versions changed, oldest first. */
|
||||
log: { from: number; to: number; keys: string[] }[];
|
||||
}
|
||||
|
||||
/** How many versions back a change can be asked for; further back is worked out afresh. */
|
||||
const LOG_LENGTH = 16;
|
||||
|
||||
const seen = new WeakMap<Record<string, string>, Seen>();
|
||||
let versions = 0;
|
||||
|
||||
function count(counts: Map<string, number>, tileId: string | undefined, by: number): boolean {
|
||||
if (!tileId) return false;
|
||||
const now = (counts.get(tileId) ?? 0) + by;
|
||||
if (now > 0) counts.set(tileId, now);
|
||||
else counts.delete(tileId);
|
||||
return now === 0 || now === by;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walked in the object's own order: the same content painted the same way comes back in the same
|
||||
* order, and a cell taken out and put back only costs a new version, never a wrong answer.
|
||||
* Sorted, so the same content gives the same list whichever way it was painted: the order of an
|
||||
* object's keys follows its history.
|
||||
*/
|
||||
function unchanged(cells: Record<string, string>, held: Seen): boolean {
|
||||
let i = 0;
|
||||
function idsOf(counts: Map<string, number>): string[] {
|
||||
return [...counts.keys()].sort();
|
||||
}
|
||||
|
||||
/** The keys whose tile differs from what was held: the ones named, or every key of both. */
|
||||
function differing(cells: Record<string, string>, held: Seen): string[] {
|
||||
const own = (key: string) => (Object.hasOwn(cells, key) ? cells[key] : undefined);
|
||||
if (held.stale !== 'all') return [...held.stale!].filter((key) => held.held.get(key) !== own(key));
|
||||
const keys: string[] = [];
|
||||
let kept = 0;
|
||||
for (const key in cells) {
|
||||
if (held.keys[i] !== key || held.values[i] !== cells[key]) return false;
|
||||
i++;
|
||||
const before = held.held.get(key);
|
||||
if (before === undefined) keys.push(key);
|
||||
else {
|
||||
kept++;
|
||||
if (before !== cells[key]) keys.push(key);
|
||||
}
|
||||
}
|
||||
return i === held.keys.length;
|
||||
if (kept < held.held.size)
|
||||
for (const key of held.held.keys()) if (!Object.hasOwn(cells, key)) keys.push(key);
|
||||
return keys;
|
||||
}
|
||||
|
||||
function look(cells: Record<string, string>): Seen {
|
||||
const held = seen.get(cells);
|
||||
if (held && !held.stale) return held;
|
||||
if (held && unchanged(cells, held)) {
|
||||
held.stale = false;
|
||||
if (held) {
|
||||
const keys = differing(cells, held);
|
||||
held.stale = null;
|
||||
if (keys.length === 0) return held;
|
||||
let idsChanged = false;
|
||||
for (const key of keys) {
|
||||
const now = Object.hasOwn(cells, key) ? cells[key] : undefined;
|
||||
idsChanged = count(held.counts, held.held.get(key), -1) || idsChanged;
|
||||
idsChanged = count(held.counts, now, 1) || idsChanged;
|
||||
if (now === undefined) held.held.delete(key);
|
||||
else held.held.set(key, now);
|
||||
}
|
||||
if (idsChanged) held.tileIds = idsOf(held.counts);
|
||||
const to = ++versions;
|
||||
held.log.push({ from: held.version, to, keys });
|
||||
if (held.log.length > LOG_LENGTH) held.log.shift();
|
||||
held.version = to;
|
||||
return held;
|
||||
}
|
||||
const keys = Object.keys(cells);
|
||||
const values = keys.map((key) => cells[key]!);
|
||||
const tileIds = [...new Set(values)].filter(Boolean);
|
||||
const fresh = { keys, values, version: ++versions, tileIds, stale: false };
|
||||
const content = new Map(Object.entries(cells));
|
||||
const counts = new Map<string, number>();
|
||||
for (const tileId of content.values()) count(counts, tileId, 1);
|
||||
const fresh: Seen = {
|
||||
held: content,
|
||||
counts,
|
||||
version: ++versions,
|
||||
tileIds: idsOf(counts),
|
||||
stale: null,
|
||||
log: [],
|
||||
};
|
||||
seen.set(cells, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Says that cells were written to in place; a replaced object needs no word, it is a new one. */
|
||||
export function cellsChanged(cells: Record<string, string>): void {
|
||||
/**
|
||||
* Says that cells were written to in place; a replaced object needs no word, it is a new one.
|
||||
* Naming the keys written saves walking all of them.
|
||||
*/
|
||||
export function cellsChanged(cells: Record<string, string>, keys?: Iterable<string>): void {
|
||||
const held = seen.get(cells);
|
||||
if (held) held.stale = true;
|
||||
if (!held || held.stale === 'all') return;
|
||||
if (!keys) {
|
||||
held.stale = 'all';
|
||||
return;
|
||||
}
|
||||
held.stale ??= new Set();
|
||||
for (const key of keys) held.stale.add(key);
|
||||
}
|
||||
|
||||
/** A number that is the same exactly as long as the cells hold the same tiles in the same places. */
|
||||
@@ -61,11 +124,25 @@ export function cellsVersion(cells: Record<string, string>): number {
|
||||
return look(cells).version;
|
||||
}
|
||||
|
||||
/** The tiles the cells name, each once. */
|
||||
/** The tiles the cells name, each once, sorted. */
|
||||
export function cellsTileIds(cells: Record<string, string>): readonly string[] {
|
||||
return look(cells).tileIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* The keys whose tile changed since `version` of these cells, each once – none when it is the
|
||||
* current one. Null when that is not known: another object's version, or one too far back.
|
||||
*/
|
||||
export function cellsChangedSince(cells: Record<string, string>, version: number): readonly string[] | null {
|
||||
const held = look(cells);
|
||||
if (version === held.version) return [];
|
||||
const start = held.log.findIndex((entry) => entry.from === version);
|
||||
if (start < 0) return null;
|
||||
const keys = new Set<string>();
|
||||
for (const entry of held.log.slice(start)) for (const key of entry.keys) keys.add(key);
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
const tokens = new WeakMap<object, number>();
|
||||
let tokenCount = 0;
|
||||
|
||||
|
||||
@@ -7,7 +7,9 @@ import { isKindWith } from '../properties/kinds';
|
||||
import { schemaForAsset } from '../properties/PropertySchema';
|
||||
import { referencesAny, trackDef } from './animation';
|
||||
import type { AnimationAsset, Asset, TileAsset } from './Asset';
|
||||
import { partsOf } from './assetParts';
|
||||
import { overrideSites } from './instance';
|
||||
import { pixelArtRefIds } from './pixelart';
|
||||
import { type ObjectHolder, objectHolders, type Project } from './Project';
|
||||
import type { SceneObject } from './SceneObject';
|
||||
|
||||
@@ -84,6 +86,79 @@ export function cellsPainted(project: Project, tileId: string): { tilemaps: numb
|
||||
return { tilemaps, cells };
|
||||
}
|
||||
|
||||
/** The nearest thing with an id of its own a use sits in: an object, a node, an item, a key. */
|
||||
export interface UsePlace {
|
||||
id: string;
|
||||
name?: string;
|
||||
type?: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
/** An asset naming another, and where in it; no places for a use on the asset itself, like a property. */
|
||||
export interface AssetUse {
|
||||
asset: Asset;
|
||||
at: UsePlace[];
|
||||
}
|
||||
|
||||
export interface Uses {
|
||||
assets: AssetUse[];
|
||||
/** The project's own settings naming it: its start scene, the master's FX, the Master routing. */
|
||||
project: ('startScene' | 'masterFx' | 'routing')[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything naming an asset or one of its parts – a frame, a clip (#433). Read as data, not field
|
||||
* by field, as `namedStrings` reads it for sounds, so no place is missed: a song item, a Sample, a
|
||||
* key, a cell of a tilemap and kinds of asset still to come alike. Ids, the owner a song keeps an
|
||||
* asset for and the nodes an edge joins are no uses: the Master routing has a node per asset.
|
||||
*/
|
||||
export function usesOf(project: Project, assetId: string): Uses {
|
||||
const target = project.assets.find((asset) => asset.id === assetId);
|
||||
if (!target) return { assets: [], project: [] };
|
||||
const wanted = new Set(
|
||||
target.type === 'pixelart' ? pixelArtRefIds(target) : [target.id, ...partsOf(target).map((p) => p.id)],
|
||||
);
|
||||
const assets: AssetUse[] = [];
|
||||
for (const asset of project.assets) {
|
||||
if (asset.id === target.id) continue;
|
||||
const found = placesNaming(asset, wanted);
|
||||
if (found) assets.push({ asset, at: found });
|
||||
}
|
||||
const settings: Uses['project'] = [];
|
||||
if (wanted.has(project.startSceneId)) settings.push('startScene');
|
||||
if (wanted.has(project.masterFx)) settings.push('masterFx');
|
||||
if (placesNaming(project.routing, wanted)) settings.push('routing');
|
||||
return { assets, project: settings };
|
||||
}
|
||||
|
||||
const NO_USE: ReadonlySet<string> = new Set(['id', 'owner', 'node']);
|
||||
|
||||
/** Where in `value` one of the ids is named; null for nowhere, no places for only outside anything with an id. */
|
||||
function placesNaming(value: unknown, wanted: ReadonlySet<string>): UsePlace[] | null {
|
||||
const places = new Map<string, UsePlace | null>();
|
||||
const walk = (inner: unknown, place: UsePlace | null): void => {
|
||||
if (typeof inner === 'string') {
|
||||
if (wanted.has(inner)) places.set(place?.id ?? '', place);
|
||||
} else if (Array.isArray(inner)) {
|
||||
for (const item of inner) walk(item, place);
|
||||
} else if (typeof inner === 'object' && inner !== null && !ArrayBuffer.isView(inner)) {
|
||||
const record = inner as Record<string, unknown>;
|
||||
const here = typeof record.id === 'string' && inner !== value ? placeOf(record) : place;
|
||||
for (const [key, field] of Object.entries(record)) if (!NO_USE.has(key)) walk(field, here);
|
||||
}
|
||||
};
|
||||
walk(value, null);
|
||||
if (places.size === 0) return null;
|
||||
return [...places.values()].filter((place): place is UsePlace => place !== null);
|
||||
}
|
||||
|
||||
function placeOf(record: Record<string, unknown>): UsePlace {
|
||||
const place: UsePlace = { id: record.id as string };
|
||||
for (const key of ['name', 'type', 'kind'] as const)
|
||||
if (typeof record[key] === 'string' && record[key] !== '') place[key] = record[key];
|
||||
return place;
|
||||
}
|
||||
|
||||
/** The animations keying any of these ids – intrinsic data, which `findPropertyUsages` does not see. */
|
||||
export function animationsUsing(project: Project, ids: readonly string[]): AnimationAsset[] {
|
||||
const wanted = new Set(ids);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* The time of the video a sprite wears, as a run counts it (#410): what `On video finished` and
|
||||
* `Video time` go by, and what `Play video`, `Pause video` and `Seek video` set.
|
||||
*
|
||||
* A free video counts from when its sprite appears, while it is switched on; a behavior that steers
|
||||
* it takes it over from there. A driven one has the time its `Video time` says.
|
||||
*/
|
||||
export interface VideoClock {
|
||||
/** The texture it counts for, a video or a clip of one; another one starts it over. */
|
||||
ref: string;
|
||||
/** Seconds into the clip: between 0 and its length, once that is known. */
|
||||
time: number;
|
||||
playing: boolean;
|
||||
/** Set once a behavior steered it: the run drives the sprite's video by this time from then on. */
|
||||
steered: boolean;
|
||||
/** The behaviors that steered it: they hear its end wherever it stands. */
|
||||
steeredBy: object[];
|
||||
}
|
||||
|
||||
export type VideoSteer = { kind: 'play' } | { kind: 'pause' } | { kind: 'seek'; time: number };
|
||||
|
||||
/** What `Video time` reads. */
|
||||
export interface VideoReading {
|
||||
time: number;
|
||||
length: number;
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
export function newVideoClock(ref: string): VideoClock {
|
||||
return { ref, time: 0, playing: true, steered: false, steeredBy: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where `time` seconds into a clip `length` long stand, by the rule of `Video time`: round again with
|
||||
* Loop, held at the end without. An unknown length (0) bounds only at the start.
|
||||
*/
|
||||
export function clipTimeOf(time: number, length: number, loop: boolean): number {
|
||||
const run = Number.isFinite(time) ? Math.max(0, time) : 0;
|
||||
if (!(length > 0)) return run;
|
||||
return loop ? run % length : Math.min(run, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves a playing clock on by `dt`. Returns whether it reached the end of a clip without Loop: it
|
||||
* stops there, so that happens once a pass.
|
||||
*/
|
||||
export function advanceVideoClock(clock: VideoClock, dt: number, length: number, loop: boolean): boolean {
|
||||
if (!clock.playing) return false;
|
||||
const time = clock.time + dt;
|
||||
if (!(length > 0) || loop || time < length) {
|
||||
clock.time = clipTimeOf(time, length, loop);
|
||||
return false;
|
||||
}
|
||||
clock.time = length;
|
||||
clock.playing = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** What a steering node does to the clock; Play at the end of a clip without Loop starts it over. */
|
||||
export function steerVideoClock(clock: VideoClock, steer: VideoSteer, length: number, loop: boolean): void {
|
||||
if (steer.kind === 'pause') clock.playing = false;
|
||||
else if (steer.kind === 'seek') clock.time = clipTimeOf(steer.time, length, loop);
|
||||
else {
|
||||
if (!loop && length > 0 && clock.time >= length) clock.time = 0;
|
||||
clock.playing = true;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
import { clipsIn, heldLoop, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import type { FadeCurve } from '../model/sound';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import { AUTOMATION_STEP, type AutomationTarget, type LaneMark, type SequenceLane } from './automation';
|
||||
import {
|
||||
heldMarks,
|
||||
heldParamsAt,
|
||||
heldValueAt,
|
||||
holdKey,
|
||||
holdsAfter,
|
||||
type LaneHold,
|
||||
type LanePlaying,
|
||||
} from './laneHolds';
|
||||
import { clipsIn, heldLoop, levelsIn, type LoopRange, notesIn, playheadAt, type Sequence } from './sequence';
|
||||
import {
|
||||
COMMON_TIME,
|
||||
DEFAULT_TEMPO,
|
||||
@@ -29,6 +41,8 @@ export interface ScheduledNote {
|
||||
/** A factor on its loudness, and the track of a song it is heard on (#334). */
|
||||
volume?: number;
|
||||
bus?: string;
|
||||
/** The values the parameter lanes of its track have where it starts (#337). */
|
||||
params?: FxParams;
|
||||
}
|
||||
|
||||
/** One clip handed to the player (#334): when it starts, how long it plays and from where in the sound. */
|
||||
@@ -42,6 +56,34 @@ export interface ScheduledClip {
|
||||
volume: number;
|
||||
bus: string;
|
||||
tempo: number;
|
||||
/** As a note's. */
|
||||
params?: FxParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* One ramp of a bus's level handed to the player (#418): from `at` for `duration` seconds, along the
|
||||
* part of the ramp from `done` (0 to 1) on – a playhead that lands inside one takes it up there.
|
||||
*/
|
||||
export interface ScheduledRamp {
|
||||
at: number;
|
||||
duration: number;
|
||||
bus: string;
|
||||
gain: number;
|
||||
rise: boolean;
|
||||
curve: FadeCurve;
|
||||
done: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a lane of automation (#337) takes a bus over one window: each mark a second of the audio
|
||||
* clock with the value the bus is ramped to, or jumps to there. With `fresh` the playing has just
|
||||
* started, and what was planned for the bus from the first mark on is to go.
|
||||
*/
|
||||
export interface ScheduledAutomation {
|
||||
bus: string;
|
||||
target: AutomationTarget;
|
||||
marks: LaneMark[];
|
||||
fresh: boolean;
|
||||
}
|
||||
|
||||
/** What the scheduler needs of the audio side; a test hands in a clock of its own. */
|
||||
@@ -60,6 +102,10 @@ export interface SchedulerHost {
|
||||
play(note: ScheduledNote): number;
|
||||
/** Starts a clip the same way; a host that plays no clips leaves it out. */
|
||||
playClip?(clip: ScheduledClip): number;
|
||||
/** Ramps the level of a bus; a host without levels leaves it out. */
|
||||
ramp?(ramp: ScheduledRamp): void;
|
||||
/** Takes a bus along its automation; a host without it leaves it out. */
|
||||
automate?(automation: ScheduledAutomation): void;
|
||||
playing(voice: number): boolean;
|
||||
/** Ends these voices; with `letRing` a note falls away in the release of its Envelope. */
|
||||
stop(voices: readonly number[], letRing?: boolean): void;
|
||||
@@ -100,6 +146,8 @@ export class Scheduler {
|
||||
private before: { anchor: number; from: number; tempo: number; loop: LoopRange | null } | null = null;
|
||||
private voices: number[] = [];
|
||||
private sleep: (() => void) | null = null;
|
||||
/** The lanes a game holds on a value of its own (#338), by `holdKey`, each's holds in the order they land. */
|
||||
private holds = new Map<string, LaneHold[]>();
|
||||
|
||||
constructor(
|
||||
private readonly host: SchedulerHost,
|
||||
@@ -121,16 +169,26 @@ export class Scheduler {
|
||||
return this.anchor;
|
||||
}
|
||||
|
||||
/** Played to its end, and none of its notes is heard any more. */
|
||||
/**
|
||||
* Played to its end, and none of its notes is heard any more. Planned to its end is not yet played
|
||||
* to it: its notes are planned ahead, and a last bar may end in silence (#373).
|
||||
*/
|
||||
get done(): boolean {
|
||||
return this.ended && this.voices.every((voice) => !this.host.playing(voice));
|
||||
return (
|
||||
this.ended &&
|
||||
this.headAt(this.host.now()) >= this.end &&
|
||||
this.voices.every((voice) => !this.host.playing(voice))
|
||||
);
|
||||
}
|
||||
|
||||
/** Plays from `from` ticks of the sequence, or from where it was last started or sent. */
|
||||
start(from = this.from): void {
|
||||
/**
|
||||
* Plays from `from` ticks of the sequence, or from where it was last started or sent – heard from
|
||||
* `at` on the audio clock where that is later than it can be (#338).
|
||||
*/
|
||||
start(from = this.from, at = -Infinity): void {
|
||||
this.stop();
|
||||
this.from = Math.max(0, Math.round(from));
|
||||
this.anchor = this.host.now() + LEAD + this.ahead();
|
||||
this.anchor = Math.max(this.host.now() + LEAD + this.ahead(), at);
|
||||
this.until = 0;
|
||||
this.before = null;
|
||||
this.ended = false;
|
||||
@@ -171,6 +229,96 @@ export class Scheduler {
|
||||
this.meter = heldMeter(meter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds the lane of `target` on the bus `bus` on `value`, in its units (#338): from the second `at`
|
||||
* of the audio clock it goes there from where it is over `fade` seconds and stays, its own points
|
||||
* no longer heard. A hold that lands before it rules until then; one that would land later, or a
|
||||
* release still to come, gives way. What is planned for it past `at` is planned again. False where
|
||||
* the sequence has no such lane.
|
||||
*/
|
||||
holdLane(bus: string, target: AutomationTarget, value: number, at: number, fade: number): boolean {
|
||||
const lane = this.laneOf(bus, target);
|
||||
if (!lane) return false;
|
||||
const key = holdKey(bus, target);
|
||||
const playing = this.lanePlaying();
|
||||
const kept = (this.holds.get(key) ?? []).filter((hold) => hold.at < at);
|
||||
const start = this.anchor === null ? value : heldValueAt(lane, kept, playing, playing.elapsed(at));
|
||||
const last = kept.at(-1);
|
||||
if (last?.release && last.release.at >= at) last.release = null;
|
||||
this.holds.set(key, [...kept, { at, fade: Math.max(0, fade), start, value, release: null }]);
|
||||
this.replan(lane, at);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets a held lane go (#338): from the second `at` it goes back over `fade` seconds to where its
|
||||
* curve is by then, and follows it from there. A hold that would land later is never heard. False
|
||||
* where the lane is not held.
|
||||
*/
|
||||
releaseLane(bus: string, target: AutomationTarget, at: number, fade: number): boolean {
|
||||
const lane = this.laneOf(bus, target);
|
||||
const key = holdKey(bus, target);
|
||||
const holds = this.holds.get(key);
|
||||
if (!lane || !holds?.length) return false;
|
||||
const kept = holds.filter((hold) => hold.at < at);
|
||||
const dropped = holds[kept.length]?.at ?? Infinity;
|
||||
const last = kept.at(-1);
|
||||
if (last && !(last.release && last.release.at <= at)) last.release = { at, fade: Math.max(0, fade) };
|
||||
if (kept.length > 0) this.holds.set(key, kept);
|
||||
else this.holds.delete(key);
|
||||
this.replan(lane, Math.min(at, dropped));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Holds the lanes `other` holds (#338): a playing that takes over from it keeps them. */
|
||||
keepHolds(other: Scheduler): void {
|
||||
for (const [key, holds] of other.holds)
|
||||
this.holds.set(
|
||||
key,
|
||||
holds.map((hold) => ({ ...hold, release: hold.release && { ...hold.release } })),
|
||||
);
|
||||
}
|
||||
|
||||
/** The lane of the sequence on that bus for that target; undefined for none. */
|
||||
private laneOf(bus: string, target: AutomationTarget): SequenceLane | undefined {
|
||||
return this.sequence().lanes?.find((lane) => lane.bus === bus && lane.target === target);
|
||||
}
|
||||
|
||||
/** How the sequence plays for its lanes: the playing time is counted from the anchor. */
|
||||
private lanePlaying(): LanePlaying {
|
||||
const anchor = this.anchor ?? 0;
|
||||
return {
|
||||
from: this.from,
|
||||
loop: this.loop,
|
||||
elapsed: (at) => ticksOfSeconds(at - anchor, this.tempo),
|
||||
};
|
||||
}
|
||||
|
||||
/** What is planned for a lane from the second `at` on is planned again, from there to where planning got. */
|
||||
private replan(lane: SequenceLane, at: number): void {
|
||||
if (this.anchor === null) return;
|
||||
const start = Math.max(0, this.lanePlaying().elapsed(at));
|
||||
if (start < this.until) this.automate(lane, start, this.until, true, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the bus of a lane along it over the playing time from `start` up to `end` (#337), along its
|
||||
* holds where they rule (#338). With `restart` what was planned for it from the first mark on goes.
|
||||
*/
|
||||
private automate(lane: SequenceLane, start: number, end: number, fresh: boolean, restart: boolean): void {
|
||||
if (!this.host.automate || this.anchor === null) return;
|
||||
const anchor = this.anchor;
|
||||
const step = ticksOfSeconds(AUTOMATION_STEP, this.tempo);
|
||||
const holds = this.holds.get(holdKey(lane.bus, lane.target)) ?? [];
|
||||
const marks = heldMarks(lane, holds, this.lanePlaying(), start, end, fresh, step);
|
||||
this.host.automate({
|
||||
bus: lane.bus,
|
||||
target: lane.target,
|
||||
marks: marks.map((mark) => ({ ...mark, at: anchor + secondsOfTicks(mark.at, this.tempo) })),
|
||||
fresh: restart,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Where what is heard right now stands: the audio clock less the output latency, so a playhead
|
||||
* drawn from it is not ahead of the sound. Before the first note it is where it starts.
|
||||
@@ -189,21 +337,26 @@ export class Scheduler {
|
||||
if (to <= this.until) return;
|
||||
this.voices = this.voices.filter((voice) => this.host.playing(voice));
|
||||
const sequence = this.sequence();
|
||||
const lanes = sequence.lanes ?? [];
|
||||
const playing = this.lanePlaying();
|
||||
for (const due of notesIn(sequence, this.from, this.loop, this.until, to)) {
|
||||
const params = heldParamsAt(lanes, this.holds, playing, due.bus, due.tick, due.at);
|
||||
const voice = this.host.play({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
note: due.note,
|
||||
velocity: due.velocity,
|
||||
instrument: due.instrument,
|
||||
tempo: this.tempo,
|
||||
tempo: due.tempo ?? this.tempo,
|
||||
...(due.volume !== undefined && { volume: due.volume }),
|
||||
...(due.bus !== undefined && { bus: due.bus }),
|
||||
...(params && { params }),
|
||||
});
|
||||
if (voice !== 0) this.voices.push(voice);
|
||||
}
|
||||
if (this.host.playClip)
|
||||
for (const due of clipsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
|
||||
const params = heldParamsAt(lanes, this.holds, playing, due.bus, due.tick + due.skip, due.at);
|
||||
const voice = this.host.playClip({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
@@ -212,12 +365,33 @@ export class Scheduler {
|
||||
fx: due.fx,
|
||||
volume: due.volume,
|
||||
bus: due.bus,
|
||||
tempo: this.tempo,
|
||||
tempo: due.tempo ?? this.tempo,
|
||||
...(params && { params }),
|
||||
});
|
||||
if (voice !== 0) this.voices.push(voice);
|
||||
}
|
||||
if (this.host.ramp)
|
||||
for (const due of levelsIn(sequence, this.from, this.loop, this.until, to, this.fresh)) {
|
||||
const whole = due.length + due.skip;
|
||||
this.host.ramp({
|
||||
at: this.anchor + secondsOfTicks(due.at, this.tempo),
|
||||
duration: secondsOfTicks(due.length, this.tempo),
|
||||
bus: due.bus,
|
||||
gain: due.gain,
|
||||
rise: due.rise,
|
||||
curve: due.curve,
|
||||
done: whole > 0 ? due.skip / whole : 0,
|
||||
});
|
||||
}
|
||||
for (const lane of lanes) this.automate(lane, this.until, to, this.fresh, this.fresh);
|
||||
this.fresh = false;
|
||||
this.until = to;
|
||||
// A hold let go and planned back to its curve is over.
|
||||
for (const [key, holds] of this.holds) {
|
||||
const left = holdsAfter(holds, playing, to);
|
||||
if (left.length > 0) this.holds.set(key, left);
|
||||
else this.holds.delete(key);
|
||||
}
|
||||
if (!this.loop && playheadAt(to, this.from, null) >= this.end) {
|
||||
this.ended = true;
|
||||
this.sleep?.();
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
/**
|
||||
* Automation of a song (#337): the volume and the pan of a track changing over the song, as points
|
||||
* on a lane. From each point the value runs to the next along that point's easing, as from one
|
||||
* animation key to the next. While a lane has points it rules its value in place of the fader: before
|
||||
* its first point it is that point's value, after its last the last one's. Two points on one tick
|
||||
* are a jump there.
|
||||
*
|
||||
* The scheduler reads a lane window by window as marks on the audio clock, each ramped or jumped to.
|
||||
* Where the value moves along a shape the clock cannot ramp, it is read every `AUTOMATION_STEP`.
|
||||
*
|
||||
* A lane can also move a parameter (`param:<name>`): of the track's sound FX, of the sound FX of its
|
||||
* items, of the instruments of its notes and the sound FX of its clips – wherever one of them has a
|
||||
* parameter of that name, as `Set sound FX parameters` reaches them. A note or clip starts at the
|
||||
* value its lane has there, so a parameter a voice only reads when it starts is heard from the next one.
|
||||
*/
|
||||
import type { KeyInterpolation, VectorPoint } from '../model/Asset';
|
||||
import { easedShare, easingOf, isKeyInterpolation } from '../model/animation';
|
||||
import type { CurveLookup } from '../model/curve';
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level';
|
||||
import { type LoopRange, playheadAt } from './sequence';
|
||||
|
||||
/** What a lane always moves on a track: its fader and its pan. */
|
||||
export type TrackTarget = 'volume' | 'pan';
|
||||
|
||||
/** A parameter by its name (#337, stage 3); no name of a parameter has a colon. */
|
||||
export type ParamTarget = `param:${string}`;
|
||||
|
||||
export type AutomationTarget = TrackTarget | ParamTarget;
|
||||
|
||||
export const AUTOMATION_TARGETS: readonly TrackTarget[] = ['volume', 'pan'];
|
||||
|
||||
const PARAM_PREFIX = 'param:';
|
||||
|
||||
export function paramTarget(name: string): ParamTarget {
|
||||
return `${PARAM_PREFIX}${name}`;
|
||||
}
|
||||
|
||||
/** The parameter a target moves; null for the volume and the pan. */
|
||||
export function paramOf(target: AutomationTarget): string | null {
|
||||
return target.startsWith(PARAM_PREFIX) ? target.slice(PARAM_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
/** Whether a text is a target a lane can have: the volume, the pan or a parameter with a name. */
|
||||
export function isAutomationTarget(value: unknown): value is AutomationTarget {
|
||||
if (value === 'volume' || value === 'pan') return true;
|
||||
if (typeof value !== 'string' || !value.startsWith(PARAM_PREFIX)) return false;
|
||||
const name = value.slice(PARAM_PREFIX.length);
|
||||
return name !== '' && !name.includes(':');
|
||||
}
|
||||
|
||||
/**
|
||||
* The target a lane is named by in a game (#338): `volume`, `pan`, or a parameter by its name, with
|
||||
* or without `param:` in front; null for a name no lane can have.
|
||||
*/
|
||||
export function targetNamed(name: string): AutomationTarget | null {
|
||||
const text = name.trim();
|
||||
if (isAutomationTarget(text)) return text;
|
||||
const target = paramTarget(text);
|
||||
return isAutomationTarget(target) ? target : null;
|
||||
}
|
||||
|
||||
export interface AutomationPoint {
|
||||
tick: number;
|
||||
/** In the units of its target: decibels for the volume, -1 (left) to 1 (right) for the pan. */
|
||||
value: number;
|
||||
/** How it runs to the next point; linear where left out. */
|
||||
ease?: KeyInterpolation;
|
||||
}
|
||||
|
||||
export interface AutomationLane {
|
||||
target: AutomationTarget;
|
||||
/** In the order of their ticks. */
|
||||
points: AutomationPoint[];
|
||||
/**
|
||||
* Only on a parameter's lane: the range its values are drawn and held in, `PARAM_RANGE` where left
|
||||
* out. A parameter says nothing of its own range, so the lane does. Such a lane is kept without a
|
||||
* point too, for its range; it rules its parameter once it has one.
|
||||
*/
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
/** What says how a lane's values are held and drawn: its target, and a parameter's range. */
|
||||
export type LaneKind = Pick<AutomationLane, 'target' | 'min' | 'max'>;
|
||||
|
||||
/** A lane's kind, or the volume or the pan by name: neither has a range of its own to name. */
|
||||
export type LaneSpec = LaneKind | TrackTarget;
|
||||
|
||||
export function laneKind(spec: LaneSpec): LaneKind {
|
||||
return typeof spec === 'string' ? { target: spec } : spec;
|
||||
}
|
||||
|
||||
/** The range of a parameter's lane that sets none. */
|
||||
export const PARAM_RANGE = { min: 0, max: 1 } as const;
|
||||
|
||||
/** Seconds between two readings of a value that moves along a shape the audio clock cannot ramp. */
|
||||
export const AUTOMATION_STEP = 0.01;
|
||||
|
||||
/** The lowest and the highest value a lane of that kind holds. */
|
||||
export function laneRange(spec: LaneSpec): { min: number; max: number } {
|
||||
const { target, min, max } = laneKind(spec);
|
||||
if (target === 'volume') return { min: TRACK_MIN_DB, max: TRACK_MAX_DB };
|
||||
if (target === 'pan') return { min: -1, max: 1 };
|
||||
return heldRange(min, max) ?? { ...PARAM_RANGE };
|
||||
}
|
||||
|
||||
/** A range a parameter's lane may have: both ends finite, the top above the bottom; null otherwise. */
|
||||
export function heldRange(min: unknown, max: unknown): { min: number; max: number } | null {
|
||||
if (typeof min !== 'number' || typeof max !== 'number') return null;
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || !(max > min)) return null;
|
||||
return { min, max };
|
||||
}
|
||||
|
||||
/** A lane's value as its bus takes it: the volume as a gain, the pan and a parameter as they are. */
|
||||
export function audioValueOf(lane: LaneKind, value: number): number {
|
||||
const { min, max } = laneRange(lane);
|
||||
const held = Math.max(min, Math.min(max, value));
|
||||
return lane.target === 'volume' ? dbToGain(held) : held;
|
||||
}
|
||||
|
||||
/**
|
||||
* Held to what the lanes of a track may be: a target it knows, one lane for each, points in whole
|
||||
* ticks from 0 with their values in range, in the order of their ticks. A lane of the volume or the
|
||||
* pan without a point is dropped; a parameter's is kept for its range.
|
||||
*/
|
||||
export function heldLanes(raw: unknown): AutomationLane[] {
|
||||
const lanes: AutomationLane[] = [];
|
||||
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
|
||||
if (typeof entry !== 'object' || entry === null) continue;
|
||||
const lane = entry as Record<string, unknown>;
|
||||
const target = lane.target;
|
||||
if (!isAutomationTarget(target) || lanes.some((l) => l.target === target)) continue;
|
||||
const range = paramOf(target) !== null ? heldRange(lane.min, lane.max) : null;
|
||||
const { min, max } = range ?? laneRange({ target });
|
||||
const points = (Array.isArray(lane.points) ? (lane.points as unknown[]) : []).flatMap(
|
||||
(point): AutomationPoint[] => {
|
||||
if (typeof point !== 'object' || point === null) return [];
|
||||
const { tick, value, ease } = point as Record<string, unknown>;
|
||||
if (typeof tick !== 'number' || !Number.isFinite(tick) || !(tick >= 0)) return [];
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return [];
|
||||
return [
|
||||
{
|
||||
tick: Math.round(tick),
|
||||
value: Math.max(min, Math.min(max, value)),
|
||||
...(isKeyInterpolation(ease) && ease !== 'linear' && { ease }),
|
||||
},
|
||||
];
|
||||
},
|
||||
);
|
||||
points.sort((a, b) => a.tick - b.tick);
|
||||
if (range) lanes.push({ target, points, ...range });
|
||||
else if (points.length > 0 || paramOf(target) !== null) lanes.push({ target, points });
|
||||
}
|
||||
return lanes;
|
||||
}
|
||||
|
||||
/** The parameters a track's lanes move, in their order, those without a point among them. */
|
||||
export function laneParams(track: { automation?: readonly AutomationLane[] }): string[] {
|
||||
return (track.automation ?? []).flatMap((lane) => paramOf(lane.target) ?? []);
|
||||
}
|
||||
|
||||
/** The lane of a track for that target, while it has a point. */
|
||||
export function laneOf(
|
||||
track: { automation?: readonly AutomationLane[] },
|
||||
target: AutomationTarget,
|
||||
): AutomationLane | undefined {
|
||||
return track.automation?.find((lane) => lane.target === target && lane.points.length > 0);
|
||||
}
|
||||
|
||||
/** A lane on the bus it acts on, the Curve assets its points ease along looked up; one gone is linear. */
|
||||
export function sequenceLaneOf(lane: AutomationLane, bus: string, curves: CurveLookup): SequenceLane {
|
||||
return {
|
||||
bus,
|
||||
target: lane.target,
|
||||
...(lane.min !== undefined && lane.max !== undefined && { min: lane.min, max: lane.max }),
|
||||
points: lane.points.map((point) => {
|
||||
const { shape, curve } = easingOf(point.ease ?? 'linear');
|
||||
const found = shape === 'curve' ? curves(curve ?? '') : undefined;
|
||||
return found ? { ...point, curve: found } : point;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A point as a sequence plays it: a Curve asset it eases along already looked up. */
|
||||
export interface SequenceLanePoint extends AutomationPoint {
|
||||
curve?: readonly VectorPoint[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A lane as a sequence plays it: on the bus it acts on, never without a point. A parameter's acts on
|
||||
* that bus's sound FX and on everything heard on a bus inside it (`reaches`).
|
||||
*/
|
||||
export interface SequenceLane extends LaneKind {
|
||||
bus: string;
|
||||
points: readonly SequenceLanePoint[];
|
||||
}
|
||||
|
||||
/** Whether a voice or a bus by that name is on the track of `bus` – it, or a bus inside it (`<bus>:…`). */
|
||||
export function reaches(bus: string, name: string): boolean {
|
||||
return name === bus || name.startsWith(`${bus}:`);
|
||||
}
|
||||
|
||||
/**
|
||||
* The values the parameter lanes of a sequence have at `tick` for what is heard on `bus`, a note's or
|
||||
* a clip's; undefined where none reaches it.
|
||||
*/
|
||||
export function paramsAt(
|
||||
lanes: readonly SequenceLane[],
|
||||
bus: string | undefined,
|
||||
tick: number,
|
||||
): FxParams | undefined {
|
||||
if (bus === undefined) return undefined;
|
||||
let params: Record<string, number> | undefined;
|
||||
for (const lane of lanes) {
|
||||
const name = paramOf(lane.target);
|
||||
if (name === null || !reaches(lane.bus, bus)) continue;
|
||||
const { min, max } = laneRange(lane);
|
||||
(params ??= {})[name] = Math.max(min, Math.min(max, laneValueAt(lane.points, tick)));
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
function between(a: SequenceLanePoint, b: SequenceLanePoint, t: number): number {
|
||||
return a.value + (b.value - a.value) * easedShare(a.ease ?? 'linear', t, () => a.curve);
|
||||
}
|
||||
|
||||
/** The index of the last point on or before `tick`, -1 for none. */
|
||||
function lastAt(points: readonly SequenceLanePoint[], tick: number): number {
|
||||
let i = -1;
|
||||
while (i + 1 < points.length && points[i + 1]!.tick <= tick) i++;
|
||||
return i;
|
||||
}
|
||||
|
||||
/** The value at `tick`, a jump there already taken. */
|
||||
export function laneValueAt(points: readonly SequenceLanePoint[], tick: number): number {
|
||||
const i = lastAt(points, tick);
|
||||
if (i < 0) return points[0]!.value;
|
||||
const a = points[i]!;
|
||||
const b = points[i + 1];
|
||||
return b ? between(a, b, (tick - a.tick) / (b.tick - a.tick)) : a.value;
|
||||
}
|
||||
|
||||
/** The value just before `tick`: what a jump there jumps from. */
|
||||
export function laneValueBefore(points: readonly SequenceLanePoint[], tick: number): number {
|
||||
let i = -1;
|
||||
while (i + 1 < points.length && points[i + 1]!.tick < tick) i++;
|
||||
if (i < 0) return points[0]!.value;
|
||||
const a = points[i]!;
|
||||
const b = points[i + 1];
|
||||
if (!b) return a.value;
|
||||
if (b.tick > tick) return between(a, b, (tick - a.tick) / (b.tick - a.tick));
|
||||
return a.ease === 'step' ? a.value : between(a, b, 1);
|
||||
}
|
||||
|
||||
/** Whether the way from `a` to `b` holds or is a straight line in lane units. */
|
||||
function straight(a: SequenceLanePoint, b: SequenceLanePoint): boolean {
|
||||
const ease = a.ease ?? 'linear';
|
||||
return a.value === b.value || ease === 'step' || ease === 'linear';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the bus takes it as one ramp: held, or a straight line also in its own units. A parameter
|
||||
* becomes its knobs along ways of their own, so only one held is a ramp of them.
|
||||
*/
|
||||
function rampable(target: AutomationTarget, a: SequenceLanePoint, b: SequenceLanePoint): boolean {
|
||||
const ease = a.ease ?? 'linear';
|
||||
return a.value === b.value || ease === 'step' || (ease === 'linear' && target === 'pan');
|
||||
}
|
||||
|
||||
/** One place on the way of a lane: `at` in ticks, the value as its bus takes it, ramped to or jumped to. */
|
||||
export interface LaneMark {
|
||||
at: number;
|
||||
value: number;
|
||||
jump: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a lane is over the playing time from `start` up to `end`, as `notesIn` lays out notes: a mark
|
||||
* where the window starts, at every point and every reading in between, and where it ends. Where the
|
||||
* playhead lands – at the start with `fresh`, after a jump back – the first mark of that stretch is a
|
||||
* jump. `step` is `AUTOMATION_STEP` in ticks.
|
||||
*/
|
||||
export function laneMarks(
|
||||
lane: SequenceLane,
|
||||
from: number,
|
||||
loop: LoopRange | null,
|
||||
start: number,
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
step: number,
|
||||
): LaneMark[] {
|
||||
const { points } = lane;
|
||||
const audio = (value: number) => audioValueOf(lane, value);
|
||||
const marks: LaneMark[] = [];
|
||||
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
|
||||
let elapsed = start;
|
||||
let head = playheadAt(start, from, loop);
|
||||
let landed = fresh;
|
||||
while (elapsed < end) {
|
||||
const span = Math.min(end - elapsed, stretchEnd - head);
|
||||
if (!(span > 0)) break;
|
||||
const to = head + span;
|
||||
const first = laneValueAt(points, head);
|
||||
marks.push({
|
||||
at: elapsed,
|
||||
value: audio(first),
|
||||
jump: landed || laneValueBefore(points, head) !== first,
|
||||
});
|
||||
for (const tick of readings(lane, head, to, step)) {
|
||||
const before = laneValueBefore(points, tick);
|
||||
const value = laneValueAt(points, tick);
|
||||
const at = elapsed + (tick - head);
|
||||
marks.push({ at, value: audio(before), jump: false });
|
||||
if (value !== before) marks.push({ at, value: audio(value), jump: true });
|
||||
}
|
||||
marks.push({ at: elapsed + span, value: audio(laneValueBefore(points, to)), jump: false });
|
||||
elapsed += span;
|
||||
landed = to >= stretchEnd;
|
||||
head = landed ? loop!.start : to;
|
||||
}
|
||||
return thinned(marks);
|
||||
}
|
||||
|
||||
/** The ticks strictly between `head` and `to` to mark: every point, and readings where the bus cannot ramp. */
|
||||
function readings(lane: SequenceLane, head: number, to: number, step: number): number[] {
|
||||
const { points, target } = lane;
|
||||
const ticks: number[] = [];
|
||||
points.forEach((a, i) => {
|
||||
if (a.tick > head && a.tick < to) ticks.push(a.tick);
|
||||
const b = points[i + 1];
|
||||
if (b && b.tick > head && a.tick < to && !rampable(target, a, b))
|
||||
ticks.push(...stepsOf(a.tick, head, Math.min(b.tick, to), step));
|
||||
});
|
||||
ticks.sort((x, y) => x - y);
|
||||
return ticks.filter((tick, i) => i === 0 || tick !== ticks[i - 1]);
|
||||
}
|
||||
|
||||
/** The ticks `step` apart from `origin` on, strictly between `after` and `before`; none without a step. */
|
||||
function stepsOf(origin: number, after: number, before: number, step: number): number[] {
|
||||
const ticks: number[] = [];
|
||||
if (!(step > 0)) return ticks;
|
||||
for (let k = Math.max(1, Math.floor((after - origin) / step) + 1); origin + k * step < before; k++)
|
||||
if (origin + k * step > after) ticks.push(origin + k * step);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
/** Without the marks a ramp would pass anyway: a value held from the mark before on. */
|
||||
function thinned(marks: readonly LaneMark[]): LaneMark[] {
|
||||
const kept: LaneMark[] = [];
|
||||
marks.forEach((mark, i) => {
|
||||
const last = kept.at(-1);
|
||||
const next = marks[i + 1];
|
||||
const held =
|
||||
last !== undefined &&
|
||||
next !== undefined &&
|
||||
!mark.jump &&
|
||||
last.value === mark.value &&
|
||||
(next.jump || next.value === mark.value);
|
||||
if (!held) kept.push(mark);
|
||||
});
|
||||
return kept;
|
||||
}
|
||||
|
||||
/**
|
||||
* A lane as it is heard over the playing time from `start` up to `end`, played from 0 round `loop` –
|
||||
* a song in an item of a composition (#418) –, as a lane of its own in ticks of that playing time:
|
||||
* each round with its points, cut at the loop end and jumping back there. A way cut in the middle
|
||||
* that is no straight line is read every `step` ticks.
|
||||
*/
|
||||
export function unrolledLane(
|
||||
points: readonly SequenceLanePoint[],
|
||||
loop: LoopRange | null,
|
||||
start: number,
|
||||
end: number,
|
||||
step: number,
|
||||
): SequenceLanePoint[] {
|
||||
const out: SequenceLanePoint[] = [];
|
||||
const stretchEnd = loop !== null && loop.end > 0 ? loop.end : Infinity;
|
||||
let elapsed = start;
|
||||
let head = playheadAt(start, 0, loop);
|
||||
while (elapsed < end) {
|
||||
const span = Math.min(end - elapsed, stretchEnd - head);
|
||||
if (!(span > 0)) break;
|
||||
const shift = elapsed - head;
|
||||
for (const point of stretchOf(points, head, head + span, step))
|
||||
out.push({ ...point, tick: point.tick + shift });
|
||||
elapsed += span;
|
||||
head = head + span >= stretchEnd ? loop!.start : head + span;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The points of a lane from `head` to `to` as a lane by itself, ending on the value it reaches there. */
|
||||
function stretchOf(
|
||||
points: readonly SequenceLanePoint[],
|
||||
head: number,
|
||||
to: number,
|
||||
step: number,
|
||||
): SequenceLanePoint[] {
|
||||
const out: SequenceLanePoint[] = [];
|
||||
const at = lastAt(points, head);
|
||||
const starts: { tick: number; own: number; whole: boolean }[] = [
|
||||
{ tick: head, own: Math.max(0, at), whole: at >= 0 && points[at]!.tick === head },
|
||||
];
|
||||
points.forEach((point, i) => {
|
||||
if (point.tick > head && point.tick < to) starts.push({ tick: point.tick, own: i, whole: true });
|
||||
});
|
||||
starts.forEach((s, k) => {
|
||||
const next = starts[k + 1]?.tick ?? to;
|
||||
const a = points[s.own]!;
|
||||
const b = points[s.own + 1];
|
||||
const value = s.whole ? a.value : laneValueAt(points, s.tick);
|
||||
if (!b || (k === 0 && at < 0)) out.push({ tick: s.tick, value });
|
||||
else if (s.whole && b.tick <= to) out.push({ ...a, tick: s.tick });
|
||||
else if (straight(a, b)) out.push({ tick: s.tick, value, ...(a.ease === 'step' && { ease: 'step' }) });
|
||||
else {
|
||||
out.push({ tick: s.tick, value });
|
||||
for (const tick of stepsOf(a.tick, s.tick, next, step))
|
||||
out.push({ tick, value: laneValueAt(points, tick) });
|
||||
}
|
||||
});
|
||||
out.push({ tick: to, value: laneValueBefore(points, to) });
|
||||
return out;
|
||||
}
|
||||
@@ -233,6 +233,8 @@ export function bakePrint(project: Project, asset: BakedAsset): string {
|
||||
clips,
|
||||
buses,
|
||||
instruments,
|
||||
// Only where there is any, so a bake made before automation (#337) still holds.
|
||||
...(sequence.lanes?.length ? [sequence.lanes] : []),
|
||||
]);
|
||||
return hashText(text);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Where a playing of a song or a pattern is heard, and the beats, bars and markers it has passed
|
||||
* (#338). The audio side keeps each playing's time as an anchor on its clock; a game looks once a
|
||||
* frame, so whatever it passed since the last look is told late, and too late is not told at all.
|
||||
*/
|
||||
import type { SongMarker } from './markers';
|
||||
import { playheadAt, type LoopRange } from './sequence';
|
||||
import { type Meter, positionOf, secondsOfTicks, ticksOfSeconds, ticksPerBar, ticksPerBeat } from './time';
|
||||
|
||||
/** The time of one playing: how fast, in what meter, from where, round what, to where. */
|
||||
export interface Timeline {
|
||||
tempo: number;
|
||||
meter: Meter;
|
||||
/** Where it started, in ticks. */
|
||||
from: number;
|
||||
/** What it runs round once it gets there; null plays it once to `length`. */
|
||||
loop: LoopRange | null;
|
||||
/** Where it ends when it does not loop, in ticks. */
|
||||
length: number;
|
||||
}
|
||||
|
||||
/** The most a spot passed is told late, and less where half a beat is shorter. */
|
||||
export const LATE_MAX = 0.15;
|
||||
|
||||
/**
|
||||
* How late a spot may be told: half a beat, at most `LATE_MAX`. A frame that slips loses no beat;
|
||||
* after a long stall the old ones are gone, and no burst of them follows.
|
||||
*/
|
||||
export function lateLimit(timeline: Pick<Timeline, 'tempo' | 'meter'>): number {
|
||||
return Math.min(LATE_MAX, secondsOfTicks(ticksPerBeat(timeline.meter), timeline.tempo) / 2);
|
||||
}
|
||||
|
||||
/** What `Song position` hands on. */
|
||||
export interface HeardPosition {
|
||||
bar: number;
|
||||
beat: number;
|
||||
/** How far into the beat, 0 to below 1. */
|
||||
fraction: number;
|
||||
/** Seconds from the start of the song to where it stands; back to the loop's start with it. */
|
||||
seconds: number;
|
||||
tick: number;
|
||||
/** Heard now: started, and not played to its end. */
|
||||
playing: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a playing stands at `heard` seconds of its playing time, as heard: before it starts (null, or
|
||||
* less than 0) where it starts, after its end at the end.
|
||||
*/
|
||||
export function heardPosition(timeline: Timeline, heard: number | null): HeardPosition {
|
||||
const { tempo, meter, from, loop, length } = timeline;
|
||||
let tick = from;
|
||||
let playing = false;
|
||||
if (heard !== null && heard >= 0) {
|
||||
tick = playheadAt(ticksOfSeconds(heard, tempo), from, loop);
|
||||
playing = !ends(timeline) || tick < length;
|
||||
if (!playing) tick = length;
|
||||
}
|
||||
const beat = ticksPerBeat(meter);
|
||||
const at = positionOf(tick, meter);
|
||||
const inBeat = tick - Math.floor(tick / beat) * beat;
|
||||
return {
|
||||
bar: at.bar,
|
||||
beat: at.beat,
|
||||
fraction: Math.min(Math.max(inBeat / beat, 0), 1 - Number.EPSILON),
|
||||
seconds: secondsOfTicks(tick, tempo),
|
||||
tick,
|
||||
playing,
|
||||
};
|
||||
}
|
||||
|
||||
/** A spot the playhead passed: the tick it is at, and how many seconds ago it was heard. */
|
||||
export interface Passed {
|
||||
tick: number;
|
||||
late: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ticks among the playhead's run from `low` to `high` that are spots of a kind. `lowOpen` leaves
|
||||
* `low` itself out (it was told before), `highOpen` leaves `high` out (the loop's end is its start).
|
||||
*/
|
||||
export type SpotsIn = (low: number, high: number, lowOpen: boolean, highOpen: boolean) => number[];
|
||||
|
||||
/** Every multiple of `step` in the run: the beats or the bars. */
|
||||
export function gridSpots(step: number): SpotsIn {
|
||||
return (low, high, lowOpen, highOpen) => {
|
||||
const spots: number[] = [];
|
||||
let n = Math.ceil(low / step);
|
||||
if (lowOpen && n * step <= low) n++;
|
||||
for (; n * step < high || (!highOpen && n * step === high); n++) spots.push(n * step);
|
||||
return spots;
|
||||
};
|
||||
}
|
||||
|
||||
/** Those of `ticks` in the run: the markers. */
|
||||
export function tickSpots(ticks: readonly number[]): SpotsIn {
|
||||
return (low, high, lowOpen, highOpen) =>
|
||||
[...new Set(ticks)].filter((t) => (lowOpen ? t > low : t >= low) && (highOpen ? t < high : t <= high));
|
||||
}
|
||||
|
||||
/** Whether it ends rather than going round. */
|
||||
function ends(timeline: Timeline): boolean {
|
||||
return timeline.loop === null;
|
||||
}
|
||||
|
||||
/** One stretch of playing time the playhead runs straight through: from `start` on, at `head`, to `end`. */
|
||||
interface Stretch {
|
||||
start: number;
|
||||
head: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
/** The stretch playing time `elapsed` (in ticks) lies in. */
|
||||
function stretchAt({ from, loop }: Timeline, elapsed: number): Stretch {
|
||||
if (!loop || from >= loop.end) return { start: 0, head: from, end: Infinity };
|
||||
const toEnd = loop.end - from;
|
||||
if (elapsed < toEnd) return { start: 0, head: from, end: toEnd };
|
||||
const round = loop.end - loop.start;
|
||||
const start = toEnd + Math.floor((elapsed - toEnd) / round) * round;
|
||||
return { start, head: loop.start, end: start + round };
|
||||
}
|
||||
|
||||
/** The stretch that runs up to where `stretch` begins; null for the first. */
|
||||
function stretchBefore({ from, loop }: Timeline, stretch: Stretch): Stretch | null {
|
||||
if (!loop || stretch.start <= 0) return null;
|
||||
const toEnd = loop.end - from;
|
||||
const round = loop.end - loop.start;
|
||||
const start = stretch.start - round;
|
||||
return start >= toEnd
|
||||
? { start, head: loop.start, end: stretch.start }
|
||||
: { start: 0, head: from, end: toEnd };
|
||||
}
|
||||
|
||||
/** No more stretches than this are looked back through: a loop shorter than a frame is no tune. */
|
||||
const MOST_STRETCHES = 16;
|
||||
|
||||
/**
|
||||
* The spots the playhead passed after `before` up to `now` (seconds of playing time heard, `before`
|
||||
* null where nothing was heard of it yet), newest first – only those no more than `lateLimit` ago.
|
||||
* A loop's start is passed each time it comes round; nothing at or after the end of one that ends.
|
||||
*/
|
||||
export function passed(timeline: Timeline, before: number | null, now: number, spots: SpotsIn): Passed[] {
|
||||
const low = Math.max(before ?? -Infinity, now - lateLimit(timeline));
|
||||
if (!(now >= 0) || !(now > low)) return [];
|
||||
const { tempo, length } = timeline;
|
||||
const elapsedLow = ticksOfSeconds(low, tempo);
|
||||
const high = ticksOfSeconds(now, tempo);
|
||||
const found: Passed[] = [];
|
||||
let stretch: Stretch | null = stretchAt(timeline, high);
|
||||
let highOpen = false;
|
||||
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
|
||||
// What was heard at `low` was told before; where the run reaches back past a stretch's start,
|
||||
// that start is among it – the very start of the playing too, before which nothing was heard.
|
||||
const lowInside = elapsedLow >= stretch.start;
|
||||
const headLow = stretch.head + ((lowInside ? elapsedLow : stretch.start) - stretch.start);
|
||||
let headHigh = stretch.head + ((highOpen ? stretch.end : high) - stretch.start);
|
||||
let open = highOpen;
|
||||
if (ends(timeline) && headHigh >= length) {
|
||||
headHigh = length;
|
||||
open = true;
|
||||
}
|
||||
const ticks = spots(headLow, headHigh, lowInside, open).sort((a, b) => b - a);
|
||||
for (const tick of ticks) {
|
||||
const at = stretch.start + (tick - stretch.head);
|
||||
found.push({ tick, late: Math.max(0, now - secondsOfTicks(at, tempo)) });
|
||||
}
|
||||
if (lowInside) break;
|
||||
stretch = stretchBefore(timeline, stretch);
|
||||
highOpen = true;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** A beat passed, as `On beat` tells it. */
|
||||
export interface PassedBeat extends Passed {
|
||||
bar: number;
|
||||
beat: number;
|
||||
}
|
||||
|
||||
/** The newest beat passed, if one is still to be told. */
|
||||
export function passedBeat(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
|
||||
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBeat(timeline.meter)));
|
||||
if (!newest) return null;
|
||||
const at = positionOf(newest.tick, timeline.meter);
|
||||
return { ...newest, bar: at.bar, beat: at.beat };
|
||||
}
|
||||
|
||||
/** A marker passed, as `On song marker` tells it. */
|
||||
export interface PassedMarker extends Passed {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/** Every marker on a tick passed, newest first; markers sharing a tick in their own order. */
|
||||
export function passedMarkers(
|
||||
timeline: Timeline,
|
||||
before: number | null,
|
||||
now: number,
|
||||
markers: readonly SongMarker[],
|
||||
): PassedMarker[] {
|
||||
if (markers.length === 0) return [];
|
||||
return passed(timeline, before, now, tickSpots(markers.map((m) => m.tick))).flatMap((spot) =>
|
||||
markers.filter((m) => m.tick === spot.tick).map((m) => ({ ...spot, id: m.id, name: m.name })),
|
||||
);
|
||||
}
|
||||
|
||||
/** Of what passed, those passed last: one spot, but every marker on it. */
|
||||
export function newestPassed<T extends Passed>(list: readonly T[]): T[] {
|
||||
const [first] = list;
|
||||
return first ? list.filter((p) => p.late === first.late) : [];
|
||||
}
|
||||
|
||||
/** Where a change a game asks of its music lands (#338): at once, or on the next of these. */
|
||||
export type Quantize = 'now' | 'beat' | 'bar' | 'marker';
|
||||
|
||||
export const QUANTIZE_OPTIONS: readonly { value: Quantize; label: string }[] = [
|
||||
{ value: 'now', label: 'Now' },
|
||||
{ value: 'beat', label: 'Next beat' },
|
||||
{ value: 'bar', label: 'Next bar' },
|
||||
{ value: 'marker', label: 'Next marker' },
|
||||
];
|
||||
|
||||
export function heldQuantize(value: unknown): Quantize {
|
||||
return QUANTIZE_OPTIONS.some((option) => option.value === value) ? (value as Quantize) : 'bar';
|
||||
}
|
||||
|
||||
/** The spots a change waits for; a song without markers, or a pattern, waits for the next bar. */
|
||||
export function quantizeSpots(
|
||||
quantize: Exclude<Quantize, 'now'>,
|
||||
meter: Meter,
|
||||
markers: readonly SongMarker[],
|
||||
): SpotsIn {
|
||||
if (quantize === 'beat') return gridSpots(ticksPerBeat(meter));
|
||||
if (quantize === 'marker' && markers.length > 0) return tickSpots(markers.map((m) => m.tick));
|
||||
return gridSpots(ticksPerBar(meter));
|
||||
}
|
||||
|
||||
/** The furthest ahead a spot is looked for on a stretch without end. */
|
||||
const LOOK_AHEAD = 600;
|
||||
|
||||
/** The stretch that follows `stretch`; null after the last one of a playing that ends. */
|
||||
function stretchAfter(timeline: Timeline, stretch: Stretch): Stretch | null {
|
||||
return stretch.end === Infinity ? null : stretchAt(timeline, stretch.end);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first spot at or after `elapsed` seconds of playing time, in seconds of playing time; null where
|
||||
* none comes before a playing that ends does – a change then lands at once.
|
||||
*/
|
||||
export function nextSpot(timeline: Timeline, elapsed: number, spots: SpotsIn): number | null {
|
||||
const { tempo, length } = timeline;
|
||||
const low = ticksOfSeconds(Math.max(0, elapsed), tempo);
|
||||
let stretch: Stretch | null = stretchAt(timeline, low);
|
||||
for (let n = 0; stretch && n < MOST_STRETCHES; n++) {
|
||||
const headLow = stretch.head + (Math.max(low, stretch.start) - stretch.start);
|
||||
// One that runs on without end is looked through for `LOOK_AHEAD` seconds, not for ever.
|
||||
let headHigh =
|
||||
stretch.head + (Math.min(stretch.end, low + ticksOfSeconds(LOOK_AHEAD, tempo)) - stretch.start);
|
||||
if (ends(timeline)) headHigh = Math.min(headHigh, length);
|
||||
if (headHigh > headLow) {
|
||||
const [first] = spots(headLow, headHigh, false, true).sort((a, b) => a - b);
|
||||
if (first !== undefined) return secondsOfTicks(stretch.start + (first - stretch.head), tempo);
|
||||
}
|
||||
if (ends(timeline) && headHigh >= length) return null;
|
||||
stretch = stretchAfter(timeline, stretch);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The newest bar line passed, if one is still to be told. */
|
||||
export function passedBar(timeline: Timeline, before: number | null, now: number): PassedBeat | null {
|
||||
const [newest] = passed(timeline, before, now, gridSpots(ticksPerBar(timeline.meter)));
|
||||
if (!newest) return null;
|
||||
return { ...newest, bar: positionOf(newest.tick, timeline.meter).bar, beat: 1 };
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* What a composition plays of its audio items (#418), as sequences the scheduler plays on the audio
|
||||
* clock. Time is counted in milliseconds – ticks at `COMPOSITION_TEMPO` –, so a frame of any `fps`
|
||||
* lands within a millisecond. A pattern or a song keeps its own tempo inside its item: its notes are
|
||||
* laid out here in the composition's time, each with the tempo a Delay synced to beats counts by.
|
||||
*
|
||||
* Every item is heard on a bus of its own at its volume, its fades ramps of that bus' level, through
|
||||
* the sound FX of its pattern or song; a song's tracks are buses of that item. One playing goes to
|
||||
* one place of the mix, so the items are split into one sequence for each place they are sent to.
|
||||
*/
|
||||
import type { CompositionAsset, CompositionItem } from '../model/Asset';
|
||||
import { compositionFps, compositionLength, compositionLoops, itemFades } from '../model/composition';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { pickedFx } from '../properties/assetFx';
|
||||
import { AUTOMATION_STEP, type SequenceLane, unrolledLane } from './automation';
|
||||
import { itemRoute, MASTER_MIX, type MixRoute } from './mixer';
|
||||
import { fxVoiceOf } from './needs';
|
||||
import { patternInstrument, patternLength, patternSequence, patternTempo } from './pattern';
|
||||
import type { BusLevel } from './playbacks';
|
||||
import {
|
||||
clipsIn,
|
||||
type LoopRange,
|
||||
notesIn,
|
||||
type Sequence,
|
||||
type SequenceClip,
|
||||
type SequenceLevel,
|
||||
type SequenceNote,
|
||||
} from './sequence';
|
||||
import { dbToGain, songLoop, songSequence, songTempo } from './song';
|
||||
import { songBuses } from './songBuses';
|
||||
import { secondsOfTicks, ticksOfSeconds } from './time';
|
||||
|
||||
/** The tempo a composition's sequences count at: 1000 ticks to the second. */
|
||||
export const COMPOSITION_TEMPO = 125;
|
||||
|
||||
/** Ticks of a composition's sequence: milliseconds. */
|
||||
export function compositionTicks(seconds: number): number {
|
||||
return Math.round(ticksOfSeconds(seconds, COMPOSITION_TEMPO));
|
||||
}
|
||||
|
||||
/** One sequence of a composition and where it goes. */
|
||||
export interface CompositionPlaying {
|
||||
route: MixRoute;
|
||||
sequence: Sequence;
|
||||
buses: BusLevel[];
|
||||
}
|
||||
|
||||
/** The bus an audio item is heard on, at its volume; a song's tracks go into it. */
|
||||
export function audioItemBus(compId: string, itemId: string): string {
|
||||
return `composition:${compId}:${itemId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every audio item of the composition on a track there is, as sequences – one for each place of the
|
||||
* mix they go to, in the order the first item for it lies in the list. With `routed` off all go to
|
||||
* the master, as one.
|
||||
*/
|
||||
export function compositionPlayings(
|
||||
project: Project,
|
||||
comp: CompositionAsset,
|
||||
routed = true,
|
||||
): CompositionPlaying[] {
|
||||
const fps = compositionFps(comp);
|
||||
const tracks = new Set(comp.tracks.map((t) => t.id));
|
||||
const byRoute = new Map<string, CompositionPlaying>();
|
||||
for (const item of comp.items) {
|
||||
if (!item.audio || !tracks.has(item.track)) continue;
|
||||
const laid = laidOut(project, comp.id, item, fps);
|
||||
if (!laid) continue;
|
||||
const route = routed ? itemRoute(project, comp.id, item.audio.bus, laid.routedAs) : MASTER_MIX;
|
||||
const key = JSON.stringify(route);
|
||||
let playing = byRoute.get(key);
|
||||
if (!playing)
|
||||
byRoute.set(key, (playing = { route, sequence: { notes: [], clips: [], levels: [] }, buses: [] }));
|
||||
(playing.sequence.notes as SequenceNote[]).push(...laid.notes);
|
||||
(playing.sequence.clips as SequenceClip[]).push(...laid.clips);
|
||||
(playing.sequence.levels as SequenceLevel[]).push(...laid.levels);
|
||||
if (laid.lanes?.length) playing.sequence.lanes = [...(playing.sequence.lanes ?? []), ...laid.lanes];
|
||||
playing.buses.push(...laid.buses);
|
||||
}
|
||||
const playings = [...byRoute.values()];
|
||||
for (const { sequence } of playings) {
|
||||
(sequence.notes as SequenceNote[]).sort((a, b) => a.tick - b.tick || a.note - b.note);
|
||||
(sequence.clips as SequenceClip[]).sort((a, b) => a.tick - b.tick);
|
||||
(sequence.levels as SequenceLevel[]).sort((a, b) => a.tick - b.tick);
|
||||
}
|
||||
return playings;
|
||||
}
|
||||
|
||||
/** How long one run of the composition is in ticks, and the loop it goes round where it loops. */
|
||||
export function compositionSpan(
|
||||
project: Project,
|
||||
comp: CompositionAsset,
|
||||
): { length: number; loop: LoopRange | null } {
|
||||
const length = compositionTicks(compositionLength(project, comp) / compositionFps(comp));
|
||||
return { length, loop: compositionLoops(comp) && length > 0 ? { start: 0, end: length } : null };
|
||||
}
|
||||
|
||||
interface Laid {
|
||||
notes: SequenceNote[];
|
||||
clips: SequenceClip[];
|
||||
/** Its fades, on its bus. */
|
||||
levels: SequenceLevel[];
|
||||
/** A song's automation (#337), round its loop as the song plays inside the item. */
|
||||
lanes?: SequenceLane[];
|
||||
buses: BusLevel[];
|
||||
/** The asset its routing is read from: the sound of a clip, else what it plays. */
|
||||
routedAs: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* An audio item in the composition's ticks: where it begins and ends, and from how far into what it
|
||||
* plays. An offset below 0 starts that much later in the item.
|
||||
*/
|
||||
function laidOut(project: Project, compId: string, item: CompositionItem, fps: number): Laid | null {
|
||||
const audio = item.audio!;
|
||||
const late = Math.max(0, -item.offset);
|
||||
const begin = compositionTicks(item.start / fps + late);
|
||||
const end = compositionTicks((item.start + item.length) / fps);
|
||||
if (end <= begin) return null;
|
||||
const into = Math.max(0, item.offset);
|
||||
const seconds = secondsOfTicks(end - begin, COMPOSITION_TEMPO);
|
||||
const bus = audioItemBus(compId, item.id);
|
||||
const own: BusLevel = { name: bus, gain: dbToGain(audio.volume), pan: 0 };
|
||||
const levels = fadesOf(item, fps, bus, own.gain);
|
||||
if (audio.kind === 'sound') {
|
||||
const ref = findSound(project, audio.asset);
|
||||
if (!ref?.asset.file) return null;
|
||||
const clip = {
|
||||
tick: begin,
|
||||
length: end - begin,
|
||||
sound: audio.asset,
|
||||
fx: pickedFx('', ref.asset.props),
|
||||
offset: into,
|
||||
volume: 1,
|
||||
bus,
|
||||
};
|
||||
return { notes: [], clips: [clip], levels, buses: [own], routedAs: ref.asset.id };
|
||||
}
|
||||
const withFx = (fxId: string, tempo: number): BusLevel => {
|
||||
const fx = fxVoiceOf(project, fxId, tempo);
|
||||
return fx ? { ...own, fx } : own;
|
||||
};
|
||||
if (audio.kind === 'pattern') {
|
||||
const pattern = findAssetOf(project, audio.asset, 'pattern');
|
||||
if (!pattern || !patternInstrument(pattern.props)) return null;
|
||||
const tempo = patternTempo(pattern.props);
|
||||
const length = patternLength(pattern);
|
||||
const heard = played(patternSequence(pattern), { start: 0, end: length }, tempo, into, seconds);
|
||||
return {
|
||||
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, bus })),
|
||||
clips: [],
|
||||
levels,
|
||||
buses: [withFx(pickedFx('', pattern.props), tempo)],
|
||||
routedAs: pattern.id,
|
||||
};
|
||||
}
|
||||
const song = findAssetOf(project, audio.asset, 'song');
|
||||
if (!song) return null;
|
||||
const tempo = songTempo(song.props);
|
||||
const heard = played(songSequence(project, song), songLoop(song), tempo, into, seconds);
|
||||
// Its tracks are the item's: two items of one song do not share them.
|
||||
const prefix = `song:${song.id}`;
|
||||
const renamed = (name: string) => (name.startsWith(prefix) ? `${bus}${name.slice(prefix.length)}` : name);
|
||||
const tracks = songBuses(project, song).map(({ name, into: to, sends, ...level }) => ({
|
||||
...level,
|
||||
name: renamed(name),
|
||||
...(to !== undefined && { into: renamed(to) }),
|
||||
...(sends && { sends: sends.map((send) => ({ ...send, to: send.to ? renamed(send.to) : bus })) }),
|
||||
}));
|
||||
return {
|
||||
notes: heard.notes.map((n) => ({ ...n, tick: begin + n.tick, ...(n.bus && { bus: renamed(n.bus) }) })),
|
||||
clips: heard.clips.map((c) => ({ ...c, tick: begin + c.tick, bus: renamed(c.bus) })),
|
||||
lanes: heard.lanes.map((lane) => ({
|
||||
...lane,
|
||||
bus: renamed(lane.bus),
|
||||
points: lane.points.map((p) => ({ ...p, tick: begin + p.tick })),
|
||||
})),
|
||||
levels,
|
||||
buses: [withFx(pickedFx('', song.props), tempo), ...tracks],
|
||||
routedAs: song.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The fades of an item as ramps of its bus, none without: up from silence at its start – at once
|
||||
* where it has no fade in, as the round before may have faded it out – and down at its end.
|
||||
*/
|
||||
function fadesOf(item: CompositionItem, fps: number, bus: string, gain: number): SequenceLevel[] {
|
||||
const { fadeIn, fadeOut, curveIn, curveOut } = itemFades(item);
|
||||
if (fadeIn === 0 && fadeOut === 0) return [];
|
||||
const tick = (frame: number) => compositionTicks(frame / fps);
|
||||
const start = tick(item.start);
|
||||
const end = tick(item.start + item.length);
|
||||
const rise = {
|
||||
tick: start,
|
||||
length: tick(item.start + fadeIn) - start,
|
||||
bus,
|
||||
gain,
|
||||
rise: true,
|
||||
curve: curveIn,
|
||||
};
|
||||
if (fadeOut === 0) return [rise];
|
||||
const fall = tick(item.start + item.length - fadeOut);
|
||||
return [rise, { tick: fall, length: end - fall, bus, gain, rise: false, curve: curveOut }];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a pattern or a song plays from `into` seconds on for `seconds`, round its loop, in ticks of
|
||||
* the composition from 0: each note and clip with its own tempo, none ringing past the end.
|
||||
*/
|
||||
function played(
|
||||
sequence: Sequence,
|
||||
loop: LoopRange,
|
||||
tempo: number,
|
||||
into: number,
|
||||
seconds: number,
|
||||
): { notes: SequenceNote[]; clips: SequenceClip[]; lanes: SequenceLane[] } {
|
||||
const from = Math.round(ticksOfSeconds(into, tempo));
|
||||
const to = from + Math.ceil(ticksOfSeconds(seconds, tempo));
|
||||
const span = compositionTicks(seconds);
|
||||
const at = (ticks: number) => compositionTicks(secondsOfTicks(ticks - from, tempo));
|
||||
const notes = notesIn(sequence, 0, loop, from, to).flatMap((n) => {
|
||||
const tick = at(n.at);
|
||||
const length = Math.min(compositionTicks(secondsOfTicks(n.length, tempo)), span - tick);
|
||||
if (tick >= span || length <= 0) return [];
|
||||
const { at: _, ...note } = n;
|
||||
return [{ ...note, tick, length: Math.max(1, length), tempo }];
|
||||
});
|
||||
const clips = clipsIn(sequence, 0, loop, from, to, true).flatMap((c) => {
|
||||
const tick = at(c.at);
|
||||
const length = Math.min(compositionTicks(secondsOfTicks(c.length, tempo)), span - tick);
|
||||
if (tick >= span || length <= 0) return [];
|
||||
const { at: _, skip, ...clip } = c;
|
||||
return [{ ...clip, tick, length, offset: c.offset + secondsOfTicks(skip, tempo), tempo }];
|
||||
});
|
||||
const step = ticksOfSeconds(AUTOMATION_STEP, tempo);
|
||||
const lanes = (sequence.lanes ?? []).map((lane) => ({
|
||||
...lane,
|
||||
points: unrolledLane(lane.points, loop, from, to, step).map((p) => ({ ...p, tick: at(p.tick) })),
|
||||
}));
|
||||
return { notes, clips, lanes };
|
||||
}
|
||||
@@ -53,20 +53,19 @@ export function embeddedIn(project: Project, songId: string): Asset[] {
|
||||
return project.assets.filter((a) => a.owner === songId);
|
||||
}
|
||||
|
||||
/** The song an asset belongs to; undefined for a project asset. */
|
||||
export function ownerSong(project: Project, asset: Asset): SongAsset | undefined {
|
||||
const owner = asset.owner ? findAsset(project, asset.owner) : undefined;
|
||||
return owner?.type === 'song' ? owner : undefined;
|
||||
}
|
||||
|
||||
export function isRowHiddenWhenEmbedded(asset: Asset, key: string): boolean {
|
||||
return isEmbedded(asset) && (HIDDEN_ROWS[asset.type]?.includes(key) ?? false);
|
||||
}
|
||||
|
||||
/** The song or composition (#419) an asset belongs to; undefined for a project asset. */
|
||||
export function ownerOf(project: Project, asset: Asset): Asset | undefined {
|
||||
return isEmbedded(asset) ? findAsset(project, asset.owner!) : undefined;
|
||||
}
|
||||
|
||||
/** "Song › Take 1" for an embedded asset, its own name for any other. */
|
||||
export function assetTitle(project: Project, asset: Asset): string {
|
||||
const song = ownerSong(project, asset);
|
||||
return song ? `${song.name} › ${asset.name}` : asset.name;
|
||||
const owner = ownerOf(project, asset);
|
||||
return owner ? `${owner.name} › ${asset.name}` : asset.name;
|
||||
}
|
||||
|
||||
/** The tempo and the meter of a song, as the props a pattern made in it starts with. */
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { FxParams } from '../soundfx/nodes';
|
||||
import {
|
||||
type AutomationTarget,
|
||||
type LaneMark,
|
||||
laneMarks,
|
||||
laneRange,
|
||||
laneValueAt,
|
||||
paramOf,
|
||||
paramsAt,
|
||||
reaches,
|
||||
type SequenceLane,
|
||||
type SequenceLanePoint,
|
||||
} from './automation';
|
||||
import { type LoopRange, playheadAt } from './sequence';
|
||||
|
||||
/**
|
||||
* A lane a game holds on a value of its own (#338): from `at` it goes from `start`, where the lane
|
||||
* is then, to `value` over `fade` and stays there, its own points no longer heard – until `release`
|
||||
* takes it back over its own `fade` to where its curve is by then, or a later hold lands. Seconds of
|
||||
* the audio clock, values in the units of the lane.
|
||||
*/
|
||||
export interface LaneHold {
|
||||
at: number;
|
||||
fade: number;
|
||||
start: number;
|
||||
value: number;
|
||||
release: { at: number; fade: number } | null;
|
||||
}
|
||||
|
||||
/** How a sequence is played: from where, round what, and its playing time in ticks at a second of the audio clock. */
|
||||
export interface LanePlaying {
|
||||
from: number;
|
||||
loop: LoopRange | null;
|
||||
elapsed(at: number): number;
|
||||
}
|
||||
|
||||
/** What the holds of a lane are kept by: the bus of the lane and its target. */
|
||||
export function holdKey(bus: string, target: AutomationTarget): string {
|
||||
return `${bus}\n${target}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The playing time each of the holds of a lane rules, in ticks, in the order they land: from where
|
||||
* it lands to where its release is through or the next one lands; open while it holds.
|
||||
*/
|
||||
export function holdSpans(
|
||||
holds: readonly LaneHold[],
|
||||
playing: LanePlaying,
|
||||
): { hold: LaneHold; start: number; end: number }[] {
|
||||
return holds
|
||||
.map((hold, i) => {
|
||||
const { release } = hold;
|
||||
const next = holds[i + 1];
|
||||
const own = release ? playing.elapsed(release.at + release.fade) : Infinity;
|
||||
return {
|
||||
hold,
|
||||
start: playing.elapsed(hold.at),
|
||||
end: next ? Math.min(own, playing.elapsed(next.at)) : own,
|
||||
};
|
||||
})
|
||||
.filter((span) => span.end > span.start);
|
||||
}
|
||||
|
||||
/** The holds of a lane still to be planned past `until`, in ticks of the playing time. */
|
||||
export function holdsAfter(holds: readonly LaneHold[], playing: LanePlaying, until: number): LaneHold[] {
|
||||
const going = new Set(
|
||||
holdSpans(holds, playing)
|
||||
.filter((span) => span.end > until)
|
||||
.map((s) => s.hold),
|
||||
);
|
||||
return holds.filter((hold) => going.has(hold));
|
||||
}
|
||||
|
||||
/**
|
||||
* The way of a hold as the points of a lane in ticks of the playing time: from where the lane was to
|
||||
* the value, and from wherever that got to by the release back to the curve where the release ends.
|
||||
*/
|
||||
export function holdPoints(hold: LaneHold, lane: SequenceLane, playing: LanePlaying): SequenceLanePoint[] {
|
||||
const held: SequenceLanePoint[] = [
|
||||
{ tick: playing.elapsed(hold.at), value: hold.start },
|
||||
{ tick: playing.elapsed(hold.at + hold.fade), value: hold.value },
|
||||
];
|
||||
const { release } = hold;
|
||||
if (!release) return held;
|
||||
const from = playing.elapsed(release.at);
|
||||
const to = playing.elapsed(release.at + release.fade);
|
||||
const back = laneValueAt(lane.points, playheadAt(Math.max(0, to), playing.from, playing.loop));
|
||||
return [
|
||||
...held.filter((point) => point.tick <= from),
|
||||
{ tick: from, value: laneValueAt(held, from) },
|
||||
{ tick: to, value: back },
|
||||
];
|
||||
}
|
||||
|
||||
/** Where a lane is at the playing time `elapsed`, in its units: along the hold that rules then, if one does. */
|
||||
export function heldValueAt(
|
||||
lane: SequenceLane,
|
||||
holds: readonly LaneHold[],
|
||||
playing: LanePlaying,
|
||||
elapsed: number,
|
||||
): number {
|
||||
const span = holdSpans(holds, playing).find((s) => elapsed >= s.start && elapsed < s.end);
|
||||
if (span) return laneValueAt(holdPoints(span.hold, lane, playing), elapsed);
|
||||
return laneValueAt(lane.points, playheadAt(Math.max(0, elapsed), playing.from, playing.loop));
|
||||
}
|
||||
|
||||
/**
|
||||
* The marks of a lane over the playing time from `start` up to `end`, as `laneMarks` gives them, but
|
||||
* along its holds where they rule: its own points between them, the way of each hold while it rules.
|
||||
* Where a hold or the way back to the lane begins is a jump, so a bus is set there and does not ramp
|
||||
* from whatever was planned for it last.
|
||||
*/
|
||||
export function heldMarks(
|
||||
lane: SequenceLane,
|
||||
holds: readonly LaneHold[],
|
||||
playing: LanePlaying,
|
||||
start: number,
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
step: number,
|
||||
): LaneMark[] {
|
||||
const { from, loop } = playing;
|
||||
const marks: LaneMark[] = [];
|
||||
let cursor = start;
|
||||
let landed = fresh;
|
||||
for (const span of holdSpans(holds, playing)) {
|
||||
if (span.end <= cursor) continue;
|
||||
if (span.start >= end) break;
|
||||
if (cursor < span.start) marks.push(...laneMarks(lane, from, loop, cursor, span.start, landed, step));
|
||||
const to = Math.min(end, span.end);
|
||||
const way: SequenceLane = { ...lane, points: holdPoints(span.hold, lane, playing) };
|
||||
marks.push(
|
||||
...laneMarks(way, 0, null, Math.max(cursor, span.start), to, fresh || span.start >= start, step),
|
||||
);
|
||||
cursor = to;
|
||||
landed = true;
|
||||
}
|
||||
if (cursor < end) marks.push(...laneMarks(lane, from, loop, cursor, end, landed, step));
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The values the parameter lanes have for what starts on `bus` at `tick` of the sequence, `elapsed`
|
||||
* of the playing time, as `paramsAt` gives them: a held one along its hold.
|
||||
*/
|
||||
export function heldParamsAt(
|
||||
lanes: readonly SequenceLane[],
|
||||
holds: ReadonlyMap<string, readonly LaneHold[]>,
|
||||
playing: LanePlaying,
|
||||
bus: string | undefined,
|
||||
tick: number,
|
||||
elapsed: number,
|
||||
): FxParams | undefined {
|
||||
const params = paramsAt(lanes, bus, tick);
|
||||
if (!params || bus === undefined || holds.size === 0) return params;
|
||||
const held: Record<string, number> = { ...params };
|
||||
for (const lane of lanes) {
|
||||
const name = paramOf(lane.target);
|
||||
const laneHolds = holds.get(holdKey(lane.bus, lane.target));
|
||||
if (name === null || !laneHolds || !reaches(lane.bus, bus)) continue;
|
||||
const { min, max } = laneRange(lane);
|
||||
held[name] = Math.max(min, Math.min(max, heldValueAt(lane, laneHolds, playing, elapsed)));
|
||||
}
|
||||
return held;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** The volume of a track of a song (#334), in decibels; at the lowest it is silent. */
|
||||
export const TRACK_MIN_DB = -60;
|
||||
export const TRACK_MAX_DB = 12;
|
||||
|
||||
export function dbToGain(db: number): number {
|
||||
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Markers of a song (#338): named spots on its time, where an event fires in the game and where a
|
||||
* switch to the song can start. A marker sits on a whole tick; two may share one, and so may their
|
||||
* names – a node names a marker by its id.
|
||||
*/
|
||||
import { newId } from '../ids';
|
||||
|
||||
export interface SongMarker {
|
||||
id: string;
|
||||
name: string;
|
||||
tick: number;
|
||||
}
|
||||
|
||||
const finite = (value: unknown) => (typeof value === 'number' && Number.isFinite(value) ? value : NaN);
|
||||
|
||||
/** Held to what a marker may be: a whole tick from 0, a name, an id no other has; in the order of their ticks. */
|
||||
export function heldSongMarkers(raw: unknown): SongMarker[] {
|
||||
const ids = new Set<string>();
|
||||
const markers: SongMarker[] = [];
|
||||
for (const entry of Array.isArray(raw) ? (raw as unknown[]) : []) {
|
||||
if (typeof entry !== 'object' || entry === null) continue;
|
||||
const m = entry as Record<string, unknown>;
|
||||
const tick = Math.round(finite(m.tick));
|
||||
if (!(tick >= 0)) continue;
|
||||
let id = typeof m.id === 'string' && m.id ? m.id : newId();
|
||||
while (ids.has(id)) id = newId();
|
||||
ids.add(id);
|
||||
const name = typeof m.name === 'string' ? m.name.trim() : '';
|
||||
markers.push({ id, name: name || `Marker ${markers.length + 1}`, tick });
|
||||
}
|
||||
return markers.sort((a, b) => a.tick - b.tick);
|
||||
}
|
||||
|
||||
/** "Marker 3": the first number no marker is called by yet. */
|
||||
export function nextMarkerName(markers: readonly SongMarker[]): string {
|
||||
const taken = new Set(markers.map((m) => m.name));
|
||||
let n = 1;
|
||||
while (taken.has(`Marker ${n}`)) n++;
|
||||
return `Marker ${n}`;
|
||||
}
|
||||
|
||||
export function newMarker(markers: readonly SongMarker[], tick: number, name?: string): SongMarker {
|
||||
return { id: newId(), name: name?.trim() || nextMarkerName(markers), tick: Math.max(0, Math.round(tick)) };
|
||||
}
|
||||
|
||||
/** The markers with one of them changed; the others as they are. */
|
||||
export function changedMarker(
|
||||
markers: readonly SongMarker[],
|
||||
id: string,
|
||||
change: Partial<Pick<SongMarker, 'name' | 'tick'>>,
|
||||
): SongMarker[] {
|
||||
return markers.map((m) => (m.id === id ? { ...m, ...change } : m));
|
||||
}
|
||||
|
||||
export function withoutMarker(markers: readonly SongMarker[], id: string): SongMarker[] {
|
||||
return markers.filter((m) => m.id !== id);
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import type { SendTap } from '../graph/Graph';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { BUS_FX_KEY, busVolumeOf, MASTER_BUS } from '../properties/buses';
|
||||
import {
|
||||
BUS_FX_KEY,
|
||||
busVolumeOf,
|
||||
MASTER_BUS,
|
||||
MASTER_MIX,
|
||||
mixBusName,
|
||||
TO_COMPOSITION_BUS,
|
||||
} from '../properties/buses';
|
||||
import { coerceAs } from '../properties/kinds';
|
||||
import type { FxVoice } from '../soundfx/nodes';
|
||||
import { fxVoiceOf } from './needs';
|
||||
@@ -9,8 +16,7 @@ import { sendOf } from './routing';
|
||||
import { dbToGain } from './song';
|
||||
import { DEFAULT_TEMPO } from './time';
|
||||
|
||||
/** What the sound library calls the master bus (#325); every other bus of the mix is `mix:<id>`. */
|
||||
export const MASTER_MIX = 'mix';
|
||||
export { MASTER_MIX, mixBusName };
|
||||
|
||||
/** One link of the master routing (#351) as the mix plays it: into that bus, at that level, from that tap. */
|
||||
export interface MixSend {
|
||||
@@ -36,11 +42,6 @@ export interface MixBus {
|
||||
fx: FxVoice | null;
|
||||
}
|
||||
|
||||
/** The name of a bus of the mix by the id of its asset; the master for '' and for `MASTER_BUS`. */
|
||||
export function mixBusName(busId: string): string {
|
||||
return busId === '' || busId === MASTER_BUS ? MASTER_MIX : `mix:${busId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an asset goes by the master routing (#351). One the routing has no node for goes into the
|
||||
* master, as every asset of a project written before there was a routing did. A link to what is
|
||||
@@ -59,10 +60,13 @@ export function routeOf(project: Project, assetId: string): MixRoute {
|
||||
return only && only.gain === 1 && only.tap === 'post' ? only.to : sends;
|
||||
}
|
||||
|
||||
const OWN_BUS_TYPES: readonly string[] = ['sound', 'instrument', 'video'];
|
||||
|
||||
/**
|
||||
* The master and every bus of the project (#325), each where the routing sends it (#351); and a bus
|
||||
* of its own for each sound and instrument the routing sends anywhere but straight into one bus, for
|
||||
* all its voices to go through. A sound FX that does not compile is none.
|
||||
* The master and every bus of the project (#325), each where the routing sends it (#351); a bus of
|
||||
* its own for each sound, instrument and video the routing sends anywhere but straight into one bus,
|
||||
* for all its voices to go through; and one for each composition, for its items. A sound FX that
|
||||
* does not compile is none.
|
||||
*/
|
||||
export function mixOf(project: Project): MixBus[] {
|
||||
const fxOf = (id: string) => fxVoiceOf(project, id, DEFAULT_TEMPO);
|
||||
@@ -78,7 +82,10 @@ export function mixOf(project: Project): MixBus[] {
|
||||
fx: fxOf(coerceAs(asset.props[BUS_FX_KEY], 'asset', '')),
|
||||
},
|
||||
];
|
||||
if ((asset.type !== 'sound' && asset.type !== 'instrument') || !isRouted(asset)) return [];
|
||||
// A composition's bus is where its items go (#418), whatever its routing.
|
||||
if (asset.type === 'composition')
|
||||
return [{ name: mixBusName(asset.id), volume: 1, ...routed(routeOf(project, asset.id)), fx: null }];
|
||||
if (!OWN_BUS_TYPES.includes(asset.type) || !isRouted(asset)) return [];
|
||||
const route = routeOf(project, asset.id);
|
||||
return typeof route === 'string'
|
||||
? []
|
||||
@@ -125,6 +132,18 @@ export function playedBus(project: Project, picked: string, assetId: string): st
|
||||
return typeof route === 'string' ? route : mixBusName(assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bus the sound of each video file is heard on, by the routing of its asset: a player knows
|
||||
* the file it plays, not the asset. Where two videos share a file, the first one says.
|
||||
*/
|
||||
export function videoBusesOf(project: Project): Map<string, string> {
|
||||
const buses = new Map<string, string>();
|
||||
for (const asset of project.assets)
|
||||
if (asset.type === 'video' && asset.file !== '' && !buses.has(asset.file))
|
||||
buses.set(asset.file, playedBus(project, '', asset.id));
|
||||
return buses;
|
||||
}
|
||||
|
||||
/** Where a playing of a pattern or a song goes: the bus a node picked, else along its routing (#351). */
|
||||
export function playedRoute(project: Project, picked: string, assetId: string): MixRoute {
|
||||
if (picked === MASTER_BUS) return MASTER_MIX;
|
||||
@@ -132,6 +151,14 @@ export function playedRoute(project: Project, picked: string, assetId: string):
|
||||
return routeOf(project, assetId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where an audio item of a composition goes: into the composition's bus for `TO_COMPOSITION_BUS`,
|
||||
* else as a node's pick (`playedRoute`) – along the routing of what it plays where it picks none.
|
||||
*/
|
||||
export function itemRoute(project: Project, compId: string, picked: string, assetId: string): MixRoute {
|
||||
return picked === TO_COMPOSITION_BUS ? mixBusName(compId) : playedRoute(project, picked, assetId);
|
||||
}
|
||||
|
||||
/** The bus of the mix a node reaches where an empty pick is the master: Set bus volume, Bus volume. */
|
||||
export function reachedBus(project: Project, picked: string): string | null {
|
||||
if (picked === '' || picked === MASTER_BUS) return MASTER_MIX;
|
||||
|
||||
@@ -22,9 +22,9 @@ export function fxVoiceOf(
|
||||
return plan ? { plan, params, tempo } : null;
|
||||
}
|
||||
|
||||
/** The sound FX a clip goes through. */
|
||||
export function clipFx(project: Project, fxId: string, tempo: number): FxVoice {
|
||||
return fxVoiceOf(project, fxId, tempo) ?? DRY_VOICE;
|
||||
/** The sound FX a clip goes through, with the values its parameters start at. */
|
||||
export function clipFx(project: Project, fxId: string, tempo: number, params: FxParams = {}): FxVoice {
|
||||
return fxVoiceOf(project, fxId, tempo, params) ?? DRY_VOICE;
|
||||
}
|
||||
|
||||
/** Every layer and FX of every note and every file and FX of every clip a sequence plays. */
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* (`preSkip`) and the last page how many there are, so a decoder gives back exactly what went in,
|
||||
* to the sample.
|
||||
*/
|
||||
import { concatBytes } from '../util/bytes';
|
||||
|
||||
/** Opus always counts at 48 kHz, whatever the rate of what went in. */
|
||||
export const OPUS_RATE = 48000;
|
||||
@@ -25,7 +26,7 @@ export interface OpusStream {
|
||||
/** What a page holds at most: 255 lacing values, and so at most a packet of about 64 kB. */
|
||||
const MAX_SEGMENTS = 255;
|
||||
const SERIAL = 0x6d616b65;
|
||||
const VENDOR = 'game-maker';
|
||||
const VENDOR = 'feuerwerk';
|
||||
|
||||
const CRC_TABLE = (() => {
|
||||
const table = new Uint32Array(256);
|
||||
@@ -101,7 +102,7 @@ export function oggOpus(stream: OpusStream): Uint8Array {
|
||||
if (i === stream.packets.length - 1) page(held, end, 0x04);
|
||||
});
|
||||
if (stream.packets.length === 0) page([], end, 0x04);
|
||||
return concat(pages);
|
||||
return concatBytes(pages);
|
||||
}
|
||||
|
||||
function oggPage(
|
||||
@@ -115,7 +116,7 @@ function oggPage(
|
||||
for (let left = packet.length; left >= 255; left -= 255) lacing.push(255);
|
||||
lacing.push(packet.length % 255);
|
||||
}
|
||||
const body = concat(packets);
|
||||
const body = concatBytes(packets);
|
||||
const page = new Uint8Array(27 + lacing.length + body.length);
|
||||
const view = new DataView(page.buffer);
|
||||
page.set(ascii('OggS'));
|
||||
@@ -179,7 +180,7 @@ export class OggOpusReader {
|
||||
private next = 0;
|
||||
|
||||
push(bytes: Uint8Array): void {
|
||||
let data = this.held.length > 0 ? concat([this.held, bytes]) : bytes;
|
||||
let data = this.held.length > 0 ? concatBytes([this.held, bytes]) : bytes;
|
||||
let at = 0;
|
||||
while (data.length - at >= 27) {
|
||||
if (text(data.subarray(at, at + 4)) !== 'OggS') throw new Error(`no page at byte ${at}`);
|
||||
@@ -209,7 +210,7 @@ export class OggOpusReader {
|
||||
this.open.push(page.subarray(from, from + value));
|
||||
from += value;
|
||||
if (value < 255) {
|
||||
this.packet(concat(this.open));
|
||||
this.packet(concatBytes(this.open));
|
||||
this.open = [];
|
||||
}
|
||||
}
|
||||
@@ -254,13 +255,3 @@ function ascii(value: string): Uint8Array {
|
||||
function text(bytes: Uint8Array): string {
|
||||
return String.fromCharCode(...bytes);
|
||||
}
|
||||
|
||||
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
||||
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
||||
let at = 0;
|
||||
for (const part of parts) {
|
||||
out.set(part, at);
|
||||
at += part.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||
import type { PatternAsset } from '../model/Asset';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { heldLoop, type LoopRange, type Sequence } from './sequence';
|
||||
import { type Swing, swungNote } from './swing';
|
||||
import { DEFAULT_SIGN, heldTempo, type Meter, signMeter, ticksPerBar } from './time';
|
||||
|
||||
export interface PatternNote {
|
||||
@@ -26,6 +27,8 @@ export const PATTERN_BARS_KEY = 'bars';
|
||||
export const PATTERN_GRID_KEY = 'grid';
|
||||
export const PATTERN_SIGN_KEY = 'sign';
|
||||
export const PATTERN_TEMPO_KEY = 'tempo';
|
||||
export const PATTERN_SWING_KEY = 'swing';
|
||||
export const PATTERN_SWING_GRID_KEY = 'swingGrid';
|
||||
|
||||
export const DEFAULT_BARS = 4;
|
||||
export const MAX_BARS = 256;
|
||||
@@ -49,6 +52,10 @@ const GRIDS: readonly (readonly [string, number])[] = [
|
||||
|
||||
export const GRID_OPTIONS = GRIDS.map(([value]) => ({ value, label: value }));
|
||||
|
||||
/** What swing (#372) pairs up: eighths or sixteenths. */
|
||||
export const SWING_GRID_OPTIONS = ['1/8', '1/16'].map((value) => ({ value, label: value }));
|
||||
export const DEFAULT_SWING_GRID = '1/8';
|
||||
|
||||
export function gridTicks(grid: string): number {
|
||||
return (GRIDS.find(([value]) => value === grid) ?? GRIDS[4]!)[1];
|
||||
}
|
||||
@@ -84,6 +91,29 @@ export function patternInstrument(props: PropertyBag): string {
|
||||
return textOf(props[PATTERN_INSTRUMENT_KEY] ?? '');
|
||||
}
|
||||
|
||||
/** The swing it is played with (#372); null without, so a pattern with none plays as it always did. */
|
||||
export function patternSwing(props: PropertyBag): Swing | null {
|
||||
const percent = props[PATTERN_SWING_KEY];
|
||||
const amount = typeof percent === 'number' && Number.isFinite(percent) ? Math.min(1, percent / 100) : 0;
|
||||
if (!(amount > 0)) return null;
|
||||
const grid = textOf(props[PATTERN_SWING_GRID_KEY] ?? DEFAULT_SWING_GRID) === '1/16' ? '1/16' : '1/8';
|
||||
return { amount, pair: 2 * gridTicks(grid), bar: ticksPerBar(patternMeter(props)) };
|
||||
}
|
||||
|
||||
/**
|
||||
* A note of the pattern as the scheduler plays it: with swing heard `late` ticks after its place and
|
||||
* held as long as its swung end says; without, as it is.
|
||||
*/
|
||||
export function playedNote(
|
||||
tick: number,
|
||||
length: number,
|
||||
swing: Swing | null,
|
||||
): { length: number; late?: number } {
|
||||
if (!swing) return { length };
|
||||
const swung = swungNote(tick, length, swing);
|
||||
return swung.late > 0 ? swung : { length: swung.length };
|
||||
}
|
||||
|
||||
/** Where the pattern ends, in ticks: a note starting there or later is kept, but not played. */
|
||||
export function patternLength(asset: PatternAsset): number {
|
||||
return patternBars(asset.props) * ticksPerBar(patternMeter(asset.props));
|
||||
@@ -102,12 +132,13 @@ export function patternSequence(asset: PatternAsset): Sequence {
|
||||
const instrument = patternInstrument(asset.props);
|
||||
if (!instrument) return { notes: [] };
|
||||
const length = patternLength(asset);
|
||||
const swing = patternSwing(asset.props);
|
||||
return {
|
||||
notes: asset.notes
|
||||
.filter((n) => n.tick < length)
|
||||
.map((n) => ({
|
||||
tick: n.tick,
|
||||
length: Math.min(n.length, length - n.tick),
|
||||
...playedNote(n.tick, Math.min(n.length, length - n.tick), swing),
|
||||
note: n.note,
|
||||
velocity: n.velocity,
|
||||
instrument,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { SendTap } from '../graph/Graph';
|
||||
import type { FxPlan, FxVoice } from '../soundfx/nodes';
|
||||
import type { AutomationTarget } from './automation';
|
||||
import type { BakePlayback } from './bake';
|
||||
import type { MixSend } from './mixer';
|
||||
import { LEAD, type ScheduledClip, type ScheduledNote, Scheduler, type SchedulerHost } from './Scheduler';
|
||||
import {
|
||||
LEAD,
|
||||
type ScheduledAutomation,
|
||||
type ScheduledClip,
|
||||
type ScheduledNote,
|
||||
type ScheduledRamp,
|
||||
Scheduler,
|
||||
type SchedulerHost,
|
||||
} from './Scheduler';
|
||||
import type { LoopRange, Sequence } from './sequence';
|
||||
import { ticksOfSeconds } from './time';
|
||||
|
||||
/** What a sequence is made of: loaded before the first of its notes and clips is planned. */
|
||||
export interface SequenceNeeds {
|
||||
@@ -22,6 +32,11 @@ export interface BusLevel {
|
||||
sends?: readonly BusSend[];
|
||||
/** The sound FX all of it goes through as one chain (#346), before its level and pan. */
|
||||
fx?: FxVoice;
|
||||
/**
|
||||
* What its automation sets while it plays (#337): `gain`, `pan` and a parameter of `fx` are then
|
||||
* only where it starts.
|
||||
*/
|
||||
automated?: readonly AutomationTarget[];
|
||||
}
|
||||
|
||||
/** One send of a track (#350): into another track's bus, or the output of the song where `to` is left out. */
|
||||
@@ -54,8 +69,22 @@ export interface SequencePlay {
|
||||
/** Starts one note as the player was asked to, and gives back the number of its voice. */
|
||||
strike(note: ScheduledNote): number;
|
||||
strikeClip?(clip: ScheduledClip): number;
|
||||
/** Ramps the level of one of its buses (#418). */
|
||||
ramp?(ramp: ScheduledRamp): void;
|
||||
/** Takes one of its buses along its automation (#337). */
|
||||
automate?(automation: ScheduledAutomation): void;
|
||||
/** Its bake (#340), played in place of its notes and clips where the host can. */
|
||||
bake?: BakePlay;
|
||||
/**
|
||||
* Where it starts, in ticks, as heard `LEAD` from now: a composition's sound (#418). Where it first
|
||||
* waits for its files, it starts as much further in, to keep to the clock it was asked for by.
|
||||
*/
|
||||
from?: number;
|
||||
/**
|
||||
* The second of the audio clock it is heard from (#338), where that is still to come once its
|
||||
* files are there: a stinger in time with another playing (`Playbacks.landOn`).
|
||||
*/
|
||||
at?: number;
|
||||
}
|
||||
|
||||
/** A bake as it is played: its file, the level it is heard at, and which stretches of it. */
|
||||
@@ -72,27 +101,64 @@ export interface Runner {
|
||||
readonly startedAt: number | null;
|
||||
/** Played to its end, and nothing of it is heard any more. */
|
||||
readonly done: boolean;
|
||||
start(from?: number): void;
|
||||
/** Plays from `from` ticks, heard from `at` on the audio clock where that is still to come (#338). */
|
||||
start(from?: number, at?: number): void;
|
||||
stop(letRing?: boolean): void;
|
||||
}
|
||||
|
||||
export interface PlaybackHost extends Omit<SchedulerHost, 'play' | 'playClip' | 'ahead'> {
|
||||
/** What a change a game asks of a playing goes by (#338): its time, where it started, the clock now. */
|
||||
export interface PlayingClock {
|
||||
time: PlayingTime;
|
||||
/** The second of the audio clock its playing time 0 is heard at; null before it starts. */
|
||||
startedAt: number | null;
|
||||
now: number;
|
||||
/** The soonest second of the audio clock the change can be heard from. */
|
||||
soonest: number;
|
||||
}
|
||||
|
||||
/** The second of the audio clock a change to a playing lands at, from its clock (#338). */
|
||||
export type LandingAt = (clock: PlayingClock) => number;
|
||||
|
||||
/** Seconds a hard cut between two playings takes, so it does not click. */
|
||||
export const CUT = 0.005;
|
||||
|
||||
/**
|
||||
* The audio side of the playings. Each is reached here by its `key`, the number its buses go by: the
|
||||
* same as its `id` but where it took over another one's (#338).
|
||||
*/
|
||||
export interface PlaybackHost extends Omit<
|
||||
SchedulerHost,
|
||||
'play' | 'playClip' | 'ahead' | 'ramp' | 'automate'
|
||||
> {
|
||||
/** Loads what that playing needs and makes its buses ready, their sound FX among them (#346). */
|
||||
prepare(needs: SequenceNeeds, id: number): Promise<void>;
|
||||
prepare(needs: SequenceNeeds, key: number): Promise<void>;
|
||||
/** How far ahead of its time a note of that playing may be started (see `SchedulerHost.ahead`). */
|
||||
ahead?(id: number): number;
|
||||
ahead?(key: number): number;
|
||||
/** The level of a playing, from where it is at `at` to `gain`, reached `seconds` later. */
|
||||
level(id: number, gain: number, at: number, seconds: number): void;
|
||||
level(key: number, gain: number, at: number, seconds: number): void;
|
||||
/** Calls `then` once the audio clock reaches `at`. */
|
||||
after(at: number, then: () => void): void;
|
||||
/** A playing is gone: stopped, or played to its end. */
|
||||
closed?(id: number): void;
|
||||
closed?(key: number): void;
|
||||
/**
|
||||
* Nothing of the playings numbered `id` is left (#373): played to their end, or `stopped`. `name`
|
||||
* is that of the one heard last, a Switch song's in place of the one it took over from.
|
||||
*/
|
||||
ended?(id: number, name: string, stopped: boolean): void;
|
||||
/** Starts the bake of that playing to be heard from `at`, as one voice; 0 where nothing plays. */
|
||||
playBaked?(id: number, bake: BakePlay, at: number): number;
|
||||
playBaked?(key: number, bake: BakePlay, at: number): number;
|
||||
}
|
||||
|
||||
export interface Playback {
|
||||
id: number;
|
||||
/** What its buses go by; its `id` but where it took over from another playing (#338). */
|
||||
key: number;
|
||||
/** The level of the whole of it, as it was asked for. */
|
||||
volume: number;
|
||||
/** The second of the audio clock another playing takes over from it at (#338); null while it is the one. */
|
||||
until: number | null;
|
||||
/** It takes over from another playing of its number, once it is heard (#338). */
|
||||
successor: boolean;
|
||||
/** What stands for the pattern or song, as a file stands for a sound: `pattern:<id>`, `song:<id>`. */
|
||||
name: string;
|
||||
owner: string;
|
||||
@@ -104,6 +170,28 @@ export interface Playback {
|
||||
fading: number | null;
|
||||
/** Played from its bake (#340) rather than its notes. */
|
||||
baked: boolean;
|
||||
/** How fast, round what and to where it plays, and from where it started (#338). */
|
||||
time: PlayingTime;
|
||||
/** A stop reached it (#373), rather than it playing to its end. */
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
/** The time of a playing (#338): its tempo, where it started, its loop and its length, in ticks. */
|
||||
export interface PlayingTime {
|
||||
tempo: number;
|
||||
from: number;
|
||||
loop: LoopRange | null;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/** A playing and how far into its playing time it is heard now, null before it starts (#338). */
|
||||
export interface HeardTime extends PlayingTime {
|
||||
id: number;
|
||||
/** Told apart from the playing of its number it took over from (`Playback.key`). */
|
||||
key: number;
|
||||
name: string;
|
||||
heard: number | null;
|
||||
baked: boolean;
|
||||
}
|
||||
|
||||
/** Whether a playing goes by `name`: its own, every one of a kind for `kind:`, every one for `''`. */
|
||||
@@ -124,6 +212,155 @@ export class Playbacks {
|
||||
|
||||
/** Starts once its files are there; a stop that came first wins. */
|
||||
start(id: number, name: string, play: SequencePlay, owner: string): void {
|
||||
const playback = this.make(id, id, name, play, owner, false);
|
||||
const asked = this.host.now();
|
||||
void this.host.prepare(play.needs, id).then(() => {
|
||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||
const from = play.from;
|
||||
playback.time.from =
|
||||
from === undefined ? 0 : Math.round(from + ticksOfSeconds(this.host.now() - asked, play.tempo));
|
||||
playback.runner.start(playback.time.from, play.at);
|
||||
// From the first note heard, which is later where the buses are (#346).
|
||||
const at = playback.runner.startedAt ?? this.host.now() + LEAD;
|
||||
this.host.level(id, playback.volume, at, Math.max(0, play.fadeIn ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays `play` in place of the playing numbered `id` (#338), on its buses `key`, from `play.from`
|
||||
* ticks: once its files are there, it is heard from the second `at` names for the playing it takes
|
||||
* over from, which falls silent there – over `fade` seconds as this one rises over them, else in a
|
||||
* cut. It keeps the number, so whatever knows the playing goes on reaching it. Asked again before
|
||||
* that, the one still waiting is dropped and the playing heard now is the one to go. Where nothing
|
||||
* of that number plays any more, it starts as soon as it can.
|
||||
*/
|
||||
switchTo(
|
||||
id: number,
|
||||
key: number,
|
||||
name: string,
|
||||
play: SequencePlay,
|
||||
owner: string,
|
||||
at: LandingAt,
|
||||
fade: number,
|
||||
): void {
|
||||
const now = this.host.now();
|
||||
const waiting = this.list.filter((p) => p.id === id && this.waits(p, now));
|
||||
this.close(waiting, false);
|
||||
for (const p of this.list)
|
||||
if (p.id === id && p.until !== null && p.until > now) {
|
||||
p.until = null;
|
||||
p.fading = null;
|
||||
this.host.level(p.key, p.volume, now, 0);
|
||||
}
|
||||
const playback = this.make(id, key, name, play, owner, true);
|
||||
// What a game holds of the lanes stays held: a lane of the same song is there again.
|
||||
const heard = this.list.find((p) => p.id === id && p !== playback && p.until === null);
|
||||
if (heard?.runner instanceof Scheduler && playback.runner instanceof Scheduler)
|
||||
playback.runner.keepHolds(heard.runner);
|
||||
void this.host.prepare(play.needs, key).then(() => {
|
||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||
const from = this.heardOne(id, playback);
|
||||
const now = this.host.now();
|
||||
const ahead = this.host.ahead?.(key) ?? 0;
|
||||
const soonest = now + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
|
||||
const clock = from && { time: from.time, startedAt: from.runner.startedAt, now, soonest };
|
||||
playback.time.from = Math.max(0, Math.round(play.from ?? 0));
|
||||
playback.runner.start(playback.time.from, clock ? at(clock) : undefined);
|
||||
const start = playback.runner.startedAt ?? soonest;
|
||||
const over = fade > 0 ? fade : 0;
|
||||
if (from) this.handOver(from, start, Math.max(over, CUT));
|
||||
this.host.level(key, playback.volume, start, over);
|
||||
});
|
||||
}
|
||||
|
||||
/** The playing of that number heard now, but `besides`: the one a switch takes over from. */
|
||||
private heardOne(id: number, besides: Playback): Playback | null {
|
||||
return this.current().find((p) => p.id === id && p !== besides && p.until === null) ?? null;
|
||||
}
|
||||
|
||||
/** Falls silent from `at` over `fade` seconds and goes then: the one taken over from (#338). */
|
||||
private handOver(playback: Playback, at: number, fade: number): void {
|
||||
const end = at + fade;
|
||||
playback.until = at;
|
||||
playback.fading = end;
|
||||
this.host.level(playback.key, 0, at, fade);
|
||||
this.host.after(end, () => {
|
||||
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
|
||||
});
|
||||
}
|
||||
|
||||
/** The clock of the playing of that number heard now (#338), and whether it is a bake; null where none is. */
|
||||
clockOf(id: number): (PlayingClock & { baked: boolean }) | null {
|
||||
const playing = this.current().find((p) => p.id === id && p.until === null && !this.waits(p));
|
||||
if (!playing) return null;
|
||||
const now = this.host.now();
|
||||
const { time, baked } = playing;
|
||||
return { time, startedAt: playing.runner.startedAt, now, soonest: now + CUT, baked };
|
||||
}
|
||||
|
||||
/**
|
||||
* The second of the audio clock `at` names on the clock of the playing of that number heard now
|
||||
* (#338), no sooner than something started now can be heard: where a stinger in time with it
|
||||
* starts. Null where none is heard.
|
||||
*/
|
||||
landOn(id: number, at: LandingAt): number | null {
|
||||
const clock = this.clockOf(id);
|
||||
if (!clock) return null;
|
||||
const soonest = clock.now + LEAD;
|
||||
return Math.max(soonest, at({ ...clock, soonest }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds a lane of the playing of that number on `value` from the second `at` (#338), as
|
||||
* `Scheduler.holdLane` does: the one heard now and one waiting to take over from it alike. Whether
|
||||
* any of them has that lane; a bake has none.
|
||||
*/
|
||||
holdLane(
|
||||
id: number,
|
||||
bus: string,
|
||||
target: AutomationTarget,
|
||||
value: number,
|
||||
at: number,
|
||||
fade: number,
|
||||
): boolean {
|
||||
let found = false;
|
||||
for (const p of this.named(id))
|
||||
if (p.runner instanceof Scheduler && p.runner.holdLane(bus, target, value, at, fade)) found = true;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** Lets a held lane of the playing of that number go from the second `at` (#338), see `Scheduler.releaseLane`. */
|
||||
releaseLane(id: number, bus: string, target: AutomationTarget, at: number, fade: number): boolean {
|
||||
let found = false;
|
||||
for (const p of this.named(id))
|
||||
if (p.runner instanceof Scheduler && p.runner.releaseLane(bus, target, at, fade)) found = true;
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The playings of that number, the one heard now and one waiting to take over from it (#338). */
|
||||
named(id: number): readonly Readonly<Playback>[] {
|
||||
return this.current().filter((p) => p.id === id);
|
||||
}
|
||||
|
||||
/** Whether the buses `key` belong to a playing still to be heard. */
|
||||
holds(key: number): boolean {
|
||||
return this.current().some((playback) => playback.key === key);
|
||||
}
|
||||
|
||||
/** A successor not heard yet: waiting on its files, or on the second it takes over at. */
|
||||
private waits(playback: Playback, now = this.host.now()): boolean {
|
||||
const at = playback.runner.startedAt;
|
||||
return playback.successor && (at === null || at > now);
|
||||
}
|
||||
|
||||
private make(
|
||||
id: number,
|
||||
key: number,
|
||||
name: string,
|
||||
play: SequencePlay,
|
||||
owner: string,
|
||||
successor: boolean,
|
||||
): Playback {
|
||||
const planned: ScheduledNote[] = [];
|
||||
const clips: ScheduledClip[] = [];
|
||||
const keep = <T>(list: T[], item: T) => {
|
||||
@@ -131,24 +368,27 @@ export class Playbacks {
|
||||
if (list.length > KEPT) list.shift();
|
||||
};
|
||||
const strikeClip = play.strikeClip;
|
||||
const ahead = () => this.host.ahead?.(id) ?? 0;
|
||||
const ahead = () => this.host.ahead?.(key) ?? 0;
|
||||
const { bake } = play;
|
||||
const playBaked = this.host.playBaked?.bind(this.host);
|
||||
const runner: Runner =
|
||||
bake && playBaked
|
||||
? new BakedRun(this.host, ahead, (at) => playBaked(id, bake, at), bake.playback.end !== null)
|
||||
? new BakedRun(this.host, ahead, (at) => playBaked(key, bake, at), bake.playback.end !== null)
|
||||
: new Scheduler(
|
||||
{
|
||||
...this.host,
|
||||
ahead,
|
||||
play: (note) => (keep(planned, note), play.strike(note)),
|
||||
...(strikeClip && { playClip: (clip: ScheduledClip) => (keep(clips, clip), strikeClip(clip)) }),
|
||||
...(play.ramp && { ramp: play.ramp }),
|
||||
...(play.automate && { automate: play.automate }),
|
||||
},
|
||||
() => play.sequence,
|
||||
{ tempo: play.tempo, loop: play.loop, end: play.length },
|
||||
);
|
||||
const playback: Playback = {
|
||||
id,
|
||||
key,
|
||||
name,
|
||||
owner,
|
||||
runner,
|
||||
@@ -156,15 +396,14 @@ export class Playbacks {
|
||||
clips,
|
||||
fading: null,
|
||||
baked: runner instanceof BakedRun,
|
||||
volume: play.volume ?? 1,
|
||||
until: null,
|
||||
successor,
|
||||
time: { tempo: play.tempo, from: 0, loop: play.loop, length: play.length },
|
||||
stopped: false,
|
||||
};
|
||||
this.list.push(playback);
|
||||
void this.host.prepare(play.needs, id).then(() => {
|
||||
if (!this.list.includes(playback) || playback.fading !== null) return;
|
||||
runner.start(0);
|
||||
// From the first note heard, which is later where the buses are (#346).
|
||||
const at = runner.startedAt ?? this.host.now() + LEAD;
|
||||
this.host.level(id, play.volume ?? 1, at, Math.max(0, play.fadeIn ?? 0));
|
||||
});
|
||||
return playback;
|
||||
}
|
||||
|
||||
/** Whether that one is still to be heard: waiting on its files, playing, fading, or its last notes ringing. */
|
||||
@@ -172,6 +411,27 @@ export class Playbacks {
|
||||
return this.current().some((playback) => playback.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every playing with how far into its playing time it is heard now: the audio clock less the
|
||||
* output latency, from the moment its first note is heard (#338). With none playing the clock is
|
||||
* not asked: asking for it makes the audio context, which a page may make only after a gesture.
|
||||
*/
|
||||
times(): HeardTime[] {
|
||||
const list = this.current();
|
||||
if (list.length === 0) return [];
|
||||
const heard = this.host.now() - this.host.latency();
|
||||
// Of a switch (#338) one is heard at a time: the old one up to where the new one is.
|
||||
const told = list.filter(
|
||||
({ until, successor, runner }) =>
|
||||
(until === null || heard < until) &&
|
||||
(!successor || (runner.startedAt !== null && heard >= runner.startedAt)),
|
||||
);
|
||||
return told.map(({ id, key, name, runner, time, baked }) => {
|
||||
const at = runner.startedAt;
|
||||
return { id, key, name, ...time, heard: at === null ? null : heard - at, baked };
|
||||
});
|
||||
}
|
||||
|
||||
/** Those that go by `name` (see `goesBy`); with `fade` they fall silent over that many seconds first. */
|
||||
stopNamed(name: string, letRing = false, fade = 0): void {
|
||||
this.stopWhere((playback) => goesBy(playback.name, name), letRing, fade);
|
||||
@@ -195,13 +455,14 @@ export class Playbacks {
|
||||
|
||||
private stopWhere(going: (playback: Playback) => boolean, letRing: boolean, fade: number): void {
|
||||
const stopping = this.list.filter(going);
|
||||
for (const playback of stopping) playback.stopped = true;
|
||||
if (!(fade > 0)) return this.close(stopping, letRing);
|
||||
const now = this.host.now();
|
||||
const end = now + fade;
|
||||
for (const playback of stopping) {
|
||||
if (playback.fading !== null && playback.fading <= end) continue;
|
||||
playback.fading = end;
|
||||
this.host.level(playback.id, 0, now, fade);
|
||||
this.host.level(playback.key, 0, now, fade);
|
||||
this.host.after(end, () => {
|
||||
if (this.list.includes(playback) && playback.fading === end) this.close([playback], false);
|
||||
});
|
||||
@@ -212,10 +473,27 @@ export class Playbacks {
|
||||
private close(going: readonly Playback[], letRing: boolean): void {
|
||||
if (going.length === 0) return;
|
||||
this.list = this.list.filter((playback) => !going.includes(playback));
|
||||
const ended = this.endedOf(going);
|
||||
for (const playback of going) {
|
||||
playback.runner.stop(letRing);
|
||||
this.host.closed?.(playback.id);
|
||||
this.host.closed?.(playback.key);
|
||||
}
|
||||
for (const { id, name, stopped } of ended) this.host.ended?.(id, name, stopped);
|
||||
}
|
||||
|
||||
/**
|
||||
* The numbers nothing is left of once `going` goes (#373). Of a switch (#338) the one taken over
|
||||
* from goes first and the number lives on in the other; where both go, it is named by the newer
|
||||
* one if that had started.
|
||||
*/
|
||||
private endedOf(going: readonly Playback[]): { id: number; name: string; stopped: boolean }[] {
|
||||
return [...new Set(going.map((playback) => playback.id))].flatMap((id) => {
|
||||
if (this.list.some((playback) => playback.id === id)) return [];
|
||||
const ones = going.filter((playback) => playback.id === id);
|
||||
const started = ones.filter((playback) => !playback.successor || playback.runner.startedAt !== null);
|
||||
const last = started[started.length - 1] ?? ones[0]!;
|
||||
return [{ id, name: last.name, stopped: ones.some((playback) => playback.stopped) }];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,10 +524,11 @@ class BakedRun implements Runner {
|
||||
return this.anchor !== null && this.ends && !this.host.playing(this.voice);
|
||||
}
|
||||
|
||||
start(): void {
|
||||
/** From its start: a bake plays from nowhere else. Heard from `at` where that is still to come. */
|
||||
start(_from?: number, at = -Infinity): void {
|
||||
this.stop();
|
||||
const ahead = this.ahead();
|
||||
this.anchor = this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0);
|
||||
this.anchor = Math.max(this.host.now() + LEAD + (Number.isFinite(ahead) && ahead > 0 ? ahead : 0), at);
|
||||
this.voice = this.play(this.anchor);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song and
|
||||
* bus, and the master, its links the sends from one to the next. Its nodes follow the assets: one
|
||||
* that comes gets a node of its own and the links most others of its type have, one that goes takes
|
||||
* its node along.
|
||||
* The master routing of a project (#351): a graph with a node per sound, instrument, pattern, song,
|
||||
* video with sound, composition and bus, and the master, its links the sends from one to the next.
|
||||
* Its nodes follow the assets: one that comes gets a node of its own and the links most others of
|
||||
* its type have, one that goes takes its node along.
|
||||
*/
|
||||
import { createEmptyGraph, type Graph, type GraphEdge, type GraphNode } from '../graph/Graph';
|
||||
import { newId } from '../ids';
|
||||
@@ -16,7 +16,15 @@ import { isEmbedded } from './embedded';
|
||||
import { addLink, heldSend } from './routing';
|
||||
import { ROUTED_ASSET, ROUTED_KIND, ROUTED_MASTER, ROUTED_NAME } from './routingNodes';
|
||||
|
||||
const ROUTED_TYPES: readonly AssetType[] = ['sound', 'instrument', 'pattern', 'song', 'bus'];
|
||||
const ROUTED_TYPES: readonly AssetType[] = [
|
||||
'sound',
|
||||
'instrument',
|
||||
'pattern',
|
||||
'song',
|
||||
'video',
|
||||
'composition',
|
||||
'bus',
|
||||
];
|
||||
/** What has its own sound FX and level on a bus of its own, and so may send from before them. */
|
||||
const TAPPED_TYPES: readonly AssetType[] = ['bus', 'song'];
|
||||
|
||||
@@ -29,8 +37,9 @@ const ROW_STEP = 80;
|
||||
const COLUMN_OF = (type: string) => (type === 'bus' ? 1 : 0);
|
||||
const MASTER_COLUMN = 2;
|
||||
|
||||
/** A video only where it has sound: one without has nothing to send. */
|
||||
export function isRouted(asset: Asset): boolean {
|
||||
return ROUTED_TYPES.includes(asset.type) && !isEmbedded(asset);
|
||||
return ROUTED_TYPES.includes(asset.type) && !isEmbedded(asset) && (asset.type !== 'video' || asset.audio);
|
||||
}
|
||||
|
||||
/** Whether links out of an asset of this type may take the sound before its sound FX or its level. */
|
||||
@@ -132,9 +141,11 @@ export function heldRouting(graph: Graph | undefined, assets: readonly Asset[],
|
||||
}
|
||||
|
||||
/**
|
||||
* The links a new sound, instrument, pattern or song takes, so it is heard where the others are: those
|
||||
* of the one whose targets most others of its type share too – the latest of them on a tie –, or of
|
||||
* all that plays where nothing of its type is linked yet. Empty where nothing is, and for a bus.
|
||||
* The links a new sound, instrument, pattern, song, video or composition takes, so it is heard where
|
||||
* the others are: those of the one whose targets most others of its type share too – the latest of
|
||||
* them on a tie –, or of all that plays where nothing of its type is linked yet. Videos, compositions
|
||||
* and the music go by each other apart: a video where no other is linked goes to the master, not
|
||||
* where the sounds go, and so does a composition, a mix of its own. Empty where nothing is, and for a bus.
|
||||
*/
|
||||
function linksLikeTheirs(
|
||||
kind: string,
|
||||
@@ -159,12 +170,17 @@ function linksLikeTheirs(
|
||||
};
|
||||
const playing = nodes.filter((node) => {
|
||||
const of = routedKind(node);
|
||||
return of !== '' && of !== 'bus' && out.has(node.id);
|
||||
return of !== '' && of !== 'bus' && peersOf(of) === peersOf(kind) && out.has(node.id);
|
||||
});
|
||||
const own = playing.filter((node) => routedKind(node) === kind);
|
||||
return pick(own.length > 0 ? own : playing);
|
||||
}
|
||||
|
||||
/** Whose links a new node may take: those of its own kind, else of what plays alike. */
|
||||
function peersOf(kind: string): string {
|
||||
return kind === 'video' || kind === 'composition' ? kind : 'music';
|
||||
}
|
||||
|
||||
/** A link like another, out of a new node: as loud, and after the sound FX where it cannot tap before. */
|
||||
function copiedLink(edge: GraphEdge, from: string, kind: string): GraphEdge {
|
||||
const raw = heldSend(edge.send);
|
||||
|
||||
@@ -41,8 +41,9 @@ export function isSongRoutingDef(def: NodeDef): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* The nodes of the master routing (#351): one per sound, instrument, pattern, song and bus of the
|
||||
* project, named and typed after its asset, and the master. Only a bus and the master take links in.
|
||||
* The nodes of the master routing (#351): one per sound, instrument, pattern, song, video with sound,
|
||||
* composition and bus of the project, named and typed after its asset, and the master. Only a bus and the master
|
||||
* take links in.
|
||||
*/
|
||||
export const ROUTED_ASSET = 'RoutedAsset';
|
||||
export const ROUTED_MASTER = 'RoutedMaster';
|
||||
@@ -54,6 +55,8 @@ const KIND_WORDS: Record<string, string> = {
|
||||
instrument: 'Instrument',
|
||||
pattern: 'Pattern',
|
||||
song: 'Song',
|
||||
video: 'Video',
|
||||
composition: 'Composition',
|
||||
bus: 'Bus',
|
||||
};
|
||||
|
||||
@@ -62,7 +65,7 @@ registerNode({
|
||||
label: 'Asset',
|
||||
category: 'sound',
|
||||
description:
|
||||
'A sound, an instrument, a pattern, a song or a bus of the project: all it plays, sent on to every link.',
|
||||
'A sound, an instrument, a pattern, a song, a video, a composition or a bus of the project: all it plays, sent on to every link.',
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
params: [],
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* ticks of playing time – `elapsed` since the start, which only ever grows – and of the sequence –
|
||||
* where the playhead stands, which jumps back at the end of a loop.
|
||||
*/
|
||||
import type { FadeCurve } from '../model/sound';
|
||||
import type { SequenceLane } from './automation';
|
||||
|
||||
export interface SequenceNote {
|
||||
/** Where it starts in the sequence, and how long it is held, in ticks. */
|
||||
@@ -19,6 +21,13 @@ export interface SequenceNote {
|
||||
volume?: number;
|
||||
/** The track of a song it is heard on (#334); straight out where left out. */
|
||||
bus?: string;
|
||||
/** What a Delay synced to beats counts by, where it is not the sequence's: a pattern in a composition (#418). */
|
||||
tempo?: number;
|
||||
/**
|
||||
* How many ticks after `tick` it is heard: the swing of its pattern (#372). Where it lies in the
|
||||
* sequence and its loop is still `tick`.
|
||||
*/
|
||||
late?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,11 +43,30 @@ export interface SequenceClip {
|
||||
offset: number;
|
||||
volume: number;
|
||||
bus: string;
|
||||
/** As a note's. */
|
||||
tempo?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A ramp of the level of a bus the sequence plays on: from `tick` for `length` ticks it rises from
|
||||
* silence to `gain` along `curve`, or falls from there to silence – the fades of a composition's
|
||||
* item. One 0 ticks long sets the bus to `gain` there.
|
||||
*/
|
||||
export interface SequenceLevel {
|
||||
tick: number;
|
||||
length: number;
|
||||
bus: string;
|
||||
gain: number;
|
||||
rise: boolean;
|
||||
curve: FadeCurve;
|
||||
}
|
||||
|
||||
export interface Sequence {
|
||||
notes: readonly SequenceNote[];
|
||||
clips?: readonly SequenceClip[];
|
||||
levels?: readonly SequenceLevel[];
|
||||
/** The automation of a song's tracks (#337), each lane on the bus it acts on. */
|
||||
lanes?: readonly SequenceLane[];
|
||||
}
|
||||
|
||||
/** From `start` up to `end`, in ticks of the sequence; `end` is where the playhead jumps back. */
|
||||
@@ -65,8 +93,8 @@ export function playheadAt(elapsed: number, from: number, loop: LoopRange | null
|
||||
}
|
||||
|
||||
/** A note of the sequence where it falls in playing time, held no longer than the loop lets it. */
|
||||
export interface DueNote extends SequenceNote {
|
||||
/** Ticks of playing time from the start. */
|
||||
export interface DueNote extends Omit<SequenceNote, 'late'> {
|
||||
/** Ticks of playing time from the start, with its swing. */
|
||||
at: number;
|
||||
}
|
||||
|
||||
@@ -74,7 +102,8 @@ export interface DueNote extends SequenceNote {
|
||||
* The notes starting in the playing time from `start` up to `end`, both whole ticks. Across the end
|
||||
* of a loop the window goes on at its start, so the next round is planned ahead like any other
|
||||
* stretch and the seam has no gap. A note reaching past the end of a loop ends there: the playhead
|
||||
* has jumped back.
|
||||
* has jumped back. One its swing would push past that end – a loop cut in the middle of a pair –
|
||||
* starts a tick before it instead.
|
||||
*/
|
||||
export function notesIn(
|
||||
sequence: Sequence,
|
||||
@@ -90,13 +119,15 @@ export function notesIn(
|
||||
while (elapsed < end) {
|
||||
const span = Math.min(end - elapsed, stretchEnd - head);
|
||||
if (!(span > 0)) break;
|
||||
for (const note of sequence.notes)
|
||||
if (note.tick >= head && note.tick < head + span)
|
||||
for (const { late: swing = 0, ...note } of sequence.notes)
|
||||
if (note.tick >= head && note.tick < head + span) {
|
||||
const late = Math.min(swing, Math.max(0, stretchEnd - note.tick - 1));
|
||||
due.push({
|
||||
...note,
|
||||
at: elapsed + (note.tick - head),
|
||||
length: Math.min(note.length, stretchEnd - note.tick),
|
||||
at: elapsed + (note.tick - head) + late,
|
||||
length: Math.min(note.length, stretchEnd - note.tick - late),
|
||||
});
|
||||
}
|
||||
elapsed += span;
|
||||
head = head + span >= stretchEnd ? loop!.start : head + span;
|
||||
}
|
||||
@@ -122,8 +153,30 @@ export function clipsIn(
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
): DueClip[] {
|
||||
const clips = sequence.clips ?? [];
|
||||
const due: DueClip[] = [];
|
||||
return spansIn(sequence.clips ?? [], from, loop, start, end, fresh);
|
||||
}
|
||||
|
||||
/** The ramps of the sequence's levels as `clipsIn` finds clips: one the playhead lands inside is taken up there. */
|
||||
export function levelsIn(
|
||||
sequence: Sequence,
|
||||
from: number,
|
||||
loop: LoopRange | null,
|
||||
start: number,
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
): (SequenceLevel & { at: number; skip: number })[] {
|
||||
return spansIn(sequence.levels ?? [], from, loop, start, end, fresh);
|
||||
}
|
||||
|
||||
function spansIn<T extends { tick: number; length: number }>(
|
||||
clips: readonly T[],
|
||||
from: number,
|
||||
loop: LoopRange | null,
|
||||
start: number,
|
||||
end: number,
|
||||
fresh: boolean,
|
||||
): (T & { at: number; skip: number })[] {
|
||||
const due: (T & { at: number; skip: number })[] = [];
|
||||
if (clips.length === 0) return due;
|
||||
const stretchEnd = loop !== null && from < loop.end ? loop.end : Infinity;
|
||||
let elapsed = start;
|
||||
|
||||
@@ -8,11 +8,21 @@
|
||||
import { newId } from '../ids';
|
||||
import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
|
||||
import type { PatternAsset, SongAsset, SoundAsset } from '../model/Asset';
|
||||
import { curveLookup } from '../model/curve';
|
||||
import { type PropertyBag, textOf } from '../model/Properties';
|
||||
import { findAssetOf, type Project } from '../model/Project';
|
||||
import { findSound } from '../model/sound';
|
||||
import { pickedFx } from '../properties/assetFx';
|
||||
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
|
||||
import { type AutomationLane, heldLanes, sequenceLaneOf } from './automation';
|
||||
import { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB } from './level';
|
||||
import {
|
||||
gridTicks,
|
||||
GRID_OPTIONS,
|
||||
patternInstrument,
|
||||
patternLength,
|
||||
patternSwing,
|
||||
playedNote,
|
||||
} from './pattern';
|
||||
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
|
||||
import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time';
|
||||
|
||||
@@ -27,6 +37,8 @@ export interface SongTrack {
|
||||
solo: boolean;
|
||||
/** The sound FX everything on the track goes through, before its volume and pan (#346); `''` for none. */
|
||||
fx: string;
|
||||
/** Its volume, pan and parameters over the song (#337); left out without a lane. */
|
||||
automation?: AutomationLane[];
|
||||
}
|
||||
|
||||
export type SongItemKind = 'pattern' | 'sound';
|
||||
@@ -66,8 +78,7 @@ const DEFAULT_TRACKS = 1;
|
||||
/** A song is never shorter than this many bars, on the ruler or when it plays. */
|
||||
const MIN_BARS = 1;
|
||||
|
||||
export const TRACK_MIN_DB = -60;
|
||||
export const TRACK_MAX_DB = 12;
|
||||
export { dbToGain, TRACK_MAX_DB, TRACK_MIN_DB };
|
||||
export const ITEM_MIN_DB = -60;
|
||||
/** A sound item can be turned up; a pattern item's notes are held to unity, as a note always is. */
|
||||
export const SOUND_ITEM_MAX_DB = 12;
|
||||
@@ -138,10 +149,6 @@ export function itemBus(songId: string, item: Pick<SongItem, 'id' | 'track'>): s
|
||||
return `${trackBus(songId, item.track)}:${item.id}`;
|
||||
}
|
||||
|
||||
export function dbToGain(db: number): number {
|
||||
return db <= TRACK_MIN_DB ? 0 : 10 ** (db / 20);
|
||||
}
|
||||
|
||||
/** The sound FX an item goes through (#352): its own pick, else the one of what it plays; '' for none. */
|
||||
export function itemFx(project: Project, item: SongItem): string {
|
||||
return pickedFx(item.fx, itemAsset(project, item)?.props ?? {});
|
||||
@@ -179,22 +186,26 @@ export function firstRound(item: SongItem, span: number): number {
|
||||
|
||||
/**
|
||||
* The notes a pattern item plays, in song ticks: its pattern over and over for as long as the item
|
||||
* is, moved by its transpose, cut at the item's ends and at the end of each round.
|
||||
* is, moved by its transpose, cut at the item's ends and at the end of each round. `swung` gives
|
||||
* them the pattern's swing (#372) as they are heard; where they are drawn they go without.
|
||||
*/
|
||||
export function itemNotes(
|
||||
pattern: PatternAsset,
|
||||
item: SongItem,
|
||||
): { tick: number; length: number; note: number; velocity: number }[] {
|
||||
swung = false,
|
||||
): { tick: number; length: number; late?: number; note: number; velocity: number }[] {
|
||||
const span = patternLength(pattern);
|
||||
const end = itemEnd(item);
|
||||
const out: { tick: number; length: number; note: number; velocity: number }[] = [];
|
||||
const swing = swung ? patternSwing(pattern.props) : null;
|
||||
const out: { tick: number; length: number; late?: number; note: number; velocity: number }[] = [];
|
||||
for (let round = firstRound(item, span); round < end; round += span)
|
||||
for (const n of pattern.notes) {
|
||||
const tick = round + n.tick;
|
||||
const note = n.note + item.transpose;
|
||||
if (n.tick >= span || tick < item.tick || tick >= end || note < LOWEST_NOTE || note > HIGHEST_NOTE)
|
||||
continue;
|
||||
out.push({ tick, length: Math.min(n.length, span - n.tick, end - tick), note, velocity: n.velocity });
|
||||
const length = Math.min(n.length, span - n.tick, end - tick);
|
||||
out.push({ tick, ...playedNote(n.tick, length, swing), note, velocity: n.velocity });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -224,11 +235,17 @@ export function songSequence(project: Project, song: SongAsset): Sequence {
|
||||
const pattern = findAssetOf(project, item.asset, 'pattern');
|
||||
const instrument = pattern && (item.instrument || patternInstrument(pattern.props));
|
||||
if (!pattern || !instrument) continue;
|
||||
for (const n of itemNotes(pattern, item)) notes.push({ ...n, instrument, volume, bus });
|
||||
for (const n of itemNotes(pattern, item, true)) notes.push({ ...n, instrument, volume, bus });
|
||||
}
|
||||
notes.sort((a, b) => a.tick - b.tick || a.note - b.note);
|
||||
clips.sort((a, b) => a.tick - b.tick);
|
||||
return { notes, clips };
|
||||
const curves = curveLookup(project);
|
||||
const lanes = song.tracks.flatMap((track) =>
|
||||
(track.automation ?? [])
|
||||
.filter((lane) => lane.points.length > 0)
|
||||
.map((lane) => sequenceLaneOf(lane, trackBus(song.id, track.id), curves)),
|
||||
);
|
||||
return { notes, clips, ...(lanes.length > 0 && { lanes }) };
|
||||
}
|
||||
|
||||
const finite = (value: unknown, fallback: number) =>
|
||||
@@ -255,6 +272,8 @@ export function heldSongTracks(raw: unknown): SongTrack[] {
|
||||
solo: t.solo === true,
|
||||
fx: text(t.fx),
|
||||
});
|
||||
const automation = heldLanes(t.automation);
|
||||
if (automation.length > 0) tracks.at(-1)!.automation = automation;
|
||||
}
|
||||
return tracks.length > 0 ? tracks : [newTrack(1)];
|
||||
}
|
||||
|
||||