sketchmark 2.1.2 → 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.
- package/bin/editor-ui.cjs +368 -123
- package/bin/editor-ui.d.ts +11 -0
- package/bin/vendor/mp4-muxer.LICENSE.txt +21 -0
- package/bin/vendor/mp4-muxer.mjs +1879 -0
- package/dist/src/browser-export.d.ts +10 -0
- package/dist/src/browser-export.js +220 -0
- package/package.json +59 -46
package/bin/editor-ui.cjs
CHANGED
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
70
|
-
.
|
|
71
|
-
.
|
|
72
|
-
.
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("/
|
|
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("/
|
|
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
|
-
"/
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1039
|
-
document.getElementById("play").onclick = togglePlay;
|
|
1040
|
-
|
|
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
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
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
|
|
1158
|
-
const
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
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
|
-
"/
|
|
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
|
-
"/
|
|
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
|
-
"/
|
|
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("/
|
|
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("/
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
2283
|
-
}
|
|
2284
|
-
|
|
2285
|
-
|
|
2482
|
+
function escapeHtml(value) {
|
|
2483
|
+
return String(value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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;
|