@pixel-point/toolcraft 0.0.16 → 0.0.17
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/package.json +1 -1
- package/src/generate-performance-iteration-execution.test.mjs +2 -2
- package/src/generate-test-demand-only-lifecycle.mjs +14 -3
- package/src/generate-test-dependency-sandbox.mjs +42 -5
- package/src/generate-test-doc-assertions.mjs +68 -18
- package/src/generate-test-doc-assertions.test.mjs +22 -0
- package/src/generate-test-file-assertions.mjs +43 -76
- package/src/generate-test-output-assertions.mjs +95 -0
- package/src/generate-test-package-assertions.mjs +6 -0
- package/src/generate-test-performance-boundary-assertions.mjs +45 -0
- package/src/generate-test-product-fixture.mjs +1 -0
- package/src/generate-test-product-fixture.test.mjs +18 -0
- package/src/generate-test-product-iteration.mjs +1 -0
- package/src/generate-test-product-iteration.test.mjs +1 -0
- package/src/generate-test-standalone-delivery-stages.mjs +14 -6
- package/src/generate.test.mjs +14 -0
- package/templates/runtime/contracts/component-contracts.choices.test.ts +12 -9
- package/templates/runtime/contracts/component-contracts.choices.ts +10 -9
- package/templates/runtime/contracts/component-contracts.runtime-acceptance.test.ts +17 -2
- package/templates/runtime/contracts/component-contracts.runtime-panels.test.ts +1 -1
- package/templates/runtime/contracts/component-contracts.runtime-shell.test.ts +1 -1
- package/templates/runtime/contracts/component-contracts.runtime.ts +3 -2
- package/templates/runtime/contracts/decision-contracts.test.ts +25 -4
- package/templates/runtime/contracts/decision-contracts.ts +3 -3
- package/templates/runtime/contracts/performance-verification-policy.test.ts +59 -6
- package/templates/runtime/contracts/performance-verification-policy.ts +5 -2
- package/templates/runtime/export/artifact-download.test.ts +71 -0
- package/templates/runtime/export/artifact-download.ts +80 -0
- package/templates/runtime/export/artifact-export-request.ts +23 -0
- package/templates/runtime/export/artifact-export-settings.test.ts +75 -0
- package/templates/runtime/export/artifact-export-settings.ts +96 -0
- package/templates/runtime/export/artifact-frame-renderer.test.ts +107 -0
- package/templates/runtime/export/artifact-frame-renderer.ts +96 -0
- package/templates/runtime/export/artifact-frame-state.test.ts +54 -0
- package/templates/runtime/export/artifact-frame-state.ts +44 -0
- package/templates/runtime/export/artifact-scene-frame.test.ts +84 -0
- package/templates/runtime/export/artifact-scene-frame.ts +118 -0
- package/templates/runtime/export/export-background.ts +65 -0
- package/templates/runtime/export/export-error.ts +43 -0
- package/templates/runtime/export/export.test.ts +6 -186
- package/templates/runtime/export/image-artifact-export.test.ts +111 -0
- package/templates/runtime/export/image-artifact-export.ts +145 -0
- package/templates/runtime/export/index.ts +14 -1
- package/templates/runtime/export/product-export-renderer.test.ts +77 -0
- package/templates/runtime/export/product-export-renderer.ts +63 -0
- package/templates/runtime/export/video-artifact-export.test.ts +166 -0
- package/templates/runtime/export/video-artifact-export.ts +164 -0
- package/templates/runtime/export/video-encoding-backend.ts +124 -0
- package/templates/runtime/export/video-encoding-policy.test.ts +68 -0
- package/templates/runtime/export/video-encoding-policy.ts +86 -0
- package/templates/runtime/export/video-frame-schedule.test.ts +58 -0
- package/templates/runtime/export/video-frame-schedule.ts +42 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/app-shell/runtime-public-api-runtime.test.tsx +291 -0
- package/templates/runtime/react/app-shell/runtime-public-api-test-support.ts +21 -0
- package/templates/runtime/react/app-shell/runtime-public-api.test.tsx +4 -303
- package/templates/runtime/react/app-shell/toolcraft-app.scene-export.test.tsx +12 -27
- package/templates/runtime/react/app-shell/toolcraft-app.tsx +22 -4
- package/templates/runtime/react/app-shell/toolcraft-source-asset-context.test.tsx +16 -57
- package/templates/runtime/react/app-shell/toolcraft-source-asset-context.tsx +75 -434
- package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.test.ts +194 -0
- package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.ts +227 -0
- package/templates/runtime/react/app-shell/toolcraft-source-asset-provider-ownership.test.tsx +243 -0
- package/templates/runtime/react/app-shell/toolcraft-source-asset-test-support.ts +51 -0
- package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.test.ts +138 -0
- package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.ts +378 -0
- package/templates/runtime/react/controls-panel/__tests__/controls-panel.footer-actions.test.tsx +7 -12
- package/templates/runtime/react/controls-panel/__tests__/controls-panel.scene-export-actions.test.tsx +17 -83
- package/templates/runtime/react/controls-panel/actions/controls-panel-actions.ts +42 -84
- package/templates/runtime/react/controls-panel/actions/export-action-runner.ts +62 -0
- package/templates/runtime/react/panel-host/panel-host-runtime-placement.test.tsx +30 -0
- package/templates/runtime/react/panel-host/panel-host.tsx +4 -0
- package/templates/runtime/scene/scene-bounds.ts +1 -2
- package/templates/runtime/schema/canvas-render-scale.ts +62 -0
- package/templates/runtime/schema/controls-panel-section-id.test.ts +1 -1
- package/templates/runtime/schema/controls-panel-section-roundtrip.test.ts +3 -3
- package/templates/runtime/schema/define-toolcraft.setup-background.test.ts +1 -1
- package/templates/runtime/schema/define-toolcraft.setup-render-scale-timeline.test.ts +71 -3
- package/templates/runtime/schema/define-toolcraft.ts +1 -1
- package/templates/runtime/schema/schema-resolvers.ts +0 -56
- package/templates/runtime/schema/types.ts +4 -4
- package/templates/runtime/state/canvas-background-state.ts +6 -0
- package/templates/runtime/testing/performance-render-plan-integration.test.ts +64 -0
- package/templates/runtime/testing/performance-renderer-pipeline-validation.ts +18 -1
- package/templates/starter/AGENTS.md +8 -7
- package/templates/starter/NOTICE.md +6 -0
- package/templates/starter/docs/toolcraft/README.md +2 -1
- package/templates/starter/docs/toolcraft/acceptance-testing.md +8 -6
- package/templates/starter/docs/toolcraft/agent-worklog.md +3 -3
- package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -3
- package/templates/starter/docs/toolcraft/core/media-upload.md +2 -2
- package/templates/starter/docs/toolcraft/core/performance.md +14 -3
- package/templates/starter/docs/toolcraft/core/runtime-boundary.md +3 -1
- package/templates/starter/docs/toolcraft/core/setup-export.md +16 -12
- package/templates/starter/docs/toolcraft/core/timeline-animation.md +4 -4
- package/templates/starter/docs/toolcraft/performance.md +20 -6
- package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
- package/templates/starter/docs/toolcraft/schema-reference.md +2 -6
- package/templates/starter/docs/toolcraft/workflow.md +7 -2
- package/templates/starter/e2e/app-browser-control-coverage.spec.ts +33 -0
- package/templates/starter/e2e/app-browser-infinity-canvas-evidence.spec.ts +43 -1
- package/templates/starter/e2e/app-browser-render-scale-evidence.spec.ts +60 -6
- package/templates/starter/e2e/app-browser-render-scale-requirements.spec.ts +77 -1
- package/templates/starter/e2e/app-browser-runtime-provenance.spec.ts +8 -0
- package/templates/starter/e2e/app-browser-runtime-reporter.spec.ts +35 -0
- package/templates/starter/e2e/app-browser-runtime-requirements.spec.ts +4 -1
- package/templates/starter/e2e/app-browser-semantic-evidence.spec.ts +0 -1
- package/templates/starter/e2e/app-browser-shell.spec.ts +13 -5
- package/templates/starter/e2e/app-performance-path-adapters.ts +8 -1
- package/templates/starter/e2e/app-performance.spec.ts +16 -9
- package/templates/starter/e2e/browser-infinity-canvas-evidence.ts +9 -330
- package/templates/starter/e2e/browser-infinity-canvas-unavailable-image-evidence.ts +370 -0
- package/templates/starter/e2e/browser-media-export-evidence.spec.ts +77 -0
- package/templates/starter/e2e/browser-media-export-evidence.ts +208 -0
- package/templates/starter/e2e/browser-render-scale-evidence.ts +37 -29
- package/templates/starter/e2e/browser-runtime-evidence-reporter.ts +4 -1
- package/templates/starter/e2e/browser-runtime-evidence-requirements.ts +43 -9
- package/templates/starter/e2e/browser-standard-timeline-evidence.ts +106 -0
- package/templates/starter/e2e/decoded-pixel-observation.spec.ts +74 -0
- package/templates/starter/e2e/decoded-pixel-observation.ts +227 -0
- package/templates/starter/e2e/export-artifact-helpers.ts +206 -25
- package/templates/starter/e2e/image-artifact-inspection.spec.ts +41 -0
- package/templates/starter/e2e/image-artifact-inspection.ts +99 -0
- package/templates/starter/e2e/performance-canvas-helpers.ts +22 -21
- package/templates/starter/e2e/performance-canvas-quality-guard.spec.ts +312 -0
- package/templates/starter/e2e/performance-canvas-quality-guard.ts +196 -0
- package/templates/starter/e2e/performance-canvas-quality-observer.ts +351 -0
- package/templates/starter/e2e/performance-control-layout-helpers.ts +2 -2
- package/templates/starter/e2e/performance-path-adapter-contract.ts +4 -4
- package/templates/starter/e2e/performance-path-helpers.spec.ts +203 -70
- package/templates/starter/e2e/performance-path-helpers.ts +210 -91
- package/templates/starter/e2e/performance-pipeline-evidence-continuity.spec.ts +76 -0
- package/templates/starter/e2e/performance-pipeline-evidence-test-fixtures.ts +11 -2
- package/templates/starter/e2e/performance-pipeline-invariants.ts +47 -4
- package/templates/starter/e2e/performance-render-scale-runner.spec.ts +140 -38
- package/templates/starter/e2e/video-artifact-inspection.spec.ts +64 -0
- package/templates/starter/e2e/video-artifact-inspection.ts +281 -0
- package/templates/starter/gitignore +1 -0
- package/templates/starter/package.json +1 -0
- package/templates/starter/scripts/check-toolcraft-docs.mjs +17 -0
- package/templates/starter/scripts/toolcraft-delivery-plan-authority.test.mjs +60 -0
- package/templates/starter/scripts/toolcraft-delivery-plan-errors.test.mjs +7 -2
- package/templates/starter/scripts/toolcraft-delivery-plan.mjs +7 -3
- package/templates/starter/scripts/toolcraft-product-boundary-ast.mjs +24 -2
- package/templates/starter/scripts/toolcraft-product-boundary.mjs +12 -0
- package/templates/starter/scripts/toolcraft-product-boundary.test.mjs +46 -0
- package/templates/starter/scripts/toolcraft-product-evidence-boundary.test.mjs +85 -20
- package/templates/starter/scripts/toolcraft-product-evidence-import-policy.mjs +43 -0
- package/templates/starter/scripts/toolcraft-product-export-boundary.mjs +168 -0
- package/templates/starter/scripts/toolcraft-typescript-analysis.d.mts +73 -0
- package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade-test-helpers.mjs +318 -0
- package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade.test.mjs +155 -206
- package/templates/starter/scripts/toolcraft-verification-inventory.mjs +1 -0
- package/templates/starter/scripts/toolcraft-verification-receipt.test.mjs +51 -0
- package/templates/starter/scripts/toolcraft-vite-env-activation.test.mjs +128 -59
- package/templates/starter/src/app/acceptance/export-artifact-coverage.ts +113 -0
- package/templates/starter/src/app/acceptance/output-background-rules.ts +1 -1
- package/templates/starter/src/app/acceptance/output-export.ts +13 -2
- package/templates/starter/src/app/acceptance/types.ts +7 -0
- package/templates/starter/src/app/acceptance/validate-coverage.ts +2 -1
- package/templates/starter/src/app/starter-acceptance.export-artifact.test.ts +131 -0
- package/templates/starter/src/app/starter-acceptance.keyframes.test.ts +14 -4
- package/templates/starter/src/app/starter-acceptance.output-export-rules.test.ts +42 -1
- package/templates/starter/src/app/starter-acceptance.ts +2 -0
- package/templates/starter/src/app/starter-performance-test-utils.ts +103 -0
- package/templates/starter/src/app/starter-performance.gates.test.ts +231 -278
- package/templates/starter/src/app/starter-performance.lifecycle.test.ts +261 -0
- package/templates/starter/src/app/test-evidence/browser-runtime-contract.ts +2 -0
- package/templates/starter/src/routes/index.tsx +1 -0
- package/templates/runtime/export/export.ts +0 -166
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { ToolcraftVideoExportFormat } from "./artifact-export-settings";
|
|
2
|
+
import { ToolcraftArtifactExportError } from "./export-error";
|
|
3
|
+
import {
|
|
4
|
+
TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES,
|
|
5
|
+
resolveToolcraftVideoEncodingPolicy,
|
|
6
|
+
type ToolcraftVideoCodec,
|
|
7
|
+
} from "./video-encoding-policy";
|
|
8
|
+
|
|
9
|
+
export type ToolcraftVideoEncoderBackend = Readonly<{
|
|
10
|
+
addFrame: (
|
|
11
|
+
timeSeconds: number,
|
|
12
|
+
durationSeconds: number,
|
|
13
|
+
keyFrame: boolean,
|
|
14
|
+
) => Promise<void>;
|
|
15
|
+
cancel: () => Promise<void>;
|
|
16
|
+
extension: ".mp4" | ".webm";
|
|
17
|
+
finalize: () => Promise<Blob>;
|
|
18
|
+
mediaType: "video/mp4" | "video/webm";
|
|
19
|
+
}>;
|
|
20
|
+
|
|
21
|
+
export type ToolcraftVideoEncoderBackendFactoryRequest = Readonly<{
|
|
22
|
+
canvas: HTMLCanvasElement;
|
|
23
|
+
durationSeconds: number;
|
|
24
|
+
height: number;
|
|
25
|
+
requestedFormat: ToolcraftVideoExportFormat;
|
|
26
|
+
width: number;
|
|
27
|
+
}>;
|
|
28
|
+
|
|
29
|
+
export type ToolcraftVideoEncoderBackendFactory = (
|
|
30
|
+
request: ToolcraftVideoEncoderBackendFactoryRequest,
|
|
31
|
+
) => Promise<ToolcraftVideoEncoderBackend>;
|
|
32
|
+
|
|
33
|
+
export async function createToolcraftVideoEncoderBackend(
|
|
34
|
+
request: ToolcraftVideoEncoderBackendFactoryRequest,
|
|
35
|
+
): Promise<ToolcraftVideoEncoderBackend> {
|
|
36
|
+
const mediabunny = await import("mediabunny");
|
|
37
|
+
const codecs: readonly ToolcraftVideoCodec[] = ["avc", "vp9", "vp8"];
|
|
38
|
+
const supportResults = await Promise.all(
|
|
39
|
+
codecs.map((codec) =>
|
|
40
|
+
mediabunny.canEncodeVideo(codec, {
|
|
41
|
+
bitrate: 12_000_000,
|
|
42
|
+
height: request.height,
|
|
43
|
+
width: request.width,
|
|
44
|
+
}),
|
|
45
|
+
),
|
|
46
|
+
);
|
|
47
|
+
const policy = resolveToolcraftVideoEncodingPolicy({
|
|
48
|
+
durationSeconds: request.durationSeconds,
|
|
49
|
+
height: request.height,
|
|
50
|
+
requestedFormat: request.requestedFormat,
|
|
51
|
+
support: {
|
|
52
|
+
avc: supportResults[0] === true,
|
|
53
|
+
vp8: supportResults[2] === true,
|
|
54
|
+
vp9: supportResults[1] === true,
|
|
55
|
+
},
|
|
56
|
+
width: request.width,
|
|
57
|
+
});
|
|
58
|
+
const format =
|
|
59
|
+
policy.mediaType === "video/mp4"
|
|
60
|
+
? new mediabunny.Mp4OutputFormat()
|
|
61
|
+
: new mediabunny.WebMOutputFormat();
|
|
62
|
+
const target = new mediabunny.BufferTarget();
|
|
63
|
+
let overflowError: ToolcraftArtifactExportError | null = null;
|
|
64
|
+
target.on("write", ({ end }) => {
|
|
65
|
+
if (end > TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES && !overflowError) {
|
|
66
|
+
overflowError = new ToolcraftArtifactExportError({
|
|
67
|
+
code: "video-artifact-too-large",
|
|
68
|
+
message: "The encoded video exceeds Toolcraft's artifact limit.",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
const output = new mediabunny.Output({ format, target });
|
|
73
|
+
const source = new mediabunny.CanvasSource(request.canvas, {
|
|
74
|
+
bitrate: policy.bitrate,
|
|
75
|
+
codec: policy.codec,
|
|
76
|
+
keyFrameInterval: 2,
|
|
77
|
+
});
|
|
78
|
+
output.addVideoTrack(source, { frameRate: 30 });
|
|
79
|
+
await output.start();
|
|
80
|
+
let closed = false;
|
|
81
|
+
let finalized = false;
|
|
82
|
+
|
|
83
|
+
function closeSource(): void {
|
|
84
|
+
if (!closed) {
|
|
85
|
+
source.close();
|
|
86
|
+
closed = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return Object.freeze({
|
|
91
|
+
addFrame: async (timeSeconds, durationSeconds, keyFrame) => {
|
|
92
|
+
await source.add(timeSeconds, durationSeconds, { keyFrame });
|
|
93
|
+
if (overflowError) throw overflowError;
|
|
94
|
+
},
|
|
95
|
+
cancel: async () => {
|
|
96
|
+
if (finalized || output.state === "canceled") {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
closeSource();
|
|
100
|
+
await output.cancel();
|
|
101
|
+
},
|
|
102
|
+
extension: policy.extension,
|
|
103
|
+
finalize: async () => {
|
|
104
|
+
closeSource();
|
|
105
|
+
await output.finalize();
|
|
106
|
+
finalized = true;
|
|
107
|
+
if (overflowError) throw overflowError;
|
|
108
|
+
if (!target.buffer) {
|
|
109
|
+
throw new ToolcraftArtifactExportError({
|
|
110
|
+
code: "video-encode-failed",
|
|
111
|
+
message: "Toolcraft video encoding produced no artifact.",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
if (target.buffer.byteLength > TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES) {
|
|
115
|
+
throw new ToolcraftArtifactExportError({
|
|
116
|
+
code: "video-artifact-too-large",
|
|
117
|
+
message: "The encoded video exceeds Toolcraft's artifact limit.",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
return new Blob([target.buffer], { type: policy.mediaType });
|
|
121
|
+
},
|
|
122
|
+
mediaType: policy.mediaType,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES,
|
|
5
|
+
getToolcraftVideoExportBitrate,
|
|
6
|
+
resolveToolcraftVideoEncodingPolicy,
|
|
7
|
+
} from "./video-encoding-policy";
|
|
8
|
+
|
|
9
|
+
describe("video encoding policy", () => {
|
|
10
|
+
it("prefers AVC for MP4 and VP9 then VP8 for WebM", () => {
|
|
11
|
+
const support = { avc: true, vp8: true, vp9: true };
|
|
12
|
+
|
|
13
|
+
expect(
|
|
14
|
+
resolveToolcraftVideoEncodingPolicy({
|
|
15
|
+
durationSeconds: 1,
|
|
16
|
+
height: 1080,
|
|
17
|
+
requestedFormat: "mp4",
|
|
18
|
+
support,
|
|
19
|
+
width: 1920,
|
|
20
|
+
}),
|
|
21
|
+
).toMatchObject({ codec: "avc", extension: ".mp4" });
|
|
22
|
+
expect(
|
|
23
|
+
resolveToolcraftVideoEncodingPolicy({
|
|
24
|
+
durationSeconds: 1,
|
|
25
|
+
height: 1080,
|
|
26
|
+
requestedFormat: "webm",
|
|
27
|
+
support,
|
|
28
|
+
width: 1920,
|
|
29
|
+
}),
|
|
30
|
+
).toMatchObject({ codec: "vp9", extension: ".webm" });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("falls back to the other real container without changing quality", () => {
|
|
34
|
+
expect(
|
|
35
|
+
resolveToolcraftVideoEncodingPolicy({
|
|
36
|
+
durationSeconds: 1,
|
|
37
|
+
height: 2160,
|
|
38
|
+
requestedFormat: "mp4",
|
|
39
|
+
support: { avc: false, vp8: true, vp9: false },
|
|
40
|
+
width: 3840,
|
|
41
|
+
}),
|
|
42
|
+
).toEqual({
|
|
43
|
+
bitrate: 12_000_000,
|
|
44
|
+
codec: "vp8",
|
|
45
|
+
extension: ".webm",
|
|
46
|
+
mediaType: "video/webm",
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("bounds bitrate and the maximum supported artifact", () => {
|
|
51
|
+
expect(getToolcraftVideoExportBitrate(320, 180)).toBe(2_000_000);
|
|
52
|
+
expect(getToolcraftVideoExportBitrate(3840, 2160)).toBe(12_000_000);
|
|
53
|
+
const projectedBytes = (12_000_000 * 60 * 1.1) / 8;
|
|
54
|
+
expect(projectedBytes).toBeLessThan(TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("fails visibly when no timestamped codec is supported", () => {
|
|
58
|
+
expect(() =>
|
|
59
|
+
resolveToolcraftVideoEncodingPolicy({
|
|
60
|
+
durationSeconds: 1,
|
|
61
|
+
height: 1080,
|
|
62
|
+
requestedFormat: "mp4",
|
|
63
|
+
support: { avc: false, vp8: false, vp9: false },
|
|
64
|
+
width: 1920,
|
|
65
|
+
}),
|
|
66
|
+
).toThrow("timestamped video encoder");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { ToolcraftVideoExportFormat } from "./artifact-export-settings";
|
|
2
|
+
import { ToolcraftArtifactExportError } from "./export-error";
|
|
3
|
+
|
|
4
|
+
export const TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES = 96 * 1024 * 1024;
|
|
5
|
+
|
|
6
|
+
export type ToolcraftVideoCodec = "avc" | "vp8" | "vp9";
|
|
7
|
+
|
|
8
|
+
export type ToolcraftVideoEncodingSupport = Readonly<{
|
|
9
|
+
avc: boolean;
|
|
10
|
+
vp8: boolean;
|
|
11
|
+
vp9: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
export type ToolcraftVideoEncodingPolicy = Readonly<{
|
|
15
|
+
bitrate: number;
|
|
16
|
+
codec: ToolcraftVideoCodec;
|
|
17
|
+
extension: ".mp4" | ".webm";
|
|
18
|
+
mediaType: "video/mp4" | "video/webm";
|
|
19
|
+
}>;
|
|
20
|
+
|
|
21
|
+
type ToolcraftVideoEncodingCandidate = Readonly<{
|
|
22
|
+
codec: ToolcraftVideoCodec;
|
|
23
|
+
extension: ".mp4" | ".webm";
|
|
24
|
+
mediaType: "video/mp4" | "video/webm";
|
|
25
|
+
}>;
|
|
26
|
+
|
|
27
|
+
const mp4Candidate: ToolcraftVideoEncodingCandidate = Object.freeze({
|
|
28
|
+
codec: "avc",
|
|
29
|
+
extension: ".mp4",
|
|
30
|
+
mediaType: "video/mp4",
|
|
31
|
+
});
|
|
32
|
+
const webmCandidates: readonly ToolcraftVideoEncodingCandidate[] = Object.freeze([
|
|
33
|
+
Object.freeze({ codec: "vp9", extension: ".webm", mediaType: "video/webm" }),
|
|
34
|
+
Object.freeze({ codec: "vp8", extension: ".webm", mediaType: "video/webm" }),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export function getToolcraftVideoExportBitrate(
|
|
38
|
+
width: number,
|
|
39
|
+
height: number,
|
|
40
|
+
): number {
|
|
41
|
+
return Math.max(
|
|
42
|
+
2_000_000,
|
|
43
|
+
Math.min(12_000_000, Math.round(width * height * 30 * 0.05)),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function assertArtifactBudget(bitrate: number, durationSeconds: number): void {
|
|
48
|
+
const projectedBytes = (bitrate * durationSeconds * 1.1) / 8;
|
|
49
|
+
if (projectedBytes > TOOLCRAFT_MAX_VIDEO_ARTIFACT_BYTES) {
|
|
50
|
+
throw new ToolcraftArtifactExportError({
|
|
51
|
+
code: "video-artifact-too-large",
|
|
52
|
+
message: "The selected video export exceeds Toolcraft's artifact limit.",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function resolveToolcraftVideoEncodingPolicy({
|
|
58
|
+
durationSeconds,
|
|
59
|
+
height,
|
|
60
|
+
requestedFormat,
|
|
61
|
+
support,
|
|
62
|
+
width,
|
|
63
|
+
}: Readonly<{
|
|
64
|
+
durationSeconds: number;
|
|
65
|
+
height: number;
|
|
66
|
+
requestedFormat: ToolcraftVideoExportFormat;
|
|
67
|
+
support: ToolcraftVideoEncodingSupport;
|
|
68
|
+
width: number;
|
|
69
|
+
}>): ToolcraftVideoEncodingPolicy {
|
|
70
|
+
const bitrate = getToolcraftVideoExportBitrate(width, height);
|
|
71
|
+
assertArtifactBudget(bitrate, durationSeconds);
|
|
72
|
+
const candidates =
|
|
73
|
+
requestedFormat === "mp4"
|
|
74
|
+
? [mp4Candidate, ...webmCandidates]
|
|
75
|
+
: [...webmCandidates, mp4Candidate];
|
|
76
|
+
const candidate = candidates.find((item) => support[item.codec]);
|
|
77
|
+
|
|
78
|
+
if (!candidate) {
|
|
79
|
+
throw new ToolcraftArtifactExportError({
|
|
80
|
+
code: "video-encoder-unavailable",
|
|
81
|
+
message: "This browser has no supported timestamped video encoder.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return Object.freeze({ ...candidate, bitrate });
|
|
86
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
TOOLCRAFT_VIDEO_EXPORT_FRAMES_PER_SECOND,
|
|
5
|
+
createToolcraftVideoFrameSchedule,
|
|
6
|
+
} from "./video-frame-schedule";
|
|
7
|
+
|
|
8
|
+
describe("createToolcraftVideoFrameSchedule", () => {
|
|
9
|
+
it("creates exact timeline timestamps without duplicating the endpoint", () => {
|
|
10
|
+
const schedule = createToolcraftVideoFrameSchedule(4.8);
|
|
11
|
+
|
|
12
|
+
expect(TOOLCRAFT_VIDEO_EXPORT_FRAMES_PER_SECOND).toBe(30);
|
|
13
|
+
expect(schedule).toHaveLength(144);
|
|
14
|
+
expect(schedule[0]).toEqual({
|
|
15
|
+
durationSeconds: 1 / 30,
|
|
16
|
+
index: 0,
|
|
17
|
+
timeSeconds: 0,
|
|
18
|
+
});
|
|
19
|
+
expect(schedule.at(-1)?.timeSeconds).toBe(143 / 30);
|
|
20
|
+
expect(
|
|
21
|
+
schedule.reduce(
|
|
22
|
+
(end, frame) =>
|
|
23
|
+
Math.max(end, frame.timeSeconds + frame.durationSeconds),
|
|
24
|
+
0,
|
|
25
|
+
),
|
|
26
|
+
).toBeCloseTo(4.8, 10);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("shortens only the final frame for a fractional duration", () => {
|
|
30
|
+
const schedule = createToolcraftVideoFrameSchedule(1.01);
|
|
31
|
+
|
|
32
|
+
expect(schedule).toHaveLength(31);
|
|
33
|
+
expect(schedule.at(-1)).toEqual({
|
|
34
|
+
durationSeconds: 1.01 - 1,
|
|
35
|
+
index: 30,
|
|
36
|
+
timeSeconds: 1,
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it.each([0.1, 0.3, 4.8, 4.9, 60])(
|
|
41
|
+
"avoids a floating-point phantom frame for %s seconds",
|
|
42
|
+
(durationSeconds) => {
|
|
43
|
+
const schedule = createToolcraftVideoFrameSchedule(durationSeconds);
|
|
44
|
+
|
|
45
|
+
expect(schedule).toHaveLength(Math.round(durationSeconds * 30));
|
|
46
|
+
expect(schedule.at(-1)?.timeSeconds).toBeLessThan(durationSeconds);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])(
|
|
51
|
+
"rejects invalid duration %s",
|
|
52
|
+
(durationSeconds) => {
|
|
53
|
+
expect(() => createToolcraftVideoFrameSchedule(durationSeconds)).toThrow(
|
|
54
|
+
"positive finite duration",
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
);
|
|
58
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const TOOLCRAFT_VIDEO_EXPORT_FRAMES_PER_SECOND = 30;
|
|
2
|
+
|
|
3
|
+
export type ToolcraftVideoFrameScheduleEntry = Readonly<{
|
|
4
|
+
durationSeconds: number;
|
|
5
|
+
index: number;
|
|
6
|
+
timeSeconds: number;
|
|
7
|
+
}>;
|
|
8
|
+
|
|
9
|
+
const frameCountTolerance = 1e-9;
|
|
10
|
+
|
|
11
|
+
export function createToolcraftVideoFrameSchedule(
|
|
12
|
+
durationSeconds: number,
|
|
13
|
+
): readonly ToolcraftVideoFrameScheduleEntry[] {
|
|
14
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
15
|
+
throw new RangeError("Video export requires a positive finite duration.");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const rawFrameCount =
|
|
19
|
+
durationSeconds * TOOLCRAFT_VIDEO_EXPORT_FRAMES_PER_SECOND;
|
|
20
|
+
const nearestFrameCount = Math.round(rawFrameCount);
|
|
21
|
+
const frameCount =
|
|
22
|
+
Math.abs(rawFrameCount - nearestFrameCount) <= frameCountTolerance
|
|
23
|
+
? nearestFrameCount
|
|
24
|
+
: Math.ceil(rawFrameCount);
|
|
25
|
+
|
|
26
|
+
return Object.freeze(
|
|
27
|
+
Array.from({ length: frameCount }, (_, index) => {
|
|
28
|
+
const timeSeconds =
|
|
29
|
+
index / TOOLCRAFT_VIDEO_EXPORT_FRAMES_PER_SECOND;
|
|
30
|
+
const endSeconds = Math.min(
|
|
31
|
+
durationSeconds,
|
|
32
|
+
(index + 1) / TOOLCRAFT_VIDEO_EXPORT_FRAMES_PER_SECOND,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return Object.freeze({
|
|
36
|
+
durationSeconds: endSeconds - timeSeconds,
|
|
37
|
+
index,
|
|
38
|
+
timeSeconds,
|
|
39
|
+
});
|
|
40
|
+
}),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -12,6 +12,7 @@ export type {
|
|
|
12
12
|
ToolcraftModelPresentationMode,
|
|
13
13
|
} from "./react/model-rendering/model-render-binding";
|
|
14
14
|
export * from "./schema/define-toolcraft";
|
|
15
|
+
export { TOOLCRAFT_CANVAS_RENDER_SCALE } from "./schema/canvas-render-scale";
|
|
15
16
|
export * from "./schema/keyframe-capability";
|
|
16
17
|
export * from "./schema/runtime-targets";
|
|
17
18
|
export * from "./schema/types";
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
} from "@testing-library/react";
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { defineToolcraft } from "../../schema/define-toolcraft";
|
|
11
|
+
import type { ToolcraftSourceAssetCoordinator } from "../../source-assets/source-asset-coordinator";
|
|
12
|
+
import { ControlsPanel } from "../controls-panel/controls-panel";
|
|
13
|
+
import { useToolcraftStore } from "./toolcraft-store-context";
|
|
14
|
+
import { ToolcraftRoot } from "./toolcraft-root";
|
|
15
|
+
import { ToolcraftSourceAssetCoordinatorContext } from "./toolcraft-source-asset-context";
|
|
16
|
+
import { runtimePublicApiSchema } from "./runtime-public-api-test-support";
|
|
17
|
+
import {
|
|
18
|
+
useToolcraft,
|
|
19
|
+
useToolcraftDispatch,
|
|
20
|
+
useToolcraftSelector,
|
|
21
|
+
} from "./use-toolcraft";
|
|
22
|
+
|
|
23
|
+
const runtimePublicApiPanelActionSchema = defineToolcraft({
|
|
24
|
+
canvas: { enabled: true },
|
|
25
|
+
panels: {
|
|
26
|
+
controls: {
|
|
27
|
+
sections: [
|
|
28
|
+
{
|
|
29
|
+
actionGroup: "secondary",
|
|
30
|
+
controls: {
|
|
31
|
+
action: {
|
|
32
|
+
actions: [
|
|
33
|
+
{
|
|
34
|
+
label: "Resolve media",
|
|
35
|
+
value: "resolve-media",
|
|
36
|
+
variant: "default",
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
target: "panel.actions",
|
|
40
|
+
type: "panelActions",
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
title: "Controls",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function createRuntimePublicApiSourceAssetCoordinator(
|
|
51
|
+
resolveResource?: ToolcraftSourceAssetCoordinator["resolveResource"],
|
|
52
|
+
): ToolcraftSourceAssetCoordinator {
|
|
53
|
+
const idleOperation = Object.freeze({
|
|
54
|
+
phase: "idle" as const,
|
|
55
|
+
target: "source",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
cancelTarget: vi.fn(),
|
|
60
|
+
clearPresentationFeedback: vi.fn(),
|
|
61
|
+
dispose: vi.fn(async () => undefined),
|
|
62
|
+
getOperation: () => idleOperation,
|
|
63
|
+
getPresentation: () => null,
|
|
64
|
+
hydrateBinaryMedia: vi.fn(async () => undefined),
|
|
65
|
+
hydrateModels: vi.fn(async () => undefined),
|
|
66
|
+
importBatch: vi.fn(async () => ({
|
|
67
|
+
assetIds: [] as const,
|
|
68
|
+
kind: "committed" as const,
|
|
69
|
+
})),
|
|
70
|
+
repairModel: vi.fn(async () => ({ kind: "cancelled" as const })),
|
|
71
|
+
reportPresentationFeedback: vi.fn(),
|
|
72
|
+
...(resolveResource ? { resolveResource } : {}),
|
|
73
|
+
subscribe: () => () => undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
afterEach(cleanup);
|
|
78
|
+
|
|
79
|
+
describe("Toolcraft template runtime public API integration", () => {
|
|
80
|
+
it("binds the source asset resolver into panel action context", async () => {
|
|
81
|
+
const bytes = new Uint8Array([11, 22, 33]);
|
|
82
|
+
const resolveResource = vi.fn<
|
|
83
|
+
NonNullable<ToolcraftSourceAssetCoordinator["resolveResource"]>
|
|
84
|
+
>(async () => bytes);
|
|
85
|
+
const coordinator = createRuntimePublicApiSourceAssetCoordinator(
|
|
86
|
+
resolveResource,
|
|
87
|
+
);
|
|
88
|
+
const signal = new AbortController().signal;
|
|
89
|
+
let resolvedBytes: Uint8Array | null | undefined;
|
|
90
|
+
|
|
91
|
+
render(
|
|
92
|
+
<ToolcraftRoot schema={runtimePublicApiPanelActionSchema}>
|
|
93
|
+
<ToolcraftSourceAssetCoordinatorContext.Provider value={coordinator}>
|
|
94
|
+
<ControlsPanel
|
|
95
|
+
framed={false}
|
|
96
|
+
onPanelAction={async ({ resolveMediaResource }) => {
|
|
97
|
+
resolvedBytes = await resolveMediaResource("media:known", {
|
|
98
|
+
signal,
|
|
99
|
+
});
|
|
100
|
+
}}
|
|
101
|
+
/>
|
|
102
|
+
</ToolcraftSourceAssetCoordinatorContext.Provider>
|
|
103
|
+
</ToolcraftRoot>,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
fireEvent.click(screen.getByRole("button", { name: "Resolve media" }));
|
|
107
|
+
|
|
108
|
+
await waitFor(() => expect(resolvedBytes).toEqual(bytes));
|
|
109
|
+
expect(resolveResource).toHaveBeenCalledWith("media:known", { signal });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("returns null from panel action media resolution when no resolver exists", async () => {
|
|
113
|
+
const coordinator = createRuntimePublicApiSourceAssetCoordinator();
|
|
114
|
+
const signal = new AbortController().signal;
|
|
115
|
+
let resolvedBytes: Uint8Array | null | undefined;
|
|
116
|
+
|
|
117
|
+
render(
|
|
118
|
+
<ToolcraftRoot schema={runtimePublicApiPanelActionSchema}>
|
|
119
|
+
<ToolcraftSourceAssetCoordinatorContext.Provider value={coordinator}>
|
|
120
|
+
<ControlsPanel
|
|
121
|
+
framed={false}
|
|
122
|
+
onPanelAction={async ({ resolveMediaResource }) => {
|
|
123
|
+
resolvedBytes = await resolveMediaResource("media:missing", {
|
|
124
|
+
signal,
|
|
125
|
+
});
|
|
126
|
+
}}
|
|
127
|
+
/>
|
|
128
|
+
</ToolcraftSourceAssetCoordinatorContext.Provider>
|
|
129
|
+
</ToolcraftRoot>,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
fireEvent.click(screen.getByRole("button", { name: "Resolve media" }));
|
|
133
|
+
|
|
134
|
+
await waitFor(() => expect(resolvedBytes).toBeNull());
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("keeps broad consumers live while isolating selector and dispatch consumers", () => {
|
|
138
|
+
const renders = {
|
|
139
|
+
broad: 0,
|
|
140
|
+
dispatch: 0,
|
|
141
|
+
playback: 0,
|
|
142
|
+
value: 0,
|
|
143
|
+
viewport: 0,
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function BroadProbe() {
|
|
147
|
+
const { state } = useToolcraft();
|
|
148
|
+
|
|
149
|
+
renders.broad += 1;
|
|
150
|
+
|
|
151
|
+
return <output data-testid="broad-time">{state.timeline.currentTimeSeconds}</output>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function DispatchProbe() {
|
|
155
|
+
const dispatch = useToolcraftDispatch();
|
|
156
|
+
|
|
157
|
+
renders.dispatch += 1;
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() =>
|
|
163
|
+
dispatch({
|
|
164
|
+
target: "selectedLayer.opacity",
|
|
165
|
+
type: "controls.setValue",
|
|
166
|
+
value: 50,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
>
|
|
170
|
+
Set value
|
|
171
|
+
</button>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function ValueProbe() {
|
|
176
|
+
const value = useToolcraftSelector(
|
|
177
|
+
(state) => state.values["selectedLayer.opacity"],
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
renders.value += 1;
|
|
181
|
+
|
|
182
|
+
return <output data-testid="selected-value">{String(value)}</output>;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function PlaybackProbe() {
|
|
186
|
+
const currentTimeSeconds = useToolcraftSelector(
|
|
187
|
+
(state) => state.timeline.currentTimeSeconds,
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
renders.playback += 1;
|
|
191
|
+
|
|
192
|
+
return <output data-testid="playback-time">{currentTimeSeconds}</output>;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function ViewportProbe() {
|
|
196
|
+
const offset = useToolcraftSelector(
|
|
197
|
+
(state) => ({ ...state.canvas.offset }),
|
|
198
|
+
(previous, next) => previous.x === next.x && previous.y === next.y,
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
renders.viewport += 1;
|
|
202
|
+
|
|
203
|
+
return <output data-testid="viewport-offset">{offset.x},{offset.y}</output>;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function TransientProbe() {
|
|
207
|
+
const store = useToolcraftStore();
|
|
208
|
+
|
|
209
|
+
return (
|
|
210
|
+
<div>
|
|
211
|
+
<button
|
|
212
|
+
type="button"
|
|
213
|
+
onClick={() =>
|
|
214
|
+
store.dispatchTransient({
|
|
215
|
+
currentTimeSeconds: 2,
|
|
216
|
+
type: "timeline.setCurrentTime",
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
>
|
|
220
|
+
Set playback time
|
|
221
|
+
</button>
|
|
222
|
+
<button
|
|
223
|
+
type="button"
|
|
224
|
+
onClick={() =>
|
|
225
|
+
store.dispatchTransient({
|
|
226
|
+
offset: { x: 0, y: 0 },
|
|
227
|
+
type: "canvas.setViewport",
|
|
228
|
+
zoom: 150,
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
>
|
|
232
|
+
Set viewport zoom
|
|
233
|
+
</button>
|
|
234
|
+
</div>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
render(
|
|
239
|
+
<ToolcraftRoot schema={runtimePublicApiSchema}>
|
|
240
|
+
<BroadProbe />
|
|
241
|
+
<DispatchProbe />
|
|
242
|
+
<ValueProbe />
|
|
243
|
+
<PlaybackProbe />
|
|
244
|
+
<ViewportProbe />
|
|
245
|
+
<TransientProbe />
|
|
246
|
+
</ToolcraftRoot>,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
expect(renders).toEqual({
|
|
250
|
+
broad: 1,
|
|
251
|
+
dispatch: 1,
|
|
252
|
+
playback: 1,
|
|
253
|
+
value: 1,
|
|
254
|
+
viewport: 1,
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
fireEvent.click(screen.getByRole("button", { name: "Set value" }));
|
|
258
|
+
|
|
259
|
+
expect(screen.getByTestId("selected-value").textContent).toBe("50");
|
|
260
|
+
expect(renders).toEqual({
|
|
261
|
+
broad: 2,
|
|
262
|
+
dispatch: 1,
|
|
263
|
+
playback: 1,
|
|
264
|
+
value: 2,
|
|
265
|
+
viewport: 1,
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
fireEvent.click(screen.getByRole("button", { name: "Set playback time" }));
|
|
269
|
+
|
|
270
|
+
expect(screen.getByTestId("broad-time").textContent).toBe("2");
|
|
271
|
+
expect(screen.getByTestId("playback-time").textContent).toBe("2");
|
|
272
|
+
expect(renders).toEqual({
|
|
273
|
+
broad: 3,
|
|
274
|
+
dispatch: 1,
|
|
275
|
+
playback: 2,
|
|
276
|
+
value: 2,
|
|
277
|
+
viewport: 1,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
fireEvent.click(screen.getByRole("button", { name: "Set viewport zoom" }));
|
|
281
|
+
|
|
282
|
+
expect(screen.getByTestId("viewport-offset").textContent).toBe("0,0");
|
|
283
|
+
expect(renders).toEqual({
|
|
284
|
+
broad: 4,
|
|
285
|
+
dispatch: 1,
|
|
286
|
+
playback: 2,
|
|
287
|
+
value: 2,
|
|
288
|
+
viewport: 1,
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineToolcraft } from "../../schema/define-toolcraft";
|
|
2
|
+
|
|
3
|
+
export const runtimePublicApiSchema = defineToolcraft({
|
|
4
|
+
canvas: { enabled: true },
|
|
5
|
+
panels: {
|
|
6
|
+
controls: {
|
|
7
|
+
sections: [
|
|
8
|
+
{
|
|
9
|
+
controls: {
|
|
10
|
+
opacity: {
|
|
11
|
+
defaultValue: 75,
|
|
12
|
+
target: "selectedLayer.opacity",
|
|
13
|
+
type: "slider",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
],
|
|
18
|
+
title: "Controls",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
});
|