@pixel-point/toolcraft 0.0.9 → 0.0.12
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 +2 -1
- package/src/cli.mjs +4 -2
- package/src/cli.test.mjs +5 -2
- package/src/generate.mjs +14 -6
- package/src/generate.test.mjs +33 -2
- package/templates/runtime/contracts/component-contracts.test.ts +175 -36
- package/templates/runtime/contracts/component-contracts.ts +90 -45
- package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
- package/templates/runtime/contracts/decision-contracts.ts +19 -6
- 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 +478 -27
- package/templates/runtime/react/controls-panel.tsx +71 -26
- 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 +225 -173
- package/templates/runtime/schema/define-toolcraft.ts +117 -247
- package/templates/runtime/schema/runtime-targets.ts +21 -0
- package/templates/runtime/schema/types.ts +41 -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 +657 -2
- package/templates/runtime/testing/performance.ts +789 -49
- package/templates/starter/AGENTS.md +22 -16
- package/templates/starter/docs/toolcraft/README.md +8 -4
- package/templates/starter/docs/toolcraft/acceptance-testing.md +41 -8
- package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
- package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
- package/templates/starter/docs/toolcraft/component-rules.md +58 -37
- package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
- package/templates/starter/docs/toolcraft/performance.md +30 -10
- package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
- 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 +57 -4
- package/templates/starter/e2e/performance-helpers.ts +45 -0
- package/templates/starter/index.html +1 -0
- package/templates/starter/package.json +3 -2
- 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 +82 -16
- package/templates/starter/scripts/toolcraft-port.mjs +178 -0
- package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
- package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
- package/templates/starter/src/app/starter-acceptance.ts +978 -81
- package/templates/starter/src/app/starter-performance.test.ts +130 -8
- 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/controls/actions/actions-control.tsx +46 -3
- package/templates/ui/components/controls/color/palette-control.tsx +34 -4
- 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 +2 -0
- package/templates/ui/components/controls/range-slider/range-slider-value.ts +19 -5
- package/templates/ui/components/controls/select/select-control.tsx +4 -26
- package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
- package/templates/ui/components/panel/panel-actions.tsx +1 -1
|
@@ -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
|
);
|