@pixel-point/toolcraft 0.0.3 → 0.0.4

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 (28) hide show
  1. package/package.json +1 -1
  2. package/templates/runtime/contracts/component-contracts.test.ts +28 -7
  3. package/templates/runtime/contracts/component-contracts.ts +14 -7
  4. package/templates/runtime/contracts/decision-contracts.ts +1 -1
  5. package/templates/runtime/export/export.test.ts +65 -0
  6. package/templates/runtime/export/export.ts +54 -1
  7. package/templates/runtime/react/controls-panel.test.tsx +54 -6
  8. package/templates/runtime/react/controls-panel.tsx +216 -0
  9. package/templates/runtime/react/settings-transfer.test.ts +6 -0
  10. package/templates/runtime/react/settings-transfer.ts +28 -2
  11. package/templates/runtime/schema/canvas-aspect-ratio-presets.ts +50 -0
  12. package/templates/runtime/schema/define-toolcraft.test.ts +45 -1
  13. package/templates/runtime/schema/define-toolcraft.ts +60 -2
  14. package/templates/runtime/schema/keyframe-capability.test.ts +7 -0
  15. package/templates/runtime/schema/keyframe-capability.ts +2 -2
  16. package/templates/runtime/schema/runtime-targets.ts +5 -0
  17. package/templates/runtime/state/create-template-state.test.ts +6 -0
  18. package/templates/runtime/state/reducer.test.ts +55 -0
  19. package/templates/runtime/state/reducer.ts +214 -9
  20. package/templates/starter/AGENTS.md +5 -3
  21. package/templates/starter/docs/toolcraft/acceptance-testing.md +3 -1
  22. package/templates/starter/docs/toolcraft/assembly-workflow.md +10 -3
  23. package/templates/starter/docs/toolcraft/component-rules.md +12 -5
  24. package/templates/starter/docs/toolcraft/schema-reference.md +45 -7
  25. package/templates/starter/src/app/starter-acceptance.test.ts +623 -21
  26. package/templates/starter/src/app/starter-acceptance.ts +290 -3
  27. package/templates/ui/components/control-layout/index.tsx +4 -4
  28. package/templates/ui/components/controls/font-picker/font-picker-control.tsx +1 -1
@@ -57,6 +57,10 @@ import type {
57
57
  ToolcraftControlSchema,
58
58
  ResolvedToolcraftAppSchema,
59
59
  } from "../schema/types";
60
+ import {
61
+ getToolcraftCanvasAspectRatioPreset,
62
+ toolcraftCanvasAspectRatioPresets,
63
+ } from "../schema/canvas-aspect-ratio-presets";
60
64
  import { getToolcraftControlKeyframeCapability } from "../schema/keyframe-capability";
61
65
  import { getToolcraftCanvasSizeTargetDimension } from "../schema/runtime-targets";
