sketchmark 2.1.1 → 2.1.3

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 (47) hide show
  1. package/bin/editor-ui.cjs +368 -123
  2. package/bin/editor-ui.d.ts +11 -0
  3. package/bin/vendor/mp4-muxer.LICENSE.txt +21 -0
  4. package/bin/vendor/mp4-muxer.mjs +1879 -0
  5. package/dist/src/browser-export.d.ts +10 -0
  6. package/dist/src/browser-export.js +220 -0
  7. package/package.json +59 -46
  8. package/dist/src/builders/index.d.ts +0 -64
  9. package/dist/src/builders/index.js +0 -212
  10. package/dist/src/compounds.d.ts +0 -13
  11. package/dist/src/compounds.js +0 -118
  12. package/dist/src/deck.d.ts +0 -4
  13. package/dist/src/deck.js +0 -91
  14. package/dist/src/export/index.d.ts +0 -8
  15. package/dist/src/export/index.js +0 -15
  16. package/dist/src/kernel.d.ts +0 -8
  17. package/dist/src/kernel.js +0 -68
  18. package/dist/src/motion.d.ts +0 -4
  19. package/dist/src/motion.js +0 -262
  20. package/dist/src/patch.d.ts +0 -5
  21. package/dist/src/patch.js +0 -72
  22. package/dist/src/player/index.d.ts +0 -68
  23. package/dist/src/player/index.js +0 -600
  24. package/dist/src/project.d.ts +0 -11
  25. package/dist/src/project.js +0 -107
  26. package/dist/src/render/raw-three.d.ts +0 -7
  27. package/dist/src/render/raw-three.js +0 -17
  28. package/dist/src/render/three-html.d.ts +0 -2
  29. package/dist/src/render/three-html.js +0 -257
  30. package/dist/src/render/three-preview-svg.d.ts +0 -3
  31. package/dist/src/render/three-preview-svg.js +0 -102
  32. package/dist/src/scenes.d.ts +0 -4
  33. package/dist/src/scenes.js +0 -26
  34. package/dist/src/sequences.d.ts +0 -43
  35. package/dist/src/sequences.js +0 -109
  36. package/dist/src/shapes/builtins.d.ts +0 -2
  37. package/dist/src/shapes/builtins.js +0 -393
  38. package/dist/src/shapes/common.d.ts +0 -9
  39. package/dist/src/shapes/common.js +0 -76
  40. package/dist/src/shapes/geometry.d.ts +0 -22
  41. package/dist/src/shapes/geometry.js +0 -166
  42. package/dist/src/shapes/index.d.ts +0 -2
  43. package/dist/src/shapes/index.js +0 -18
  44. package/dist/src/shapes/registry.d.ts +0 -8
  45. package/dist/src/shapes/registry.js +0 -31
  46. package/dist/src/shapes/types.d.ts +0 -32
  47. package/dist/src/shapes/types.js +0 -2
