@forgeax/engine-graphics-extras 0.1.2
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-probe.unit.test.d.ts +2 -0
- package/dist/__tests__/video-capability-probe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/video-loader.unit.test.d.ts +2 -0
- package/dist/__tests__/video-loader.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 +37 -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-probe.unit.test.ts +130 -0
- package/src/__tests__/video-loader.unit.test.ts +27 -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 +36 -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 +61 -0
- package/src/video-player.ts +51 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"video-player-system.d.ts","sourceRoot":"","sources":["../src/video-player-system.ts"],"names":[],"mappings":"AAkBA;;;;;GAKG;AACH,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,IAAI,EAAE;QAAE,QAAQ,CAAC,WAAW,EAAE,QAAQ,GAAG,aAAa,GAAG,aAAa,GAAG,MAAM,CAAA;KAAE,CAAC;IAC3F;;;;;;OAMG;IACH,QAAQ,CAAC,qBAAqB,CAAC,EAAE,OAAO,CAAC;CAC1C;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,wBAAwB,CAAC,MAAM,EAAE,qBAAqB,GAAG,SAAS,GAAG,OAAO,CAQ3F"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VideoPlayer — attaches video play state to an entity.
|
|
3
|
+
*
|
|
4
|
+
* Fields:
|
|
5
|
+
* - `clip: shared<VideoAsset>` — handle to the VideoAsset describing the
|
|
6
|
+
* source URL (mirrors `AudioSource.clip`). Resolved into an
|
|
7
|
+
* HTMLVideoElement at frame time via the host `VideoElementProvider`
|
|
8
|
+
* (the engine never decodes video bytes — D-1).
|
|
9
|
+
* - `playing: bool` — whether the clip advances this frame (default false).
|
|
10
|
+
* - `loop: bool` — whether the clip restarts at end (default false).
|
|
11
|
+
* - `currentTime: f32` — playback head in seconds (default 0).
|
|
12
|
+
*
|
|
13
|
+
* Multiple entities may reference the same `clip` GUID with distinct
|
|
14
|
+
* play state — each entity's playing / loop / currentTime occupy independent
|
|
15
|
+
* archetype column slots (AC-05).
|
|
16
|
+
*/
|
|
17
|
+
export declare const VideoPlayer: import("@forgeax/engine-ecs").Component<"VideoPlayer", {
|
|
18
|
+
readonly clip: "shared<VideoAsset>";
|
|
19
|
+
readonly playing: "bool";
|
|
20
|
+
readonly loop: "bool";
|
|
21
|
+
readonly currentTime: "f32";
|
|
22
|
+
}>;
|
|
23
|
+
//# sourceMappingURL=video-player.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"video-player.d.ts","sourceRoot":"","sources":["../src/video-player.ts"],"names":[],"mappings":"AA2BA;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,WAAW;;;;;EAOtB,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@forgeax/engine-graphics-extras",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"description": "Pure-logic graphics-adjacent modules -- text glyph layout + mesh bake, tilemap bit encoding, and video playback -- for forgeax-engine (Tier 2.3 -- extracted from @forgeax/engine-runtime).",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.mjs"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"main": "./dist/index.mjs",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"src",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@forgeax/engine-ecs": "0.1.2",
|
|
26
|
+
"@forgeax/engine-geometry": "0.1.2",
|
|
27
|
+
"@forgeax/engine-rhi": "0.1.2",
|
|
28
|
+
"@forgeax/engine-types": "0.1.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/node": "^20.14.0"
|
|
32
|
+
},
|
|
33
|
+
"forgeax": {
|
|
34
|
+
"metrics": {
|
|
35
|
+
"bundle-size": {
|
|
36
|
+
"enabled": true,
|
|
37
|
+
"path": "dist/index.mjs",
|
|
38
|
+
"compression": "gzip"
|
|
39
|
+
},
|
|
40
|
+
"fps": {
|
|
41
|
+
"enabled": false,
|
|
42
|
+
"reason": "pure-logic glyph/tilemap/video modules, no runtime canvas; fps reported by hello-* apps that consume them"
|
|
43
|
+
},
|
|
44
|
+
"bench": {
|
|
45
|
+
"enabled": false,
|
|
46
|
+
"reason": "glyph layout + mesh bake run at text-change time, tile-bit codec is O(1) per cell; no per-frame hot path warranting a CI micro-benchmark"
|
|
47
|
+
},
|
|
48
|
+
"gate": {
|
|
49
|
+
"enabled": false,
|
|
50
|
+
"reason": "no package-level binary gate; smoke gate covered by tilemap-object-layer / video-texture / text hello-* apps that consume these modules"
|
|
51
|
+
},
|
|
52
|
+
"spike-report": {
|
|
53
|
+
"enabled": false,
|
|
54
|
+
"reason": "not a spike package; graphics-extras cluster extracted from runtime in feat-20260705-runtime-tier2-decomposition"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"scripts": {
|
|
59
|
+
"build": "tsup",
|
|
60
|
+
"test": "vitest run"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// tile-bits.test - encodeTileBits / decodeTileBits round-trip + boundary tests
|
|
2
|
+
// (feat-20260608 M0 baseline rebuild).
|
|
3
|
+
//
|
|
4
|
+
// Tile id encoding (Tiled .tmj wire format compatibility, feat-20260604 D-2):
|
|
5
|
+
// - low 28 bits: tileId (0..0x0FFFFFFF).
|
|
6
|
+
// - high 4 bits, MSB -> LSB: flipHorizontal, flipVertical, flipDiagonal,
|
|
7
|
+
// flipHex120 (the latter is the Tiled hex 120deg flip slot; preserved for
|
|
8
|
+
// wire fidelity even though M0 does not consume it).
|
|
9
|
+
// - tileId === 0 means "empty cell" downstream in TileLayer.tiles (sentinel).
|
|
10
|
+
//
|
|
11
|
+
// Anchors: requirements integration points (engine-runtime); plan-tasks m0-t3.
|
|
12
|
+
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import { decodeTileBits, encodeTileBits } from '../tile-bits';
|
|
15
|
+
|
|
16
|
+
const FLIP_VARIANTS: ReadonlyArray<readonly [boolean, boolean, boolean, boolean]> = [
|
|
17
|
+
[false, false, false, false],
|
|
18
|
+
[true, false, false, false],
|
|
19
|
+
[false, true, false, false],
|
|
20
|
+
[false, false, true, false],
|
|
21
|
+
[false, false, false, true],
|
|
22
|
+
[true, true, false, false],
|
|
23
|
+
[true, false, true, false],
|
|
24
|
+
[true, true, true, true],
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const TILE_ID_BOUNDARIES: readonly number[] = [0, 1, 0x0fffffff - 1, 0x0fffffff];
|
|
28
|
+
|
|
29
|
+
describe('encodeTileBits / decodeTileBits round-trip (M0 baseline)', () => {
|
|
30
|
+
for (const tileId of TILE_ID_BOUNDARIES) {
|
|
31
|
+
for (const [flipH, flipV, flipDiagonal, flipHex120] of FLIP_VARIANTS) {
|
|
32
|
+
it(`tileId=${tileId} flipH=${flipH} flipV=${flipV} flipD=${flipDiagonal} flipHex120=${flipHex120}`, () => {
|
|
33
|
+
const packed = encodeTileBits(tileId, flipH, flipV, flipDiagonal, flipHex120);
|
|
34
|
+
const decoded = decodeTileBits(packed);
|
|
35
|
+
expect(decoded.tileId).toBe(tileId);
|
|
36
|
+
expect(decoded.flipH).toBe(flipH);
|
|
37
|
+
expect(decoded.flipV).toBe(flipV);
|
|
38
|
+
expect(decoded.flipDiagonal).toBe(flipDiagonal);
|
|
39
|
+
expect(decoded.flipHex120).toBe(flipHex120);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('encodeTileBits — high-bit wire layout (MSB H / V / D / Hex120)', () => {
|
|
46
|
+
it('flipH sets bit 31 (MSB)', () => {
|
|
47
|
+
const packed = encodeTileBits(0, true, false, false, false);
|
|
48
|
+
expect(packed >>> 31).toBe(1);
|
|
49
|
+
expect((packed >>> 30) & 0x1).toBe(0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('flipV sets bit 30', () => {
|
|
53
|
+
const packed = encodeTileBits(0, false, true, false, false);
|
|
54
|
+
expect((packed >>> 31) & 0x1).toBe(0);
|
|
55
|
+
expect((packed >>> 30) & 0x1).toBe(1);
|
|
56
|
+
expect((packed >>> 29) & 0x1).toBe(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('flipDiagonal sets bit 29', () => {
|
|
60
|
+
const packed = encodeTileBits(0, false, false, true, false);
|
|
61
|
+
expect((packed >>> 29) & 0x1).toBe(1);
|
|
62
|
+
expect((packed >>> 28) & 0x1).toBe(0);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('flipHex120 sets bit 28', () => {
|
|
66
|
+
const packed = encodeTileBits(0, false, false, false, true);
|
|
67
|
+
expect((packed >>> 28) & 0x1).toBe(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('tileId fits in the low 28 bits', () => {
|
|
71
|
+
const packed = encodeTileBits(0x0fffffff, false, false, false, false);
|
|
72
|
+
expect(packed >>> 0).toBe(0x0fffffff);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('encodeTileBits — overflow RangeError (charter P3)', () => {
|
|
77
|
+
it('tileId === 0x10000000 throws RangeError', () => {
|
|
78
|
+
expect(() => encodeTileBits(0x10000000, false, false, false, false)).toThrow(RangeError);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('tileId < 0 throws RangeError', () => {
|
|
82
|
+
expect(() => encodeTileBits(-1, false, false, false, false)).toThrow(RangeError);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('non-integer tileId throws RangeError', () => {
|
|
86
|
+
expect(() => encodeTileBits(1.5, false, false, false, false)).toThrow(RangeError);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// feat-20260623-world-space-video-asset M4 / w17 — AC-09 capability probe.
|
|
2
|
+
//
|
|
3
|
+
// AC-09 / D-2: the high-perf GPUExternalTexture upload path is left in place as
|
|
4
|
+
// an EXPLICIT, grep-able capability-probe branch (not a TODO comment); the
|
|
5
|
+
// general copyExternalImageToTexture path is the one actually wired (w16). This
|
|
6
|
+
// test pins the probe's truth table + the source-level guarantee that the
|
|
7
|
+
// reserved hook is a real code branch keyed on backendKind +
|
|
8
|
+
// importExternalTexture presence.
|
|
9
|
+
//
|
|
10
|
+
// OOS-5: the high-perf upload body is intentionally NOT implemented; the probe
|
|
11
|
+
// returns false for every device the engine produces today (no RHI exposes
|
|
12
|
+
// `importExternalTexture`). The probe flips on automatically the day a future
|
|
13
|
+
// feat adds that entry point on a WebGPU backend.
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
19
|
+
import { videoLoader } from '../video-loader';
|
|
20
|
+
import { probeVideoHighPerfUpload, type VideoCapabilityDevice } from '../video-player-system';
|
|
21
|
+
|
|
22
|
+
const SYS_SRC = fileURLToPath(new URL('../video-player-system.ts', import.meta.url));
|
|
23
|
+
|
|
24
|
+
describe('AC-09 — probeVideoHighPerfUpload truth table (M4 / w17)', () => {
|
|
25
|
+
it('rejects a malformed durable URL descriptor without fetching', () => {
|
|
26
|
+
expect(
|
|
27
|
+
videoLoader.load({ kind: 'video', url: 'not a url' }, undefined, {} as never),
|
|
28
|
+
).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it.each([
|
|
32
|
+
['/cutscene.webm', '/cutscene.webm'],
|
|
33
|
+
['cutscene.webm', 'cutscene.webm'],
|
|
34
|
+
['http://cdn.example/video.mp4', 'http://cdn.example/video.mp4'],
|
|
35
|
+
['https://cdn.example/video.mp4', 'https://cdn.example/video.mp4'],
|
|
36
|
+
] as const)('accepts browser-resolvable URL %s without normalizing it', (input, output) => {
|
|
37
|
+
expect(videoLoader.load({ kind: 'video', url: input }, undefined, {} as never)).toEqual({
|
|
38
|
+
kind: 'video',
|
|
39
|
+
url: output,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns a valid URL descriptor without invoking network fetch', () => {
|
|
44
|
+
const fetch = vi.fn(() => Promise.reject(new Error('video Cook must not fetch')));
|
|
45
|
+
vi.stubGlobal('fetch', fetch);
|
|
46
|
+
try {
|
|
47
|
+
expect(
|
|
48
|
+
videoLoader.load(
|
|
49
|
+
{ kind: 'video', url: 'https://cdn.example/video.mp4' },
|
|
50
|
+
undefined,
|
|
51
|
+
{} as never,
|
|
52
|
+
),
|
|
53
|
+
).toEqual({ kind: 'video', url: 'https://cdn.example/video.mp4' });
|
|
54
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
55
|
+
} finally {
|
|
56
|
+
vi.unstubAllGlobals();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
'javascript:alert(1)',
|
|
62
|
+
'data:video/webm;base64,AAAA',
|
|
63
|
+
'file:///tmp/cutscene.webm',
|
|
64
|
+
'//cdn.example/cutscene.webm',
|
|
65
|
+
'cut\nscene.webm',
|
|
66
|
+
'',
|
|
67
|
+
' ',
|
|
68
|
+
])('rejects unsafe or empty URL %j', (url) => {
|
|
69
|
+
expect(videoLoader.load({ kind: 'video', url }, undefined, {} as never)).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it.each([undefined, null, 42, {}, []])('rejects non-string URL %j', (url) => {
|
|
73
|
+
expect(videoLoader.load({ kind: 'video', url }, undefined, {} as never)).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('keeps descriptor loading independent from the optional GPU capability', () => {
|
|
77
|
+
expect(probeVideoHighPerfUpload(undefined)).toBe(false);
|
|
78
|
+
expect(
|
|
79
|
+
videoLoader.load(
|
|
80
|
+
{ kind: 'video', url: 'https://cdn.example/cutscene.webm' },
|
|
81
|
+
undefined,
|
|
82
|
+
{} as never,
|
|
83
|
+
),
|
|
84
|
+
).toEqual({ kind: 'video', url: 'https://cdn.example/cutscene.webm' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('returns false when no device is wired', () => {
|
|
88
|
+
expect(probeVideoHighPerfUpload(undefined)).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('returns false on a WebGPU backend without importExternalTexture (today, OOS-5)', () => {
|
|
92
|
+
const device: VideoCapabilityDevice = { caps: { backendKind: 'webgpu' } };
|
|
93
|
+
expect(probeVideoHighPerfUpload(device)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns false on non-WebGPU backends even if importExternalTexture existed', () => {
|
|
97
|
+
const native: VideoCapabilityDevice = {
|
|
98
|
+
caps: { backendKind: 'wgpu-native' },
|
|
99
|
+
importExternalTexture: () => undefined,
|
|
100
|
+
};
|
|
101
|
+
const webgl2: VideoCapabilityDevice = {
|
|
102
|
+
caps: { backendKind: 'wgpu-webgl2' },
|
|
103
|
+
importExternalTexture: () => undefined,
|
|
104
|
+
};
|
|
105
|
+
expect(probeVideoHighPerfUpload(native)).toBe(false);
|
|
106
|
+
expect(probeVideoHighPerfUpload(webgl2)).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('returns true ONLY when a WebGPU backend exposes importExternalTexture (future hook)', () => {
|
|
110
|
+
// Simulates the day a future feat lands the RHI entry point. Proves the
|
|
111
|
+
// reserved branch is live (not dead code) and flips on without touching the
|
|
112
|
+
// call sites — the AC-09 "two paths left in place" guarantee.
|
|
113
|
+
const future: VideoCapabilityDevice = {
|
|
114
|
+
caps: { backendKind: 'webgpu' },
|
|
115
|
+
importExternalTexture: () => undefined,
|
|
116
|
+
};
|
|
117
|
+
expect(probeVideoHighPerfUpload(future)).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe('AC-09 — reserved high-perf hook is an explicit code branch (M4 / w17)', () => {
|
|
122
|
+
it('the probe source references GPUExternalTexture import as a real condition, not a comment', () => {
|
|
123
|
+
const src = readFileSync(SYS_SRC, 'utf8');
|
|
124
|
+
const stripped = src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, '');
|
|
125
|
+
// Executable code (comments stripped) must test importExternalTexture
|
|
126
|
+
// presence and gate on the WebGPU backend — the grep-able two-path boundary.
|
|
127
|
+
expect(stripped.includes('importExternalTexture')).toBe(true);
|
|
128
|
+
expect(stripped.includes('backendKind')).toBe(true);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { videoContribution, videoLoader } from '../video-loader';
|
|
3
|
+
|
|
4
|
+
describe('video asset owner', () => {
|
|
5
|
+
it('exposes the host video consumer contribution', () => {
|
|
6
|
+
expect(videoContribution.kind.kind).toBe('video');
|
|
7
|
+
expect(videoContribution.consumer).toBe('VideoElementProvider');
|
|
8
|
+
});
|
|
9
|
+
it('returns the durable descriptor for a browser-resolvable source', () => {
|
|
10
|
+
expect(
|
|
11
|
+
videoLoader.load(
|
|
12
|
+
{ kind: 'video', url: 'https://cdn.example/cutscene.webm' },
|
|
13
|
+
undefined,
|
|
14
|
+
{} as never,
|
|
15
|
+
),
|
|
16
|
+
).toEqual({
|
|
17
|
+
kind: 'video',
|
|
18
|
+
url: 'https://cdn.example/cutscene.webm',
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('returns no payload for a malformed durable source', () => {
|
|
23
|
+
expect(
|
|
24
|
+
videoLoader.load({ kind: 'video', url: 'javascript:alert(1)' }, undefined, {} as never),
|
|
25
|
+
).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// feat-20260623-world-space-video-asset M3 / w6 — AC-04: VideoPlayer ECS
|
|
2
|
+
// component registration + mount + read-back.
|
|
3
|
+
//
|
|
4
|
+
// AC-04 (requirements.md:66): a new `VideoPlayer` component must register
|
|
5
|
+
// through `defineComponent`, mount on a spawned entity, and read back its
|
|
6
|
+
// field values via `world.get`. The reference field `clip` must be a handle
|
|
7
|
+
// type (NOT a bare GUID string); the field set must include at least
|
|
8
|
+
// playing / loop / currentTime.
|
|
9
|
+
//
|
|
10
|
+
// Decision anchors:
|
|
11
|
+
// - plan-strategy D-4 (clip: Handle<'VideoAsset','shared'>, brand string
|
|
12
|
+
// 'VideoAsset' aligns with AudioSource.clip: Handle<'AudioClipAsset',
|
|
13
|
+
// 'shared'>).
|
|
14
|
+
// - research Finding 5 (ECS schema vocab is closed — VideoPlayer fields use
|
|
15
|
+
// only shared<T> / bool / f32, no opaque/object field type).
|
|
16
|
+
// - charter P4 (consistent abstraction: same defineComponent pattern as
|
|
17
|
+
// AudioSource / Transform / Camera).
|
|
18
|
+
|
|
19
|
+
import { World } from '@forgeax/engine-ecs';
|
|
20
|
+
import type { Handle } from '@forgeax/engine-types';
|
|
21
|
+
import { toShared } from '@forgeax/engine-types';
|
|
22
|
+
import { describe, expect, it } from 'vitest';
|
|
23
|
+
|
|
24
|
+
import { VideoPlayer } from '../video-player';
|
|
25
|
+
|
|
26
|
+
describe('AC-04 — VideoPlayer component registration + mount + read-back', () => {
|
|
27
|
+
it('VideoPlayer is a schema token owned by the importing package', () => {
|
|
28
|
+
expect(VideoPlayer.name).toBe('VideoPlayer');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('spawn entity with VideoPlayer + read back clip/playing/loop/currentTime', () => {
|
|
32
|
+
const world = new World();
|
|
33
|
+
const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(42);
|
|
34
|
+
|
|
35
|
+
const e = world
|
|
36
|
+
.spawn({
|
|
37
|
+
component: VideoPlayer,
|
|
38
|
+
data: { clip, playing: true, loop: true, currentTime: 3.5 },
|
|
39
|
+
})
|
|
40
|
+
.unwrap();
|
|
41
|
+
|
|
42
|
+
const r = world.get(e, VideoPlayer).unwrap();
|
|
43
|
+
// clip is the raw u32 carried inside the Handle brand (43 in handle terms).
|
|
44
|
+
expect(r.clip).toBe(42);
|
|
45
|
+
expect(r.playing).toBe(true);
|
|
46
|
+
expect(r.loop).toBe(true);
|
|
47
|
+
expect(r.currentTime).toBe(3.5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('clip is a handle type (numeric u32), not a bare GUID string', () => {
|
|
51
|
+
const world = new World();
|
|
52
|
+
const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(7);
|
|
53
|
+
|
|
54
|
+
const e = world.spawn({ component: VideoPlayer, data: { clip } }).unwrap();
|
|
55
|
+
|
|
56
|
+
const r = world.get(e, VideoPlayer).unwrap();
|
|
57
|
+
// AC-04 red line: the reference field is a handle (number), never a GUID
|
|
58
|
+
// string. A bare GUID string would fail the typeof check below.
|
|
59
|
+
expect(typeof r.clip).toBe('number');
|
|
60
|
+
expect(r.clip).toBe(7);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('playing/loop/currentTime default to false/false/0 when omitted at spawn', () => {
|
|
64
|
+
const world = new World();
|
|
65
|
+
const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(1);
|
|
66
|
+
|
|
67
|
+
const e = world.spawn({ component: VideoPlayer, data: { clip } }).unwrap();
|
|
68
|
+
const r = world.get(e, VideoPlayer).unwrap();
|
|
69
|
+
expect(r.playing).toBe(false);
|
|
70
|
+
expect(r.loop).toBe(false);
|
|
71
|
+
expect(r.currentTime).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// feat-20260623-world-space-video-asset M3 / w8 — AC-05: multiple entities
|
|
2
|
+
// share one VideoAsset GUID with independent play state.
|
|
3
|
+
//
|
|
4
|
+
// AC-05 (requirements.md:67): two entities referencing the SAME VideoAsset
|
|
5
|
+
// GUID, each with its own VideoPlayer, may hold different loop / currentTime
|
|
6
|
+
// without crosstalk. Per-entity playing / loop / currentTime occupy
|
|
7
|
+
// independent archetype column slots (research Finding 6 — AudioSource already
|
|
8
|
+
// proves this pattern).
|
|
9
|
+
//
|
|
10
|
+
// Decision anchors:
|
|
11
|
+
// - requirements AC-05 (same GUID, different play state, no crosstalk).
|
|
12
|
+
// - research Finding 6 (ECS archetype columns isolate per-entity fields).
|
|
13
|
+
|
|
14
|
+
import { World } from '@forgeax/engine-ecs';
|
|
15
|
+
import type { Handle } from '@forgeax/engine-types';
|
|
16
|
+
import { toShared } from '@forgeax/engine-types';
|
|
17
|
+
import { describe, expect, it } from 'vitest';
|
|
18
|
+
|
|
19
|
+
import { VideoPlayer } from '../video-player';
|
|
20
|
+
|
|
21
|
+
describe('AC-05 — multi-entity VideoPlayer independent play state', () => {
|
|
22
|
+
it('two entities share one VideoAsset GUID with distinct loop/currentTime', () => {
|
|
23
|
+
const world = new World();
|
|
24
|
+
// Same VideoAsset handle (one GUID) referenced by both entities.
|
|
25
|
+
const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(99);
|
|
26
|
+
|
|
27
|
+
const a = world
|
|
28
|
+
.spawn({
|
|
29
|
+
component: VideoPlayer,
|
|
30
|
+
data: { clip, playing: true, loop: true, currentTime: 5 },
|
|
31
|
+
})
|
|
32
|
+
.unwrap();
|
|
33
|
+
const b = world
|
|
34
|
+
.spawn({
|
|
35
|
+
component: VideoPlayer,
|
|
36
|
+
data: { clip, playing: false, loop: false, currentTime: 10 },
|
|
37
|
+
})
|
|
38
|
+
.unwrap();
|
|
39
|
+
|
|
40
|
+
const ra = world.get(a, VideoPlayer).unwrap();
|
|
41
|
+
const rb = world.get(b, VideoPlayer).unwrap();
|
|
42
|
+
|
|
43
|
+
// Both reference the identical clip GUID.
|
|
44
|
+
expect(ra.clip).toBe(99);
|
|
45
|
+
expect(rb.clip).toBe(99);
|
|
46
|
+
|
|
47
|
+
// Play state is per-entity, no crosstalk.
|
|
48
|
+
expect(ra.loop).toBe(true);
|
|
49
|
+
expect(rb.loop).toBe(false);
|
|
50
|
+
expect(ra.currentTime).toBe(5);
|
|
51
|
+
expect(rb.currentTime).toBe(10);
|
|
52
|
+
expect(ra.playing).toBe(true);
|
|
53
|
+
expect(rb.playing).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('mutating entity A play state does not leak into entity B', () => {
|
|
57
|
+
const world = new World();
|
|
58
|
+
const clip: Handle<'VideoAsset', 'shared'> = toShared<'VideoAsset'>(42);
|
|
59
|
+
|
|
60
|
+
const a = world.spawn({ component: VideoPlayer, data: { clip, currentTime: 0 } }).unwrap();
|
|
61
|
+
const b = world.spawn({ component: VideoPlayer, data: { clip, currentTime: 0 } }).unwrap();
|
|
62
|
+
|
|
63
|
+
// Advance only A's playhead.
|
|
64
|
+
world.set(a, VideoPlayer, { currentTime: 7.25, loop: true });
|
|
65
|
+
|
|
66
|
+
const ra = world.get(a, VideoPlayer).unwrap();
|
|
67
|
+
const rb = world.get(b, VideoPlayer).unwrap();
|
|
68
|
+
|
|
69
|
+
expect(ra.currentTime).toBe(7.25);
|
|
70
|
+
expect(ra.loop).toBe(true);
|
|
71
|
+
// B untouched — independent column slot.
|
|
72
|
+
expect(rb.currentTime).toBe(0);
|
|
73
|
+
expect(rb.loop).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
});
|