sketchmark 2.0.0 → 2.1.1

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 (56) hide show
  1. package/README.md +274 -188
  2. package/bin/editor-ui.cjs +2285 -0
  3. package/bin/preview-ui.cjs +74 -0
  4. package/bin/sketchmark.cjs +648 -2008
  5. package/dist/src/animatable.d.ts +21 -0
  6. package/dist/src/animatable.js +439 -0
  7. package/dist/src/builders/index.d.ts +1 -11
  8. package/dist/src/builders/index.js +1 -19
  9. package/dist/src/diagnostics.js +1 -64
  10. package/dist/src/edit.d.ts +27 -0
  11. package/dist/src/edit.js +162 -0
  12. package/dist/src/index.d.ts +4 -13
  13. package/dist/src/index.js +4 -13
  14. package/dist/src/keyframes.d.ts +48 -0
  15. package/dist/src/keyframes.js +182 -0
  16. package/dist/src/motion.d.ts +4 -0
  17. package/dist/src/motion.js +262 -0
  18. package/dist/src/normalize.js +120 -151
  19. package/dist/src/presets/characters.d.ts +15 -0
  20. package/dist/src/presets/characters.js +113 -0
  21. package/dist/src/presets/compose.d.ts +5 -0
  22. package/dist/src/presets/compose.js +80 -0
  23. package/dist/src/presets/effects.d.ts +40 -0
  24. package/dist/src/presets/effects.js +79 -0
  25. package/dist/src/presets/helpers.d.ts +33 -0
  26. package/dist/src/presets/helpers.js +165 -0
  27. package/dist/src/presets/index.d.ts +9 -0
  28. package/dist/src/presets/index.js +48 -0
  29. package/dist/src/presets/motions.d.ts +33 -0
  30. package/dist/src/presets/motions.js +75 -0
  31. package/dist/src/presets/scenes.d.ts +35 -0
  32. package/dist/src/presets/scenes.js +134 -0
  33. package/dist/src/presets/shapes.d.ts +71 -0
  34. package/dist/src/presets/shapes.js +96 -0
  35. package/dist/src/presets/transitions.d.ts +29 -0
  36. package/dist/src/presets/transitions.js +113 -0
  37. package/dist/src/presets/types.d.ts +34 -0
  38. package/dist/src/presets/types.js +2 -0
  39. package/dist/src/render/html.js +1 -4
  40. package/dist/src/render/svg.d.ts +2 -2
  41. package/dist/src/render/svg.js +86 -82
  42. package/dist/src/render/three-html.js +67 -113
  43. package/dist/src/scenes.js +1 -0
  44. package/dist/src/schema.js +218 -280
  45. package/dist/src/shapes/builtins.js +11 -47
  46. package/dist/src/shapes/common.js +12 -11
  47. package/dist/src/shapes/registry.d.ts +0 -1
  48. package/dist/src/shapes/registry.js +0 -4
  49. package/dist/src/shapes/types.d.ts +1 -3
  50. package/dist/src/types.d.ts +57 -288
  51. package/dist/src/utils.d.ts +2 -11
  52. package/dist/src/utils.js +13 -70
  53. package/dist/src/validate.js +321 -275
  54. package/dist/tests/run.js +576 -510
  55. package/package.json +46 -52
  56. package/schema/visual.schema.json +1086 -930
@@ -2,102 +2,122 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.renderToSvg = renderToSvg;
4
4
  exports.renderResolvedSvg = renderResolvedSvg;
5
- const kernel_1 = require("../kernel");
5
+ const normalize_1 = require("../normalize");
6
6
  const utils_1 = require("../utils");
7
+ const DEFAULT_FONT_FAMILY = "Roboto, Arial, sans-serif";
8
+ const LEGACY_DEFAULT_FONT_STACKS = new Set([
9
+ "inter,system-ui,sans-serif",
10
+ "system-ui,sans-serif",
11
+ "arial,helvetica,sans-serif"
12
+ ]);
7
13
  function renderToSvg(document, options = {}) {
8
- const frame = (0, kernel_1.resolveKernelFrame)(document, options.time ?? 0);
9
- return renderResolvedSvg(frame, options);
14
+ return renderResolvedSvg((0, normalize_1.resolveVisualFrame)(document, options.time ?? 0), options);
10
15
  }
