@pixel-point/toolcraft 0.0.9 → 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.
Files changed (75) hide show
  1. package/README.md +42 -9
  2. package/package.json +1 -1
  3. package/src/generate.mjs +14 -6
  4. package/src/generate.test.mjs +28 -0
  5. package/templates/runtime/contracts/component-contracts.test.ts +148 -34
  6. package/templates/runtime/contracts/component-contracts.ts +79 -38
  7. package/templates/runtime/contracts/decision-contracts.test.ts +33 -1
  8. package/templates/runtime/contracts/decision-contracts.ts +19 -6
  9. package/templates/runtime/export/export.test.ts +63 -0
  10. package/templates/runtime/export/export.ts +55 -0
  11. package/templates/runtime/index.ts +1 -0
  12. package/templates/runtime/react/canvas-shell.test.tsx +58 -4
  13. package/templates/runtime/react/canvas-shell.tsx +31 -9
  14. package/templates/runtime/react/controls-panel.test.tsx +474 -27
  15. package/templates/runtime/react/controls-panel.tsx +71 -26
  16. package/templates/runtime/react/runtime-public-api.test.tsx +1 -1
  17. package/templates/runtime/react/settings-transfer.test.ts +4 -0
  18. package/templates/runtime/react/settings-transfer.ts +6 -1
  19. package/templates/runtime/react/timeline-panel.test.tsx +14 -0
  20. package/templates/runtime/react/timeline-panel.tsx +44 -7
  21. package/templates/runtime/react/toolcraft-app.integration.test.tsx +9 -1
  22. package/templates/runtime/react/toolcraft-app.test.tsx +112 -3
  23. package/templates/runtime/react/toolcraft-app.tsx +56 -37
  24. package/templates/runtime/schema/define-toolcraft.test.ts +225 -173
  25. package/templates/runtime/schema/define-toolcraft.ts +117 -247
  26. package/templates/runtime/schema/runtime-targets.ts +21 -0
  27. package/templates/runtime/schema/types.ts +41 -0
  28. package/templates/runtime/state/create-template-state.test.ts +156 -0
  29. package/templates/runtime/state/create-template-state.ts +38 -8
  30. package/templates/runtime/state/media-defaults.ts +105 -0
  31. package/templates/runtime/state/persistence.test.ts +58 -0
  32. package/templates/runtime/state/persistence.ts +105 -1
  33. package/templates/runtime/state/reducer.test.ts +280 -4
  34. package/templates/runtime/state/reducer.ts +195 -9
  35. package/templates/runtime/state/timeline-loop.test.ts +71 -0
  36. package/templates/runtime/state/timeline-loop.ts +35 -0
  37. package/templates/runtime/state/types.ts +27 -0
  38. package/templates/runtime/testing/performance.test.ts +657 -2
  39. package/templates/runtime/testing/performance.ts +789 -49
  40. package/templates/starter/AGENTS.md +22 -16
  41. package/templates/starter/docs/toolcraft/README.md +8 -4
  42. package/templates/starter/docs/toolcraft/acceptance-testing.md +38 -7
  43. package/templates/starter/docs/toolcraft/agent-worklog.md +1 -0
  44. package/templates/starter/docs/toolcraft/assembly-workflow.md +51 -20
  45. package/templates/starter/docs/toolcraft/component-rules.md +49 -37
  46. package/templates/starter/docs/toolcraft/decision-contract.md +2 -0
  47. package/templates/starter/docs/toolcraft/performance.md +30 -10
  48. package/templates/starter/docs/toolcraft/schema-reference.md +134 -33
  49. package/templates/starter/docs/toolcraft/workflow.md +5 -2
  50. package/templates/starter/e2e/app-browser-acceptance.spec.ts +3 -3
  51. package/templates/starter/e2e/app-performance.spec.ts +55 -2
  52. package/templates/starter/e2e/performance-helpers.ts +45 -0
  53. package/templates/starter/index.html +1 -0
  54. package/templates/starter/package.json +1 -0
  55. package/templates/starter/playwright.config.ts +1 -1
  56. package/templates/starter/scripts/check-toolcraft-docs.mjs +1 -0
  57. package/templates/starter/scripts/run-vite-on-free-port.mjs +82 -16
  58. package/templates/starter/scripts/toolcraft-port.mjs +178 -0
  59. package/templates/starter/scripts/toolcraft-port.test.mjs +147 -0
  60. package/templates/starter/src/app/starter-acceptance.test.ts +2573 -313
  61. package/templates/starter/src/app/starter-acceptance.ts +978 -81
  62. package/templates/starter/src/app/starter-performance.test.ts +111 -7
  63. package/templates/starter/src/app/starter-performance.ts +5 -0
  64. package/templates/starter/src/app/starter-schema.test.ts +32 -7
  65. package/templates/starter/src/app/starter-schema.ts +6 -2
  66. package/templates/starter/vite.config.ts +58 -2
  67. package/templates/ui/components/controls/actions/actions-control.tsx +46 -3
  68. package/templates/ui/components/controls/color/palette-control.tsx +34 -4
  69. package/templates/ui/components/controls/file-drop/file-drop-control.tsx +186 -14
  70. package/templates/ui/components/controls/file-drop/index.ts +6 -1
  71. package/templates/ui/components/controls/index.ts +2 -0
  72. package/templates/ui/components/controls/range-slider/range-slider-value.ts +3 -1
  73. package/templates/ui/components/controls/select/select-control.tsx +4 -26
  74. package/templates/ui/components/controls/vector/vector-control.tsx +25 -4
  75. package/templates/ui/components/panel/panel-actions.tsx +1 -1
