@forgeax/engine-graphics-extras 0.0.0-dev.8d955ade1c79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +165 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/__tests__/tile-bits.test.d.ts +2 -0
- package/dist/__tests__/tile-bits.test.d.ts.map +1 -0
- package/dist/__tests__/video-capability-backend-kind-owner.test-d.d.ts +2 -0
- package/dist/__tests__/video-capability-backend-kind-owner.test-d.d.ts.map +1 -0
- package/dist/__tests__/video-capability-probe.unit.test.d.ts +2 -0
- package/dist/__tests__/video-capability-probe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/video-player-component.unit.test.d.ts +2 -0
- package/dist/__tests__/video-player-component.unit.test.d.ts.map +1 -0
- package/dist/__tests__/video-player-multi-entity.unit.test.d.ts +2 -0
- package/dist/__tests__/video-player-multi-entity.unit.test.d.ts.map +1 -0
- package/dist/glyph-layout.d.ts +48 -0
- package/dist/glyph-layout.d.ts.map +1 -0
- package/dist/glyph-mesh-bake.d.ts +30 -0
- package/dist/glyph-mesh-bake.d.ts.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +306 -0
- package/dist/index.mjs.map +1 -0
- package/dist/tile-bits.d.ts +21 -0
- package/dist/tile-bits.d.ts.map +1 -0
- package/dist/tileset-decoder.d.ts +4 -0
- package/dist/tileset-decoder.d.ts.map +1 -0
- package/dist/video-element-provider.d.ts +41 -0
- package/dist/video-element-provider.d.ts.map +1 -0
- package/dist/video-loader.d.ts +4 -0
- package/dist/video-loader.d.ts.map +1 -0
- package/dist/video-player-system.d.ts +36 -0
- package/dist/video-player-system.d.ts.map +1 -0
- package/dist/video-player.d.ts +23 -0
- package/dist/video-player.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/__tests__/tile-bits.test.ts +88 -0
- package/src/__tests__/video-capability-backend-kind-owner.test-d.ts +7 -0
- package/src/__tests__/video-capability-probe.unit.test.ts +130 -0
- package/src/__tests__/video-player-component.unit.test.ts +73 -0
- package/src/__tests__/video-player-multi-entity.unit.test.ts +75 -0
- package/src/glyph-layout.ts +208 -0
- package/src/glyph-mesh-bake.ts +151 -0
- package/src/index.ts +37 -0
- package/src/tile-bits.ts +77 -0
- package/src/tileset-decoder.ts +64 -0
- package/src/video-element-provider.ts +72 -0
- package/src/video-loader.ts +80 -0
- package/src/video-player-system.ts +63 -0
- package/src/video-player.ts +51 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras - glyph mesh bake helper
|
|
2
|
+
// (feat-20260531-world-space-msdf-text-rendering M4 / w17).
|
|
3
|
+
//
|
|
4
|
+
// Turns the pure layout output (w15) into a registered `MeshAsset` and a
|
|
5
|
+
// conservative bounding-sphere cube AABB (plan-strategy D-4 / D-5). Called by
|
|
6
|
+
// the `glyphTextLayoutSystem` (w18) -- not a public AI-user API; AI users
|
|
7
|
+
// declare a `GlyphText` component and the system bakes behind the scenes.
|
|
8
|
+
//
|
|
9
|
+
// 12-float stride (R-2 hard gate): the layout already produced interleaved
|
|
10
|
+
// vertices at the canonical `PROCEDURAL_FLOATS_PER_VERTEX` stride (position + uv
|
|
11
|
+
// real, normal (0,0,1) / tangent (0,0,0,1) placeholder). We deinterleave into
|
|
12
|
+
// the `VertexAttributeMap` (position / normal / uv / tangent) so
|
|
13
|
+
// `AssetRegistry.register` can derive the GPU vertex layout + AABB. The
|
|
14
|
+
// register call fail-fasts with `mesh-vertex-stride-mismatch` if the stride is
|
|
15
|
+
// ever wrong -- this module never bypasses that gate.
|
|
16
|
+
//
|
|
17
|
+
// Empty string -> 0-vertex / 0-index mesh; `register` accepts the empty mesh
|
|
18
|
+
// (validateMeshPayload branch (b)) and pick skips it (inverted-infinity AABB).
|
|
19
|
+
//
|
|
20
|
+
// Conservative cube AABB (D-5): pick must be orientation-independent (text may
|
|
21
|
+
// billboard toward the camera at draw time, but `pick.ts` raycasts against the
|
|
22
|
+
// static local AABB x world matrix without the billboard rotation). The baked
|
|
23
|
+
// mesh's `attributes.position` carries the 8 corners of a cube centered at the
|
|
24
|
+
// anchor with half-side = the layout radius R; `AssetRegistry.register`
|
|
25
|
+
// computes the local AABB from that position attribute (`computeAABB`), so the
|
|
26
|
+
// REGISTERED mesh AABB is the conservative cube -- a ray that would hit the
|
|
27
|
+
// text from any in-plane orientation is caught. The GPU vertex buffer is built
|
|
28
|
+
// from the interleaved `vertices` (real glyph quads), which is fully decoupled
|
|
29
|
+
// from `attributes.position` (uploadMeshById reads `mesh.vertices`). `pick.ts`
|
|
30
|
+
// is NOT modified -- the cube is purely a bake-step property of the mesh.
|
|
31
|
+
|
|
32
|
+
import type { World } from '@forgeax/engine-ecs';
|
|
33
|
+
import {
|
|
34
|
+
buildMeshAttributeMapForUvSets,
|
|
35
|
+
PROCEDURAL_FLOATS_PER_VERTEX,
|
|
36
|
+
} from '@forgeax/engine-geometry';
|
|
37
|
+
import { ok, type Result } from '@forgeax/engine-rhi';
|
|
38
|
+
import type { AssetError, Handle, MeshAsset } from '@forgeax/engine-types';
|
|
39
|
+
|
|
40
|
+
import type { GlyphLayoutResult } from './glyph-layout';
|
|
41
|
+
|
|
42
|
+
/** Result of baking a glyph layout into a registered mesh. */
|
|
43
|
+
export interface GlyphMeshBakeResult {
|
|
44
|
+
/** The registered unmanaged mesh handle (feed to `MeshFilter.assetHandle`). */
|
|
45
|
+
readonly handle: Handle<'MeshAsset', 'shared'>;
|
|
46
|
+
/**
|
|
47
|
+
* Conservative bounding-sphere cube AABB in local space: 6 floats
|
|
48
|
+
* [-R,-R,-R, R,R,R] centered at the anchor (plan-strategy D-5). Empty
|
|
49
|
+
* layout -> all-zero box.
|
|
50
|
+
*/
|
|
51
|
+
readonly aabb: Float32Array;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build the MeshAsset POD (12-float stride) from a glyph layout. */
|
|
55
|
+
export function buildGlyphMeshAsset(layout: GlyphLayoutResult): MeshAsset {
|
|
56
|
+
const { vertices, indices, radius } = layout;
|
|
57
|
+
return {
|
|
58
|
+
kind: 'mesh',
|
|
59
|
+
vertices,
|
|
60
|
+
indices,
|
|
61
|
+
// `attributes.position` carries the 8 conservative-cube corners (half-side
|
|
62
|
+
// = radius) so `register` computes the orientation-independent cube AABB
|
|
63
|
+
// (D-5). The GPU vertex buffer is built from the interleaved `vertices`
|
|
64
|
+
// (uploadMeshById reads `mesh.vertices`), fully decoupled from this
|
|
65
|
+
// position attribute -- which exists only to drive `computeAABB`.
|
|
66
|
+
attributes: {
|
|
67
|
+
// Keep the conservative pick/cull position stream and the canonical
|
|
68
|
+
// 12-float upload layout in one geometry-owned projection.
|
|
69
|
+
...buildMeshAttributeMapForUvSets(1),
|
|
70
|
+
position: cubeCornerAttributes(radius).position,
|
|
71
|
+
},
|
|
72
|
+
submeshes: [
|
|
73
|
+
{
|
|
74
|
+
indexOffset: 0,
|
|
75
|
+
indexCount: indices.length,
|
|
76
|
+
vertexCount: vertices.length / PROCEDURAL_FLOATS_PER_VERTEX,
|
|
77
|
+
topology: 'triangle-list',
|
|
78
|
+
materialSlot: 0,
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
materialSlots: [{ slotName: 'Default' }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Conservative cube AABB centered at the anchor with half-side = layout radius. */
|
|
86
|
+
export function conservativeCubeAabb(radius: number): Float32Array {
|
|
87
|
+
return Float32Array.of(-radius, -radius, -radius, radius, radius, radius);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Bake a glyph layout into a registered mesh + conservative cube AABB.
|
|
92
|
+
*
|
|
93
|
+
* @param assets The AssetRegistry that owns the mesh handle lifecycle.
|
|
94
|
+
* @param layout The pure layout output from `layoutGlyphText` (w15).
|
|
95
|
+
* @returns `Result.ok({ handle, aabb })` or `Result.err(AssetError)` when
|
|
96
|
+
* `register` fail-fasts (e.g. stride mismatch -- should never happen for a
|
|
97
|
+
* layout produced by w15, but the gate is honored, not bypassed).
|
|
98
|
+
*/
|
|
99
|
+
export function bakeGlyphMesh(
|
|
100
|
+
world: World,
|
|
101
|
+
layout: GlyphLayoutResult,
|
|
102
|
+
): Result<GlyphMeshBakeResult, AssetError> {
|
|
103
|
+
const aabb = conservativeCubeAabb(layout.radius);
|
|
104
|
+
// feat-20260614 M8 (D-17/D-19): the baked text mesh is a runtime-minted
|
|
105
|
+
// user-tier asset allocated directly into the world's SharedRefStore. Unlike
|
|
106
|
+
// `AssetRegistry.catalog` (which runs `withMeshAabb` to compute the AABB from
|
|
107
|
+
// `attributes.position`), `allocSharedRef` stores the payload verbatim -- so
|
|
108
|
+
// the mesh POD must carry its own `.aabb` for the cull / pick path to read it.
|
|
109
|
+
const meshAsset: MeshAsset = { ...buildGlyphMeshAsset(layout), aabb };
|
|
110
|
+
const handle = world.allocSharedRef<'MeshAsset', MeshAsset>('MeshAsset', meshAsset);
|
|
111
|
+
return ok({ handle, aabb });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The 8 corners of a cube centered at the anchor with half-side = radius,
|
|
116
|
+
* packed as a flat `position` attribute (x,y,z per corner). `computeAABB`
|
|
117
|
+
* reduces this to the conservative cube AABB (D-5). For an empty layout
|
|
118
|
+
* (radius 0) all corners collapse to the origin, yielding a zero-volume box
|
|
119
|
+
* that pick treats as a point miss -- consistent with the empty-string path.
|
|
120
|
+
*/
|
|
121
|
+
function cubeCornerAttributes(radius: number): { position: Float32Array } {
|
|
122
|
+
const r = radius;
|
|
123
|
+
return {
|
|
124
|
+
position: Float32Array.of(
|
|
125
|
+
-r,
|
|
126
|
+
-r,
|
|
127
|
+
-r,
|
|
128
|
+
r,
|
|
129
|
+
-r,
|
|
130
|
+
-r,
|
|
131
|
+
r,
|
|
132
|
+
r,
|
|
133
|
+
-r,
|
|
134
|
+
-r,
|
|
135
|
+
r,
|
|
136
|
+
-r,
|
|
137
|
+
-r,
|
|
138
|
+
-r,
|
|
139
|
+
r,
|
|
140
|
+
r,
|
|
141
|
+
-r,
|
|
142
|
+
r,
|
|
143
|
+
r,
|
|
144
|
+
r,
|
|
145
|
+
r,
|
|
146
|
+
-r,
|
|
147
|
+
r,
|
|
148
|
+
r,
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras -- public barrel.
|
|
2
|
+
//
|
|
3
|
+
// feat-20260705-runtime-tier2-decomposition M3: pure-logic graphics-adjacent
|
|
4
|
+
// modules extracted from @forgeax/engine-runtime. System entry points
|
|
5
|
+
// (tilemapChunkExtractSystem / glyphTextLayoutSystem) stay in runtime and
|
|
6
|
+
// import from here -- see README.md for the package boundary declaration.
|
|
7
|
+
|
|
8
|
+
// ─── glyph text layout (feat-20260531-world-space-msdf-text-rendering) ─────
|
|
9
|
+
export {
|
|
10
|
+
FONT_CONCURRENCY_LIMIT,
|
|
11
|
+
type GlyphLayoutResult,
|
|
12
|
+
layoutGlyphText,
|
|
13
|
+
resetFontConcurrency,
|
|
14
|
+
trackFontConcurrency,
|
|
15
|
+
VERTEX_OFFSET,
|
|
16
|
+
} from './glyph-layout';
|
|
17
|
+
export {
|
|
18
|
+
bakeGlyphMesh,
|
|
19
|
+
buildGlyphMeshAsset,
|
|
20
|
+
conservativeCubeAabb,
|
|
21
|
+
type GlyphMeshBakeResult,
|
|
22
|
+
} from './glyph-mesh-bake';
|
|
23
|
+
|
|
24
|
+
// ─── tile-bits SSOT (feat-20260608-tilemap-object-layer-rendering) ─────────
|
|
25
|
+
export { decodeTileBits, encodeTileBits } from './tile-bits';
|
|
26
|
+
export { tilesetContribution } from './tileset-decoder';
|
|
27
|
+
|
|
28
|
+
// ─── VideoElementProvider host bridge ─────────────────────────────────────
|
|
29
|
+
export {
|
|
30
|
+
VIDEO_ELEMENT_PROVIDER_KEY,
|
|
31
|
+
type VideoElementProvider,
|
|
32
|
+
} from './video-element-provider';
|
|
33
|
+
export { videoContribution, videoLoader } from './video-loader';
|
|
34
|
+
// ─── VideoPlayer component ────────────────────────────────────────────────
|
|
35
|
+
export { VideoPlayer } from './video-player';
|
|
36
|
+
// ─── video high-perf upload capability probe ──────────────────────────────
|
|
37
|
+
export { probeVideoHighPerfUpload } from './video-player-system';
|
package/src/tile-bits.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras - tile-bits (Tiled .tmj wire-format helpers).
|
|
2
|
+
//
|
|
3
|
+
// Tiled .tmj packs each cell as a uint32 where the high 4 bits encode flip /
|
|
4
|
+
// rotation slots and the low 28 bits encode the 1-indexed tile id (id 0 means
|
|
5
|
+
// "empty cell"). The wire layout, from MSB to LSB:
|
|
6
|
+
//
|
|
7
|
+
// bit 31 flipHorizontal
|
|
8
|
+
// bit 30 flipVertical
|
|
9
|
+
// bit 29 flipDiagonal (== 90deg CW rotation in Tiled semantics)
|
|
10
|
+
// bit 28 flipHex120 (Tiled hex 120deg flip; preserved for fidelity,
|
|
11
|
+
// unused by the orthogonal renderer in M0)
|
|
12
|
+
// bits 0..27 tileId (0..0x0FFFFFFF; 0 sentinel = empty)
|
|
13
|
+
//
|
|
14
|
+
// Anchors: requirements integration points (engine-runtime tile-bits SSOT);
|
|
15
|
+
// plan-strategy §M0 targetFiles (tile-bits.ts); feat-20260604 D-2 wire
|
|
16
|
+
// compatibility lock.
|
|
17
|
+
//
|
|
18
|
+
// charter mapping: F1 (single-import barrel — encode/decode pair lives at
|
|
19
|
+
// `@forgeax/engine-runtime/tile-bits`), P3 (overflow / negative / non-integer
|
|
20
|
+
// tile id surface as RangeError, never silently clamp), P4 (encode and decode
|
|
21
|
+
// share the same bit layout and are inverse functions over the supported
|
|
22
|
+
// 0..0x0FFFFFFF tile id range).
|
|
23
|
+
|
|
24
|
+
const TILE_ID_MAX = 0x0fffffff;
|
|
25
|
+
const FLIP_H_BIT = 1 << 31;
|
|
26
|
+
const FLIP_V_BIT = 1 << 30;
|
|
27
|
+
const FLIP_D_BIT = 1 << 29;
|
|
28
|
+
const FLIP_HEX120_BIT = 1 << 28;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pack a Tiled .tmj cell into its u32 wire form.
|
|
32
|
+
*
|
|
33
|
+
* @param tileId 1-indexed tile id in [0, 0x0FFFFFFF]; `0` is the empty-cell
|
|
34
|
+
* sentinel that `TileLayer.tiles` carries downstream. `1` is the first real
|
|
35
|
+
* tile entry index into `TilesetAsset.tiles[0]`.
|
|
36
|
+
* @throws RangeError when `tileId` is non-integer, negative, or > 0x0FFFFFFF.
|
|
37
|
+
*/
|
|
38
|
+
export function encodeTileBits(
|
|
39
|
+
tileId: number,
|
|
40
|
+
flipH: boolean,
|
|
41
|
+
flipV: boolean,
|
|
42
|
+
flipDiagonal: boolean,
|
|
43
|
+
flipHex120: boolean,
|
|
44
|
+
): number {
|
|
45
|
+
if (!Number.isInteger(tileId) || tileId < 0 || tileId > TILE_ID_MAX) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
`encodeTileBits: tileId must be an integer in [0, ${TILE_ID_MAX}]; got ${tileId}`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
let packed = tileId >>> 0;
|
|
51
|
+
if (flipH) packed |= FLIP_H_BIT;
|
|
52
|
+
if (flipV) packed |= FLIP_V_BIT;
|
|
53
|
+
if (flipDiagonal) packed |= FLIP_D_BIT;
|
|
54
|
+
if (flipHex120) packed |= FLIP_HEX120_BIT;
|
|
55
|
+
return packed >>> 0;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Unpack a Tiled .tmj cell from its u32 wire form into the structured shape
|
|
60
|
+
* consumed by `tilemap-chunk-extract-system.ts:spawnDerivedRenderEntities`.
|
|
61
|
+
*/
|
|
62
|
+
export function decodeTileBits(packed: number): {
|
|
63
|
+
readonly tileId: number;
|
|
64
|
+
readonly flipH: boolean;
|
|
65
|
+
readonly flipV: boolean;
|
|
66
|
+
readonly flipDiagonal: boolean;
|
|
67
|
+
readonly flipHex120: boolean;
|
|
68
|
+
} {
|
|
69
|
+
const u = packed >>> 0;
|
|
70
|
+
return {
|
|
71
|
+
tileId: u & TILE_ID_MAX,
|
|
72
|
+
flipH: (u & FLIP_H_BIT) !== 0,
|
|
73
|
+
flipV: (u & FLIP_V_BIT) !== 0,
|
|
74
|
+
flipDiagonal: (u & FLIP_D_BIT) !== 0,
|
|
75
|
+
flipHex120: (u & FLIP_HEX120_BIT) !== 0,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AssetDecoderContribution,
|
|
3
|
+
type AssetKind,
|
|
4
|
+
err,
|
|
5
|
+
ok,
|
|
6
|
+
type TilesetAsset,
|
|
7
|
+
} from '@forgeax/engine-types';
|
|
8
|
+
|
|
9
|
+
/** Tileset validation is owned here; no physics consumer exists yet. */
|
|
10
|
+
export const tilesetContribution: AssetDecoderContribution<TilesetAsset, 'tileset'> = {
|
|
11
|
+
kind: { kind: 'tileset' } as AssetKind<TilesetAsset, 'tileset'>,
|
|
12
|
+
consumer: 'tilemapChunkExtractSystem (physics consumer: missing evidence)',
|
|
13
|
+
decoder: {
|
|
14
|
+
async decode({ envelope }) {
|
|
15
|
+
const payload = envelope.payload as unknown;
|
|
16
|
+
if (payload !== null && typeof payload === 'object') {
|
|
17
|
+
const source = payload as Record<string, unknown>;
|
|
18
|
+
const rawAtlases = source.atlases;
|
|
19
|
+
const regions = source.regions;
|
|
20
|
+
const tiles = source.tiles;
|
|
21
|
+
const atlases = Array.isArray(rawAtlases)
|
|
22
|
+
? rawAtlases.map((value) =>
|
|
23
|
+
typeof value === 'number' && Number.isSafeInteger(value)
|
|
24
|
+
? envelope.refs[value]
|
|
25
|
+
: value,
|
|
26
|
+
)
|
|
27
|
+
: undefined;
|
|
28
|
+
if (
|
|
29
|
+
source.kind === 'tileset' &&
|
|
30
|
+
atlases !== undefined &&
|
|
31
|
+
atlases.length > 0 &&
|
|
32
|
+
atlases.every(
|
|
33
|
+
(atlas): atlas is string => typeof atlas === 'string' && atlas.length > 0,
|
|
34
|
+
) &&
|
|
35
|
+
Array.isArray(regions) &&
|
|
36
|
+
regions.length > 0 &&
|
|
37
|
+
Array.isArray(tiles) &&
|
|
38
|
+
tiles.every(
|
|
39
|
+
(tile) =>
|
|
40
|
+
tile !== null &&
|
|
41
|
+
typeof tile === 'object' &&
|
|
42
|
+
typeof (tile as { regionIndex?: unknown }).regionIndex === 'number' &&
|
|
43
|
+
(tile as { regionIndex: number }).regionIndex >= 0 &&
|
|
44
|
+
(tile as { regionIndex: number }).regionIndex < regions.length,
|
|
45
|
+
)
|
|
46
|
+
) {
|
|
47
|
+
return ok({
|
|
48
|
+
...source,
|
|
49
|
+
kind: 'tileset',
|
|
50
|
+
atlases,
|
|
51
|
+
regions,
|
|
52
|
+
tiles,
|
|
53
|
+
} as unknown as TilesetAsset);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return err({
|
|
57
|
+
code: 'asset-package-invalid',
|
|
58
|
+
expected: 'a tileset with atlas GUIDs and in-range tile regions',
|
|
59
|
+
hint: 'repair the tileset regions; physics consumption is not installed by this owner',
|
|
60
|
+
detail: { guid: envelope.guid, reason: 'tileset owner validation failed' },
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras — VideoElementProvider contract + Resource key
|
|
2
|
+
// (feat-20260623-world-space-video-asset M3 / w9).
|
|
3
|
+
//
|
|
4
|
+
// D-1: the channel by which a host HTMLVideoElement reaches the engine is a
|
|
5
|
+
// host-registered `VideoElementProvider` stored as a World Resource. The host
|
|
6
|
+
// implements this interface, owns the `<video>` DOM lifecycle (create / set
|
|
7
|
+
// src / autoplay / mute / dispose) single-sidedly, and registers it via
|
|
8
|
+
// `world.insertResource(VIDEO_ELEMENT_PROVIDER_KEY, provider)`. The engine's
|
|
9
|
+
// per-frame record stage (`render-system-record.ts` `videoTextureView`) reads
|
|
10
|
+
// it back each draw via `world.getResource(VIDEO_ELEMENT_PROVIDER_KEY)` and asks
|
|
11
|
+
// for the element to sample — it NEVER constructs an HTMLVideoElement, sets
|
|
12
|
+
// `.src`, or touches the DOM (requirements constraint: HTMLVideoElement is
|
|
13
|
+
// host-provided). There is no separate ECS "video player system" to register.
|
|
14
|
+
//
|
|
15
|
+
// Why a World Resource (not an ECS field): the ECS schema vocab is closed
|
|
16
|
+
// (component.ts:315-333) and admits no opaque/object field type, so an
|
|
17
|
+
// HTMLVideoElement reference cannot live inside a component. A World Resource
|
|
18
|
+
// is the typed singleton channel for host-owned services — the same shape used
|
|
19
|
+
// by animation-domain lookups / TransparentSortConfig (research Finding 5,
|
|
20
|
+
// plan-strategy D-1; plan-decisions F-3 correction: this uses the REAL World
|
|
21
|
+
// Resource API insertResource/getResource, not audio's direct-parameter
|
|
22
|
+
// injection).
|
|
23
|
+
//
|
|
24
|
+
// This module is a pure contract: an interface plus a typed key constant, no
|
|
25
|
+
// runtime behavior. Behavior is exercised by w10/w11 via a mock provider
|
|
26
|
+
// (the host's real implementation lives in the M5 demo, w20).
|
|
27
|
+
|
|
28
|
+
import type { EntityHandle } from '@forgeax/engine-ecs';
|
|
29
|
+
import type { Handle } from '@forgeax/engine-types';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resource key under which the host inserts its {@link VideoElementProvider}.
|
|
33
|
+
*
|
|
34
|
+
* Consumers import this constant rather than the bare string so a typo
|
|
35
|
+
* degrades to an import error rather than a silent missing-resource at runtime
|
|
36
|
+
* (charter P3). Naming mirrors the animation-domain lookup boundary /
|
|
37
|
+
* `TRANSPARENT_SORT_CONFIG_KEY`.
|
|
38
|
+
*
|
|
39
|
+
* @example Host registers its provider once per World:
|
|
40
|
+
* world.insertResource(VIDEO_ELEMENT_PROVIDER_KEY, myProvider);
|
|
41
|
+
* // ...the record stage reads it back each renderer.draw...
|
|
42
|
+
*/
|
|
43
|
+
export const VIDEO_ELEMENT_PROVIDER_KEY = 'VideoElementProvider' as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Host-implemented bridge from a VideoPlayer entity (+ its `clip` handle) to
|
|
47
|
+
* the host-owned HTMLVideoElement the engine samples each frame.
|
|
48
|
+
*
|
|
49
|
+
* The engine calls {@link getElement} during the video tick; the host returns
|
|
50
|
+
* the element it owns for that clip, or `undefined` when no element is
|
|
51
|
+
* available yet (clip not loaded / metadata pending / host has none). The
|
|
52
|
+
* engine treats `undefined` as "no source this frame" and routes through the
|
|
53
|
+
* structured failure / degrade path (AC-10) rather than sampling garbage.
|
|
54
|
+
*
|
|
55
|
+
* The engine NEVER mutates the returned element — it only reads it for the
|
|
56
|
+
* per-frame `copyExternalImageToTexture` upload (M4). DOM lifecycle is the
|
|
57
|
+
* host's sole responsibility (D-1).
|
|
58
|
+
*/
|
|
59
|
+
export interface VideoElementProvider {
|
|
60
|
+
/**
|
|
61
|
+
* Return the host-owned HTMLVideoElement for the given entity + clip, or
|
|
62
|
+
* `undefined` when none is available this frame.
|
|
63
|
+
*
|
|
64
|
+
* @param entity - the VideoPlayer entity requesting its element.
|
|
65
|
+
* @param clipHandle - the entity's `VideoPlayer.clip` handle
|
|
66
|
+
* (`Handle<'VideoAsset','shared'>`).
|
|
67
|
+
*/
|
|
68
|
+
getElement(
|
|
69
|
+
entity: EntityHandle,
|
|
70
|
+
clipHandle: Handle<'VideoAsset', 'shared'>,
|
|
71
|
+
): HTMLVideoElement | undefined;
|
|
72
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras - video loader (feat-20260623-world-space-video-asset M2 / w4).
|
|
2
|
+
//
|
|
3
|
+
// Descriptor-only loader for the 'video' asset kind. VideoAsset is a pure
|
|
4
|
+
// `{ url }` descriptor (no pixel decode, no import/cook pipeline — OOS-1);
|
|
5
|
+
// the runtime resolves it into an HTMLVideoElement via the host-provided
|
|
6
|
+
// `VideoElementProvider` World Resource (plan-strategy D-1).
|
|
7
|
+
//
|
|
8
|
+
// The loader returns the payload as VideoAsset synchronously — no fetch or
|
|
9
|
+
// decode. Audio differs because its renderer-injected catalog-entry loader
|
|
10
|
+
// fetches and decodes a Web Audio payload before cataloguing it.
|
|
11
|
+
//
|
|
12
|
+
// Registered in wireDefaultLoaders alongside the other 10 default kinds
|
|
13
|
+
// (plan-strategy D-7: engine-own kind goes in the default set so AI users
|
|
14
|
+
// don't have to manually register it).
|
|
15
|
+
|
|
16
|
+
import {
|
|
17
|
+
type AssetDecoderContribution,
|
|
18
|
+
type AssetKind,
|
|
19
|
+
err,
|
|
20
|
+
type Loader,
|
|
21
|
+
ok,
|
|
22
|
+
type VideoAsset,
|
|
23
|
+
} from '@forgeax/engine-types';
|
|
24
|
+
|
|
25
|
+
const VIDEO_URL_RESOLUTION_BASE = 'https://forgeax.invalid/';
|
|
26
|
+
const VIDEO_URL_WHITESPACE = /\s/u;
|
|
27
|
+
|
|
28
|
+
function hasVideoUrlControlCharacter(value: string): boolean {
|
|
29
|
+
for (const character of value) {
|
|
30
|
+
const code = character.charCodeAt(0);
|
|
31
|
+
if (code <= 0x1f || code === 0x7f) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isBrowserResolvableVideoUrl(value: unknown): value is string {
|
|
37
|
+
if (
|
|
38
|
+
typeof value !== 'string' ||
|
|
39
|
+
value.length === 0 ||
|
|
40
|
+
value.trim().length === 0 ||
|
|
41
|
+
value !== value.trim() ||
|
|
42
|
+
hasVideoUrlControlCharacter(value) ||
|
|
43
|
+
VIDEO_URL_WHITESPACE.test(value) ||
|
|
44
|
+
value.startsWith('//')
|
|
45
|
+
) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const resolved = new URL(value, VIDEO_URL_RESOLUTION_BASE);
|
|
50
|
+
return resolved.protocol === 'http:' || resolved.protocol === 'https:';
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const videoLoader: Loader<VideoAsset> = {
|
|
57
|
+
kind: 'video',
|
|
58
|
+
load(payload: Record<string, unknown>): VideoAsset | undefined {
|
|
59
|
+
if (!isBrowserResolvableVideoUrl(payload.url)) return undefined;
|
|
60
|
+
return { kind: 'video', url: payload.url };
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export const videoContribution: AssetDecoderContribution<VideoAsset, 'video'> = {
|
|
65
|
+
kind: { kind: 'video' } as AssetKind<VideoAsset, 'video'>,
|
|
66
|
+
consumer: 'VideoElementProvider',
|
|
67
|
+
decoder: {
|
|
68
|
+
async decode({ envelope }) {
|
|
69
|
+
const payload = envelope.payload;
|
|
70
|
+
return payload.kind === 'video' && isBrowserResolvableVideoUrl(payload.url)
|
|
71
|
+
? ok(payload)
|
|
72
|
+
: err({
|
|
73
|
+
code: 'asset-package-invalid',
|
|
74
|
+
expected: 'a browser-resolvable video URL descriptor',
|
|
75
|
+
hint: 'publish an http(s) video URL and let the host create the video element',
|
|
76
|
+
detail: { guid: envelope.guid, reason: 'video owner validation failed' },
|
|
77
|
+
});
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras — video high-perf upload capability probe
|
|
2
|
+
// (feat-20260623-world-space-video-asset M4 / w17).
|
|
3
|
+
//
|
|
4
|
+
// The single per-frame video upload path lives in the record stage
|
|
5
|
+
// (`render-system-record.ts` `videoTextureView`): it reads the host-registered
|
|
6
|
+
// VideoElementProvider (World Resource, D-1), uploads the current frame via
|
|
7
|
+
// `DynamicTextureStore.uploadFrame` (copyExternalImageToTexture), and fires the
|
|
8
|
+
// structured `VideoUploadUnsupportedError` on the engine error channel when a
|
|
9
|
+
// VideoPlayer entity can reach NEITHER the general path (no host element) NOR
|
|
10
|
+
// the high-perf path (AC-10 double-miss, charter P3). There is exactly ONE video
|
|
11
|
+
// upload/failure path — this module only contributes the capability probe the
|
|
12
|
+
// record stage consults to decide whether the reserved high-perf branch is
|
|
13
|
+
// available.
|
|
14
|
+
//
|
|
15
|
+
// Decision anchors:
|
|
16
|
+
// - requirements AC-09 (two paths left in place, capability probe explicit).
|
|
17
|
+
// - plan-strategy D-2 (grep-able capability branch, not a TODO).
|
|
18
|
+
|
|
19
|
+
import type { RhiCaps } from '@forgeax/engine-rhi';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Minimal device shape the high-perf capability probe inspects: the backend
|
|
23
|
+
* kind plus the (currently-absent) `importExternalTexture` method. Declared
|
|
24
|
+
* structurally so the probe stays decoupled from the full RhiDevice surface and
|
|
25
|
+
* unit tests drive it with a small object.
|
|
26
|
+
*/
|
|
27
|
+
export interface VideoCapabilityDevice {
|
|
28
|
+
readonly caps: Pick<RhiCaps, 'backendKind'>;
|
|
29
|
+
/**
|
|
30
|
+
* The WebGPU zero-copy video import entry point. forgeax exposes NO such RHI
|
|
31
|
+
* method today (research Finding 4 confirmed `importExternalTexture` grep=0),
|
|
32
|
+
* so this is always `undefined` — the probe's presence check is the explicit,
|
|
33
|
+
* grep-able boundary between the general path and the reserved high-perf path
|
|
34
|
+
* (D-2 / AC-09; OOS-5 keeps the upload body unimplemented).
|
|
35
|
+
*/
|
|
36
|
+
readonly importExternalTexture?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* AC-09 / D-2 capability probe: decide whether the high-perf zero-copy
|
|
41
|
+
* GPUExternalTexture upload path is available for video this frame. This is the
|
|
42
|
+
* EXPLICIT reserved hook the AC-09 "two paths left in place" acceptance is
|
|
43
|
+
* checked against by code review — it is a real, grep-able code branch, not a
|
|
44
|
+
* TODO comment.
|
|
45
|
+
*
|
|
46
|
+
* The high-perf path requires BOTH a WebGPU backend (GPUExternalTexture is a
|
|
47
|
+
* browser-WebGPU feature) AND the RHI exposing an `importExternalTexture` entry
|
|
48
|
+
* point. The latter does not exist in forgeax today (OOS-5: importing the
|
|
49
|
+
* external texture + a `texture_external` MaterialParamType + WGSL external
|
|
50
|
+
* sampling is out of scope), so this probe ALWAYS returns false and the general
|
|
51
|
+
* `copyExternalImageToTexture` path (record stage) is the sole route end-to-end.
|
|
52
|
+
* The day a future feat lands `importExternalTexture`, this probe flips on for
|
|
53
|
+
* WebGPU backends without touching the call sites.
|
|
54
|
+
*/
|
|
55
|
+
export function probeVideoHighPerfUpload(device: VideoCapabilityDevice | undefined): boolean {
|
|
56
|
+
if (device === undefined) return false;
|
|
57
|
+
// GPUExternalTexture is browser-WebGPU only (wgpu-native / wgpu-webgl2 lack it).
|
|
58
|
+
if (device.caps.backendKind !== 'webgpu') return false;
|
|
59
|
+
// Reserved high-perf hook: available only when the RHI exposes the import
|
|
60
|
+
// entry point. It is absent today (OOS-5), so this is the false-returning
|
|
61
|
+
// boundary the AC-09 two-path code review verifies.
|
|
62
|
+
return typeof device.importExternalTexture === 'function';
|
|
63
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @forgeax/engine-graphics-extras — VideoPlayer ECS component
|
|
2
|
+
// (feat-20260623-world-space-video-asset M3 / w7).
|
|
3
|
+
//
|
|
4
|
+
// Single-component play-state surface for world-space video textures. An
|
|
5
|
+
// entity carries one VideoPlayer referencing a VideoAsset via the `clip`
|
|
6
|
+
// handle; the per-entity playing / loop / currentTime live in independent
|
|
7
|
+
// archetype column slots so multiple entities share one VideoAsset GUID with
|
|
8
|
+
// independent play state (AC-05, research Finding 6).
|
|
9
|
+
//
|
|
10
|
+
// Schema vocab is the CLOSED ECS set (component.ts:315-333): clip uses
|
|
11
|
+
// `shared<VideoAsset>` (a branded u32 handle, NOT a bare GUID string), the
|
|
12
|
+
// three play-state fields use `bool` / `f32`. No opaque / object field type is
|
|
13
|
+
// introduced — the host HTMLVideoElement reference travels through the
|
|
14
|
+
// VideoElementProvider World Resource (plan-strategy D-1 / w9), never inside an
|
|
15
|
+
// ECS field (research Finding 5: schema vocab closed).
|
|
16
|
+
//
|
|
17
|
+
// Decision anchors:
|
|
18
|
+
// - requirements AC-04 (VideoPlayer registers via defineComponent; reference
|
|
19
|
+
// field is a handle type, not a bare GUID; play-state fields playing/loop/
|
|
20
|
+
// currentTime).
|
|
21
|
+
// - plan-strategy D-4 (clip: Handle<'VideoAsset','shared'>, brand string
|
|
22
|
+
// 'VideoAsset' mirrors AudioSource.clip: Handle<'AudioClipAsset','shared'>
|
|
23
|
+
// so AI users carry over the audio naming intuition — charter P4).
|
|
24
|
+
// - charter P1 (progressive disclosure: 4-field minimal surface).
|
|
25
|
+
|
|
26
|
+
import { defineComponent } from '@forgeax/engine-ecs';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* VideoPlayer — attaches video play state to an entity.
|
|
30
|
+
*
|
|
31
|
+
* Fields:
|
|
32
|
+
* - `clip: shared<VideoAsset>` — handle to the VideoAsset describing the
|
|
33
|
+
* source URL (mirrors `AudioSource.clip`). Resolved into an
|
|
34
|
+
* HTMLVideoElement at frame time via the host `VideoElementProvider`
|
|
35
|
+
* (the engine never decodes video bytes — D-1).
|
|
36
|
+
* - `playing: bool` — whether the clip advances this frame (default false).
|
|
37
|
+
* - `loop: bool` — whether the clip restarts at end (default false).
|
|
38
|
+
* - `currentTime: f32` — playback head in seconds (default 0).
|
|
39
|
+
*
|
|
40
|
+
* Multiple entities may reference the same `clip` GUID with distinct
|
|
41
|
+
* play state — each entity's playing / loop / currentTime occupy independent
|
|
42
|
+
* archetype column slots (AC-05).
|
|
43
|
+
*/
|
|
44
|
+
export const VideoPlayer = defineComponent('VideoPlayer', {
|
|
45
|
+
// The host HTMLVideoElement owns the live asset/presentation binding; the
|
|
46
|
+
// portable play controls remain in the simulation projection.
|
|
47
|
+
clip: { type: 'shared<VideoAsset>' },
|
|
48
|
+
playing: { type: 'bool', default: false },
|
|
49
|
+
loop: { type: 'bool', default: false },
|
|
50
|
+
currentTime: { type: 'f32', default: 0, transient: true },
|
|
51
|
+
});
|