11
16
  function renderResolvedSvg(document, options = {}) {
12
- const kernel = isKernelVisualDocument(document) ? document : (0, kernel_1.lowerResolvedVisualDocument)(document);
13
17
  const width = document.canvas.width;
14
18
  const height = document.canvas.height;
15
19
  const background = document.canvas.background ?? "#ffffff";
16
- const context = { defs: [], nextId: 0, markerIds: new Map() };
17
- const elements = (kernel.elements ?? []).map((element) => renderElement(element, context)).join("");
20
+ const context = { defs: [], nextId: 0 };
21
+ const elements = document.elements.map((element) => renderElement(element, context)).join("");
18
22
  const backdrop = options.transparent ? "" : `<rect x="0" y="0" width="${width}" height="${height}" fill="${escapeAttr(background)}"/>`;
19
23
  const defs = context.defs.length ? `<defs>${context.defs.join("")}</defs>` : "";
20
24
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">${defs}${backdrop}${elements}</svg>`;
21
25
  }
22
26
  function renderElement(element, context) {
23
- const attrs = commonAttrs(element, context);
27
+ const attrs = commonAttrParts(element, context);
24
28
  const fill = paintValue(element.fill, context, element.type === "text" ? "#111827" : "none");
25
29
  const stroke = strokeAttrs(element, context, "none");
26
30
  switch (element.type) {
27
31
  case "point":
28
32
  return "";
29
- case "path": {
30
- const marker = element.metadata?.markerEnd === "arrow" ? ` marker-end="url(#${markerForStroke(element.stroke, context)})"` : "";
31
- return `<path${attrs} d="${escapeAttr(element.d)}" fill="${fill}"${stroke}${marker}${drawAttrs(element)}/>`;
32
- }
33
+ case "path":
34
+ return `<path${joinCommonAttrs(attrs)} d="${escapeAttr(element.d)}" fill="${fill}"${stroke}${drawAttrs(element)}/>`;
33
35
  case "text":
34
- return renderText(element, attrs, fill);
36
+ return renderText(element, joinCommonAttrs(attrs), fill);
35
37
  case "image":
36
38
  return renderImage(element, attrs, context);
37
39
  case "group": {
38
- const transform = groupTransform(element);
39
- const children = (element.children ?? []).map((child) => renderElement(child, context)).join("");
40
- return `<g${attrs}${transform}>${children}</g>`;
40
+ const children = element.children.map((child) => renderElement(child, context)).join("");
41
+ return `<g${joinCommonAttrs(attrs)}${groupTransform(element)}>${children}</g>`;
41
42
  }
42
43
  default:
43
44
  return "";
44
45
  }
45
46
  }
46
- function isKernelVisualDocument(document) {
47
- return (document.elements ?? []).every((element) => element.type === "group" || element.type === "path" || element.type === "text" || element.type === "image" || element.type === "point" || element.type === "group3d" || element.type === "mesh3d" || element.type === "line3d" || element.type === "text3d" || element.type === "point3d" || element.type === "light");
48
- }
49
47
  function renderImage(element, attrs, context) {
48
+ const imageClip = imageSourceClipAttr(element, context);
49
+ const wrapped = !!attrs.clip;
50
+ const imageAttrs = joinCommonAttrs(attrs, wrapped ? ["clip", "transform"] : []);
50
51
  if (element.source) {
51
- const id = nextId(context, "image-crop");
52
52
  const scaleX = element.width / element.source.width;
53
53
  const scaleY = element.height / element.source.height;
54
54
  const imageX = element.x - element.source.x * scaleX;
55
55
  const imageY = element.y - element.source.y * scaleY;
56
56
  const imageWidth = element.source.imageWidth * scaleX;
57
57
  const imageHeight = element.source.imageHeight * scaleY;
58
- context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><rect x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}"/></clipPath>`);
59
- return `<image${attrs} href="${escapeAttr(element.src)}" x="${imageX}" y="${imageY}" width="${imageWidth}" height="${imageHeight}" preserveAspectRatio="none" clip-path="url(#${id})"/>`;
58
+ const image = `<image${imageAttrs} href="${escapeAttr(element.src)}" x="${imageX}" y="${imageY}" width="${imageWidth}" height="${imageHeight}" preserveAspectRatio="none"${imageClip}/>`;
59
+ return wrapped ? `<g${attrs.clip}${attrs.transform}>${image}</g>` : image;
60
60
  }