@@ -65,6 +65,42 @@ describe("createToolcraftState", () => {
65
65
  });
66
66
  });
67
67
 
68
+ it("keeps runtime timeline presentation out of product values and defaults", () => {
69
+ const app = defineToolcraft({
70
+ canvas: { enabled: true, renderScale: true, sizing: { mode: "editable-output" } },
71
+ panels: {
72
+ controls: {
73
+ sections: [],
74
+ title: "Controls",
75
+ },
76
+ timeline: { mode: "playback" },
77
+ },
78
+ });
79
+
80
+ const state = createToolcraftState(app);
81
+
82
+ expect(state.values).not.toHaveProperty("panels.timeline.extended");
83
+ expect(state.values).not.toHaveProperty("panels.timeline.visible");
84
+ expect(state.defaults).not.toHaveProperty("panels.timeline.extended");
85
+ expect(state.defaults).not.toHaveProperty("panels.timeline.visible");
86
+ expect(state.panels.timeline.extended).toBeUndefined();
87
+ expect(state.panels.timeline.hidden).toBeUndefined();
88
+ });
89
+
90
+ it("initializes timeline duration from the schema loop duration", () => {
91
+ const app = defineToolcraft({
92
+ canvas: { enabled: true },
93
+ panels: {
94
+ timeline: { defaultDurationSeconds: 14, mode: "playback" },
95
+ },
96
+ });
97
+
98
+ const state = createToolcraftState(app);
99
+
100
+ expect(state.timeline.durationSeconds).toBe(14);
101
+ expect(state.timeline.isLooping).toBe(true);
102
+ });
103
+
68
104
  it("initializes canvas state from resolved canvas size", () => {
69
105
  const size = { width: 1200, height: 900, unit: "px" } as const;
70
106
  const app = defineToolcraft({
@@ -122,6 +158,126 @@ describe("createToolcraftState", () => {
122
158
  expect(state.selectedLayerId).toBeNull();
123
159
  });
124
160
 
161
+ it("initializes predefined media files as runtime attachments", () => {
162
+ const app = defineToolcraft({
163
+ canvas: { enabled: true, upload: true },
164
+ media: {
165
+ defaultAssets: [
166
+ {
167
+ dataUrl: "data:image/png;base64,AAAA",
168
+ fileName: "background.png",
169
+ id: "default-background",
170
+ layerId: "default-background-layer",
171
+ mimeType: "image/png",
172
+ size: { height: 1080, unit: "px", width: 1920 },
173
+ sourceTarget: "media.background",
174
+ },
175
+ ],
176
+ },
177
+ panels: {
178
+ controls: {
179
+ sections: [
180
+ {
181
+ controls: {
182
+ background: {
183
+ defaultValue: null,
184
+ target: "media.background",
185
+ type: "fileDrop",
186
+ },
187
+ },
188
+ },
189
+ ],
190
+ title: "Controls",
191
+ },
192
+ },
193
+ });
194
+
195
+ const state = createToolcraftState(app);
196
+
197
+ expect(state.mediaAssets).toEqual([
198
+ expect.objectContaining({
199
+ dataUrl: "data:image/png;base64,AAAA",
200
+ fileName: "background.png",
201
+ id: "default-background",
202
+ layerId: "default-background-layer",
203
+ mimeType: "image/png",
204
+ sourceTarget: "media.background",
205
+ }),
206
+ ]);
207
+ expect(state.layers).toEqual([
208
+ expect.objectContaining({
209
+ id: "default-background-layer",
210
+ name: "background",
211
+ visible: true,
212
+ }),
213
+ ]);
214
+ expect(state.selectedLayerId).toBe("default-background-layer");
215
+ });
216
+
217
+ it("lets persisted media state override schema default media", () => {
218
+ const app = defineToolcraft({
219
+ canvas: { enabled: true, upload: true },
220
+ media: {
221
+ defaultAssets: [
222
+ {
223
+ dataUrl: "data:image/png;base64,AAAA",
224
+ fileName: "background.png",
225
+ sourceTarget: "media.background",
226
+ },
227
+ ],
228
+ },
229
+ panels: {},
230
+ });
231
+
232
+ const state = createToolcraftState(app, { mediaAssets: [] });
233
+
234
+ expect(state.mediaAssets).toEqual([]);
235
+ expect(state.layers).toEqual([]);
236
+ expect(state.selectedLayerId).toBeNull();
237
+ });
238
+
239
+ it("restores default media layer names when persisted media omits layer state", () => {
240
+ const app = defineToolcraft({
241
+ canvas: { enabled: true, upload: true },
242
+ media: {
243
+ defaultAssets: [
244
+ {
245
+ dataUrl: "data:image/png;base64,AAAA",
246
+ fileName: "background.png",
247
+ id: "default-background",
248
+ layerId: "default-background-layer",
249
+ layerName: "Hero source",
250
+ sourceTarget: "media.background",
251
+ },
252
+ ],
253
+ },
254
+ panels: {},
255
+ });
256
+
257
+ const state = createToolcraftState(app, {
258
+ mediaAssets: [
259
+ {
260
+ dataUrl: "data:image/png;base64,AAAA",
261
+ fileName: "background.png",
262
+ id: "default-background",
263
+ layerId: "default-background-layer",
264
+ mimeType: "image/png",
265
+ position: { x: 0, y: 0 },
266
+ sourceTarget: "media.background",
267
+ },
268
+ ],
269
+ });
270
+
271
+ expect(state.layers).toEqual([
272
+ expect.objectContaining({
273
+ displayName: "Hero source",
274
+ id: "default-background-layer",
275
+ name: "Hero source",
276
+ }),
277
+ ]);
278
+ expect(state.selectedLayerId).toBe("default-background-layer");
279
+ });
280
+
125
281
  it("accepts seeded runtime layer state", () => {
126
282
  const app = defineToolcraft({
127
283
  canvas: { enabled: true },
@@ -1,4 +1,5 @@
1
1
  import type { ResolvedToolcraftAppSchema } from "../schema/types";
2
+ import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
2
3
  import type {
3
4
  ToolcraftInitialState,
4
5
  ToolcraftState,
@@ -6,6 +7,12 @@ import type {
6
7
  ToolcraftTimelineState,
7
8
  } from "./types";
8
9
  import { toolcraftCanvasZoomDefault } from "./canvas-zoom";
10
+ import {
11
+ cloneToolcraftLayers,
12
+ cloneToolcraftMediaAssets,
13
+ createToolcraftDefaultMediaState,
14
+ createToolcraftLayersFromMediaAssets,
15
+ } from "./media-defaults";
9
16
  import { getMediaReadyTimelineState } from "./timeline-readiness";
10
17
 
11
18
  function cloneTimelineKeyframeGroups(
@@ -26,12 +33,16 @@ function cloneTimelineKeyframeGroups(
26
33
  }));
27
34
  }
28
35
 
29
- function createDefaultTimelineState(
30
- timeline?: Partial<ToolcraftTimelineState>,
31
- ): ToolcraftTimelineState {
36
+ function createDefaultTimelineState({
37
+ defaultDurationSeconds,
38
+ timeline,
39
+ }: {
40
+ defaultDurationSeconds: number;
41
+ timeline?: Partial<ToolcraftTimelineState>;
42
+ }): ToolcraftTimelineState {
32
43
  return {
33
44
  currentTimeSeconds: 0,
34
- durationSeconds: 8,
45
+ durationSeconds: defaultDurationSeconds,
35
46
  expanded: false,
36
47
  isLooping: true,
37
48
  isPlaying: true,
@@ -46,15 +57,34 @@ export function createToolcraftState(
46
57
  initialState: ToolcraftInitialState = {},
47
58
  ): ToolcraftState {
48
59
  const defaults: Record<string, unknown> = {};
49
- const mediaAssets = initialState.mediaAssets ?? [];
60
+ const defaultMediaState = createToolcraftDefaultMediaState(schema);
61
+ const hasInitialMediaAssets = Object.hasOwn(initialState, "mediaAssets");
62
+ const mediaAssets = hasInitialMediaAssets
63
+ ? cloneToolcraftMediaAssets(initialState.mediaAssets ?? [])
64
+ : cloneToolcraftMediaAssets(defaultMediaState.mediaAssets);
65
+ const layers =
66
+ initialState.layers ??
67
+ (hasInitialMediaAssets
68
+ ? createToolcraftLayersFromMediaAssets(mediaAssets, defaultMediaState.layers)
69
+ : cloneToolcraftLayers(defaultMediaState.layers));
70
+ const selectedLayerId =
71
+ initialState.selectedLayerId ??
72
+ (hasInitialMediaAssets ? (layers[0]?.id ?? null) : defaultMediaState.selectedLayerId);
50
73
  const timeline = getMediaReadyTimelineState(
51
74
  schema,
52
- createDefaultTimelineState(initialState.timeline),
75
+ createDefaultTimelineState({
76
+ defaultDurationSeconds: schema.panels.timeline?.defaultDurationSeconds ?? 8,
77
+ timeline: initialState.timeline,
78
+ }),
53
79
  mediaAssets,
54
80
  );
55
81
 
56
82
  for (const section of schema.panels.controls?.sections ?? []) {
57
83
  for (const control of Object.values(section.controls)) {
84
+ if (isToolcraftTimelinePanelRuntimeTarget(control.target)) {
85
+ continue;
86
+ }
87
+
58
88
  defaults[control.target] = control.defaultValue;
59
89
  }
60
90
  }
@@ -79,7 +109,7 @@ export function createToolcraftState(
79
109
  redo: [],
80
110
  undo: [],
81
111
  },
82
- layers: initialState.layers ?? [],
112
+ layers,
83
113
  mediaAssets,
84
114
  panels: {
85
115
  controls: { ...panels.controls, ...initialState.panels?.controls },
@@ -88,7 +118,7 @@ export function createToolcraftState(
88
118
  toolbar: { ...panels.toolbar, ...initialState.panels?.toolbar },
89
119
  },
90
120
  schema,
91
- selectedLayerId: initialState.selectedLayerId ?? null,
121
+ selectedLayerId,
92
122
  timeline,
93
123
  values: { ...defaults, ...initialState.values },
94
124
  };
@@ -0,0 +1,105 @@
1
+ import type { ResolvedToolcraftAppSchema } from "../schema/types";
2
+ import type { ToolcraftLayer, ToolcraftMediaAsset } from "./types";
3
+
4
+ export type ToolcraftDefaultMediaState = {
5
+ layers: ToolcraftLayer[];
6
+ mediaAssets: ToolcraftMediaAsset[];
7
+ selectedLayerId: string | null;
8
+ };
9
+
10
+ function getDefaultLayerName(fileName: string): string {
11
+ const baseName = fileName.replace(/\.[^.]+$/u, "").trim();
12
+
13
+ return baseName || "Media";
14
+ }
15
+
16
+ function cloneMediaAsset(asset: ToolcraftMediaAsset): ToolcraftMediaAsset {
17
+ return {
18
+ ...asset,
19
+ position: { ...asset.position },
20
+ ...(asset.size ? { size: { ...asset.size } } : {}),
21
+ ...(asset.transform ? { transform: { ...asset.transform } } : {}),
22
+ };
23
+ }
24
+
25
+ function cloneLayer(layer: ToolcraftLayer): ToolcraftLayer {
26
+ return { ...layer };
27
+ }
28
+
29
+ export function cloneToolcraftMediaAssets(
30
+ mediaAssets: readonly ToolcraftMediaAsset[],
31
+ ): ToolcraftMediaAsset[] {
32
+ return mediaAssets.map(cloneMediaAsset);
33
+ }
34
+
35
+ export function cloneToolcraftLayers(layers: readonly ToolcraftLayer[]): ToolcraftLayer[] {
36
+ return layers.map(cloneLayer);
37
+ }
38
+
39
+ export function createToolcraftLayersFromMediaAssets(
40
+ mediaAssets: readonly ToolcraftMediaAsset[],
41
+ defaultLayers: readonly ToolcraftLayer[] = [],
42
+ ): ToolcraftLayer[] {
43
+ const layers: ToolcraftLayer[] = [];
44
+ const defaultLayerById = new Map(defaultLayers.map((layer) => [layer.id, layer]));
45
+ const seenLayerIds = new Set<string>();
46
+
47
+ for (const asset of mediaAssets) {
48
+ if (seenLayerIds.has(asset.layerId)) {
49
+ continue;
50
+ }
51
+
52
+ seenLayerIds.add(asset.layerId);
53
+ layers.push(
54
+ cloneLayer(
55
+ defaultLayerById.get(asset.layerId) ?? {
56
+ displayName: getDefaultLayerName(asset.fileName),
57
+ id: asset.layerId,
58
+ kind: "layer",
59
+ name: getDefaultLayerName(asset.fileName),
60
+ visible: true,
61
+ },
62
+ ),
63
+ );
64
+ }
65
+
66
+ return layers;
67
+ }
68
+
69
+ export function createToolcraftDefaultMediaState(
70
+ schema: ResolvedToolcraftAppSchema,
71
+ ): ToolcraftDefaultMediaState {
72
+ const layers: ToolcraftLayer[] = [];
73
+ const mediaAssets: ToolcraftMediaAsset[] = [];
74
+
75
+ schema.media.defaultAssets.forEach((asset, index) => {
76
+ const layerId = asset.layerId ?? `default-media-layer-${index + 1}`;
77
+ const layerName = asset.layerName ?? getDefaultLayerName(asset.fileName);
78
+
79
+ layers.push({
80
+ displayName: layerName,
81
+ id: layerId,
82
+ kind: "layer",
83
+ name: layerName,
84
+ visible: true,
85
+ });
86
+ mediaAssets.push({
87
+ ...(asset.assetKind ? { assetKind: asset.assetKind } : {}),
88
+ dataUrl: asset.dataUrl,
89
+ fileName: asset.fileName,
90
+ id: asset.id ?? `default-media-${index + 1}`,
91
+ layerId,
92
+ mimeType: asset.mimeType ?? (asset.assetKind === "file" ? "application/octet-stream" : "image/*"),
93
+ position: asset.position ?? { x: 0, y: 0 },
94
+ ...(asset.size ? { size: asset.size } : {}),
95
+ ...(asset.sourceTarget ? { sourceTarget: asset.sourceTarget } : {}),
96
+ ...(asset.transform ? { transform: asset.transform } : {}),
97
+ });
98
+ });
99
+
100
+ return {
101
+ layers,
102
+ mediaAssets,
103
+ selectedLayerId: layers[0]?.id ?? null,
104
+ };
105
+ }
@@ -77,6 +77,7 @@ describe("Toolcraft template state persistence", () => {
77
77
  ],
78
78
  panels: {
79
79
  controls: { offset: { x: 20, y: 10 } },
80
+ timeline: { hidden: true },
80
81
  },
81
82
  selectedLayerId: "layer-1",
82
83
  timeline: {
@@ -125,6 +126,7 @@ describe("Toolcraft template state persistence", () => {
125
126
  ],
126
127
  panels: {
127
128
  controls: { offset: { x: 20, y: 10 } },
129
+ timeline: { hidden: true },
128
130
  },
129
131
  selectedLayerId: "layer-1",
130
132
  timeline: {
@@ -146,10 +148,63 @@ describe("Toolcraft template state persistence", () => {
146
148
  expect(snapshot?.state.values).not.toHaveProperty("legacy.target");
147
149
  });
148
150
 
151
+ it("persists media state when the app opts into media persistence", () => {
152
+ const schema = defineToolcraft({
153
+ canvas: { enabled: true, upload: true },
154
+ media: {
155
+ defaultAssets: [
156
+ {
157
+ dataUrl: "data:image/png;base64,AAAA",
158
+ fileName: "background.png",
159
+ id: "default-background",
160
+ layerId: "default-background-layer",
161
+ mimeType: "image/png",
162
+ sourceTarget: "media.background",
163
+ },
164
+ ],
165
+ },
166
+ panels: {
167
+ controls: {
168
+ sections: [
169
+ {
170
+ controls: {
171
+ background: {
172
+ defaultValue: null,
173
+ target: "media.background",
174
+ type: "fileDrop",
175
+ },
176
+ },
177
+ },
178
+ ],
179
+ title: "Controls",
180
+ },
181
+ },
182
+ persistence: {
183
+ include: ["values", "panels", "media"],
184
+ key: "toolcraft:media-persistence-test:state:v1",
185
+ storage: "localStorage",
186
+ version: 1,
187
+ },
188
+ });
189
+ const state = createToolcraftState(schema, { mediaAssets: [] });
190
+ const snapshot = createToolcraftPersistenceSnapshot(state, schema.persistence);
191
+ const parsed = parseToolcraftPersistenceSnapshot(
192
+ schema,
193
+ JSON.stringify(snapshot),
194
+ );
195
+
196
+ expect(snapshot?.state.mediaAssets).toEqual([]);
197
+ expect(parsed?.mediaAssets).toEqual([]);
198
+ expect(createToolcraftState(schema, parsed).mediaAssets).toEqual([]);
199
+ });
200
+
149
201
  it("parses matching versions and ignores stale targets or invalid versions", () => {
150
202
  const schema = createPersistentSchema();
151
203
  const rawValue = JSON.stringify({
152
204
  state: {
205
+ panels: {
206
+ timeline: { hidden: true },
207
+ },
153
208
  values: {
154
209
  "generation.prompt": "Restored prompt",
155
210
  "legacy.target": "remove me",
@@ -160,6 +215,9 @@ describe("Toolcraft template state persistence", () => {
160
215
  });
161
216
 
162
217
  expect(parseToolcraftPersistenceSnapshot(schema, rawValue)).toEqual({
218
+ panels: {
219
+ timeline: { hidden: true },
220
+ },
163
221
  values: {
164
222
  "generation.prompt": "Restored prompt",
165
223
  "selectedLayer.opacity": 55,
@@ -2,10 +2,12 @@ import type {
2
2
  ToolcraftLocalStoragePersistenceSchema,
3
3
  ResolvedToolcraftAppSchema,
4
4
  } from "../schema/types";
5
+ import { isToolcraftTimelinePanelRuntimeTarget } from "../schema/runtime-targets";
5
6
  import type {
6
7
  ToolcraftCanvasState,
7
8
  ToolcraftInitialState,
8
9
  ToolcraftLayer,
10
+ ToolcraftMediaAsset,
9
11
  ToolcraftPanelId,
10
12
  ToolcraftPanelState,
11
13
  ToolcraftState,
@@ -92,6 +94,14 @@ function readPanel(value: unknown): Partial<ToolcraftPanelState> | undefined {
92
94
  panel.collapsed = value.collapsed;
93
95
  }
94
96
 
97
+ if (typeof value.extended === "boolean") {
98
+ panel.extended = value.extended;
99
+ }
100
+
101
+ if (typeof value.hidden === "boolean") {
102
+ panel.hidden = value.hidden;
103
+ }
104
+
95
105
  return Object.keys(panel).length > 0 ? panel : undefined;
96
106
  }
97
107
 
@@ -161,6 +171,85 @@ function readLayers(value: unknown): ToolcraftLayer[] | undefined {
161
171
  });
162
172
  }
163
173
 
174
+ function readMediaAsset(value: unknown): ToolcraftMediaAsset | undefined {
175
+ if (
176
+ !isRecord(value) ||
177
+ typeof value.id !== "string" ||
178
+ typeof value.layerId !== "string" ||
179
+ typeof value.dataUrl !== "string" ||
180
+ typeof value.fileName !== "string" ||
181
+ typeof value.mimeType !== "string"
182
+ ) {
183
+ return undefined;
184
+ }
185
+
186
+ const position = readPoint(value.position);
187
+
188
+ if (!position) {
189
+ return undefined;
190
+ }
191
+
192
+ const mediaAsset: ToolcraftMediaAsset = {
193
+ dataUrl: value.dataUrl,
194
+ fileName: value.fileName,
195
+ id: value.id,
196
+ layerId: value.layerId,
197
+ mimeType: value.mimeType,
198
+ position,
199
+ };
200
+ const size = readCanvasSize(value.size);
201
+
202
+ if (value.assetKind === "file" || value.assetKind === "image") {
203
+ mediaAsset.assetKind = value.assetKind;
204
+ }
205
+
206
+ if (size) {
207
+ mediaAsset.size = size;
208
+ }
209
+
210
+ if (typeof value.sourceTarget === "string") {
211
+ mediaAsset.sourceTarget = value.sourceTarget;
212
+ }
213
+
214
+ if (isRecord(value.transform)) {
215
+ mediaAsset.transform = {};
216
+
217
+ if (typeof value.transform.flipHorizontal === "boolean") {
218
+ mediaAsset.transform.flipHorizontal = value.transform.flipHorizontal;
219
+ }
220
+
221
+ if (typeof value.transform.flipVertical === "boolean") {
222
+ mediaAsset.transform.flipVertical = value.transform.flipVertical;
223
+ }
224
+
225
+ if (
226
+ value.transform.rotationDeg === 0 ||
227
+ value.transform.rotationDeg === 90 ||
228
+ value.transform.rotationDeg === 180 ||
229
+ value.transform.rotationDeg === 270
230
+ ) {
231
+ mediaAsset.transform.rotationDeg = value.transform.rotationDeg;
232
+ }
233
+
234
+ if (Object.keys(mediaAsset.transform).length === 0) {
235
+ delete mediaAsset.transform;
236
+ }
237
+ }
238
+
239
+ return mediaAsset;
240
+ }
241
+
242
+ function readMediaAssets(value: unknown): ToolcraftMediaAsset[] | undefined {
243
+ if (!Array.isArray(value)) {
244
+ return undefined;
245
+ }
246
+
247
+ return value.flatMap((item) => {
248
+ const mediaAsset = readMediaAsset(item);
249
+ return mediaAsset ? [mediaAsset] : [];
250
+ });
251
+ }
252
+
164
253
  function readBezierControlPoints(
165
254
  value: unknown,
166
255
  ): ToolcraftTimelineBezierControlPoints | undefined {
@@ -296,7 +385,10 @@ function getKnownValueTargets(schema: ResolvedToolcraftAppSchema): Set<string> {
296
385
 
297
386
  for (const section of schema.panels.controls?.sections ?? []) {
298
387
  for (const control of Object.values(section.controls)) {
299
- if (control.type !== "panelActions") {
388
+ if (
389
+ control.type !== "panelActions" &&
390
+ !isToolcraftTimelinePanelRuntimeTarget(control.target)
391
+ ) {
300
392
  targets.add(control.target);
301
393
  }
302
394
  }
@@ -369,6 +461,10 @@ export function createToolcraftPersistenceSnapshot(
369
461
  initialState.selectedLayerId = state.selectedLayerId;
370
462
  }
371
463
 
464
+ if (included.has("media")) {
465
+ initialState.mediaAssets = state.mediaAssets;
466
+ }
467
+
372
468
  return {
373
469
  state: initialState,
374
470
  version: persistence.version,
@@ -450,6 +546,14 @@ export function parseToolcraftPersistenceSnapshot(
450
546
  }
451
547
  }
452
548
 
549
+ if (included.has("media")) {
550
+ const mediaAssets = readMediaAssets(persistedState.mediaAssets);
551
+
552
+ if (mediaAssets) {
553
+ initialState.mediaAssets = mediaAssets;
554
+ }
555
+ }
556
+
453
557
  return Object.keys(initialState).length > 0 ? initialState : undefined;
454
558
  }
455
559