62
66
  import type {
@@ -112,9 +116,22 @@ type FooterActionProgressEntry = {
112
116
  id: number;
113
117
  progress: number | null;
114
118
  };
119
+ type CanvasAspectRatioValue = {
120
+ height: number;
121
+ mode: "custom" | "preset";
122
+ value: string;
123
+ width: number;
124
+ };
115
125
 
116
126
  const hiddenDiscreteMarkerCount = 2;
117
127
  const controlsPanelSectionCollapseStorageVersion = 1;
128
+ const canvasAspectRatioOptions = [
129
+ ...toolcraftCanvasAspectRatioPresets.map((preset) => ({
130
+ label: preset.value,
131
+ value: preset.value,
132
+ })),
133
+ { label: "Custom...", value: "custom" },
134
+ ] as const;
118
135
 
119
136
  const sectionedCompoundControlTypes = new Set([
120
137
  "channelMixer",
@@ -257,6 +274,86 @@ function asString(value: unknown, fallback = ""): string {
257
274
  return typeof value === "number" && Number.isFinite(value) ? String(value) : fallback;
258
275
  }
259
276
 
277
+ function parseCanvasAspectRatioOption(value: string): CanvasAspectRatioValue | null {
278
+ const preset = getToolcraftCanvasAspectRatioPreset(value);
279
+
280
+ if (preset) {
281
+ return {
282
+ height: preset.ratioHeight,
283
+ mode: "preset",
284
+ value: preset.value,
285
+ width: preset.ratioWidth,
286
+ };
287
+ }
288
+
289
+ const match = /^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$/u.exec(value);
290
+
291
+ if (!match) {
292
+ return null;
293
+ }
294
+
295
+ const width = Number.parseFloat(match[1] ?? "");
296
+ const height = Number.parseFloat(match[2] ?? "");
297
+
298
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
299
+ return null;
300
+ }
301
+
302
+ return {
303
+ height: Math.round(height),
304
+ mode: "custom",
305
+ value: `${Math.round(width)}:${Math.round(height)}`,
306
+ width: Math.round(width),
307
+ };
308
+ }
309
+
310
+ function asCanvasAspectRatioValue(
311
+ value: unknown,
312
+ fallback: unknown,
313
+ ): CanvasAspectRatioValue {
314
+ if (isRecord(value)) {
315
+ const width = asNumber(value.width, NaN);
316
+ const height = asNumber(value.height, NaN);
317
+ const mode = value.mode === "preset" ? "preset" : "custom";
318
+
319
+ if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
320
+ return {
321
+ height: Math.round(height),
322
+ mode,
323
+ value:
324
+ typeof value.value === "string"
325
+ ? value.value
326
+ : `${Math.round(width)}:${Math.round(height)}`,
327
+ width: Math.round(width),
328
+ };
329
+ }
330
+ }
331
+
332
+ if (typeof value === "string") {
333
+ const parsed = parseCanvasAspectRatioOption(value);
334
+
335
+ if (parsed) {
336
+ return parsed;
337
+ }
338
+ }
339
+
340
+ if (fallback !== value) {
341
+ return asCanvasAspectRatioValue(fallback, {
342
+ height: 1,
343
+ mode: "preset",
344
+ value: "1:1",
345
+ width: 1,
346
+ });
347
+ }
348
+
349
+ return {
350
+ height: 1,
351
+ mode: "preset",
352
+ value: "1:1",
353
+ width: 1,
354
+ };
355
+ }
356
+
260
357
  function asBoolean(value: unknown, fallback = false): boolean {
261
358
  return typeof value === "boolean" ? value : fallback;
262
359
  }
@@ -330,6 +427,8 @@ function formatControlValueLabel(
330
427
  }
331
428
 
332
429
  switch (control.type) {
430
+ case "aspectRatio":
431
+ return asCanvasAspectRatioValue(value, control.defaultValue).value;
333
432
  case "checkbox":
334
433
  case "switch":
335
434
  return asBoolean(value) ? "On" : "Off";
@@ -505,6 +604,111 @@ function asFontPickerValue(value: unknown): FontPickerValue {
505
604
  };
506
605
  }
507
606
 
607
+ function CanvasAspectRatioControl({
608
+ defaultValue,
609
+ name,
610
+ onValueChange,
611
+ value,
612
+ }: {
613
+ defaultValue: unknown;
614
+ name: string;
615
+ onValueChange?: (
616
+ value: CanvasAspectRatioValue,
617
+ meta?: ControlChangeMeta,
618
+ ) => void;
619
+ value: unknown;
620
+ }): React.JSX.Element {
621
+ const ratio = asCanvasAspectRatioValue(value, defaultValue);
622
+ const selectedValue = ratio.mode === "custom" ? "custom" : ratio.value;
623
+
624
+ function commitRatio(
625
+ nextRatio: CanvasAspectRatioValue,
626
+ meta?: ControlChangeMeta,
627
+ ): void {
628
+ onValueChange?.(nextRatio, meta);
629
+ }
630
+
631
+ function updatePreset(nextValue: string): void {
632
+ if (nextValue === "custom") {
633
+ commitRatio({
634
+ height: ratio.height,
635
+ mode: "custom",
636
+ value: `${ratio.width}:${ratio.height}`,
637
+ width: ratio.width,
638
+ });
639
+ return;
640
+ }
641
+
642
+ const nextRatio = parseCanvasAspectRatioOption(nextValue);
643
+
644
+ if (nextRatio) {
645
+ commitRatio(nextRatio);
646
+ }
647
+ }
648
+
649
+ function updateCustomDimension(
650
+ dimension: "height" | "width",
651
+ nextValue: string,
652
+ meta?: ControlChangeMeta,
653
+ ): void {
654
+ const numberValue = Number.parseFloat(nextValue);
655
+
656
+ if (!Number.isFinite(numberValue) || numberValue <= 0) {
657
+ return;
658
+ }
659
+
660
+ const width =
661
+ dimension === "width" ? Math.max(1, Math.round(numberValue)) : ratio.width;
662
+ const height =
663
+ dimension === "height" ? Math.max(1, Math.round(numberValue)) : ratio.height;
664
+
665
+ commitRatio(
666
+ {
667
+ height,
668
+ mode: "custom",
669
+ value: `${width}:${height}`,
670
+ width,
671
+ },
672
+ meta,
673
+ );
674
+ }
675
+
676
+ return (
677
+ <div className="min-w-0 space-y-2" data-slot="canvas-aspect-ratio-control">
678
+ <Select
679
+ layout="stacked"
680
+ name={name}
681
+ onValueChange={updatePreset}
682
+ options={canvasAspectRatioOptions}
683
+ value={selectedValue}
684
+ />
685
+ {ratio.mode === "custom" ? (
686
+ <TextInput
687
+ inputs={[
688
+ {
689
+ commitOnBlur: true,
690
+ defaultValue: String(ratio.width),
691
+ name: "Width",
692
+ onValueChange: (nextValue, meta) =>
693
+ updateCustomDimension("width", nextValue, meta),
694
+ value: String(ratio.width),
695
+ },
696
+ {
697
+ commitOnBlur: true,
698
+ defaultValue: String(ratio.height),
699
+ name: "Height",
700
+ onValueChange: (nextValue, meta) =>
701
+ updateCustomDimension("height", nextValue, meta),
702
+ value: String(ratio.height),
703
+ },
704
+ ]}
705
+ inputsPerRow={2}
706
+ />
707
+ ) : null}
708
+ </div>
709
+ );
710
+ }
711
+
508
712
  function asActionSchemas(
509
713
  actions: readonly (ToolcraftActionSchema | string)[] | undefined,
510
714
  ): readonly ToolcraftActionSchema[] {
@@ -690,6 +894,7 @@ function isRuntimeSetupSection(section: ToolcraftControlSectionSchema): boolean
690
894
  return Object.values(section.controls).some(
691
895
  (control) =>
692
896
  control.type === "settingsTransfer" ||
897
+ control.target === "canvas.aspectRatio" ||
693
898
  control.target === "canvas.size.width" ||
694
899
  control.target === "canvas.size.height",
695
900
  );
@@ -1765,6 +1970,17 @@ export function ControlsPanel({
1765
1970
  );
1766
1971
  }
1767
1972
 
1973
+ case "aspectRatio":
1974
+ return (
1975
+ <CanvasAspectRatioControl
1976
+ defaultValue={control.defaultValue}
1977
+ key={id}
1978
+ name={name}
1979
+ onValueChange={commit}
1980
+ value={value}
1981
+ />
1982
+ );
1983
+
1768
1984
  case "anchorGrid":
1769
1985
  return withKeyframeLabelAction({
1770
1986
  children: (
@@ -130,6 +130,12 @@ describe("settings transfer", () => {
130
130
 
131
131
  expect(state.values["generation.prompt"]).toBe("Imported prompt");
132
132
  expect(state.values["unknown.target"]).toBeUndefined();
133
+ expect(state.values["canvas.aspectRatio"]).toEqual({
134
+ height: 900,
135
+ mode: "custom",
136
+ value: "1600:900",
137
+ width: 1600,
138
+ });
133
139
  expect(state.canvas.size).toEqual({ height: 900, unit: "px", width: 1600 });
134
140
  expect(state.timeline).toMatchObject({
135
141
  currentTimeSeconds: 3,
@@ -123,9 +123,33 @@ export function parseToolcraftSettingsPayload(
123
123
  }
124
124
 
125
125
  function applyCanvasSize(
126
- dispatch: ToolcraftDispatch,
126
+ { dispatch, state }: ImportContext,
127
127
  size: ToolcraftSettingsTransferPayload["canvas"]["size"],
128
+ options: { deriveAspectRatio: boolean },
128
129
  ): void {
130
+ const importableTargets = getKnownValueTargets(state.schema);
131
+
132
+ if (
133
+ options.deriveAspectRatio &&
134
+ importableTargets.has("canvas.aspectRatio") &&
135
+ isFinitePositiveNumber(size.width) &&
136
+ isFinitePositiveNumber(size.height)
137
+ ) {
138
+ dispatch({
139
+ history: "merge",
140
+ historyGroup: settingsTransferImportHistoryGroup,
141
+ label: "Import settings",
142
+ target: "canvas.aspectRatio",
143
+ type: "controls.setValue",
144
+ value: {
145
+ height: size.height,
146
+ mode: "custom",
147
+ value: `${size.width}:${size.height}`,
148
+ width: size.width,
149
+ },
150
+ });
151
+ }
152
+
129
153
  if (isFinitePositiveNumber(size.width)) {
130
154
  dispatch({
131
155
  history: "merge",
@@ -211,7 +235,9 @@ export function applyToolcraftSettingsPayload(
211
235
  });
212
236
  }
213
237
 
214
- applyCanvasSize(context.dispatch, payload.canvas.size);
238
+ applyCanvasSize(context, payload.canvas.size, {
239
+ deriveAspectRatio: !Object.hasOwn(payload.values, "canvas.aspectRatio"),
240
+ });
215
241
  applyTimeline(context, payload.timeline);
216
242
  }
217
243
 
@@ -0,0 +1,50 @@
1
+ import type { ToolcraftCanvasSize } from "./types";
2
+
3
+ export type ToolcraftCanvasAspectRatioPresetValue =
4
+ | "1:1"
5
+ | "3:2"
6
+ | "16:9"
7
+ | "3:4"
8
+ | "9:16"
9
+ | "2:3"
10
+ | "4:3";
11
+
12
+ export type ToolcraftCanvasAspectRatioPreset = {
13
+ height: number;
14
+ ratioHeight: number;
15
+ ratioWidth: number;
16
+ value: ToolcraftCanvasAspectRatioPresetValue;
17
+ width: number;
18
+ };
19
+
20
+ export const toolcraftCanvasAspectRatioPresets = [
21
+ { height: 1080, ratioHeight: 1, ratioWidth: 1, value: "1:1", width: 1080 },
22
+ { height: 1080, ratioHeight: 2, ratioWidth: 3, value: "3:2", width: 1620 },
23
+ { height: 1080, ratioHeight: 9, ratioWidth: 16, value: "16:9", width: 1920 },
24
+ { height: 1440, ratioHeight: 4, ratioWidth: 3, value: "3:4", width: 1080 },
25
+ { height: 1920, ratioHeight: 16, ratioWidth: 9, value: "9:16", width: 1080 },
26
+ { height: 1620, ratioHeight: 3, ratioWidth: 2, value: "2:3", width: 1080 },
27
+ { height: 1080, ratioHeight: 3, ratioWidth: 4, value: "4:3", width: 1440 },
28
+ ] as const satisfies readonly ToolcraftCanvasAspectRatioPreset[];
29
+
30
+ export const toolcraftCanvasAspectRatioPresetValues = new Set<string>(
31
+ toolcraftCanvasAspectRatioPresets.map((preset) => preset.value),
32
+ );
33
+
34
+ export function getToolcraftCanvasAspectRatioPreset(
35
+ value: string,
36
+ ): ToolcraftCanvasAspectRatioPreset | null {
37
+ return (
38
+ toolcraftCanvasAspectRatioPresets.find((preset) => preset.value === value) ?? null
39
+ );
40
+ }
41
+
42
+ export function getToolcraftCanvasAspectRatioPresetBySize(
43
+ size: ToolcraftCanvasSize,
44
+ ): ToolcraftCanvasAspectRatioPreset | null {
45
+ return (
46
+ toolcraftCanvasAspectRatioPresets.find(
47
+ (preset) => preset.width === size.width && preset.height === size.height,
48
+ ) ?? null
49
+ );
50
+ }
@@ -10,6 +10,7 @@ import {
10
10
  toolcraftRuntimeOwnedTargets,
11
11
  getToolcraftCanvasSizeTargetDimension,
12
12
  isToolcraftReservedTarget,
13
+ isToolcraftRuntimeOwnedTarget,
13
14
  } from "./runtime-targets";
14
15
 
15
16
  describe("defineToolcraft", () => {
@@ -111,6 +112,10 @@ describe("defineToolcraft", () => {
111
112
  mode: "auto",
112
113
  });
113
114
  expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
115
+ expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toMatchObject({
116
+ target: "canvas.aspectRatio",
117
+ type: "aspectRatio",
118
+ });
114
119
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
115
120
  });
116
121
 
@@ -231,7 +236,7 @@ describe("defineToolcraft", () => {
231
236
  });
232
237
  });
233
238
 
234
- it("ignores runtime canvas size controls in settings transfer eligibility", () => {
239
+ it("ignores runtime canvas setup controls in settings transfer eligibility", () => {
235
240
  const app = defineToolcraft({
236
241
  canvas: { enabled: true, size: { height: 720, unit: "px", width: 1280 } },
237
242
  panels: {
@@ -248,6 +253,7 @@ describe("defineToolcraft", () => {
248
253
  settingsTransfer: false,
249
254
  });
250
255
 
256
+ expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toBeTruthy();
251
257
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toBeTruthy();
252
258
  expect(app.panels.controls?.sections[0]?.controls.canvasHeight).toBeTruthy();
253
259
  expect(getToolcraftSettingsTransferEligibility({ panels: app.panels })).toEqual({
@@ -326,9 +332,20 @@ describe("defineToolcraft", () => {
326
332
  expect(runtimeSettingsSection?.title).toBe("Setup");
327
333
  expect(Object.keys(runtimeSettingsSection?.controls ?? {})).toEqual([
328
334
  "settingsTransfer",
335
+ "canvasAspectRatio",
329
336
  "canvasWidth",
330
337
  "canvasHeight",
331
338
  ]);
339
+ expect(runtimeSettingsSection?.controls.canvasAspectRatio).toMatchObject({
340
+ defaultValue: {
341
+ height: 9,
342
+ mode: "preset",
343
+ value: "16:9",
344
+ width: 16,
345
+ },
346
+ target: "canvas.aspectRatio",
347
+ type: "aspectRatio",
348
+ });
332
349
  expect(runtimeSettingsSection?.controls.canvasWidth).toBeTruthy();
333
350
  expect(runtimeSettingsSection?.controls.canvasHeight).toBeTruthy();
334
351
  expect(runtimeSettingsSection?.controls.settingsTransfer?.type).toBe(
@@ -594,10 +611,12 @@ describe("defineToolcraft", () => {
594
611
 
595
612
  it("publishes reserved targets for AI assembly boundaries", () => {
596
613
  expect(toolcraftRuntimeOwnedTargets).toEqual([
614
+ "canvas.aspectRatio",
597
615
  "canvas.size.width",
598
616
  "canvas.size.height",
599
617
  ]);
600
618
  expect(toolcraftReservedTargets).toEqual([
619
+ "canvas.aspectRatio",
601
620
  "canvas.size.width",
602
621
  "canvas.size.height",
603
622
  "selectedLayer.opacity",
@@ -606,6 +625,7 @@ describe("defineToolcraft", () => {
606
625
  expect(getToolcraftCanvasSizeTargetDimension("canvas.size.width")).toBe("width");
607
626
  expect(getToolcraftCanvasSizeTargetDimension("canvas.size.height")).toBe("height");
608
627
  expect(getToolcraftCanvasSizeTargetDimension("generation.prompt")).toBeNull();
628
+ expect(isToolcraftRuntimeOwnedTarget("canvas.aspectRatio")).toBe(true);
609
629
  expect(isToolcraftReservedTarget("selectedLayer.opacity")).toBe(true);
610
630
  expect(isToolcraftReservedTarget("generation.prompt")).toBe(false);
611
631
  });
@@ -674,6 +694,18 @@ describe("defineToolcraft", () => {
674
694
  expect(app.canvas.sizeSource).toBe("app");
675
695
  expect(app.canvas.sizing).toEqual({ mode: "editable-output" });
676
696
  expect(app.panels.controls?.sections[0]?.title).toBe("Setup");
697
+ expect(app.panels.controls?.sections[0]?.controls.canvasAspectRatio).toMatchObject({
698
+ defaultValue: {
699
+ height: 256,
700
+ mode: "custom",
701
+ value: "455:256",
702
+ width: 455,
703
+ },
704
+ label: "Aspect ratio",
705
+ performanceRole: "workload",
706
+ target: "canvas.aspectRatio",
707
+ type: "aspectRatio",
708
+ });
677
709
  expect(app.panels.controls?.sections[0]?.controls.canvasWidth).toMatchObject({
678
710
  defaultValue: 1365,
679
711
  performanceRole: "workload",
@@ -724,6 +756,18 @@ describe("defineToolcraft", () => {
724
756
  const [canvasSection, generationSection] = app.panels.controls?.sections ?? [];
725
757
 
726
758
  expect(canvasSection?.title).toBe("Setup");
759
+ expect(canvasSection?.controls.canvasAspectRatio).toMatchObject({
760
+ defaultValue: {
761
+ height: 5,
762
+ mode: "custom",
763
+ value: "8:5",
764
+ width: 8,
765
+ },
766
+ label: "Aspect ratio",
767
+ performanceRole: "workload",
768
+ target: "canvas.aspectRatio",
769
+ type: "aspectRatio",
770
+ });
727
771
  expect(canvasSection?.controls.canvasWidth).toMatchObject({
728
772
  defaultValue: 1440,
729
773
  label: "Canvas width",
@@ -1,4 +1,8 @@
1
1
  import { TOOLCRAFT_COMPONENT_CONTRACTS } from "../contracts/component-contracts";
2
+ import {
3
+ getToolcraftCanvasAspectRatioPreset,
4
+ getToolcraftCanvasAspectRatioPresetBySize,
5
+ } from "./canvas-aspect-ratio-presets";
2
6
  import type {
3
7
  ToolcraftAssemblyCapability,
4
8
  ToolcraftAssemblyCommand,
@@ -41,6 +45,7 @@ const canvasSizeControlTargets = {
41
45
  height: "canvas.size.height",
42
46
  width: "canvas.size.width",
43
47
  } as const;
48
+ const canvasAspectRatioTarget = "canvas.aspectRatio";
44
49
  const maxAutoInlineControlLabelLength = 18;
45
50
  const settingsTransferTarget = "runtime.settingsTransfer";
46
51
  const runtimeSetupSectionTitle = "Setup";
@@ -553,6 +558,7 @@ function isRuntimeOnlyActionControl(control: ToolcraftControlSchema): boolean {
553
558
  function isSettingsTransferEligibilityControl(control: ToolcraftControlSchema): boolean {
554
559
  return (
555
560
  !isRuntimeOnlyActionControl(control) &&
561
+ control.target !== canvasAspectRatioTarget &&
556
562
  control.target !== canvasSizeControlTargets.width &&
557
563
  control.target !== canvasSizeControlTargets.height
558
564
  );
@@ -699,25 +705,67 @@ function getCanvasSizeLayoutGroups(
699
705
  : undefined;
700
706
  }
701
707
 
708
+ function getGreatestCommonDivisor(left: number, right: number): number {
709
+ let a = Math.abs(Math.round(left));
710
+ let b = Math.abs(Math.round(right));
711
+
712
+ while (b !== 0) {
713
+ const next = b;
714
+ b = a % b;
715
+ a = next;
716
+ }
717
+
718
+ return a || 1;
719
+ }
720
+
721
+ function getCanvasAspectRatioDefaultValue(size: ToolcraftCanvasSize): {
722
+ height: number;
723
+ mode: "custom" | "preset";
724
+ value: string;
725
+ width: number;
726
+ } {
727
+ const divisor = getGreatestCommonDivisor(size.width, size.height);
728
+ const width = Math.max(1, Math.round(size.width / divisor));
729
+ const height = Math.max(1, Math.round(size.height / divisor));
730
+ const value = `${width}:${height}`;
731
+ const preset =
732
+ getToolcraftCanvasAspectRatioPreset(value) ??
733
+ getToolcraftCanvasAspectRatioPresetBySize(size);
734
+
735
+ return {
736
+ height: preset?.ratioHeight ?? height,
737
+ mode: preset ? "preset" : "custom",
738
+ value: preset?.value ?? value,
739
+ width: preset?.ratioWidth ?? width,
740
+ };
741
+ }
742
+
702
743
  function createCanvasSizeSection({
744
+ aspectRatioControl,
703
745
  sizeControlIds,
704
746
  sizeControls,
705
747
  }: {
748
+ aspectRatioControl: ToolcraftControlSchema;
706
749
  sizeControlIds: readonly string[];
707
750
  sizeControls: ToolcraftControlSectionSchema["controls"];
708
751
  }): ToolcraftControlSectionSchema {
709
752
  return {
710
- controls: sizeControls,
753
+ controls: {
754
+ canvasAspectRatio: aspectRatioControl,
755
+ ...sizeControls,
756
+ },
711
757
  layoutGroups: getCanvasSizeLayoutGroups(sizeControlIds),
712
758
  title: runtimeSetupSectionTitle,
713
759
  };
714
760
  }
715
761
 
716
762
  function mergeCanvasSizeControlsIntoSettingsTransferSection({
763
+ aspectRatioControl,
717
764
  settingsTransferSection,
718
765
  sizeControlIds,
719
766
  sizeControls,
720
767
  }: {
768
+ aspectRatioControl: ToolcraftControlSchema;
721
769
  settingsTransferSection: ToolcraftControlSectionSchema;
722
770
  sizeControlIds: readonly string[];
723
771
  sizeControls: ToolcraftControlSectionSchema["controls"];
@@ -728,6 +776,7 @@ function mergeCanvasSizeControlsIntoSettingsTransferSection({
728
776
  ...settingsTransferSection,
729
777
  controls: {
730
778
  ...settingsTransferSection.controls,
779
+ canvasAspectRatio: aspectRatioControl,
731
780
  ...sizeControls,
732
781
  },
733
782
  layoutGroups:
@@ -1275,6 +1324,14 @@ function normalizePanels({
1275
1324
 
1276
1325
  const sizeControls: ToolcraftControlSectionSchema["controls"] = {};
1277
1326
  const sizeControlIds: string[] = [];
1327
+ const aspectRatioControl: ToolcraftControlSchema = {
1328
+ defaultValue: getCanvasAspectRatioDefaultValue(canvas.size),
1329
+ label: "Aspect ratio",
1330
+ performanceReason: "Aspect ratio changes output dimensions and renderer workload.",
1331
+ performanceRole: "workload",
1332
+ target: canvasAspectRatioTarget,
1333
+ type: "aspectRatio",
1334
+ };
1278
1335
 
1279
1336
  if (!hasControlTarget(panels, canvasSizeControlTargets.width)) {
1280
1337
  sizeControls.canvasWidth = {
@@ -1315,11 +1372,12 @@ function normalizePanels({
1315
1372
 
1316
1373
  const runtimeSettingsSection = settingsTransferSection
1317
1374
  ? mergeCanvasSizeControlsIntoSettingsTransferSection({
1375
+ aspectRatioControl,
1318
1376
  settingsTransferSection,
1319
1377
  sizeControlIds,
1320
1378
  sizeControls,
1321
1379
  })
1322
- : createCanvasSizeSection({ sizeControlIds, sizeControls });
1380
+ : createCanvasSizeSection({ aspectRatioControl, sizeControlIds, sizeControls });
1323
1381
 
1324
1382
  return {
1325
1383
  ...normalizedPanels,
@@ -51,6 +51,13 @@ describe("getToolcraftControlKeyframeCapability", () => {
51
51
  });
52
52
 
53
53
  it("blocks runtime-owned canvas size targets even when the visual type could otherwise animate", () => {
54
+ expect(
55
+ getToolcraftControlKeyframeCapability(control("slider", "canvas.aspectRatio")),
56
+ ).toEqual({
57
+ capable: false,
58
+ reason: "runtime-owned-target",
59
+ });
60
+
54
61
  expect(
55
62
  getToolcraftControlKeyframeCapability(control("text", "canvas.size.width")),
56
63
  ).toEqual({
@@ -1,4 +1,4 @@
1
- import { getToolcraftCanvasSizeTargetDimension } from "./runtime-targets";
1
+ import { isToolcraftRuntimeOwnedTarget } from "./runtime-targets";
2
2
  import type { ToolcraftControlSchema } from "./types";
3
3
 
4
4
  export type ToolcraftControlKeyframeCapabilityReason =
@@ -30,7 +30,7 @@ const keyframeCapableControlTypes = new Set([
30
30
  export function getToolcraftControlKeyframeCapability(
31
31
  control: ToolcraftControlSchema,
32
32
  ): ToolcraftControlKeyframeCapability {
33
- if (getToolcraftCanvasSizeTargetDimension(control.target)) {
33
+ if (isToolcraftRuntimeOwnedTarget(control.target)) {
34
34
  return {
35
35
  capable: false,
36
36
  reason: "runtime-owned-target",
@@ -1,4 +1,5 @@
1
1
  export const toolcraftRuntimeOwnedTargets = [
2
+ "canvas.aspectRatio",
2
3
  "canvas.size.width",
3
4
  "canvas.size.height",
4
5
  ] as const;
@@ -27,6 +28,10 @@ export function getToolcraftCanvasSizeTargetDimension(
27
28
  }
28
29
  }
29
30
 
31
+ export function isToolcraftCanvasAspectRatioTarget(target: string): boolean {
32
+ return target === "canvas.aspectRatio";
33
+ }
34
+
30
35
  export function isToolcraftReservedTarget(
31
36
  target: string,
32
37
  ): target is ToolcraftReservedTarget {
@@ -54,6 +54,12 @@ describe("createToolcraftState", () => {
54
54
  const state = createToolcraftState(app);
55
55
 
56
56
  expect(state.values).toMatchObject({
57
+ "canvas.aspectRatio": {
58
+ height: 1,
59
+ mode: "preset",
60
+ value: "1:1",
61
+ width: 1,
62
+ },
57
63
  "canvas.size.height": 1024,
58
64
  "canvas.size.width": 1024,
59
65
  });