@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.
Files changed (170) hide show
  1. package/package.json +1 -1
  2. package/src/generate-performance-iteration-execution.test.mjs +2 -2
  3. package/src/generate-test-demand-only-lifecycle.mjs +14 -3
  4. package/src/generate-test-dependency-sandbox.mjs +42 -5
  5. package/src/generate-test-doc-assertions.mjs +68 -18
  6. package/src/generate-test-doc-assertions.test.mjs +22 -0
  7. package/src/generate-test-file-assertions.mjs +43 -76
  8. package/src/generate-test-output-assertions.mjs +95 -0
  9. package/src/generate-test-package-assertions.mjs +6 -0
  10. package/src/generate-test-performance-boundary-assertions.mjs +45 -0
  11. package/src/generate-test-product-fixture.mjs +1 -0
  12. package/src/generate-test-product-fixture.test.mjs +18 -0
  13. package/src/generate-test-product-iteration.mjs +1 -0
  14. package/src/generate-test-product-iteration.test.mjs +1 -0
  15. package/src/generate-test-standalone-delivery-stages.mjs +14 -6
  16. package/src/generate.test.mjs +14 -0
  17. package/templates/runtime/contracts/component-contracts.choices.test.ts +12 -9
  18. package/templates/runtime/contracts/component-contracts.choices.ts +10 -9
  19. package/templates/runtime/contracts/component-contracts.runtime-acceptance.test.ts +17 -2
  20. package/templates/runtime/contracts/component-contracts.runtime-panels.test.ts +1 -1
  21. package/templates/runtime/contracts/component-contracts.runtime-shell.test.ts +1 -1
  22. package/templates/runtime/contracts/component-contracts.runtime.ts +3 -2
  23. package/templates/runtime/contracts/decision-contracts.test.ts +25 -4
  24. package/templates/runtime/contracts/decision-contracts.ts +3 -3
  25. package/templates/runtime/contracts/performance-verification-policy.test.ts +59 -6
  26. package/templates/runtime/contracts/performance-verification-policy.ts +5 -2
  27. package/templates/runtime/export/artifact-download.test.ts +71 -0
  28. package/templates/runtime/export/artifact-download.ts +80 -0
  29. package/templates/runtime/export/artifact-export-request.ts +23 -0
  30. package/templates/runtime/export/artifact-export-settings.test.ts +75 -0
  31. package/templates/runtime/export/artifact-export-settings.ts +96 -0
  32. package/templates/runtime/export/artifact-frame-renderer.test.ts +107 -0
  33. package/templates/runtime/export/artifact-frame-renderer.ts +96 -0
  34. package/templates/runtime/export/artifact-frame-state.test.ts +54 -0
  35. package/templates/runtime/export/artifact-frame-state.ts +44 -0
  36. package/templates/runtime/export/artifact-scene-frame.test.ts +84 -0
  37. package/templates/runtime/export/artifact-scene-frame.ts +118 -0
  38. package/templates/runtime/export/export-background.ts +65 -0
  39. package/templates/runtime/export/export-error.ts +43 -0
  40. package/templates/runtime/export/export.test.ts +6 -186
  41. package/templates/runtime/export/image-artifact-export.test.ts +111 -0
  42. package/templates/runtime/export/image-artifact-export.ts +145 -0
  43. package/templates/runtime/export/index.ts +14 -1
  44. package/templates/runtime/export/product-export-renderer.test.ts +77 -0
  45. package/templates/runtime/export/product-export-renderer.ts +63 -0
  46. package/templates/runtime/export/video-artifact-export.test.ts +166 -0
  47. package/templates/runtime/export/video-artifact-export.ts +164 -0
  48. package/templates/runtime/export/video-encoding-backend.ts +124 -0
  49. package/templates/runtime/export/video-encoding-policy.test.ts +68 -0
  50. package/templates/runtime/export/video-encoding-policy.ts +86 -0
  51. package/templates/runtime/export/video-frame-schedule.test.ts +58 -0
  52. package/templates/runtime/export/video-frame-schedule.ts +42 -0
  53. package/templates/runtime/index.ts +1 -0
  54. package/templates/runtime/react/app-shell/runtime-public-api-runtime.test.tsx +291 -0
  55. package/templates/runtime/react/app-shell/runtime-public-api-test-support.ts +21 -0
  56. package/templates/runtime/react/app-shell/runtime-public-api.test.tsx +4 -303
  57. package/templates/runtime/react/app-shell/toolcraft-app.scene-export.test.tsx +12 -27
  58. package/templates/runtime/react/app-shell/toolcraft-app.tsx +22 -4
  59. package/templates/runtime/react/app-shell/toolcraft-source-asset-context.test.tsx +16 -57
  60. package/templates/runtime/react/app-shell/toolcraft-source-asset-context.tsx +75 -434
  61. package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.test.ts +194 -0
  62. package/templates/runtime/react/app-shell/toolcraft-source-asset-owner.ts +227 -0
  63. package/templates/runtime/react/app-shell/toolcraft-source-asset-provider-ownership.test.tsx +243 -0
  64. package/templates/runtime/react/app-shell/toolcraft-source-asset-test-support.ts +51 -0
  65. package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.test.ts +138 -0
  66. package/templates/runtime/react/app-shell/toolcraft-unavailable-resource-proof.ts +378 -0
  67. package/templates/runtime/react/controls-panel/__tests__/controls-panel.footer-actions.test.tsx +7 -12
  68. package/templates/runtime/react/controls-panel/__tests__/controls-panel.scene-export-actions.test.tsx +17 -83
  69. package/templates/runtime/react/controls-panel/actions/controls-panel-actions.ts +42 -84
  70. package/templates/runtime/react/controls-panel/actions/export-action-runner.ts +62 -0
  71. package/templates/runtime/react/panel-host/panel-host-runtime-placement.test.tsx +30 -0
  72. package/templates/runtime/react/panel-host/panel-host.tsx +4 -0
  73. package/templates/runtime/scene/scene-bounds.ts +1 -2
  74. package/templates/runtime/schema/canvas-render-scale.ts +62 -0
  75. package/templates/runtime/schema/controls-panel-section-id.test.ts +1 -1
  76. package/templates/runtime/schema/controls-panel-section-roundtrip.test.ts +3 -3
  77. package/templates/runtime/schema/define-toolcraft.setup-background.test.ts +1 -1
  78. package/templates/runtime/schema/define-toolcraft.setup-render-scale-timeline.test.ts +71 -3
  79. package/templates/runtime/schema/define-toolcraft.ts +1 -1
  80. package/templates/runtime/schema/schema-resolvers.ts +0 -56
  81. package/templates/runtime/schema/types.ts +4 -4
  82. package/templates/runtime/state/canvas-background-state.ts +6 -0
  83. package/templates/runtime/testing/performance-render-plan-integration.test.ts +64 -0
  84. package/templates/runtime/testing/performance-renderer-pipeline-validation.ts +18 -1
  85. package/templates/starter/AGENTS.md +8 -7
  86. package/templates/starter/NOTICE.md +6 -0
  87. package/templates/starter/docs/toolcraft/README.md +2 -1
  88. package/templates/starter/docs/toolcraft/acceptance-testing.md +8 -6
  89. package/templates/starter/docs/toolcraft/agent-worklog.md +3 -3
  90. package/templates/starter/docs/toolcraft/assembly-workflow.md +4 -3
  91. package/templates/starter/docs/toolcraft/core/media-upload.md +2 -2
  92. package/templates/starter/docs/toolcraft/core/performance.md +14 -3
  93. package/templates/starter/docs/toolcraft/core/runtime-boundary.md +3 -1
  94. package/templates/starter/docs/toolcraft/core/setup-export.md +16 -12
  95. package/templates/starter/docs/toolcraft/core/timeline-animation.md +4 -4
  96. package/templates/starter/docs/toolcraft/performance.md +20 -6
  97. package/templates/starter/docs/toolcraft/renderer-technique.md +1 -1
  98. package/templates/starter/docs/toolcraft/schema-reference.md +2 -6
  99. package/templates/starter/docs/toolcraft/workflow.md +7 -2
  100. package/templates/starter/e2e/app-browser-control-coverage.spec.ts +33 -0
  101. package/templates/starter/e2e/app-browser-infinity-canvas-evidence.spec.ts +43 -1
  102. package/templates/starter/e2e/app-browser-render-scale-evidence.spec.ts +60 -6
  103. package/templates/starter/e2e/app-browser-render-scale-requirements.spec.ts +77 -1
  104. package/templates/starter/e2e/app-browser-runtime-provenance.spec.ts +8 -0
  105. package/templates/starter/e2e/app-browser-runtime-reporter.spec.ts +35 -0
  106. package/templates/starter/e2e/app-browser-runtime-requirements.spec.ts +4 -1
  107. package/templates/starter/e2e/app-browser-semantic-evidence.spec.ts +0 -1
  108. package/templates/starter/e2e/app-browser-shell.spec.ts +13 -5
  109. package/templates/starter/e2e/app-performance-path-adapters.ts +8 -1
  110. package/templates/starter/e2e/app-performance.spec.ts +16 -9
  111. package/templates/starter/e2e/browser-infinity-canvas-evidence.ts +9 -330
  112. package/templates/starter/e2e/browser-infinity-canvas-unavailable-image-evidence.ts +370 -0
  113. package/templates/starter/e2e/browser-media-export-evidence.spec.ts +77 -0
  114. package/templates/starter/e2e/browser-media-export-evidence.ts +208 -0
  115. package/templates/starter/e2e/browser-render-scale-evidence.ts +37 -29
  116. package/templates/starter/e2e/browser-runtime-evidence-reporter.ts +4 -1
  117. package/templates/starter/e2e/browser-runtime-evidence-requirements.ts +43 -9
  118. package/templates/starter/e2e/browser-standard-timeline-evidence.ts +106 -0
  119. package/templates/starter/e2e/decoded-pixel-observation.spec.ts +74 -0
  120. package/templates/starter/e2e/decoded-pixel-observation.ts +227 -0
  121. package/templates/starter/e2e/export-artifact-helpers.ts +206 -25
  122. package/templates/starter/e2e/image-artifact-inspection.spec.ts +41 -0
  123. package/templates/starter/e2e/image-artifact-inspection.ts +99 -0
  124. package/templates/starter/e2e/performance-canvas-helpers.ts +22 -21
  125. package/templates/starter/e2e/performance-canvas-quality-guard.spec.ts +312 -0
  126. package/templates/starter/e2e/performance-canvas-quality-guard.ts +196 -0
  127. package/templates/starter/e2e/performance-canvas-quality-observer.ts +351 -0
  128. package/templates/starter/e2e/performance-control-layout-helpers.ts +2 -2
  129. package/templates/starter/e2e/performance-path-adapter-contract.ts +4 -4
  130. package/templates/starter/e2e/performance-path-helpers.spec.ts +203 -70
  131. package/templates/starter/e2e/performance-path-helpers.ts +210 -91
  132. package/templates/starter/e2e/performance-pipeline-evidence-continuity.spec.ts +76 -0
  133. package/templates/starter/e2e/performance-pipeline-evidence-test-fixtures.ts +11 -2
  134. package/templates/starter/e2e/performance-pipeline-invariants.ts +47 -4
  135. package/templates/starter/e2e/performance-render-scale-runner.spec.ts +140 -38
  136. package/templates/starter/e2e/video-artifact-inspection.spec.ts +64 -0
  137. package/templates/starter/e2e/video-artifact-inspection.ts +281 -0
  138. package/templates/starter/gitignore +1 -0
  139. package/templates/starter/package.json +1 -0
  140. package/templates/starter/scripts/check-toolcraft-docs.mjs +17 -0
  141. package/templates/starter/scripts/toolcraft-delivery-plan-authority.test.mjs +60 -0
  142. package/templates/starter/scripts/toolcraft-delivery-plan-errors.test.mjs +7 -2
  143. package/templates/starter/scripts/toolcraft-delivery-plan.mjs +7 -3
  144. package/templates/starter/scripts/toolcraft-product-boundary-ast.mjs +24 -2
  145. package/templates/starter/scripts/toolcraft-product-boundary.mjs +12 -0
  146. package/templates/starter/scripts/toolcraft-product-boundary.test.mjs +46 -0
  147. package/templates/starter/scripts/toolcraft-product-evidence-boundary.test.mjs +85 -20
  148. package/templates/starter/scripts/toolcraft-product-evidence-import-policy.mjs +43 -0
  149. package/templates/starter/scripts/toolcraft-product-export-boundary.mjs +168 -0
  150. package/templates/starter/scripts/toolcraft-typescript-analysis.d.mts +73 -0
  151. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade-test-helpers.mjs +318 -0
  152. package/templates/starter/scripts/toolcraft-unavailable-resource-evidence-facade.test.mjs +155 -206
  153. package/templates/starter/scripts/toolcraft-verification-inventory.mjs +1 -0
  154. package/templates/starter/scripts/toolcraft-verification-receipt.test.mjs +51 -0
  155. package/templates/starter/scripts/toolcraft-vite-env-activation.test.mjs +128 -59
  156. package/templates/starter/src/app/acceptance/export-artifact-coverage.ts +113 -0
  157. package/templates/starter/src/app/acceptance/output-background-rules.ts +1 -1
  158. package/templates/starter/src/app/acceptance/output-export.ts +13 -2
  159. package/templates/starter/src/app/acceptance/types.ts +7 -0
  160. package/templates/starter/src/app/acceptance/validate-coverage.ts +2 -1
  161. package/templates/starter/src/app/starter-acceptance.export-artifact.test.ts +131 -0
  162. package/templates/starter/src/app/starter-acceptance.keyframes.test.ts +14 -4
  163. package/templates/starter/src/app/starter-acceptance.output-export-rules.test.ts +42 -1
  164. package/templates/starter/src/app/starter-acceptance.ts +2 -0
  165. package/templates/starter/src/app/starter-performance-test-utils.ts +103 -0
  166. package/templates/starter/src/app/starter-performance.gates.test.ts +231 -278
  167. package/templates/starter/src/app/starter-performance.lifecycle.test.ts +261 -0
  168. package/templates/starter/src/app/test-evidence/browser-runtime-contract.ts +2 -0
  169. package/templates/starter/src/routes/index.tsx +1 -0
  170. package/templates/runtime/export/export.ts +0 -166
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { downloadToolcraftArtifact } from "./artifact-download";
4
+
5
+ describe("downloadToolcraftArtifact", () => {
6
+ it("sanitizes the filename and revokes the object URL after the click", () => {
7
+ const events: string[] = [];
8
+ const anchor = {
9
+ click: vi.fn(() => events.push("click")),
10
+ download: "",
11
+ href: "",
12
+ remove: vi.fn(() => events.push("remove")),
13
+ };
14
+ const schedule = vi.fn((callback: () => void) => {
15
+ events.push("schedule-revoke");
16
+ callback();
17
+ return 1;
18
+ });
19
+
20
+ const result = downloadToolcraftArtifact({
21
+ appendAnchor: () => events.push("append"),
22
+ blob: new Blob(["png"], { type: "image/png" }),
23
+ createAnchor: () => anchor,
24
+ createObjectUrl: () => "blob:artifact",
25
+ extension: ".png",
26
+ rawBaseFileName: " Unsafe / name ",
27
+ revokeObjectUrl: () => events.push("revoke"),
28
+ schedule,
29
+ });
30
+
31
+ expect(result.fileName).toBe("Unsafe-name.png");
32
+ expect(anchor.download).toBe("Unsafe-name.png");
33
+ expect(events).toEqual([
34
+ "append",
35
+ "click",
36
+ "remove",
37
+ "schedule-revoke",
38
+ "revoke",
39
+ ]);
40
+ });
41
+
42
+ it("types click failures and still removes and revokes", () => {
43
+ const remove = vi.fn();
44
+ const revoke = vi.fn();
45
+
46
+ expect(() =>
47
+ downloadToolcraftArtifact({
48
+ appendAnchor: vi.fn(),
49
+ blob: new Blob(["png"]),
50
+ createAnchor: () => ({
51
+ click: () => {
52
+ throw new Error("blocked");
53
+ },
54
+ download: "",
55
+ href: "",
56
+ remove,
57
+ }),
58
+ createObjectUrl: () => "blob:artifact",
59
+ extension: ".png",
60
+ rawBaseFileName: "",
61
+ revokeObjectUrl: revoke,
62
+ schedule: (callback) => {
63
+ callback();
64
+ return 1;
65
+ },
66
+ }),
67
+ ).toThrow("could not start the artifact download");
68
+ expect(remove).toHaveBeenCalledOnce();
69
+ expect(revoke).toHaveBeenCalledWith("blob:artifact");
70
+ });
71
+ });
@@ -0,0 +1,80 @@
1
+ import {
2
+ normalizeToolcraftExportError,
3
+ type ToolcraftArtifactExportFailure,
4
+ } from "./export-error";
5
+
6
+ export type ToolcraftArtifactFileExtension =
7
+ | ".jpg"
8
+ | ".mp4"
9
+ | ".png"
10
+ | ".webm";
11
+
12
+ type ToolcraftDownloadAnchor = {
13
+ click: () => void;
14
+ download: string;
15
+ href: string;
16
+ remove: () => void;
17
+ };
18
+
19
+ export type ToolcraftArtifactDownloadRequest = Readonly<{
20
+ appendAnchor?: (anchor: ToolcraftDownloadAnchor) => void;
21
+ blob: Blob;
22
+ createAnchor?: () => ToolcraftDownloadAnchor;
23
+ createObjectUrl?: (blob: Blob) => string;
24
+ extension: ToolcraftArtifactFileExtension;
25
+ rawBaseFileName: string;
26
+ revokeObjectUrl?: (url: string) => void;
27
+ schedule?: (callback: () => void, delay: number) => unknown;
28
+ }>;
29
+
30
+ export type ToolcraftArtifactDownloadResult = Readonly<{ fileName: string }>;
31
+
32
+ export function sanitizeToolcraftArtifactBaseFileName(value: string): string {
33
+ const sanitized = value
34
+ .trim()
35
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
36
+ .replace(/^[.-]+|[.-]+$/g, "");
37
+
38
+ return sanitized || "toolcraft-export";
39
+ }
40
+
41
+ const downloadFailure: ToolcraftArtifactExportFailure = Object.freeze({
42
+ code: "artifact-download-failed",
43
+ message: "Toolcraft could not start the artifact download.",
44
+ });
45
+
46
+ export function downloadToolcraftArtifact(
47
+ request: ToolcraftArtifactDownloadRequest,
48
+ ): ToolcraftArtifactDownloadResult {
49
+ const createAnchor =
50
+ request.createAnchor ?? (() => document.createElement("a"));
51
+ const appendAnchor =
52
+ request.appendAnchor ??
53
+ ((anchor) => document.body.append(anchor as HTMLAnchorElement));
54
+ const createObjectUrl = request.createObjectUrl ?? URL.createObjectURL;
55
+ const revokeObjectUrl = request.revokeObjectUrl ?? URL.revokeObjectURL;
56
+ const schedule = request.schedule ?? globalThis.setTimeout;
57
+ const fileName = `${sanitizeToolcraftArtifactBaseFileName(
58
+ request.rawBaseFileName,
59
+ )}${request.extension}`;
60
+ let anchor: ToolcraftDownloadAnchor | null = null;
61
+ let objectUrl: string | null = null;
62
+
63
+ try {
64
+ objectUrl = createObjectUrl(request.blob);
65
+ anchor = createAnchor();
66
+ anchor.download = fileName;
67
+ anchor.href = objectUrl;
68
+ appendAnchor(anchor);
69
+ anchor.click();
70
+ return { fileName };
71
+ } catch (error) {
72
+ throw normalizeToolcraftExportError(error, downloadFailure);
73
+ } finally {
74
+ anchor?.remove();
75
+ if (objectUrl) {
76
+ const urlToRevoke = objectUrl;
77
+ schedule(() => revokeObjectUrl(urlToRevoke), 0);
78
+ }
79
+ }
80
+ }
@@ -0,0 +1,23 @@
1
+ import type { ToolcraftRendererPipelineClient } from "../rendering";
2
+ import type {
3
+ ToolcraftProductSceneBoundsProvider,
4
+ ToolcraftRuntimeSceneVisibility,
5
+ } from "../scene";
6
+ import type { ToolcraftState } from "../state/types";
7
+ import type { ToolcraftExportFrame } from "./export-frame";
8
+ import type { ToolcraftProductExportRenderer } from "./product-export-renderer";
9
+
10
+ export type ToolcraftArtifactExportRequest = Readonly<{
11
+ boundsProvider: ToolcraftProductSceneBoundsProvider | undefined;
12
+ exportRenderer: ToolcraftProductExportRenderer | undefined;
13
+ productSceneRequired: boolean;
14
+ renderRuntimeScene: (
15
+ canvas: HTMLCanvasElement,
16
+ frame: ToolcraftExportFrame,
17
+ state: ToolcraftState,
18
+ ) => Promise<unknown>;
19
+ rendererPipeline: ToolcraftRendererPipelineClient | null;
20
+ reportProgress: (progress: number) => void;
21
+ state: ToolcraftState;
22
+ visibility: ToolcraftRuntimeSceneVisibility;
23
+ }>;
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineToolcraft } from "../schema/define-toolcraft";
4
+ import { createToolcraftState } from "../state/create-template-state";
5
+ import {
6
+ resolveToolcraftImageExportSettings,
7
+ resolveToolcraftVideoExportSettings,
8
+ toolcraftImageExportFormatTarget,
9
+ toolcraftImageExportResolutionTarget,
10
+ toolcraftVideoExportFormatTarget,
11
+ toolcraftVideoExportResolutionTarget,
12
+ } from "./artifact-export-settings";
13
+ import { ToolcraftArtifactExportError } from "./export-error";
14
+
15
+ function createState() {
16
+ return createToolcraftState(
17
+ defineToolcraft({ canvas: { enabled: true }, panels: {} }),
18
+ );
19
+ }
20
+
21
+ describe("artifact export settings", () => {
22
+ it("uses canonical defaults when controls are absent", () => {
23
+ const state = createState();
24
+
25
+ expect(resolveToolcraftImageExportSettings(state)).toEqual({
26
+ format: "png",
27
+ resolution: "4k",
28
+ });
29
+ expect(resolveToolcraftVideoExportSettings(state)).toEqual({
30
+ format: "mp4",
31
+ resolution: "current",
32
+ });
33
+ });
34
+
35
+ it("prefers current values over normalized defaults", () => {
36
+ const state = createState();
37
+ Object.assign(state.defaults, {
38
+ [toolcraftImageExportFormatTarget]: "png",
39
+ [toolcraftImageExportResolutionTarget]: "4k",
40
+ [toolcraftVideoExportFormatTarget]: "mp4",
41
+ [toolcraftVideoExportResolutionTarget]: "current",
42
+ });
43
+ Object.assign(state.values, {
44
+ [toolcraftImageExportFormatTarget]: "jpg",
45
+ [toolcraftImageExportResolutionTarget]: "8k",
46
+ [toolcraftVideoExportFormatTarget]: "webm",
47
+ [toolcraftVideoExportResolutionTarget]: "4k",
48
+ });
49
+
50
+ expect(resolveToolcraftImageExportSettings(state)).toEqual({
51
+ format: "jpg",
52
+ resolution: "8k",
53
+ });
54
+ expect(resolveToolcraftVideoExportSettings(state)).toEqual({
55
+ format: "webm",
56
+ resolution: "4k",
57
+ });
58
+ });
59
+
60
+ it("rejects unsupported present values with the exact target", () => {
61
+ const state = createState();
62
+ state.values[toolcraftVideoExportFormatTarget] = "mov";
63
+
64
+ try {
65
+ resolveToolcraftVideoExportSettings(state);
66
+ throw new Error("Expected invalid export setting.");
67
+ } catch (error) {
68
+ expect(error).toBeInstanceOf(ToolcraftArtifactExportError);
69
+ expect((error as ToolcraftArtifactExportError).feedback).toMatchObject({
70
+ code: "invalid-export-setting",
71
+ target: toolcraftVideoExportFormatTarget,
72
+ });
73
+ }
74
+ });
75
+ });
@@ -0,0 +1,96 @@
1
+ import type { ToolcraftState } from "../state/types";
2
+ import { ToolcraftArtifactExportError } from "./export-error";
3
+
4
+ export const toolcraftImageExportFormatTarget = "export.image.format";
5
+ export const toolcraftImageExportResolutionTarget = "export.image.resolution";
6
+ export const toolcraftVideoExportFormatTarget = "export.video.format";
7
+ export const toolcraftVideoExportResolutionTarget = "export.video.resolution";
8
+
9
+ export type ToolcraftImageExportFormat = "jpg" | "png";
10
+ export type ToolcraftImageExportPresetResolution = "2k" | "4k" | "8k";
11
+ export type ToolcraftVideoExportFormat = "mp4" | "webm";
12
+ export type ToolcraftVideoExportPresetResolution = "4k" | "current";
13
+
14
+ export type ToolcraftResolvedImageExportSettings = Readonly<{
15
+ format: ToolcraftImageExportFormat;
16
+ resolution: ToolcraftImageExportPresetResolution;
17
+ }>;
18
+
19
+ export type ToolcraftResolvedVideoExportSettings = Readonly<{
20
+ format: ToolcraftVideoExportFormat;
21
+ resolution: ToolcraftVideoExportPresetResolution;
22
+ }>;
23
+
24
+ function getSettingValue(
25
+ state: ToolcraftState,
26
+ target: string,
27
+ fallback: string,
28
+ ): unknown {
29
+ return state.values[target] ?? state.defaults[target] ?? fallback;
30
+ }
31
+
32
+ function invalidSetting(target: string): never {
33
+ throw new ToolcraftArtifactExportError({
34
+ code: "invalid-export-setting",
35
+ message: `Toolcraft export setting ${target} is invalid.`,
36
+ target,
37
+ });
38
+ }
39
+
40
+ function resolveImageFormat(state: ToolcraftState): ToolcraftImageExportFormat {
41
+ const value = getSettingValue(state, toolcraftImageExportFormatTarget, "png");
42
+ return value === "jpg" || value === "png"
43
+ ? value
44
+ : invalidSetting(toolcraftImageExportFormatTarget);
45
+ }
46
+
47
+ function resolveImageResolution(
48
+ state: ToolcraftState,
49
+ ): ToolcraftImageExportPresetResolution {
50
+ const value = getSettingValue(
51
+ state,
52
+ toolcraftImageExportResolutionTarget,
53
+ "4k",
54
+ );
55
+ return value === "2k" || value === "4k" || value === "8k"
56
+ ? value
57
+ : invalidSetting(toolcraftImageExportResolutionTarget);
58
+ }
59
+
60
+ function resolveVideoFormat(state: ToolcraftState): ToolcraftVideoExportFormat {
61
+ const value = getSettingValue(state, toolcraftVideoExportFormatTarget, "mp4");
62
+ return value === "mp4" || value === "webm"
63
+ ? value
64
+ : invalidSetting(toolcraftVideoExportFormatTarget);
65
+ }
66
+
67
+ function resolveVideoResolution(
68
+ state: ToolcraftState,
69
+ ): ToolcraftVideoExportPresetResolution {
70
+ const value = getSettingValue(
71
+ state,
72
+ toolcraftVideoExportResolutionTarget,
73
+ "current",
74
+ );
75
+ return value === "4k" || value === "current"
76
+ ? value
77
+ : invalidSetting(toolcraftVideoExportResolutionTarget);
78
+ }
79
+
80
+ export function resolveToolcraftImageExportSettings(
81
+ state: ToolcraftState,
82
+ ): ToolcraftResolvedImageExportSettings {
83
+ return Object.freeze({
84
+ format: resolveImageFormat(state),
85
+ resolution: resolveImageResolution(state),
86
+ });
87
+ }
88
+
89
+ export function resolveToolcraftVideoExportSettings(
90
+ state: ToolcraftState,
91
+ ): ToolcraftResolvedVideoExportSettings {
92
+ return Object.freeze({
93
+ format: resolveVideoFormat(state),
94
+ resolution: resolveVideoResolution(state),
95
+ });
96
+ }
@@ -0,0 +1,107 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { defineToolcraft } from "../schema/define-toolcraft";
4
+ import { createToolcraftState } from "../state/create-template-state";
5
+ import { renderToolcraftArtifactFrame } from "./artifact-frame-renderer";
6
+
7
+ function deferred() {
8
+ let resolve!: () => void;
9
+ const promise = new Promise<void>((done) => {
10
+ resolve = done;
11
+ });
12
+ return { promise, resolve };
13
+ }
14
+
15
+ function createContext(events: string[]) {
16
+ return {
17
+ clearRect: vi.fn(() => events.push("clear")),
18
+ fillRect: vi.fn(() => events.push("background")),
19
+ fillStyle: "",
20
+ restore: vi.fn(() => events.push("restore")),
21
+ save: vi.fn(() => events.push("save")),
22
+ scale: vi.fn(() => events.push("scale")),
23
+ setTransform: vi.fn(() => events.push("reset-transform")),
24
+ translate: vi.fn(() => events.push("translate")),
25
+ };
26
+ }
27
+
28
+ describe("renderToolcraftArtifactFrame", () => {
29
+ it("keeps the scene transform active until async product rendering settles", async () => {
30
+ const events: string[] = [];
31
+ const pending = deferred();
32
+ const context = createContext(events);
33
+ const canvas = {
34
+ getContext: vi.fn(() => context),
35
+ height: 200,
36
+ width: 400,
37
+ } as unknown as HTMLCanvasElement;
38
+ const state = createToolcraftState(
39
+ defineToolcraft({ canvas: { enabled: true }, panels: {} }),
40
+ );
41
+ const rendering = renderToolcraftArtifactFrame({
42
+ backgroundColor: "#123456",
43
+ canvas,
44
+ frame: { height: 100, width: 200, x: -10, y: -20 },
45
+ includeBackground: true,
46
+ pixelRatio: 2,
47
+ renderProductFrame: async () => {
48
+ events.push("product-start");
49
+ await pending.promise;
50
+ events.push("product-end");
51
+ },
52
+ renderRuntimeScene: async () => {
53
+ events.push("runtime-scene");
54
+ },
55
+ rendererPipeline: null,
56
+ state,
57
+ });
58
+
59
+ await Promise.resolve();
60
+ expect(events).toEqual([
61
+ "reset-transform",
62
+ "clear",
63
+ "background",
64
+ "runtime-scene",
65
+ "save",
66
+ "scale",
67
+ "translate",
68
+ "product-start",
69
+ ]);
70
+ pending.resolve();
71
+ await rendering;
72
+ expect(events.at(-2)).toBe("product-end");
73
+ expect(events.at(-1)).toBe("restore");
74
+ });
75
+
76
+ it("restores the context and types an unknown renderer failure", async () => {
77
+ const events: string[] = [];
78
+ const context = createContext(events);
79
+ const canvas = {
80
+ getContext: vi.fn(() => context),
81
+ height: 100,
82
+ width: 100,
83
+ } as unknown as HTMLCanvasElement;
84
+ const state = createToolcraftState(
85
+ defineToolcraft({ canvas: { enabled: true }, panels: {} }),
86
+ );
87
+
88
+ await expect(
89
+ renderToolcraftArtifactFrame({
90
+ backgroundColor: "#000000",
91
+ canvas,
92
+ frame: { height: 100, width: 100, x: 0, y: 0 },
93
+ includeBackground: false,
94
+ pixelRatio: 1,
95
+ renderProductFrame: () => {
96
+ throw new Error("broken product");
97
+ },
98
+ renderRuntimeScene: async () => undefined,
99
+ rendererPipeline: null,
100
+ state,
101
+ }),
102
+ ).rejects.toMatchObject({
103
+ feedback: { code: "product-frame-render-failed" },
104
+ });
105
+ expect(context.restore).toHaveBeenCalledOnce();
106
+ });
107
+ });
@@ -0,0 +1,96 @@
1
+ import type { ToolcraftRendererPipelineClient } from "../rendering";
2
+ import type { ToolcraftState } from "../state/types";
3
+ import type { ToolcraftExportFrame } from "./export-frame";
4
+ import {
5
+ normalizeToolcraftExportError,
6
+ ToolcraftArtifactExportError,
7
+ } from "./export-error";
8
+ import type { ToolcraftProductExportFrameRenderer } from "./product-export-renderer";
9
+
10
+ export type ToolcraftArtifactFrameRenderRequest = Readonly<{
11
+ backgroundColor: string;
12
+ canvas: HTMLCanvasElement;
13
+ frame: ToolcraftExportFrame;
14
+ includeBackground: boolean;
15
+ pixelRatio: number;
16
+ renderProductFrame: ToolcraftProductExportFrameRenderer | null;
17
+ renderRuntimeScene: (
18
+ canvas: HTMLCanvasElement,
19
+ frame: ToolcraftExportFrame,
20
+ state: ToolcraftState,
21
+ ) => Promise<unknown>;
22
+ rendererPipeline: ToolcraftRendererPipelineClient | null;
23
+ state: ToolcraftState;
24
+ }>;
25
+
26
+ function getTimelineProgress(state: ToolcraftState): number {
27
+ if (state.timeline.durationSeconds <= 0) {
28
+ return 0;
29
+ }
30
+
31
+ return Math.max(
32
+ 0,
33
+ Math.min(
34
+ 1,
35
+ state.timeline.currentTimeSeconds / state.timeline.durationSeconds,
36
+ ),
37
+ );
38
+ }
39
+
40
+ export async function renderToolcraftArtifactFrame(
41
+ request: ToolcraftArtifactFrameRenderRequest,
42
+ ): Promise<void> {
43
+ const context = request.canvas.getContext("2d");
44
+ if (!context) {
45
+ throw new ToolcraftArtifactExportError({
46
+ code: "canvas-context-unavailable",
47
+ message: "Toolcraft export requires a 2D canvas context.",
48
+ });
49
+ }
50
+
51
+ context.setTransform(1, 0, 0, 1, 0, 0);
52
+ context.clearRect(0, 0, request.canvas.width, request.canvas.height);
53
+ if (request.includeBackground) {
54
+ context.fillStyle = request.backgroundColor;
55
+ context.fillRect(0, 0, request.canvas.width, request.canvas.height);
56
+ }
57
+
58
+ try {
59
+ await request.renderRuntimeScene(
60
+ request.canvas,
61
+ request.frame,
62
+ request.state,
63
+ );
64
+ } catch (error) {
65
+ throw normalizeToolcraftExportError(error, {
66
+ code: "runtime-scene-render-failed",
67
+ message: "Toolcraft could not render runtime scene layers for export.",
68
+ });
69
+ }
70
+
71
+ if (!request.renderProductFrame) {
72
+ return;
73
+ }
74
+
75
+ context.save();
76
+ context.scale(request.pixelRatio, request.pixelRatio);
77
+ context.translate(-request.frame.x, -request.frame.y);
78
+ try {
79
+ await request.renderProductFrame({
80
+ context,
81
+ frame: request.frame,
82
+ pixelRatio: request.pixelRatio,
83
+ rendererPipeline: request.rendererPipeline,
84
+ state: request.state,
85
+ timeSeconds: request.state.timeline.currentTimeSeconds,
86
+ timelineProgress: getTimelineProgress(request.state),
87
+ });
88
+ } catch (error) {
89
+ throw normalizeToolcraftExportError(error, {
90
+ code: "product-frame-render-failed",
91
+ message: "Toolcraft could not render product pixels for export.",
92
+ });
93
+ } finally {
94
+ context.restore();
95
+ }
96
+ }
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { defineToolcraft } from "../schema/define-toolcraft";
4
+ import { createToolcraftState } from "../state/create-template-state";
5
+ import { createToolcraftArtifactFrameState } from "./artifact-frame-state";
6
+
7
+ describe("createToolcraftArtifactFrameState", () => {
8
+ it("evaluates keyframed values without mutating the base snapshot", () => {
9
+ const state = createToolcraftState(
10
+ defineToolcraft({ canvas: { enabled: true }, panels: {} }),
11
+ {
12
+ timeline: {
13
+ currentTimeSeconds: 0,
14
+ isPlaying: true,
15
+ keyframeGroups: [
16
+ {
17
+ controlId: "shape.opacity",
18
+ label: "Opacity",
19
+ keyframes: [
20
+ {
21
+ controlId: "shape.opacity",
22
+ controlLabel: "Opacity",
23
+ id: "a",
24
+ timeSeconds: 0,
25
+ value: 0,
26
+ valueLabel: "0",
27
+ },
28
+ {
29
+ controlId: "shape.opacity",
30
+ controlLabel: "Opacity",
31
+ id: "b",
32
+ timeSeconds: 1,
33
+ value: 1,
34
+ valueLabel: "1",
35
+ },
36
+ ],
37
+ },
38
+ ],
39
+ },
40
+ values: { "shape.opacity": 0 },
41
+ },
42
+ );
43
+
44
+ const frameState = createToolcraftArtifactFrameState(state, 0.5);
45
+
46
+ expect(frameState).not.toBe(state);
47
+ expect(frameState.timeline.currentTimeSeconds).toBe(0.5);
48
+ expect(frameState.timeline.isPlaying).toBe(false);
49
+ expect(frameState.values["shape.opacity"]).toBeCloseTo(0.5, 5);
50
+ expect(state.timeline.currentTimeSeconds).toBe(0);
51
+ expect(state.timeline.isPlaying).toBe(true);
52
+ expect(state.values["shape.opacity"]).toBe(0);
53
+ });
54
+ });
@@ -0,0 +1,44 @@
1
+ import { evaluateToolcraftTimelineValues } from "../state/keyframe-evaluation";
2
+ import type { ToolcraftState } from "../state/types";
3
+ import type { ToolcraftVideoFrameScheduleEntry } from "./video-frame-schedule";
4
+
5
+ export type ToolcraftArtifactFrameState = Readonly<ToolcraftState>;
6
+
7
+ export type ToolcraftVideoArtifactFramePlanEntry = Readonly<{
8
+ scheduleEntry: ToolcraftVideoFrameScheduleEntry;
9
+ state: ToolcraftArtifactFrameState;
10
+ }>;
11
+
12
+ export function createToolcraftArtifactFrameState(
13
+ baseState: ToolcraftState,
14
+ timeSeconds: number,
15
+ ): ToolcraftArtifactFrameState {
16
+ return Object.freeze({
17
+ ...baseState,
18
+ timeline: Object.freeze({
19
+ ...baseState.timeline,
20
+ currentTimeSeconds: timeSeconds,
21
+ isPlaying: false,
22
+ }),
23
+ values: Object.freeze(
24
+ evaluateToolcraftTimelineValues(baseState, timeSeconds),
25
+ ),
26
+ });
27
+ }
28
+
29
+ export function createToolcraftVideoArtifactFramePlan(
30
+ baseState: ToolcraftState,
31
+ schedule: readonly ToolcraftVideoFrameScheduleEntry[],
32
+ ): readonly ToolcraftVideoArtifactFramePlanEntry[] {
33
+ return Object.freeze(
34
+ schedule.map((scheduleEntry) =>
35
+ Object.freeze({
36
+ scheduleEntry,
37
+ state: createToolcraftArtifactFrameState(
38
+ baseState,
39
+ scheduleEntry.timeSeconds,
40
+ ),
41
+ }),
42
+ ),
43
+ );
44
+ }