@pixel-point/toolcraft 0.0.8 → 0.0.11
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/README.md +42 -9
- package/package.json +1 -1
- package/src/generate.mjs +42 -5
- package/src/generate.test.mjs +40 -0
- package/src/package-json.mjs +15 -0
- package/src/package-json.test.mjs +14 -1
- package/templates/runtime/contracts/component-contracts.test.ts +251 -47
- package/templates/runtime/contracts/component-contracts.ts +130 -57
- package/templates/runtime/contracts/decision-contracts.test.ts +38 -1
- package/templates/runtime/contracts/decision-contracts.ts +22 -9
- package/templates/runtime/export/export.test.ts +63 -0
- package/templates/runtime/export/export.ts +55 -0
- package/templates/runtime/index.ts +1 -0
- package/templates/runtime/react/canvas-shell.test.tsx +58 -4
- package/templates/runtime/react/canvas-shell.tsx +31 -9
- package/templates/runtime/react/controls-panel.test.tsx +916 -108
- package/templates/runtime/react/controls-panel.tsx +136 -30
- package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
- package/templates/runtime/react/settings-transfer.test.ts +4 -0
- package/templates/runtime/react/settings-transfer.ts +6 -1
- package/templates/runtime/react/timeline-panel.test.tsx +14 -0
- package/templates/runtime/react/timeline-panel.tsx +44 -7
- package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
- package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
- package/templates/runtime/react/toolcraft-app.tsx +56 -37
- package/templates/runtime/schema/define-toolcraft.test.ts +266 -170
- package/templates/runtime/schema/define-toolcraft.ts +140 -246
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +44 -0
- package/templates/runtime/state/create-template-state.test.ts +156 -0
- package/templates/runtime/state/create-template-state.ts +38 -8
- package/templates/runtime/state/media-defaults.ts +105 -0
- package/templates/runtime/state/persistence.test.ts +58 -0
- package/templates/runtime/state/persistence.ts +105 -1
- package/templates/runtime/state/reducer.test.ts +280 -4
- package/templates/runtime/state/reducer.ts +195 -9
- package/templates/runtime/state/timeline-loop.test.ts +71 -0
- package/templates/runtime/state/timeline-loop.ts +35 -0
- package/templates/runtime/state/types.ts +27 -0
- package/templates/runtime/testing/performance.test.ts +810 -21
- package/templates/runtime/testing/performance.ts +823 -53
- package/templates/starter/AGENTS.md +24 -18
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +43 -10
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +55 -20
- package/templates/starter/docs/toolcraft/component-rules.md +75 -42
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +37 -11
- package/templates/starter/docs/toolcraft/schema-reference.md +171 -41
- package/templates/starter/docs/toolcraft/workflow.md +5 -2
- package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
- package/templates/starter/e2e/app-performance.spec.ts +55 -2
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/gitignore +1 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +3 -0
- package/templates/starter/playwright.config.ts +1 -1
- package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
- package/templates/starter/scripts/run-vite-on-free-port.mjs +114 -13
- package/templates/starter/scripts/toolcraft-port.mjs +280 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +207 -1
- package/templates/starter/src/app/starter-acceptance.test.ts +3412 -479
- package/templates/starter/src/app/starter-acceptance.ts +1453 -97
- package/templates/starter/src/app/starter-performance.test.ts +111 -7
- package/templates/starter/src/app/starter-performance.ts +5 -0
- package/templates/starter/src/app/starter-schema.test.ts +32 -7
- package/templates/starter/src/app/starter-schema.ts +6 -2
- package/templates/starter/vite.config.ts +58 -2
- package/templates/ui/components/control-layout/index.tsx +8 -3
- package/templates/ui/components/controls/actions/actions-control.tsx +56 -5
- package/templates/ui/components/controls/code-textarea/code-textarea-control.tsx +7 -3
- package/templates/ui/components/controls/color/index.ts +4 -1
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- package/templates/ui/components/controls/color/style-guide-color-picker-logic.ts +7 -2
- package/templates/ui/components/controls/color/style-guide-color-picker.tsx +2 -2
- package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
- package/templates/ui/components/controls/file-drop/index.ts +6 -1
- package/templates/ui/components/controls/index.ts +4 -0
- package/templates/ui/components/controls/range-input/range-input-control.tsx +12 -4
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
- package/templates/ui/components/controls/select/select-control.tsx +8 -25
- package/templates/ui/components/controls/slider/slider-value.ts +0 -1
- package/templates/ui/components/controls/text-input/text-input-control.tsx +4 -1
- package/templates/ui/components/controls/vector/index.ts +1 -0
- package/templates/ui/components/controls/vector/vector-control.tsx +109 -12
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
- package/templates/ui/components/panel/panel-section.tsx +29 -5
|
@@ -24,7 +24,7 @@ describe("Toolcraft template decision contract", () => {
|
|
|
24
24
|
for (const rule of TOOLCRAFT_DECISION_CONTRACT) {
|
|
25
25
|
expect(rule.id).toMatch(/^[a-z0-9-]+$/);
|
|
26
26
|
expect(rule.area).toMatch(
|
|
27
|
-
/^(runtime-shell|canvas|panels|layers|timeline|controls|renderer|reference-clone|acceptance|performance|persistence|workflow)$/,
|
|
27
|
+
/^(runtime-shell|canvas|panels|layers|timeline|controls|renderer|reference-analysis|reference-clone|acceptance|performance|persistence|workflow)$/,
|
|
28
28
|
);
|
|
29
29
|
expect(rule.title.trim().length).toBeGreaterThan(0);
|
|
30
30
|
expect(rule.currentConstraint.trim().length).toBeGreaterThan(0);
|
|
@@ -40,6 +40,11 @@ describe("Toolcraft template decision contract", () => {
|
|
|
40
40
|
expect(rule?.verdict).toBe("move-to-validator");
|
|
41
41
|
expect(rule?.enforcement).toContain("browser-helper");
|
|
42
42
|
expect(rule?.desiredBehavior).toMatch(/product result/i);
|
|
43
|
+
expect(rule?.desiredBehavior).toMatch(/uploaded or imported content is part/i);
|
|
44
|
+
expect(rule?.desiredBehavior).toMatch(/fake sample output/i);
|
|
45
|
+
expect(rule?.desiredBehavior).toMatch(/agent-invented preset modes/i);
|
|
46
|
+
expect(rule?.desiredBehavior).toMatch(/worklog records that evidence/i);
|
|
47
|
+
expect(rule?.currentConstraint).toMatch(/uploaded\/imported source-material flows/i);
|
|
43
48
|
});
|
|
44
49
|
|
|
45
50
|
it("keeps runtime shell bypass prevention in validators, not only docs", () => {
|
|
@@ -80,6 +85,36 @@ describe("Toolcraft template decision contract", () => {
|
|
|
80
85
|
expect(rule?.enforcement).toContain("performance-validator");
|
|
81
86
|
});
|
|
82
87
|
|
|
88
|
+
it("requires reference clone functionality to be inventoried and acceptance-backed", () => {
|
|
89
|
+
const rule = getToolcraftDecisionRule("reference-clone-source-of-truth");
|
|
90
|
+
|
|
91
|
+
expect(rule?.level).toBe("invariant");
|
|
92
|
+
expect(rule?.desiredBehavior).toMatch(/referenceStudy evidence/i);
|
|
93
|
+
expect(rule?.desiredBehavior).toMatch(/running the original or restoring it locally/i);
|
|
94
|
+
expect(rule?.desiredBehavior).toMatch(/reference feature inventory/i);
|
|
95
|
+
expect(rule?.desiredBehavior).toMatch(/feature-level behavior evidence/i);
|
|
96
|
+
expect(rule?.desiredBehavior).toMatch(/user-visible and output-affecting feature/i);
|
|
97
|
+
expect(rule?.desiredBehavior).toMatch(/Toolcraft implementation/i);
|
|
98
|
+
expect(rule?.desiredBehavior).toMatch(/acceptance coverage/i);
|
|
99
|
+
expect(rule?.desiredBehavior).toMatch(/explicit user approval/i);
|
|
100
|
+
expect(rule?.enforcement).toContain("acceptance-validator");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("requires video references to be studied as behavior before implementation", () => {
|
|
104
|
+
const rule = getToolcraftDecisionRule("video-reference-analysis");
|
|
105
|
+
|
|
106
|
+
expect(rule?.level).toBe("invariant");
|
|
107
|
+
expect(rule?.area).toBe("reference-analysis");
|
|
108
|
+
expect(rule?.desiredBehavior).toMatch(/video, GIF, screen recording/i);
|
|
109
|
+
expect(rule?.desiredBehavior).toMatch(/storyboard/i);
|
|
110
|
+
expect(rule?.desiredBehavior).toMatch(/timecoded frame observations/i);
|
|
111
|
+
expect(rule?.desiredBehavior).toMatch(/frame-to-frame transitions/i);
|
|
112
|
+
expect(rule?.desiredBehavior).toMatch(/map those observed behaviors to acceptance rows/i);
|
|
113
|
+
expect(rule?.desiredBehavior).toMatch(/independent from loop duration or timeline choice/i);
|
|
114
|
+
expect(rule?.enforcement).toContain("acceptance-validator");
|
|
115
|
+
expect(rule?.enforcement).toContain("starter-agents");
|
|
116
|
+
});
|
|
117
|
+
|
|
83
118
|
it("splits performance into workload and lightweight responsiveness coverage", () => {
|
|
84
119
|
const rule = getToolcraftDecisionRule("performance-coverage-levels");
|
|
85
120
|
|
|
@@ -89,6 +124,8 @@ describe("Toolcraft template decision contract", () => {
|
|
|
89
124
|
expect(rule?.desiredBehavior).toMatch(/canvas drag, pan, pinch, zoom, and radar\/center/i);
|
|
90
125
|
expect(rule?.desiredBehavior).toMatch(/playback state/i);
|
|
91
126
|
expect(rule?.desiredBehavior).toMatch(/first working version/i);
|
|
127
|
+
expect(rule?.desiredBehavior).toMatch(/agent's controlled browser/i);
|
|
128
|
+
expect(rule?.desiredBehavior).toMatch(/Playwright is only the fallback runner/i);
|
|
92
129
|
expect(rule?.desiredBehavior).toMatch(/optimize performance/i);
|
|
93
130
|
expect(rule?.desiredBehavior).toMatch(/otherwise complains about performance/i);
|
|
94
131
|
expect(rule?.desiredBehavior).toMatch(/targeted functional\/browser checks/i);
|
|
@@ -6,6 +6,7 @@ export type ToolcraftDecisionArea =
|
|
|
6
6
|
| "panels"
|
|
7
7
|
| "performance"
|
|
8
8
|
| "persistence"
|
|
9
|
+
| "reference-analysis"
|
|
9
10
|
| "reference-clone"
|
|
10
11
|
| "renderer"
|
|
11
12
|
| "runtime-shell"
|
|
@@ -68,9 +69,9 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
|
|
|
68
69
|
{
|
|
69
70
|
area: "canvas",
|
|
70
71
|
currentConstraint:
|
|
71
|
-
"canvasContent must not contain buttons, forms, CTAs, helper text, upload prompts, menus, or
|
|
72
|
+
"canvasContent must not contain buttons, forms, CTAs, helper text, upload prompts, menus, settings UI, or invented placeholder/demo artwork for apps with uploaded/imported source-material flows.",
|
|
72
73
|
desiredBehavior:
|
|
73
|
-
"Canvas renders only product result, source material, renderer output, and valid product editing handles.",
|
|
74
|
+
"Canvas renders only real product result, source material, renderer output derived from current state, and valid product editing handles. If uploaded or imported content is part of the product source flow, the pre-content canvas stays neutral and runtime-backed until real content exists. Do not add agent-invented preset modes, demo backgrounds, CTA copy, fake sample output, or decorative placeholders to fill the canvas; a default procedural/reference source is allowed only when the prompt or reference explicitly defines it and the worklog records that evidence.",
|
|
74
75
|
enforcement: ["browser-helper", "starter-agents", "spec-checklist"],
|
|
75
76
|
id: "canvas-no-app-ui",
|
|
76
77
|
level: "invariant",
|
|
@@ -139,10 +140,10 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
|
|
|
139
140
|
{
|
|
140
141
|
area: "timeline",
|
|
141
142
|
currentConstraint:
|
|
142
|
-
"Timeline is optional
|
|
143
|
+
"Timeline is optional only for animated products that have no video export and are explicitly classified as autonomous decorative output.",
|
|
143
144
|
desiredBehavior:
|
|
144
|
-
"AI writes an Animation Intent Inventory before choosing no timeline, playback, keyframes, or custom reference timeline; user-requested product animation defaults to playback unless explicitly justified as autonomous output.",
|
|
145
|
-
enforcement: ["starter-agents", "spec-checklist"],
|
|
145
|
+
"AI writes an Animation Intent Inventory before choosing no timeline, playback, keyframes, or custom reference timeline; user-requested product animation defaults to playback unless explicitly justified as autonomous output without video export. Export Video always requires a top Toolcraft timeline.",
|
|
146
|
+
enforcement: ["starter-agents", "spec-checklist", "acceptance-validator"],
|
|
146
147
|
id: "timeline-mode-choice",
|
|
147
148
|
level: "heuristic",
|
|
148
149
|
title: "Timeline mode is chosen from behavior",
|
|
@@ -189,7 +190,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
|
|
|
189
190
|
currentConstraint:
|
|
190
191
|
"Labels, color placement, section grouping, selector order, and inline density need product-aware decisions.",
|
|
191
192
|
desiredBehavior:
|
|
192
|
-
"Apps
|
|
193
|
+
"Apps export a Control Section Inventory before schema authoring: every product controls section has a product entity or workflow stage, exact targets, a grouping reason, and split evidence when one target entity is intentionally divided.",
|
|
193
194
|
enforcement: ["acceptance-validator", "schema-normalization", "docs", "starter-agents"],
|
|
194
195
|
id: "controls-layout-heuristics",
|
|
195
196
|
level: "heuristic",
|
|
@@ -211,15 +212,27 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
|
|
|
211
212
|
{
|
|
212
213
|
area: "reference-clone",
|
|
213
214
|
currentConstraint:
|
|
214
|
-
"Reference-runtime-clone mode preserves the reference runtime as source of truth unless a redesign is explicit.",
|
|
215
|
+
"Reference-runtime-clone mode preserves the reference runtime as source of truth unless a redesign is explicit; functionality must be inventoried from inspected reference behavior before implementation.",
|
|
215
216
|
desiredBehavior:
|
|
216
|
-
"Ported apps keep reference loops, mutable state, transport semantics, media lifecycle, and export behavior before Toolcraft refinements.",
|
|
217
|
+
"Ported apps keep reference loops, mutable state, transport semantics, media lifecycle, and export behavior before Toolcraft refinements. The agent records referenceStudy evidence from source inspection plus running the original or restoring it locally when runnable/reconstructable, builds a reference feature inventory from inspected source/runtime/UI behavior, gives each inventory item feature-level behavior evidence from that study, maps every user-visible and output-affecting feature to Toolcraft implementation and acceptance coverage, and marks intentional behavior changes only with explicit user approval evidence.",
|
|
217
218
|
enforcement: ["acceptance-validator", "browser-helper", "starter-agents"],
|
|
218
219
|
id: "reference-clone-source-of-truth",
|
|
219
220
|
level: "invariant",
|
|
220
221
|
title: "Reference clone preserves behavior",
|
|
221
222
|
verdict: "keep-hard",
|
|
222
223
|
},
|
|
224
|
+
{
|
|
225
|
+
area: "reference-analysis",
|
|
226
|
+
currentConstraint:
|
|
227
|
+
"Video, GIF, and screen-recording references are often treated as static visual inspiration or summarized from a few frames.",
|
|
228
|
+
desiredBehavior:
|
|
229
|
+
"Whenever a supplied reference is a video, GIF, screen recording, contact sheet, or extracted frame sequence, the agent studies it as behavioral evidence before implementation: extract or inspect a storyboard, record timecoded frame observations, compare frame-to-frame transitions, decompose changing entities and state into product behavior, and map those observed behaviors to acceptance rows. This applies to new Toolcraft apps and reference-runtime-clone work; it is independent from loop duration or timeline choice.",
|
|
230
|
+
enforcement: ["acceptance-validator", "browser-helper", "docs", "starter-agents"],
|
|
231
|
+
id: "video-reference-analysis",
|
|
232
|
+
level: "invariant",
|
|
233
|
+
title: "Video references require storyboard behavior study",
|
|
234
|
+
verdict: "move-to-validator",
|
|
235
|
+
},
|
|
223
236
|
{
|
|
224
237
|
area: "acceptance",
|
|
225
238
|
currentConstraint:
|
|
@@ -237,7 +250,7 @@ export const TOOLCRAFT_DECISION_CONTRACT = [
|
|
|
237
250
|
currentConstraint:
|
|
238
251
|
"Performance coverage currently asks every visible non-action control for a performance scenario.",
|
|
239
252
|
desiredBehavior:
|
|
240
|
-
"Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required only when the first working version of an app exists, or whenever the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance. Renderer, canvas, animation, export, timeline, layers, canvas.renderScale, bug fixes, and performance-sensitive controls need targeted functional/browser checks first and targeted performance scenarios only for touched workload/viewport/export paths. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance
|
|
253
|
+
"Heavy workload controls get min/default/max workload coverage; ordinary controls get lightweight responsiveness coverage so they cannot hang or break input. Animated previews suspend or coalesce non-essential animation work during canvas drag, pan, pinch, zoom, and radar/center interactions without changing user playback state. A full performance checkpoint is required only when the first working version of an app exists, or whenever the user explicitly asks to optimize performance, fix lag, remove jank, speed up animation, stabilize drag/zoom, or otherwise complains about performance. The checkpoint uses the current AI agent's controlled browser first; Playwright is only the fallback runner when no agent browser is available or in CI/non-agent automation. Renderer, canvas, animation, export, timeline, layers, canvas.renderScale, bug fixes, and performance-sensitive controls need targeted functional/browser checks first and targeted performance scenarios only for touched workload/viewport/export paths. Performance fixes must preserve the selected render scale and must not pass budgets by silently downsampling, stretching a lower-resolution backing canvas, blurring output, or clamping canvas.renderScale below the user's chosen value. Browser performance checks read budgets from typed performance config and run sequentially for stable measurements.",
|
|
241
254
|
enforcement: ["performance-validator", "browser-helper", "starter-agents"],
|
|
242
255
|
id: "performance-coverage-levels",
|
|
243
256
|
level: "invariant",
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getToolcraftImageExportSize,
|
|
8
8
|
getToolcraftRetinaExportPixelRatio,
|
|
9
9
|
getToolcraftRetinaExportSize,
|
|
10
|
+
getToolcraftVideoExportSize,
|
|
10
11
|
shouldIncludeToolcraftExportBackground,
|
|
11
12
|
shouldIncludeToolcraftPreviewBackground,
|
|
12
13
|
} from "./export";
|
|
@@ -185,6 +186,68 @@ describe("Toolcraft export helpers", () => {
|
|
|
185
186
|
});
|
|
186
187
|
});
|
|
187
188
|
|
|
189
|
+
it("resolves current video export to even current output dimensions", () => {
|
|
190
|
+
const state = createState();
|
|
191
|
+
state.canvas.size = { height: 101, unit: "px", width: 201 };
|
|
192
|
+
|
|
193
|
+
expect(getToolcraftVideoExportSize({ devicePixelRatio: 2, resolution: "current", state }))
|
|
194
|
+
.toEqual({
|
|
195
|
+
height: 102,
|
|
196
|
+
pixelRatio: 102 / 101,
|
|
197
|
+
width: 202,
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("keeps current video export distinct from 4k export at default canvas size", () => {
|
|
202
|
+
const state = createState();
|
|
203
|
+
state.canvas.size = { height: 1080, unit: "px", width: 1920 };
|
|
204
|
+
|
|
205
|
+
expect(getToolcraftVideoExportSize({ devicePixelRatio: 2, resolution: "current", state }))
|
|
206
|
+
.toEqual({
|
|
207
|
+
height: 1080,
|
|
208
|
+
pixelRatio: 1,
|
|
209
|
+
width: 1920,
|
|
210
|
+
});
|
|
211
|
+
expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
|
|
212
|
+
height: 2160,
|
|
213
|
+
pixelRatio: 2,
|
|
214
|
+
width: 3840,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("fits 4k video export inside an encoder-safe UHD box", () => {
|
|
219
|
+
const state = createState();
|
|
220
|
+
state.canvas.size = { height: 900, unit: "px", width: 1440 };
|
|
221
|
+
|
|
222
|
+
expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
|
|
223
|
+
height: 2160,
|
|
224
|
+
pixelRatio: 2.4,
|
|
225
|
+
width: 3456,
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("preserves 16:9 at UHD dimensions for 4k video export", () => {
|
|
230
|
+
const state = createState();
|
|
231
|
+
state.canvas.size = { height: 1080, unit: "px", width: 1920 };
|
|
232
|
+
|
|
233
|
+
expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
|
|
234
|
+
height: 2160,
|
|
235
|
+
pixelRatio: 2,
|
|
236
|
+
width: 3840,
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("reports the actual rounded pixel ratio for 4k video export", () => {
|
|
241
|
+
const state = createState();
|
|
242
|
+
state.canvas.size = { height: 201, unit: "px", width: 333 };
|
|
243
|
+
|
|
244
|
+
expect(getToolcraftVideoExportSize({ resolution: "4k", state })).toEqual({
|
|
245
|
+
height: 2160,
|
|
246
|
+
pixelRatio: 2160 / 201,
|
|
247
|
+
width: 3578,
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
188
251
|
it("creates a transparent retina png canvas when png background is disabled", () => {
|
|
189
252
|
const schema = defineToolcraft({
|
|
190
253
|
canvas: { enabled: true },
|
|
@@ -4,6 +4,7 @@ import type { ToolcraftState } from "../state/types";
|
|
|
4
4
|
export type ToolcraftExportFormat = "png" | "video";
|
|
5
5
|
|
|
6
6
|
export type ToolcraftImageExportResolution = "current" | "2k" | "4k" | "8k";
|
|
7
|
+
export type ToolcraftVideoExportResolution = "current" | "4k";
|
|
7
8
|
|
|
8
9
|
export type ToolcraftRetinaExportSize = {
|
|
9
10
|
height: number;
|
|
@@ -30,6 +31,10 @@ export type ToolcraftImageExportSizeOptions = ToolcraftExportSizeOptions & {
|
|
|
30
31
|
resolution?: ToolcraftImageExportResolution | string;
|
|
31
32
|
};
|
|
32
33
|
|
|
34
|
+
export type ToolcraftVideoExportSizeOptions = ToolcraftExportSizeOptions & {
|
|
35
|
+
resolution?: ToolcraftVideoExportResolution | string;
|
|
36
|
+
};
|
|
37
|
+
|
|
33
38
|
export type ToolcraftPngRenderContext = {
|
|
34
39
|
canvas: HTMLCanvasElement;
|
|
35
40
|
context: CanvasRenderingContext2D;
|
|
@@ -60,6 +65,17 @@ const toolcraftImageExportLongEdges: Record<
|
|
|
60
65
|
"8k": 8192,
|
|
61
66
|
};
|
|
62
67
|
|
|
68
|
+
const toolcraftVideoExportMaxSizes: Record<
|
|
69
|
+
Exclude<ToolcraftVideoExportResolution, "current">,
|
|
70
|
+
{ height: number; width: number }
|
|
71
|
+
> = {
|
|
72
|
+
"4k": { height: 2160, width: 3840 },
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function roundVideoDimension(value: number): number {
|
|
76
|
+
return Math.max(2, Math.round(value / 2) * 2);
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
export function getToolcraftRetinaExportPixelRatio(devicePixelRatio?: number): number {
|
|
64
80
|
const globalPixelRatio = (globalThis as typeof globalThis & { devicePixelRatio?: number })
|
|
65
81
|
.devicePixelRatio;
|
|
@@ -123,6 +139,45 @@ export function getToolcraftImageExportSize({
|
|
|
123
139
|
};
|
|
124
140
|
}
|
|
125
141
|
|
|
142
|
+
export function getToolcraftVideoExportSize({
|
|
143
|
+
resolution,
|
|
144
|
+
state,
|
|
145
|
+
}: ToolcraftVideoExportSizeOptions): ToolcraftRetinaExportSize {
|
|
146
|
+
const normalizedResolution = String(resolution ?? "current").toLowerCase();
|
|
147
|
+
const targetMaxSize =
|
|
148
|
+
toolcraftVideoExportMaxSizes[
|
|
149
|
+
normalizedResolution as Exclude<ToolcraftVideoExportResolution, "current">
|
|
150
|
+
];
|
|
151
|
+
|
|
152
|
+
if (!targetMaxSize) {
|
|
153
|
+
const cssWidth = Math.max(1, state.canvas.size.width);
|
|
154
|
+
const cssHeight = Math.max(1, state.canvas.size.height);
|
|
155
|
+
const width = roundVideoDimension(cssWidth);
|
|
156
|
+
const height = roundVideoDimension(cssHeight);
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
height,
|
|
160
|
+
pixelRatio: Math.max(width / cssWidth, height / cssHeight),
|
|
161
|
+
width,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const cssWidth = Math.max(1, state.canvas.size.width);
|
|
166
|
+
const cssHeight = Math.max(1, state.canvas.size.height);
|
|
167
|
+
const pixelRatio = Math.min(
|
|
168
|
+
targetMaxSize.width / cssWidth,
|
|
169
|
+
targetMaxSize.height / cssHeight,
|
|
170
|
+
);
|
|
171
|
+
const width = roundVideoDimension(cssWidth * pixelRatio);
|
|
172
|
+
const height = roundVideoDimension(cssHeight * pixelRatio);
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
height,
|
|
176
|
+
pixelRatio: Math.max(width / cssWidth, height / cssHeight),
|
|
177
|
+
width,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
export function shouldIncludeToolcraftExportBackground({
|
|
127
182
|
format,
|
|
128
183
|
schema,
|
|
@@ -10,5 +10,6 @@ export * from "./state/create-template-state";
|
|
|
10
10
|
export * from "./state/keyframe-evaluation";
|
|
11
11
|
export * from "./state/persistence";
|
|
12
12
|
export * from "./state/reducer";
|
|
13
|
+
export * from "./state/timeline-loop";
|
|
13
14
|
export * from "./state/types";
|
|
14
15
|
export * from "./testing/performance";
|
|
@@ -22,7 +22,18 @@ function createSchema() {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function
|
|
25
|
+
function createIntrinsicMediaSchema() {
|
|
26
|
+
return defineToolcraft({
|
|
27
|
+
canvas: {
|
|
28
|
+
enabled: true,
|
|
29
|
+
sizing: { mode: "intrinsic-media" },
|
|
30
|
+
upload: true,
|
|
31
|
+
},
|
|
32
|
+
panels: {},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function createUploadDefaultSchema() {
|
|
26
37
|
return defineToolcraft({
|
|
27
38
|
canvas: {
|
|
28
39
|
enabled: true,
|
|
@@ -87,9 +98,9 @@ describe("CanvasShell", () => {
|
|
|
87
98
|
expect(editableCanvas?.style.height).toBe("200px");
|
|
88
99
|
});
|
|
89
100
|
|
|
90
|
-
it("does not render a fixed editable canvas
|
|
101
|
+
it("does not render a fixed editable canvas for explicit intrinsic media sizing", () => {
|
|
91
102
|
const { container } = render(
|
|
92
|
-
<ToolcraftRoot schema={
|
|
103
|
+
<ToolcraftRoot schema={createIntrinsicMediaSchema()}>
|
|
93
104
|
<div style={{ height: 640, position: "relative", width: 640 }}>
|
|
94
105
|
<CanvasShell />
|
|
95
106
|
</div>
|
|
@@ -101,7 +112,7 @@ describe("CanvasShell", () => {
|
|
|
101
112
|
|
|
102
113
|
it("renders the canvas slot inside the editable canvas when custom app content is provided", () => {
|
|
103
114
|
const { container } = render(
|
|
104
|
-
<ToolcraftRoot schema={
|
|
115
|
+
<ToolcraftRoot schema={createUploadDefaultSchema()}>
|
|
105
116
|
<div style={{ height: 640, position: "relative", width: 640 }}>
|
|
106
117
|
<CanvasShell>
|
|
107
118
|
<div data-testid="custom-canvas-renderer" />
|
|
@@ -497,4 +508,47 @@ describe("CanvasShell", () => {
|
|
|
497
508
|
|
|
498
509
|
expect(screen.queryByRole("button", { name: "Select material.png" })).toBeNull();
|
|
499
510
|
});
|
|
511
|
+
|
|
512
|
+
it("renders uploaded canvas images with cover crop and runtime transforms", () => {
|
|
513
|
+
render(
|
|
514
|
+
<ToolcraftRoot
|
|
515
|
+
initialState={{
|
|
516
|
+
layers: [
|
|
517
|
+
{
|
|
518
|
+
displayName: "Layer 1",
|
|
519
|
+
id: "layer-1",
|
|
520
|
+
kind: "layer",
|
|
521
|
+
name: "layer-1",
|
|
522
|
+
visible: true,
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
mediaAssets: [
|
|
526
|
+
{
|
|
527
|
+
dataUrl: "data:image/png;base64,test",
|
|
528
|
+
fileName: "material.png",
|
|
529
|
+
id: "media-1",
|
|
530
|
+
layerId: "layer-1",
|
|
531
|
+
mimeType: "image/png",
|
|
532
|
+
position: { x: 48, y: -24 },
|
|
533
|
+
size: { height: 200, unit: "px", width: 300 },
|
|
534
|
+
transform: { flipHorizontal: true, rotationDeg: 90 },
|
|
535
|
+
},
|
|
536
|
+
],
|
|
537
|
+
selectedLayerId: "layer-1",
|
|
538
|
+
}}
|
|
539
|
+
schema={createSchema()}
|
|
540
|
+
>
|
|
541
|
+
<div style={{ height: 640, position: "relative", width: 640 }}>
|
|
542
|
+
<CanvasShell />
|
|
543
|
+
</div>
|
|
544
|
+
</ToolcraftRoot>,
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const mediaButton = screen.getByRole("button", { name: "Select material.png" });
|
|
548
|
+
const mediaImage = screen.getByRole("img", { name: "material.png" });
|
|
549
|
+
|
|
550
|
+
expect(mediaButton.className).toContain("inset-0");
|
|
551
|
+
expect(mediaImage.className).toContain("object-cover");
|
|
552
|
+
expect(mediaImage.getAttribute("style")).toContain("rotate(90deg) scale(-1.5, 1.5)");
|
|
553
|
+
});
|
|
500
554
|
});
|
|
@@ -5,6 +5,7 @@ import * as React from "react";
|
|
|
5
5
|
import { clampToolcraftCanvasZoom } from "../state/canvas-zoom";
|
|
6
6
|
import type {
|
|
7
7
|
ToolcraftMediaAsset,
|
|
8
|
+
ToolcraftMediaTransform,
|
|
8
9
|
ToolcraftPoint,
|
|
9
10
|
ToolcraftState,
|
|
10
11
|
} from "../state/types";
|
|
@@ -176,6 +177,33 @@ function isDefaultCanvasImageAsset(
|
|
|
176
177
|
);
|
|
177
178
|
}
|
|
178
179
|
|
|
180
|
+
function normalizeMediaRotation(rotationDeg: number | undefined): 0 | 90 | 180 | 270 {
|
|
181
|
+
const normalized = ((Math.round((rotationDeg ?? 0) / 90) * 90) % 360 + 360) % 360;
|
|
182
|
+
|
|
183
|
+
return normalized === 90 || normalized === 180 || normalized === 270 ? normalized : 0;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getCanvasMediaTransformStyle(
|
|
187
|
+
transform: ToolcraftMediaTransform | undefined,
|
|
188
|
+
canvasSize: ToolcraftState["canvas"]["size"],
|
|
189
|
+
): React.CSSProperties {
|
|
190
|
+
const rotationDeg = normalizeMediaRotation(transform?.rotationDeg);
|
|
191
|
+
const coverScale =
|
|
192
|
+
rotationDeg === 90 || rotationDeg === 270
|
|
193
|
+
? Math.max(canvasSize.width / canvasSize.height, canvasSize.height / canvasSize.width)
|
|
194
|
+
: 1;
|
|
195
|
+
const scaleX = (transform?.flipHorizontal ? -1 : 1) * coverScale;
|
|
196
|
+
const scaleY = (transform?.flipVertical ? -1 : 1) * coverScale;
|
|
197
|
+
|
|
198
|
+
if (rotationDeg === 0 && scaleX === 1 && scaleY === 1) {
|
|
199
|
+
return {};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
transform: `rotate(${rotationDeg}deg) scale(${scaleX}, ${scaleY})`,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
179
207
|
function getZoomedCanvasOffset({
|
|
180
208
|
clientX,
|
|
181
209
|
clientY,
|
|
@@ -507,7 +535,7 @@ export function CanvasShell({
|
|
|
507
535
|
<button
|
|
508
536
|
aria-label={`Select ${mediaAsset.fileName}`}
|
|
509
537
|
className={cn(
|
|
510
|
-
"absolute
|
|
538
|
+
"absolute inset-0 block cursor-pointer overflow-hidden rounded-none border bg-[color:color-mix(in_oklab,var(--background)_84%,transparent)] p-0 shadow-sm transition-[border-color,box-shadow] duration-150 ease-out",
|
|
511
539
|
selected
|
|
512
540
|
? "border-[color:var(--link)] shadow-[0_0_0_1px_color-mix(in_oklab,var(--link)_48%,transparent)]"
|
|
513
541
|
: "border-[color:color-mix(in_oklab,var(--border)_10%,transparent)] hover:border-[color:color-mix(in_oklab,var(--border)_24%,transparent)]",
|
|
@@ -522,21 +550,15 @@ export function CanvasShell({
|
|
|
522
550
|
});
|
|
523
551
|
}}
|
|
524
552
|
onPointerDown={(event) => event.stopPropagation()}
|
|
525
|
-
style={{
|
|
526
|
-
transform: `translate(calc(-50% + ${mediaAsset.position.x}px), calc(-50% + ${mediaAsset.position.y}px))`,
|
|
527
|
-
}}
|
|
528
553
|
type="button"
|
|
529
554
|
>
|
|
530
555
|
<img
|
|
531
556
|
alt={mediaAsset.fileName}
|
|
532
|
-
className="block select-none object-
|
|
557
|
+
className="block size-full select-none object-cover"
|
|
533
558
|
data-toolcraft-generated-output=""
|
|
534
559
|
draggable={false}
|
|
535
560
|
src={mediaAsset.dataUrl}
|
|
536
|
-
style={
|
|
537
|
-
height: mediaAsset.size.height,
|
|
538
|
-
width: mediaAsset.size.width,
|
|
539
|
-
}}
|
|
561
|
+
style={getCanvasMediaTransformStyle(mediaAsset.transform, size)}
|
|
540
562
|
/>
|
|
541
563
|
</button>
|
|
542
564
|
);
|