package/bin/editor-ui.cjs CHANGED
@@ -1,7 +1,14 @@
1
- "use strict";
2
-
3
- function editorHtml(title) {
4
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sketchmark Editor - ${escapeHtml(title)}</title><style>
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+
6
+ function editorHtml(title, options = {}) {
7
+ const apiBase = normalizeApiBase(options.apiBase || "/api");
8
+ const mp4MuxerUrl = options.mp4MuxerUrl || "";
9
+ const mp4MuxerSource = mp4MuxerUrl ? "" : resolveMp4MuxerSource(options.mp4MuxerSource);
10
+ const serverExportFallback = options.serverExportFallback !== false;
11
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Sketchmark Editor - ${escapeHtml(title)}</title><style>
5
12
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Light.ttf') format('truetype');font-weight:300;font-style:normal;font-display:swap}
6
13
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Regular.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
7
14
  @font-face{font-family:'Roboto';src:url('/fonts/Roboto-Bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap}
@@ -66,13 +73,11 @@ textarea{min-height:88px;padding:4px;resize:vertical}
66
73
  .curveModalBar{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:8px}
67
74
  .curveModalContent .curvePanel{margin-top:0}
68
75
  #tree::-webkit-scrollbar,#inspector::-webkit-scrollbar,#timeline::-webkit-scrollbar,.curveModal::-webkit-scrollbar{width:0;height:0}
69
- #error{color:#900;min-height:18px;margin-top:6px}.tiny{font-size:11px;color:#444}.toolbar{display:grid;grid-template-columns:auto 1fr auto auto auto;gap:6px;align-items:center}
70
- .menuWrap{position:relative}
71
- .menuBtn{min-width:72px}
72
- .menuList{position:absolute;right:0;top:calc(100% + 4px);display:grid;gap:2px;padding:4px;background:#f3f4f6;border:1px solid #8f96a3;box-shadow:0 4px 12px rgba(0,0,0,.2);z-index:5}
73
- .menuList button{min-width:88px;text-align:left;padding:2px 8px}
74
- .menuList.hidden{display:none}
75
- </style></head><body><aside id="tree"></aside><main id="stageWrap"><div id="stage"></div><div id="viewportHud"><button id="zoomOut" type="button" title="Zoom out">-</button><button id="zoomIn" type="button" title="Zoom in">+</button><button id="zoomFit" type="button" title="Reset zoom and pan">Fit</button><span id="zoomLabel">100%</span></div></main><aside id="inspector"></aside><section id="timeline"></section><div id="curveModalBackdrop" class="modalBackdrop hidden"><div id="curveModal" class="curveModal" role="dialog" aria-modal="true" aria-label="Interpolation Graph"><div class="curveModalBar"><strong>Interpolation Graph</strong><button id="curveModalClose" type="button">Close</button></div><div id="curveModalContent" class="curveModalContent"></div></div></div><script>
76
+ #error{color:#900;min-height:18px;margin-top:6px}.tiny{font-size:11px;color:#444}.toolbar{display:grid;grid-template-columns:auto 1fr auto auto;gap:6px;align-items:center}
77
+ .exportButtons{display:grid;grid-template-columns:1fr 1fr;gap:4px}
78
+ .exportButtons button{width:100%;text-align:left;padding:4px 6px}
79
+ .exportButtons button.exportWide{grid-column:1/3}
80
+ </style></head><body><aside id="tree"></aside><main id="stageWrap"><div id="stage"></div><div id="viewportHud"><button id="zoomOut" type="button" title="Zoom out">-</button><button id="zoomIn" type="button" title="Zoom in">+</button><button id="zoomFit" type="button" title="Reset zoom and pan">Fit</button><span id="zoomLabel">100%</span></div></main><aside id="inspector"></aside><section id="timeline"></section><div id="curveModalBackdrop" class="modalBackdrop hidden"><div id="curveModal" class="curveModal" role="dialog" aria-modal="true" aria-label="Interpolation Graph"><div class="curveModalBar"><strong>Interpolation Graph</strong><button id="curveModalClose" type="button">Close</button></div><div id="curveModalContent" class="curveModalContent"></div></div></div><script>
76
81
  const tree = document.getElementById("tree");
77
82
  const stageWrap = document.getElementById("stageWrap");
78
83
  const stage = document.getElementById("stage");
@@ -81,7 +86,7 @@ const zoomIn = document.getElementById("zoomIn");
81
86
  const zoomFit = document.getElementById("zoomFit");
82
87
  const zoomLabel = document.getElementById("zoomLabel");
83
88
  const inspector = document.getElementById("inspector");
84
- const timeline = document.getElementById("timeline");
89
+ const timeline = document.getElementById("timeline");
85
90
  const curveModalBackdrop = document.getElementById("curveModalBackdrop");
86
91
  const curveModal = document.getElementById("curveModal");
87
92
  const curveModalContent = document.getElementById("curveModalContent");
@@ -119,10 +124,20 @@ let sidebarCommitTimers = Object.create(null);
119
124
  let selectedSegment = null;
120
125
  let panelOpenState = Object.create(null);
121
126
  let viewport = { initialized: false, baseWidth: 1, baseHeight: 1, x: 0, y: 0, width: 1, height: 1 };
122
- let viewportPan = null;
123
- let spacePanActive = false;
124
-
125
- curveModalClose.onclick = closeCurveModal;
127
+ let viewportPan = null;
128
+ let spacePanActive = false;
129
+ const API_BASE = ${scriptJson(apiBase)};
130
+ const EDITOR_TITLE = ${scriptJson(title || "sketchmark")};
131
+ const MP4_MUXER_URL = ${scriptJson(mp4MuxerUrl)};
132
+ const MP4_MUXER_SOURCE = ${scriptJson(mp4MuxerSource)};
133
+ const SERVER_EXPORT_FALLBACK = ${scriptJson(serverExportFallback)};
134
+ let mp4MuxerObjectUrl = "";
135
+
136
+ function apiPath(path) {
137
+ return API_BASE + path;
138
+ }
139
+
140
+ curveModalClose.onclick = closeCurveModal;
126
141
  curveModalBackdrop.onclick = (event) => {
127
142
  if (event.target === curveModalBackdrop) closeCurveModal();
128
143
  };
@@ -130,24 +145,16 @@ curveModal.onclick = (event) => event.stopPropagation();
130
145
  zoomOut.onclick = () => zoomBy(1.12);
131
146
  zoomIn.onclick = () => zoomBy(1 / 1.12);
132
147
  zoomFit.onclick = () => resetViewport(true);
133
- document.addEventListener("click", (event) => {
134
- const wrap = document.getElementById("exportMenuWrap");
135
- const menu = document.getElementById("exportMenu");
136
- if (!wrap || !menu) return;
137
- if (wrap.contains(event.target)) return;
138
- menu.classList.add("hidden");
139
- });
140
-
141
- async function api(path, options) {
148
+ async function api(path, options) {
142
149
  const response = await fetch(path, options || { cache: "no-store" });
143
150
  const data = await response.json();
144
151
  if (!data.ok) throw new Error(data.error || "Request failed.");
145
152
  return data;
146
153
  }
147
154
 
148
- async function load() {
149
- clearSidebarCommitTimers();
150
- const data = await api("/api/document");
155
+ async function load() {
156
+ clearSidebarCommitTimers();
157
+ const data = await api(apiPath("/document"));
151
158
  doc = data.document;
152
159
  refs = data.elements;
153
160
  rebuildElementIndex();
@@ -167,7 +174,7 @@ async function draw() {
167
174
  drawInFlight = true;
168
175
  const time = currentTime;
169
176
  try {
170
- const data = await api("/api/frame?time=" + encodeURIComponent(time));
177
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
171
178
  resolvedDoc = data.resolved || null;
172
179
  stage.innerHTML = data.svg;
173
180
  const svg = stage.querySelector("svg");
@@ -509,7 +516,7 @@ function scheduleCanvasCommit(property, reader) {
509
516
  const value = reader();
510
517
  if (value === undefined) return;
511
518
  await mutate(
512
- "/api/canvas",
519
+ apiPath("/canvas"),
513
520
  { [property]: value },
514
521
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
515
522
  );
@@ -675,12 +682,15 @@ function deselect() {
675
682
  requestDraw();
676
683
  }
677
684
 
678
- function renderInspector() {
679
- const element = findElement(selectedId);
680
- if (!element) {
681
- inspector.innerHTML = "<div class='muted'>Select an element.</div>";
682
- return;
683
- }
685
+ function renderInspector() {
686
+ const element = findElement(selectedId);
687
+ const exportPanel = renderExportPanel();
688
+ if (!element) {
689
+ inspector.innerHTML = exportPanel + "<div class='muted'>Select an element.</div>";
690
+ bindPanelStates(inspector);
691
+ bindExportButtons();
692
+ return;
693
+ }
684
694
  const displayElement = findResolvedElement(selectedId) || element;
685
695
  const supportsPosition = ["path","point","text","image","group"].includes(element.type);
686
696
  const supportsOrigin = ["path","text","image","group"].includes(element.type);
@@ -729,11 +739,10 @@ function renderInspector() {
729
739
  const sourceRows = element.type === "image" ? renderImageSourceRows(displayElement, lockDisabled) : "";
730
740
  const structuredPaintRows = supportsPaint ? renderStructuredPaintRows(displayElement, "fill", lockDisabled) + renderStructuredPaintRows(displayElement, "stroke", lockDisabled) : "";
731
741
  const selectedMeta = escapeText(element.type) + (hidden ? " | hidden" : "") + (locked ? " | locked" : "");
732
- const selectedRows =
733
- "<strong>" + escapeText(element.id || "") + "</strong>" +
734
- "<div class='muted'>" + selectedMeta + "</div>" +
735
- (locked ? "<div class='tiny'>Locked elements and groups cannot be edited from canvas or inspector.</div>" : "") +
736
- "<div id='error'></div>";
742
+ const selectedRows =
743
+ "<strong>" + escapeText(element.id || "") + "</strong>" +
744
+ "<div class='muted'>" + selectedMeta + "</div>" +
745
+ (locked ? "<div class='tiny'>Locked elements and groups cannot be edited from canvas or inspector.</div>" : "");
737
746
  const transformRows =
738
747
  "<div class='row'><label>X<input id='propX' type='number' step='1' value='" + valueOr(displayElement.x, 0) + "' " + positionDisabled + "></label><label>Y<input id='propY' type='number' step='1' value='" + valueOr(displayElement.y, 0) + "' " + positionDisabled + "></label></div>" +
739
748
  "<div class='row'><label>Rotation<input id='propRotation' type='number' step='1' value='" + valueOr(displayElement.rotation, 0) + "' " + lockDisabled + "></label><label>Scale<input id='propScale' type='number' step='0.05' value='" + valueOr(displayElement.scale, 1) + "' " + lockDisabled + "></label></div>" +
@@ -749,15 +758,17 @@ function renderInspector() {
749
758
  "<p class='tiny'>Changing sidebar values updates keyframes at the current time.</p>" +
750
759
  "<p class='tiny'>Interpolation curves are edited from timeline badges.</p>" +
751
760
  "<p class='tiny'>Drag to move. Use the square to scale and the round handle to rotate.</p>";
752
- inspector.innerHTML =
753
- panelDetails("inspector-selected", "Selected", selectedRows, { defaultOpen: true, meta: element.type }) +
761
+ inspector.innerHTML =
762
+ exportPanel +
763
+ panelDetails("inspector-selected", "Selected", selectedRows, { defaultOpen: true, meta: element.type }) +
754
764
  panelDetails("inspector-transform", "Transform", transformRows, { defaultOpen: false }) +
755
765
  panelDetails("inspector-appearance", "Appearance", appearanceRows, { defaultOpen: false }) +
756
766
  (contentRows ? panelDetails("inspector-content", "Path / Content", contentRows, { defaultOpen: false }) : "") +
757
767
  (supportsEffects ? panelDetails("inspector-effects", "Effects", effectsRows, { defaultOpen: false }) : "") +
758
768
  (structuredPaintRows ? panelDetails("inspector-structured-paint", "Structured Paint", structuredPaintRows, { defaultOpen: false }) : "") +
759
- panelDetails("inspector-keyframe", "Keyframe", keyframeRows, { defaultOpen: false });
760
- bindPanelStates(inspector);
769
+ panelDetails("inspector-keyframe", "Keyframe", keyframeRows, { defaultOpen: false });
770
+ bindPanelStates(inspector);
771
+ bindExportButtons();
761
772
  if (supportsPaint) {
762
773
  setInput("propFill", typeof displayElement.fill === "string" ? displayElement.fill : "");
763
774
  setInput("propStroke", typeof displayElement.stroke === "string" ? displayElement.stroke : "");
@@ -817,10 +828,39 @@ function renderInspector() {
817
828
  }
818
829
  bindDynamicInspectorInputs(bindAutoKeyframe);
819
830
  }
820
- bindColorPickersInScope(inspector);
821
- }
822
-
823
- function fontFamilyOptionsHtml(currentValue) {
831
+ bindColorPickersInScope(inspector);
832
+ }
833
+
834
+ function renderExportPanel() {
835
+ const rows =
836
+ "<div class='exportButtons'>" +
837
+ "<button id='exportSvg' type='button' title='Export current frame as SVG'>SVG</button>" +
838
+ "<button id='exportPng' type='button' title='Export current frame as PNG'>PNG</button>" +
839
+ "<button id='exportJpg' type='button' title='Export current frame as JPG'>JPG</button>" +
840
+ "<button id='exportHtml' type='button' title='Export current frame as HTML'>HTML</button>" +
841
+ "<button id='exportJson' type='button' title='Export kernel JSON'>JSON</button>" +
842
+ "<button id='exportMp4' type='button' title='Export full animation as MP4'>MP4</button>" +
843
+ "</div>" +
844
+ "<div id='error'></div>" +
845
+ "<p class='tiny'>MP4 exports in the browser. Chrome or Edge is recommended.</p>";
846
+ return panelDetails("inspector-export", "Export", rows, { defaultOpen: true });
847
+ }
848
+
849
+ function bindExportButtons() {
850
+ bindExportButton("exportSvg", "svg");
851
+ bindExportButton("exportPng", "png");
852
+ bindExportButton("exportJpg", "jpg");
853
+ bindExportButton("exportHtml", "html");
854
+ bindExportButton("exportJson", "json");
855
+ bindExportButton("exportMp4", "mp4");
856
+ }
857
+
858
+ function bindExportButton(id, format) {
859
+ const button = document.getElementById(id);
860
+ if (button) button.onclick = () => exportDocument(format, button);
861
+ }
862
+
863
+ function fontFamilyOptionsHtml(currentValue) {
824
864
  const current = String(valueOr(currentValue, "")).trim();
825
865
  const seen = new Set();
826
866
  const options = [];
@@ -1032,31 +1072,12 @@ function bindDynamicInspectorInputs(bindAutoKeyframe) {
1032
1072
  }
1033
1073
  }
1034
1074
 
1035
- function renderTimeline() {
1036
- const element = findElement(selectedId);
1037
- const tracks = element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
1038
- timeline.innerHTML = "<div class='toolbar'><button id='play'>" + (playing ? "Pause" : "Play") + "</button><input id='scrub' type='range' min='0' max='" + Math.max(Number(doc.canvas.duration || 0), 0.01) + "' step='0.005' value='" + currentTime + "'><strong id='timeLabel'>" + currentTime.toFixed(2) + "s</strong><button id='refresh'>Refresh</button><div id='exportMenuWrap' class='menuWrap'><button id='exportMenuBtn' class='menuBtn' type='button' title='Export options'>Export</button><div id='exportMenu' class='menuList hidden'><button id='exportSvg' type='button' title='Export current frame as SVG'>SVG</button><button id='exportPng' type='button' title='Export current frame as PNG'>PNG</button><button id='exportMp4' type='button' title='Export full animation as MP4'>MP4</button></div></div></div>";
1039
- document.getElementById("play").onclick = togglePlay;
1040
- const exportMenuBtn = document.getElementById("exportMenuBtn");
1041
- const exportMenu = document.getElementById("exportMenu");
1042
- exportMenuBtn.onclick = (event) => {
1043
- event.stopPropagation();
1044
- if (exportMenu) exportMenu.classList.toggle("hidden");
1045
- };
1046
- if (exportMenu) exportMenu.onclick = (event) => event.stopPropagation();
1047
- document.getElementById("exportSvg").onclick = () => {
1048
- if (exportMenu) exportMenu.classList.add("hidden");
1049
- exportDocument("svg", exportMenuBtn);
1050
- };
1051
- document.getElementById("exportPng").onclick = () => {
1052
- if (exportMenu) exportMenu.classList.add("hidden");
1053
- exportDocument("png", exportMenuBtn);
1054
- };
1055
- document.getElementById("exportMp4").onclick = () => {
1056
- if (exportMenu) exportMenu.classList.add("hidden");
1057
- exportDocument("mp4", exportMenuBtn);
1058
- };
1059
- document.getElementById("refresh").onclick = load;
1075
+ function renderTimeline() {
1076
+ const element = findElement(selectedId);
1077
+ const tracks = element && element.timeline && element.timeline.tracks ? element.timeline.tracks : {};
1078
+ timeline.innerHTML = "<div class='toolbar'><button id='play'>" + (playing ? "Pause" : "Play") + "</button><input id='scrub' type='range' min='0' max='" + Math.max(Number(doc.canvas.duration || 0), 0.01) + "' step='0.005' value='" + currentTime + "'><strong id='timeLabel'>" + currentTime.toFixed(2) + "s</strong><button id='refresh'>Refresh</button></div>";
1079
+ document.getElementById("play").onclick = togglePlay;
1080
+ document.getElementById("refresh").onclick = load;
1060
1081
  document.getElementById("scrub").oninput = (event) => {
1061
1082
  setCurrentTime(event.target.value);
1062
1083
  };
@@ -1119,49 +1140,229 @@ function renderTimeline() {
1119
1140
  if (isCurveModalOpen()) refreshCurveModal(tracks);
1120
1141
  }
1121
1142
 
1122
- async function exportDocument(format, triggerButton) {
1123
- const button = triggerButton || document.getElementById("exportMenuBtn");
1124
- const label = button ? button.textContent : "";
1125
- try {
1126
- if (button) {
1127
- button.disabled = true;
1128
- button.textContent = format === "mp4" ? "Exporting..." : "Export...";
1129
- }
1130
- const response = await fetch("/api/export?format=" + encodeURIComponent(format) + "&time=" + encodeURIComponent(currentTime), { cache: "no-store" });
1131
- if (!response.ok) {
1132
- let message = "Export failed.";
1133
- try {
1134
- const data = await response.json();
1135
- message = data.error || message;
1136
- } catch {}
1137
- throw new Error(message);
1138
- }
1139
- const blob = await response.blob();
1140
- const link = document.createElement("a");
1141
- link.href = URL.createObjectURL(blob);
1142
- link.download = filenameFromDisposition(response.headers.get("content-disposition")) || ("sketchmark." + format);
1143
- document.body.appendChild(link);
1144
- link.click();
1145
- link.remove();
1146
- setTimeout(() => URL.revokeObjectURL(link.href), 1000);
1147
- } catch (error) {
1148
- showError(error);
1149
- } finally {
1143
+ async function exportDocument(format, triggerButton) {
1144
+ const button = triggerButton || document.getElementById("exportMenuBtn");
1145
+ const label = button ? button.textContent : "";
1146
+ try {
1147
+ if (button) {
1148
+ button.disabled = true;
1149
+ button.textContent = format === "mp4" ? "Exporting..." : "Export...";
1150
+ }
1151
+ if (format === "json") {
1152
+ downloadBlob(
1153
+ new Blob([JSON.stringify(doc, null, 2)], { type: "application/json;charset=utf-8" }),
1154
+ safeFileName(EDITOR_TITLE) + ".json"
1155
+ );
1156
+ return;
1157
+ }
1158
+ if (format === "svg" || format === "png" || format === "jpg" || format === "html") {
1159
+ await exportCurrentFrameInBrowser(format);
1160
+ return;
1161
+ }
1162
+ if (format === "mp4") {
1163
+ try {
1164
+ await exportMp4InBrowser(button);
1165
+ return;
1166
+ } catch (error) {
1167
+ if (!SERVER_EXPORT_FALLBACK) throw error;
1168
+ }
1169
+ }
1170
+ await exportViaServer(format);
1171
+ } catch (error) {
1172
+ showError(error);
1173
+ } finally {
1150
1174
  if (button) {
1151
1175
  button.disabled = false;
1152
1176
  button.textContent = label;
1153
1177
  }
1154
- }
1155
- }
1156
-
1157
- function filenameFromDisposition(header) {
1158
- const match = /filename="([^"]+)"/.exec(header || "");
1159
- return match ? match[1] : "";
1160
- }
1161
-
1162
- function isCurveModalOpen() {
1163
- return !curveModalBackdrop.classList.contains("hidden");
1164
- }
1178
+ }
1179
+ }
1180
+
1181
+ async function exportCurrentFrameInBrowser(format) {
1182
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(currentTime));
1183
+ const width = Math.max(1, Number(data.canvas && data.canvas.width || 1));
1184
+ const height = Math.max(1, Number(data.canvas && data.canvas.height || 1));
1185
+ const baseName = safeFileName(EDITOR_TITLE);
1186
+ const frameName = baseName + "-t" + Number(currentTime).toFixed(2).replace(".", "-");
1187
+ if (format === "svg") {
1188
+ downloadBlob(new Blob([data.svg], { type: "image/svg+xml;charset=utf-8" }), frameName + ".svg");
1189
+ return;
1190
+ }
1191
+ if (format === "html") {
1192
+ const html = "<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>" + escapeText(EDITOR_TITLE) + "</title></head><body style='margin:0'>" + data.svg + "</body></html>";
1193
+ downloadBlob(new Blob([html], { type: "text/html;charset=utf-8" }), frameName + ".html");
1194
+ return;
1195
+ }
1196
+ const mimeType = format === "jpg" ? "image/jpeg" : "image/png";
1197
+ const blob = await rasterizeSvgBlob(data.svg, width, height, mimeType, format === "jpg" ? 0.92 : undefined);
1198
+ downloadBlob(blob, frameName + "." + format);
1199
+ }
1200
+
1201
+ async function exportViaServer(format) {
1202
+ const response = await fetch(apiPath("/export") + "?format=" + encodeURIComponent(format) + "&time=" + encodeURIComponent(currentTime), { cache: "no-store" });
1203
+ if (!response.ok) {
1204
+ let message = "Export failed.";
1205
+ try {
1206
+ const data = await response.json();
1207
+ message = data.error || message;
1208
+ } catch {}
1209
+ throw new Error(message);
1210
+ }
1211
+ const blob = await response.blob();
1212
+ downloadBlob(blob, filenameFromDisposition(response.headers.get("content-disposition")) || ("sketchmark." + format));
1213
+ }
1214
+
1215
+ async function exportMp4InBrowser(button) {
1216
+ const VideoEncoderCtor = window.VideoEncoder;
1217
+ const VideoFrameCtor = window.VideoFrame;
1218
+ if (!VideoEncoderCtor || !VideoFrameCtor) {
1219
+ throw new Error("Browser MP4 export requires WebCodecs. Try Chrome or Edge.");
1220
+ }
1221
+ if (!MP4_MUXER_URL && !MP4_MUXER_SOURCE) {
1222
+ throw new Error("Browser MP4 export is not configured for this editor.");
1223
+ }
1224
+ const duration = Number(doc && doc.canvas && doc.canvas.duration || 0);
1225
+ if (!Number.isFinite(duration) || duration <= 0) {
1226
+ throw new Error("MP4 export requires a positive canvas.duration.");
1227
+ }
1228
+ const fps = Math.max(1, Math.round(Number(doc && doc.canvas && doc.canvas.fps || 30) || 30));
1229
+ const sourceWidth = Math.max(1, Number(doc && doc.canvas && doc.canvas.width || 1));
1230
+ const sourceHeight = Math.max(1, Number(doc && doc.canvas && doc.canvas.height || 1));
1231
+ const width = evenDimension(sourceWidth);
1232
+ const height = evenDimension(sourceHeight);
1233
+ const totalFrames = Math.max(1, Math.ceil(duration * fps));
1234
+ const muxerModule = await importMp4Muxer();
1235
+ const target = new muxerModule.ArrayBufferTarget();
1236
+ const muxer = new muxerModule.Muxer({
1237
+ target,
1238
+ video: { codec: "avc", width, height },
1239
+ fastStart: "in-memory"
1240
+ });
1241
+ let encoderError = null;
1242
+ const encoder = new VideoEncoderCtor({
1243
+ output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
1244
+ error: (error) => { encoderError = error; }
1245
+ });
1246
+ encoder.configure({
1247
+ codec: "avc1.640028",
1248
+ width,
1249
+ height,
1250
+ bitrate: 5000000,
1251
+ framerate: fps
1252
+ });
1253
+ const canvas = document.createElement("canvas");
1254
+ canvas.width = width;
1255
+ canvas.height = height;
1256
+ try {
1257
+ for (let frameIndex = 0; frameIndex < totalFrames; frameIndex += 1) {
1258
+ const time = Math.min(duration, frameIndex / fps);
1259
+ const data = await api(apiPath("/frame") + "?time=" + encodeURIComponent(time));
1260
+ await drawSvgToCanvas(data.svg, canvas, width, height);
1261
+ const frame = new VideoFrameCtor(canvas, {
1262
+ timestamp: Math.round((frameIndex / fps) * 1000000),
1263
+ duration: Math.round((1 / fps) * 1000000)
1264
+ });
1265
+ encoder.encode(frame, { keyFrame: frameIndex % Math.max(1, fps * 2) === 0 });
1266
+ frame.close();
1267
+ if (encoderError) throw encoderError;
1268
+ if (frameIndex % 5 === 0 || frameIndex === totalFrames - 1) {
1269
+ const progress = Math.round(((frameIndex + 1) / totalFrames) * 100);
1270
+ if (button) button.textContent = "Exporting " + progress + "%";
1271
+ await yieldToBrowser();
1272
+ }
1273
+ }
1274
+ await encoder.flush();
1275
+ if (encoderError) throw encoderError;
1276
+ encoder.close();
1277
+ muxer.finalize();
1278
+ downloadBlob(new Blob([target.buffer], { type: "video/mp4" }), safeFileName(EDITOR_TITLE) + ".mp4");
1279
+ } catch (error) {
1280
+ try { encoder.close(); } catch {}
1281
+ throw error;
1282
+ }
1283
+ }
1284
+
1285
+ async function importMp4Muxer() {
1286
+ if (MP4_MUXER_URL) return import(MP4_MUXER_URL);
1287
+ if (MP4_MUXER_SOURCE) {
1288
+ if (!mp4MuxerObjectUrl) {
1289
+ mp4MuxerObjectUrl = URL.createObjectURL(new Blob([MP4_MUXER_SOURCE], { type: "text/javascript" }));
1290
+ }
1291
+ return import(mp4MuxerObjectUrl);
1292
+ }
1293
+ throw new Error("Browser MP4 export is not available in this editor build.");
1294
+ }
1295
+
1296
+ function filenameFromDisposition(header) {
1297
+ const match = /filename="([^"]+)"/.exec(header || "");
1298
+ return match ? match[1] : "";
1299
+ }
1300
+
1301
+ function rasterizeSvgBlob(svg, width, height, mimeType, quality) {
1302
+ const canvas = document.createElement("canvas");
1303
+ canvas.width = width;
1304
+ canvas.height = height;
1305
+ return drawSvgToCanvas(svg, canvas, width, height).then(() => canvasToBlob(canvas, mimeType || "image/png", quality));
1306
+ }
1307
+
1308
+ function drawSvgToCanvas(svg, canvas, width, height) {
1309
+ return new Promise((resolve, reject) => {
1310
+ const context = canvas.getContext("2d");
1311
+ if (!context) {
1312
+ reject(new Error("Could not create canvas context."));
1313
+ return;
1314
+ }
1315
+ const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml;charset=utf-8" }));
1316
+ const image = new Image();
1317
+ image.onload = () => {
1318
+ URL.revokeObjectURL(url);
1319
+ context.clearRect(0, 0, width, height);
1320
+ context.drawImage(image, 0, 0, width, height);
1321
+ resolve();
1322
+ };
1323
+ image.onerror = () => {
1324
+ URL.revokeObjectURL(url);
1325
+ reject(new Error("Could not rasterize current SVG frame."));
1326
+ };
1327
+ image.src = url;
1328
+ });
1329
+ }
1330
+
1331
+ function canvasToBlob(canvas, mimeType, quality) {
1332
+ return new Promise((resolve, reject) => {
1333
+ canvas.toBlob((blob) => {
1334
+ if (blob) resolve(blob);
1335
+ else reject(new Error("Could not export the canvas frame. Cross-origin image assets can block browser raster export."));
1336
+ }, mimeType, quality);
1337
+ });
1338
+ }
1339
+
1340
+ function downloadBlob(blob, filename) {
1341
+ const link = document.createElement("a");
1342
+ link.href = URL.createObjectURL(blob);
1343
+ link.download = filename;
1344
+ document.body.appendChild(link);
1345
+ link.click();
1346
+ link.remove();
1347
+ setTimeout(() => URL.revokeObjectURL(link.href), 1000);
1348
+ }
1349
+
1350
+ function evenDimension(value) {
1351
+ const rounded = Math.max(2, Math.round(Number(value) || 2));
1352
+ return rounded % 2 === 0 ? rounded : rounded + 1;
1353
+ }
1354
+
1355
+ function safeFileName(value) {
1356
+ return String(value || "sketchmark").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "sketchmark";
1357
+ }
1358
+
1359
+ function yieldToBrowser() {
1360
+ return new Promise((resolve) => window.setTimeout(resolve, 0));
1361
+ }
1362
+
1363
+ function isCurveModalOpen() {
1364
+ return !curveModalBackdrop.classList.contains("hidden");
1365
+ }
1165
1366
 
1166
1367
  function openCurveModal() {
1167
1368
  refreshCurveModal();
@@ -1504,7 +1705,7 @@ async function applySegmentPreset(property, segmentIndex, preset) {
1504
1705
  if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
1505
1706
  const start = frames[segmentIndex];
1506
1707
  await mutate(
1507
- "/api/keyframe",
1708
+ apiPath("/keyframe"),
1508
1709
  { id: selectedId, property, value: start.value, time: start.time, curvePreset: preset },
1509
1710
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1510
1711
  );
@@ -1519,7 +1720,7 @@ async function applySegmentCurve(property, segmentIndex, curve) {
1519
1720
  if (segmentIndex < 0 || segmentIndex >= frames.length - 1) return;
1520
1721
  const start = frames[segmentIndex];
1521
1722
  await mutate(
1522
- "/api/keyframe",
1723
+ apiPath("/keyframe"),
1523
1724
  { id: selectedId, property, value: start.value, time: start.time, curve },
1524
1725
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1525
1726
  );
@@ -1662,7 +1863,7 @@ function scheduleSidebarKeyframe(property, valueReader) {
1662
1863
  if (value === null || value === undefined) return;
1663
1864
  try {
1664
1865
  await mutate(
1665
- "/api/keyframe",
1866
+ apiPath("/keyframe"),
1666
1867
  { id: selectedId, property, value, time: currentTime, curvePreset: "linear" },
1667
1868
  { refreshTree: false, refreshInspector: false, refreshTimeline: true }
1668
1869
  );
@@ -1687,7 +1888,7 @@ function readTextInput(id) {
1687
1888
  async function removeKeyframe(property, time) {
1688
1889
  try {
1689
1890
  if (!ensureElementEditable(selectedId)) return;
1690
- await mutate("/api/remove-keyframe", { id: selectedId, property, time });
1891
+ await mutate(apiPath("/remove-keyframe"), { id: selectedId, property, time });
1691
1892
  } catch (error) {
1692
1893
  showError(error);
1693
1894
  }
@@ -1900,7 +2101,7 @@ async function commitDrag(snapshot) {
1900
2101
 
1901
2102
  async function commitEditedProperty(element, property, value) {
1902
2103
  if (!ensureElementEditable(element.id)) return;
1903
- await mutate("/api/keyframe", { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
2104
+ await mutate(apiPath("/keyframe"), { id: element.id, property, value, time: currentTime, curvePreset: "linear" });
1904
2105
  }
1905
2106
 
1906
2107
  function ensureElementEditable(id) {
@@ -2278,8 +2479,52 @@ load().catch(showError);
2278
2479
  }
2279
2480
 
2280
2481
 
2281
- function escapeHtml(value) {
2282
- return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2283
- }
2284
-
2285
- module.exports = { editorHtml };
2482
+ function escapeHtml(value) {
2483
+ return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
2484
+ }
2485
+
2486
+ function normalizeApiBase(value) {
2487
+ const text = String(value || "/api").replace(/\/+$/, "");
2488
+ return text.startsWith("/") ? text : `/${text}`;
2489
+ }
2490
+
2491
+ function editorMp4MuxerSource(value) {
2492
+ if (value === false) return "";
2493
+ if (typeof value === "string") return value;
2494
+ for (const candidate of mp4MuxerSourceCandidates()) {
2495
+ try {
2496
+ if (candidate && fs.existsSync(candidate)) return fs.readFileSync(candidate, "utf8");
2497
+ } catch {
2498
+ // Try the next candidate.
2499
+ }
2500
+ }
2501
+ return "";
2502
+ }
2503
+
2504
+ function resolveMp4MuxerSource(value) {
2505
+ return editorMp4MuxerSource(value);
2506
+ }
2507
+
2508
+ function mp4MuxerSourceCandidates() {
2509
+ const candidates = [path.join(__dirname, "vendor", "mp4-muxer.mjs")];
2510
+ try {
2511
+ const resolved = require.resolve("mp4-muxer");
2512
+ candidates.push(resolved.replace(/\.js$/, ".mjs"));
2513
+ candidates.push(path.join(path.dirname(resolved), "mp4-muxer.mjs"));
2514
+ } catch {
2515
+ // Dependency may be bundled differently by the host app.
2516
+ }
2517
+ candidates.push(path.join(process.cwd(), "node_modules", "mp4-muxer", "build", "mp4-muxer.mjs"));
2518
+ return candidates;
2519
+ }
2520
+
2521
+ function scriptJson(value) {
2522
+ return JSON.stringify(value)
2523
+ .replace(/</g, "\\u003c")
2524
+ .replace(/>/g, "\\u003e")
2525
+ .replace(/&/g, "\\u0026")
2526
+ .replace(/\u2028/g, "\\u2028")
2527
+ .replace(/\u2029/g, "\\u2029");
2528
+ }
2529
+
2530
+ module.exports = { editorHtml, editorMp4MuxerSource };
@@ -0,0 +1,11 @@
1
+ export function editorHtml(
2
+ title: string,
3
+ options?: {
4
+ apiBase?: string;
5
+ mp4MuxerUrl?: string;
6
+ mp4MuxerSource?: string | false;
7
+ serverExportFallback?: boolean;
8
+ }
9
+ ): string;
10
+
11
+ export function editorMp4MuxerSource(value?: string | false): string;