61
- return `<image${attrs} href="${escapeAttr(element.src)}" x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}" preserveAspectRatio="${imageFit(element.fit)}"/>`;
61
+ const image = `<image${imageAttrs} href="${escapeAttr(element.src)}" x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}" preserveAspectRatio="${imageFit(element.fit)}"${imageClip}/>`;
62
+ return wrapped ? `<g${attrs.clip}${attrs.transform}>${image}</g>` : image;
62
63
  }
63
64
  function renderText(element, attrs, fill) {
64
- const anchor = element.align === "right" ? "end" : element.align === "left" ? "start" : "middle";
65
+ const anchor = element.align === "center" ? "middle" : element.align === "right" ? "end" : "start";
65
66
  const fontSize = Number(element.fontSize ?? 16);
66
67
  const lineHeight = fontSize * Number(element.lineHeight ?? 1.2);
67
68
  const lines = (0, utils_1.textLines)(element);
68
69
  const weight = escapeAttr(String(element.weight ?? 400));
69
- const fontFamily = escapeAttr(String(element.fontFamily ?? "Inter, Arial, sans-serif"));
70
+ const fontFamily = escapeAttr(resolveFontFamily(element.fontFamily));
70
71
  const fontStyle = element.fontStyle ? ` font-style="${escapeAttr(String(element.fontStyle))}"` : "";
71
72
  const letterSpacing = (0, utils_1.isFiniteNumber)(element.letterSpacing) ? ` letter-spacing="${element.letterSpacing}"` : "";
72
73
  const firstY = textFirstLineY(element, lines.length, fontSize, lineHeight);
73
- const content = lines
74
- .map((line, index) => `<tspan x="${element.x}" y="${firstY + index * lineHeight}">${escapeText(line)}</tspan>`)
75
- .join("");
76
- return `<text${attrs} text-anchor="${anchor}" dominant-baseline="middle" font-family="${fontFamily}" font-size="${fontSize}" font-weight="${weight}"${fontStyle}${letterSpacing} fill="${fill}">${content}</text>`;
74
+ const content = lines.map((line, index) => `<tspan x="${element.x}" y="${firstY + index * lineHeight}">${escapeTextLine(line)}</tspan>`).join("");
75
+ return `<text${attrs} xml:space="preserve" text-anchor="${anchor}" dominant-baseline="middle" font-family="${fontFamily}" font-size="${fontSize}" font-weight="${weight}"${fontStyle}${letterSpacing} fill="${fill}">${content}</text>`;
76
+ }
77
+ function resolveFontFamily(value) {
78
+ const text = String(value ?? "").trim();
79
+ if (!text)
80
+ return DEFAULT_FONT_FAMILY;
81
+ const normalized = normalizeFontStack(text);
82
+ if (LEGACY_DEFAULT_FONT_STACKS.has(normalized))
83
+ return DEFAULT_FONT_FAMILY;
84
+ return text;
85
+ }
86
+ function normalizeFontStack(value) {
87
+ return value
88
+ .split(",")
89
+ .map((item) => item.trim().replace(/^['"]|['"]$/g, "").toLowerCase())
90
+ .filter(Boolean)
91
+ .join(",");
77
92
  }
78
93
  function textFirstLineY(element, lineCount, fontSize, lineHeight) {
79
- if (element.valign === "top")
80
- return element.y + fontSize / 2;
94
+ if (element.valign === "middle")
95
+ return element.y - ((lineCount - 1) * lineHeight) / 2;
81
96
  if (element.valign === "bottom")
82
97
  return element.y - fontSize / 2 - (lineCount - 1) * lineHeight;
83
- return element.y - ((lineCount - 1) * lineHeight) / 2;
98
+ return element.y + fontSize / 2;
99
+ }
100
+ function commonAttrParts(element, context) {
101
+ return {
102
+ id: element.id ? ` id="${escapeAttr(element.id)}"` : "",
103
+ opacity: element.opacity === undefined ? "" : ` opacity="${Number(element.opacity)}"`,
104
+ filter: effectFilter(element.effects, context),
105
+ clip: clipPath(element.clip, context),
106
+ mask: maskPath(element.mask, context),
107
+ blend: element.blendMode && element.blendMode !== "normal" ? ` style="mix-blend-mode:${escapeAttr(element.blendMode)}"` : "",
108
+ transform: element.type === "group" ? "" : elementTransform(element)
109
+ };
84
110
  }
85
- function commonAttrs(element, context) {
86
- const id = element.id ? ` id="${escapeAttr(element.id)}"` : "";
87
- const opacity = element.opacity === undefined ? "" : ` opacity="${Number(element.opacity)}"`;
88
- const filter = effectFilter(element.effects, context);
89
- const clip = clipPath(element.clip, context);
90
- const mask = maskPath(element.mask, context);
91
- const blend = element.blendMode && element.blendMode !== "normal" ? ` style="mix-blend-mode:${escapeAttr(element.blendMode)}"` : "";
92
- const transform = element.type === "group" ? "" : elementTransform(element);
93
- return `${id}${opacity}${filter}${clip}${mask}${blend}${transform}`;
111
+ function joinCommonAttrs(parts, omit = []) {
112
+ const skipped = new Set(omit);
113
+ return `${skipped.has("id") ? "" : parts.id}${skipped.has("opacity") ? "" : parts.opacity}${skipped.has("filter") ? "" : parts.filter}${skipped.has("clip") ? "" : parts.clip}${skipped.has("mask") ? "" : parts.mask}${skipped.has("blend") ? "" : parts.blend}${skipped.has("transform") ? "" : parts.transform}`;
94
114
  }
95
115
  function strokeAttrs(element, context, fallback) {
96
116
  const hasStroke = element.stroke !== undefined || element.strokeWidth !== undefined || fallback !== "none";
97
117
  if (!hasStroke)
98
118
  return "";
99
119
  const stroke = paintValue(element.stroke, context, fallback);
100
- const width = Number(element.strokeWidth ?? (fallback === "none" ? 0 : 1));
120
+ const width = Number(element.strokeWidth ?? (element.stroke !== undefined || fallback !== "none" ? 1 : 0));
101
121
  const cap = element.strokeCap ? ` stroke-linecap="${escapeAttr(element.strokeCap)}"` : "";
102
122
  const join = element.strokeJoin ? ` stroke-linejoin="${escapeAttr(element.strokeJoin)}"` : "";
103
123
  const miter = (0, utils_1.isFiniteNumber)(element.miterLimit) ? ` stroke-miterlimit="${element.miterLimit}"` : "";
@@ -137,23 +157,11 @@ function paintValue(paint, context, fallback) {
137
157
  return `url(#${id})`;
138
158
  }
139
159
  function gradientStops(stops) {
140
- return stops
141
- .map((stop) => {
160
+ return stops.map((stop) => {
142
161
  const offset = Array.isArray(stop) ? stop[0] : stop.offset;
143
162
  const color = Array.isArray(stop) ? stop[1] : stop.color;
144
163
  return `<stop offset="${Number(offset) * 100}%" stop-color="${escapeAttr(String(color))}"/>`;
145
- })
146
- .join("");
147
- }
148
- function markerForStroke(stroke, context) {
149
- const color = typeof stroke === "string" ? stroke : "#111827";
150
- const cached = context.markerIds.get(color);
151
- if (cached)
152
- return cached;
153
- const id = nextId(context, "arrow");
154
- context.markerIds.set(color, id);
155
- context.defs.push(`<marker id="${id}" markerWidth="10" markerHeight="10" refX="8" refY="5" orient="auto" markerUnits="strokeWidth"><path d="M 0 0 L 10 5 L 0 10 z" fill="${escapeAttr(color)}"/></marker>`);
156
- return id;
164
+ }).join("");
157
165
  }
158
166
  function effectFilter(effects, context) {
159
167
  if (!effects)
@@ -185,15 +193,14 @@ function clipPath(clip, context) {
185
193
  if (!clip)
186
194
  return "";
187
195
  const id = nextId(context, "clip");
188
- if (clip.type === "rect") {
189
- context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><rect x="${clip.x}" y="${clip.y}" width="${clip.width}" height="${clip.height}" rx="${Number(clip.radius ?? 0)}"/></clipPath>`);
190
- }
191
- else if (clip.type === "circle") {
192
- context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><circle cx="${clip.cx}" cy="${clip.cy}" r="${clip.radius}"/></clipPath>`);
193
- }
194
- else {
195
- context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><path d="${escapeAttr(clip.d)}"/></clipPath>`);
196
- }
196
+ context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><path d="${escapeAttr(clip.d)}"/></clipPath>`);
197
+ return ` clip-path="url(#${id})"`;
198
+ }
199
+ function imageSourceClipAttr(element, context) {
200
+ if (!element.source)
201
+ return "";
202
+ const id = nextId(context, "image-clip");
203
+ context.defs.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><rect x="${element.x}" y="${element.y}" width="${element.width}" height="${element.height}"/></clipPath>`);
197
204
  return ` clip-path="url(#${id})"`;
198
205
  }
199
206
  function maskPath(mask, context) {
@@ -201,25 +208,21 @@ function maskPath(mask, context) {
201
208
  return "";
202
209
  const id = nextId(context, "mask");
203
210
  const opacity = mask.opacity === undefined ? "" : ` opacity="${Number(mask.opacity)}"`;
204
- if (mask.type === "rect") {
205
- context.defs.push(`<mask id="${id}" maskUnits="userSpaceOnUse" x="-100000" y="-100000" width="200000" height="200000"><rect x="${mask.x}" y="${mask.y}" width="${mask.width}" height="${mask.height}" rx="${Number(mask.radius ?? 0)}" fill="#ffffff"${opacity}/></mask>`);
206
- }
207
- else if (mask.type === "circle") {
208
- context.defs.push(`<mask id="${id}" maskUnits="userSpaceOnUse" x="-100000" y="-100000" width="200000" height="200000"><circle cx="${mask.cx}" cy="${mask.cy}" r="${mask.radius}" fill="#ffffff"${opacity}/></mask>`);
209
- }
210
- else {
211
- context.defs.push(`<mask id="${id}" maskUnits="userSpaceOnUse" x="-100000" y="-100000" width="200000" height="200000"><path d="${escapeAttr(mask.d)}" fill="#ffffff"${opacity}/></mask>`);
212
- }
211
+ context.defs.push(`<mask id="${id}" maskUnits="userSpaceOnUse" x="-100000" y="-100000" width="200000" height="200000"><path d="${escapeAttr(mask.d)}" fill="#ffffff"${opacity}/></mask>`);
213
212
  return ` mask="url(#${id})"`;
214
213
  }
215
214
  function elementTransform(element) {
215
+ const x = element.type === "path" ? Number(element.x ?? 0) : 0;
216
+ const y = element.type === "path" ? Number(element.y ?? 0) : 0;
216
217
  const rotation = Number(element.rotation ?? 0);
217
218
  const scaleX = Number(element.scaleX ?? element.scale ?? 1);
218
219
  const scaleY = Number(element.scaleY ?? element.scale ?? 1);
219
- if (rotation === 0 && scaleX === 1 && scaleY === 1)
220
+ if (x === 0 && y === 0 && rotation === 0 && scaleX === 1 && scaleY === 1)
220
221
  return "";
221
222
  const origin = originPoint(element);
222
223
  const transforms = [];
224
+ if (x !== 0 || y !== 0)
225
+ transforms.push(`translate(${x} ${y})`);
223
226
  if (rotation !== 0)
224
227
  transforms.push(`rotate(${rotation} ${origin[0]} ${origin[1]})`);
225
228
  if (scaleX !== 1 || scaleY !== 1)
@@ -227,12 +230,11 @@ function elementTransform(element) {
227
230
  return transforms.length ? ` transform="${transforms.join(" ")}"` : "";
228
231
  }
229
232
  function groupTransform(element) {
230
- const base = `translate(${element.x} ${element.y})`;
233
+ const transforms = [`translate(${element.x} ${element.y})`];
231
234
  const rotation = Number(element.rotation ?? 0);
232
235
  const scaleX = Number(element.scaleX ?? element.scale ?? 1);
233
236
  const scaleY = Number(element.scaleY ?? element.scale ?? 1);
234
237
  const origin = groupOrigin(element);
235
- const transforms = [base];
236
238
  if (rotation !== 0)
237
239
  transforms.push(`rotate(${rotation} ${origin[0]} ${origin[1]})`);
238
240
  if (scaleX !== 1 || scaleY !== 1)
@@ -242,17 +244,16 @@ function groupTransform(element) {
242
244
  function originPoint(element) {
243
245
  if ((0, utils_1.isPoint2)(element.origin))
244
246
  return element.origin;
247
+ if (element.type === "text" || element.type === "point") {
248
+ return [Number(element.x ?? 0), Number(element.y ?? 0)];
249
+ }
245
250
  const box = (0, utils_1.elementBox)(element);
246
- if (!box)
247
- return [0, 0];
248
- return (0, utils_1.anchorPoint)(box, typeof element.origin === "string" ? element.origin : "center");
251
+ return box ? [box.x + box.width / 2, box.y + box.height / 2] : [0, 0];
249
252
  }
250
253
  function groupOrigin(element) {
251
254
  if ((0, utils_1.isPoint2)(element.origin))
252
255
  return [element.origin[0] - element.x, element.origin[1] - element.y];
253
- const width = Number(element.width ?? 0);
254
- const height = Number(element.height ?? 0);
255
- return (0, utils_1.anchorPoint)({ x: 0, y: 0, width, height }, typeof element.origin === "string" ? element.origin : "center");
256
+ return [Number(element.width ?? 0) / 2, Number(element.height ?? 0) / 2];
256
257
  }
257
258
  function imageFit(fit) {
258
259
  if (fit === "contain")
@@ -272,6 +273,9 @@ function escapeAttr(value) {
272
273
  function escapeText(value) {
273
274
  return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
274
275
  }
276
+ function escapeTextLine(value) {
277
+ return escapeText(value.replace(/\t/g, " ")).replace(/ /g, "&#160;");
278
+ }
275
279
  function clamp(value, min, max) {
276
280
  return Math.max(min, Math.min(max, value));
277
281
  }
@@ -7,9 +7,8 @@ function renderToThreeHtml(document, options = {}) {
7
7
  const width = document.canvas.width;
8
8
  const height = document.canvas.height;
9
9
  const background = options.transparent ? "transparent" : (document.canvas.background ?? "#ffffff");
10
- const kernel = (0, kernel_1.lowerVisualDocument)(document);
11
- const elements = JSON.stringify(kernel.elements ?? []);
12
10
  const initialTime = Number(options.time ?? 0);
11
+ const frames = JSON.stringify(sampleKernelFrames(document, initialTime));
13
12
  const threeRuntime = options.threeRuntime ?? DEFAULT_THREE_RUNTIME;
14
13
  return `<!doctype html>
15
14
  <html>
@@ -54,7 +53,7 @@ import * as THREE from ${JSON.stringify(threeRuntime)};
54
53
  const width = ${JSON.stringify(width)};
55
54
  const height = ${JSON.stringify(height)};
56
55
  const background = ${JSON.stringify(background)};
57
- const elements = ${elements};
56
+ const frames = ${frames};
58
57
  const canvas = document.getElementById("stage");
59
58
  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true });
60
59
  renderer.setSize(width, height, false);
@@ -67,24 +66,9 @@ const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 1000);
67
66
  camera.position.set(6, 4, 8);
68
67
  camera.lookAt(0, 0, 0);
69
68
 
70
- const objects = [];
71
- let hasLight = false;
72
- for (const element of elements) {
73
- const object = createObject(element);
74
- if (!object) continue;
75
- bindObject(object, element);
76
- scene.add(object);
77
- objects.push(object);
78
- if (element.type === "light") hasLight = true;
79
- }
80
-
81
- if (!hasLight) {
82
- const ambient = new THREE.AmbientLight(0xffffff, 0.6);
83
- scene.add(ambient);
84
- const light = new THREE.DirectionalLight(0xffffff, 1);
85
- light.position.set(5, 8, 5);
86
- scene.add(light);
87
- }
69
+ const content = new THREE.Group();
70
+ scene.add(content);
71
+ let currentFrameIndex = -1;
88
72
 
89
73
  function createObject(element) {
90
74
  if (element.type === "mesh3d") {
@@ -165,107 +149,62 @@ function materialFor(element) {
165
149
  });
166
150
  }
167
151
 
168
- function bindObject(object, element) {
169
- object.userData.sketchmark = {
170
- element,
171
- basePosition: object.position.clone(),
172
- baseRotation: object.rotation.clone(),
173
- baseScale: object.scale.clone(),
174
- baseOpacity: materialOpacity(object),
175
- baseIntensity: typeof object.intensity === "number" ? object.intensity : undefined
176
- };
177
- }
178
-
179
152
  function showTime(rawTime = 0) {
180
153
  const time = Number.isFinite(Number(rawTime)) ? Number(rawTime) : 0;
181
- for (const object of objects) applyAnimatedObject(object, time);
154
+ const frameIndex = nearestFrameIndex(time);
155
+ if (frameIndex !== currentFrameIndex) {
156
+ currentFrameIndex = frameIndex;
157
+ setElements(frames[frameIndex] ? frames[frameIndex].elements : []);
158
+ }
182
159
  renderer.render(scene, camera);
183
160
  return true;
184
161
  }
185
162
 
186
- function applyAnimatedObject(object, time) {
187
- const data = object.userData.sketchmark;
188
- if (!data) return;
189
- const element = data.element;
190
- const animation = element.animate || {};
191
-
192
- const basePosition = vector(element.position, [data.basePosition.x, data.basePosition.y, data.basePosition.z]);
193
- const px = valueAt(animation.positionX, time, basePosition[0]);
194
- const py = valueAt(animation.positionY, time, basePosition[1]);
195
- const pz = valueAt(animation.positionZ, time, basePosition[2]);
196
- object.position.set(px, py, pz);
197
-
198
- const rx = data.baseRotation.x + degrees(valueAt(animation.rotationX, time, number(element.rotationX, 0)));
199
- const ry = data.baseRotation.y + degrees(valueAt(animation.rotationY, time, number(element.rotationY, 0)));
200
- const rz = data.baseRotation.z + degrees(valueAt(animation.rotationZ, time, number(element.rotationZ, 0)));
201
- object.rotation.set(rx, ry, rz);
202
-
203
- const baseScale = data.baseScale;
204
- const allScale = valueAt(animation.scale, time, number(element.scale, 1));
205
- const sx = baseScale.x * valueAt(animation.scaleX, time, number(element.scaleX, allScale));
206
- const sy = baseScale.y * valueAt(animation.scaleY, time, number(element.scaleY, allScale));
207
- const sz = baseScale.z * valueAt(animation.scaleZ, time, number(element.scaleZ, allScale));
208
- object.scale.set(sx, sy, sz);
209
-
210
- const opacity = valueAt(animation.opacity, time, number(element.opacity, data.baseOpacity ?? 1));
211
- setMaterialOpacity(object, opacity);
212
-
213
- if (typeof object.intensity === "number") {
214
- object.intensity = valueAt(animation.intensity, time, number(element.intensity, data.baseIntensity ?? 1));
163
+ function setElements(elements) {
164
+ while (content.children.length) {
165
+ const child = content.children[0];
166
+ content.remove(child);
167
+ disposeObject(child);
168
+ }
169
+ let hasLight = false;
170
+ for (const element of elements || []) {
171
+ const object = createObject(element);
172
+ if (!object) continue;
173
+ content.add(object);
174
+ if (element.type === "light") hasLight = true;
175
+ }
176
+ if (!hasLight) {
177
+ const ambient = new THREE.AmbientLight(0xffffff, 0.6);
178
+ content.add(ambient);
179
+ const light = new THREE.DirectionalLight(0xffffff, 1);
180
+ light.position.set(5, 8, 5);
181
+ content.add(light);
215
182
  }
216
183
  }
217
184
 
218
- function valueAt(animation, time, fallback) {
219
- if (!animation || typeof animation !== "object") return fallback;
220
- if (Array.isArray(animation.keyframes) && animation.keyframes.length) {
221
- const frames = animation.keyframes
222
- .filter((frame) => Array.isArray(frame) && Number.isFinite(Number(frame[0])) && Number.isFinite(Number(frame[1])))
223
- .map((frame) => [Number(frame[0]), Number(frame[1])])
224
- .sort((a, b) => a[0] - b[0]);
225
- if (!frames.length) return fallback;
226
- if (time <= frames[0][0]) return frames[0][1];
227
- for (let index = 0; index < frames.length - 1; index += 1) {
228
- const from = frames[index];
229
- const to = frames[index + 1];
230
- if (time > to[0]) continue;
231
- const span = Math.max(0.000001, to[0] - from[0]);
232
- const t = easeValue((time - from[0]) / span, animation.ease);
233
- return from[1] + (to[1] - from[1]) * t;
185
+ function nearestFrameIndex(time) {
186
+ if (!frames.length) return 0;
187
+ let best = 0;
188
+ let bestDistance = Infinity;
189
+ for (let index = 0; index < frames.length; index += 1) {
190
+ const distance = Math.abs(number(frames[index].time, 0) - time);
191
+ if (distance < bestDistance) {
192
+ best = index;
193
+ bestDistance = distance;
234
194
  }
235
- return frames[frames.length - 1][1];
236
195
  }
237
-
238
- const from = number(animation.from, fallback);
239
- const to = number(animation.to, fallback);
240
- const delay = number(animation.delay, 0);
241
- const duration = Math.max(0.000001, number(animation.duration, 1));
242
- const t = easeValue((time - delay) / duration, animation.ease);
243
- if (time <= delay) return from;
244
- if (time >= delay + duration) return to;
245
- return from + (to - from) * t;
246
- }
247
-
248
- function easeValue(value, kind) {
249
- const t = clamp(value, 0, 1);
250
- if (kind === "ease-in") return t * t;
251
- if (kind === "ease-out") return 1 - (1 - t) * (1 - t);
252
- if (kind === "ease-in-out") return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
253
- return t;
196
+ return best;
254
197
  }
255
198
 
256
- function materialOpacity(object) {
257
- const material = Array.isArray(object.material) ? object.material[0] : object.material;
258
- return material && typeof material.opacity === "number" ? material.opacity : 1;
259
- }
260
-
261
- function setMaterialOpacity(object, opacity) {
262
- if (!object.material) return;
263
- const materials = Array.isArray(object.material) ? object.material : [object.material];
264
- for (const material of materials) {
265
- material.opacity = opacity;
266
- material.transparent = opacity < 1;
267
- material.needsUpdate = true;
268
- }
199
+ function disposeObject(object) {
200
+ object.traverse((child) => {
201
+ if (child.geometry) child.geometry.dispose();
202
+ const materials = Array.isArray(child.material) ? child.material : child.material ? [child.material] : [];
203
+ for (const material of materials) {
204
+ if (material.map) material.map.dispose();
205
+ material.dispose();
206
+ }
207
+ });
269
208
  }
270
209
 
271
210
  function applyPosition(object, position) {
@@ -286,10 +225,6 @@ function degrees(value) {
286
225
  return number(value, 0) * Math.PI / 180;
287
226
  }
288
227
 
289
- function clamp(value, min, max) {
290
- return Math.max(min, Math.min(max, Number(value) || 0));
291
- }
292
-
293
228
  window.__SKETCHMARK_SHOW_TIME__ = showTime;
294
229
  window.__SKETCHMARK_READY__ = true;
295
230
  window.addEventListener("message", (event) => {
@@ -301,3 +236,22 @@ showTime(${JSON.stringify(initialTime)});
301
236
  </body>
302
237
  </html>`;
303
238
  }
239
+ function sampleKernelFrames(document, initialTime) {
240
+ const duration = finiteNumber(document.canvas.duration, 0);
241
+ const fps = Math.max(1, Math.min(60, finiteNumber(document.canvas.fps, 30)));
242
+ const times = new Set([0, Math.max(0, initialTime)]);
243
+ if (duration > 0) {
244
+ const frameCount = Math.ceil(duration * fps);
245
+ for (let index = 0; index <= frameCount; index += 1) {
246
+ times.add(Number((index / fps).toFixed(6)));
247
+ }
248
+ times.add(duration);
249
+ }
250
+ return [...times]
251
+ .sort((left, right) => left - right)
252
+ .map((time) => ({ time, elements: (0, kernel_1.resolveKernelFrame)(document, time).elements ?? [] }));
253
+ }
254
+ function finiteNumber(value, fallback) {
255
+ const numeric = Number(value);
256
+ return Number.isFinite(numeric) ? numeric : fallback;
257
+ }
@@ -16,6 +16,7 @@ function documentForScene(document, sceneId) {
16
16
  ...(0, utils_1.clone)(document),
17
17
  canvas: { ...document.canvas, ...(scene.canvas ?? {}) },
18
18
  elements: (0, utils_1.clone)(scene.elements),
19
+ motion: (0, utils_1.clone)(scene.motion),
19
20
  scenes: undefined,
20
21
  sequences: undefined
21
22
  };