flowsvg 0.1.0 → 0.2.0
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/CHANGELOG.md +54 -1
- package/PATTERNS.md +71 -4
- package/README.md +148 -438
- package/README.zh-CN.md +148 -438
- package/dist/adapters/react.d.ts +55 -0
- package/dist/adapters/react.js +138 -0
- package/dist/adapters/vue.d.ts +158 -0
- package/dist/adapters/vue.js +177 -0
- package/dist/adapters/web-component.d.ts +111 -0
- package/dist/adapters/web-component.js +425 -0
- package/dist/{player-controls.d.ts → browser/controls.d.ts} +7 -3
- package/dist/{player-controls.js → browser/controls.js} +23 -31
- package/dist/browser/export-controls.d.ts +32 -0
- package/dist/browser/export-controls.js +151 -0
- package/dist/browser/player.d.ts +77 -0
- package/dist/browser/player.js +412 -0
- package/dist/browser/select-control.d.ts +33 -0
- package/dist/browser/select-control.js +171 -0
- package/dist/browser.d.ts +5 -69
- package/dist/browser.js +3 -353
- package/dist/core/color.d.ts +3 -0
- package/dist/core/color.js +18 -0
- package/dist/core/diagram.d.ts +40 -0
- package/dist/core/diagram.js +481 -0
- package/dist/core/edge-geometry.d.ts +45 -0
- package/dist/core/edge-geometry.js +609 -0
- package/dist/{core.d.ts → core/model.d.ts} +37 -61
- package/dist/core/model.js +1 -0
- package/dist/core/node-content-measurement.d.ts +34 -0
- package/dist/core/node-content-measurement.js +66 -0
- package/dist/{node-content-paint.d.ts → core/node-content-paint.d.ts} +2 -2
- package/dist/{node-content-paint.js → core/node-content-paint.js} +11 -20
- package/dist/{node-content.d.ts → core/node-content.d.ts} +18 -1
- package/dist/{node-content.js → core/node-content.js} +30 -6
- package/dist/core/svg-number.d.ts +5 -0
- package/dist/core/svg-number.js +7 -0
- package/dist/{theme.d.ts → core/theme.d.ts} +6 -0
- package/dist/core/theme.js +17 -0
- package/dist/{timeline.d.ts → core/timeline.d.ts} +11 -5
- package/dist/{timeline.js → core/timeline.js} +30 -22
- package/dist/core/validation.d.ts +11 -0
- package/dist/core/validation.js +399 -0
- package/dist/export/browser.d.ts +9 -0
- package/dist/export/browser.js +172 -0
- package/dist/export/captions.d.ts +18 -0
- package/dist/export/captions.js +44 -0
- package/dist/export/plan.d.ts +25 -0
- package/dist/export/plan.js +94 -0
- package/dist/export/types.d.ts +64 -0
- package/dist/export/types.js +9 -0
- package/dist/export/worker-source.d.ts +1 -0
- package/dist/export/worker-source.js +1 -0
- package/dist/export.d.ts +3 -0
- package/dist/export.js +2 -0
- package/dist/index.d.ts +7 -1
- package/dist/index.js +3 -1
- package/dist/node/cli.d.ts +2 -0
- package/dist/node/cli.js +72 -0
- package/dist/node/gif.d.ts +16 -0
- package/dist/node/gif.js +184 -0
- package/dist/node/html.d.ts +19 -0
- package/dist/node/html.js +61 -0
- package/dist/node/output.d.ts +2 -0
- package/dist/node/output.js +22 -0
- package/dist/node.d.ts +6 -0
- package/dist/node.js +3 -0
- package/dist/portable/LICENSE.esbuild.txt +21 -0
- package/dist/portable/LICENSE.flowsvg.txt +21 -0
- package/dist/portable/LICENSE.gifenc.txt +21 -0
- package/dist/portable/export.js +5 -0
- package/dist/portable/manifest.json +36 -0
- package/dist/portable/player.js +2 -0
- package/dist/portable/styles.css +227 -0
- package/dist/react.d.ts +3 -48
- package/dist/react.js +2 -126
- package/dist/styles.css +194 -14
- package/dist/vue.d.ts +3 -132
- package/dist/vue.js +2 -161
- package/dist/web-component.d.ts +3 -103
- package/dist/web-component.js +2 -396
- package/docs/images/entity-graph.png +0 -0
- package/docs/images/facts-timeline.png +0 -0
- package/docs/images/facts-timeline.zh-CN.png +0 -0
- package/docs/images/flowsvg-wordmark.gif +0 -0
- package/docs/images/node-content.png +0 -0
- package/docs/images/numbered-list.png +0 -0
- package/docs/images/numbered-list.zh-CN.png +0 -0
- package/docs/images/quick-start.png +0 -0
- package/docs/images/record-tags.png +0 -0
- package/package.json +38 -6
- package/dist/core.js +0 -1149
- package/dist/theme.js +0 -6
- /package/dist/{player-accessibility.d.ts → browser/accessibility.d.ts} +0 -0
- /package/dist/{player-accessibility.js → browser/accessibility.js} +0 -0
- /package/dist/{player-lifecycle.d.ts → browser/lifecycle.d.ts} +0 -0
- /package/dist/{player-lifecycle.js → browser/lifecycle.js} +0 -0
- /package/dist/{svg-dom.d.ts → browser/svg-dom.d.ts} +0 -0
- /package/dist/{svg-dom.js → browser/svg-dom.js} +0 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** Browser-only export owns its canvas, image URLs and inline encoder worker; core rendering stays DOM-free. */
|
|
2
|
+
import { createDiagram } from "../core/diagram.js";
|
|
3
|
+
import { resolveThemePalette } from "../core/theme.js";
|
|
4
|
+
import { CAPTION_FONT, CAPTION_LINE_HEIGHT, CAPTION_PADDING_X, CAPTION_PADDING_Y, collectCaptions, preflightCaptions, wrapCaption } from "./captions.js";
|
|
5
|
+
import { planGif, validateGifOptions } from "./plan.js";
|
|
6
|
+
import { GIF_WORKER_SOURCE } from "./worker-source.js";
|
|
7
|
+
import { GifExportError } from "./types.js";
|
|
8
|
+
/**
|
|
9
|
+
* Return opaque GIF bytes without downloading or changing playback. Snapshot inputs synchronously
|
|
10
|
+
* and release task resources on success, failure or cancellation. Captions are measured before sampling.
|
|
11
|
+
* @throws {GifExportError} With a distinct code for invalid options/document, limits, caption overflow,
|
|
12
|
+
* unavailable browser services, rasterization, encoding or cancellation. See GifExportOptions for limits.
|
|
13
|
+
*/
|
|
14
|
+
export async function exportGif(input, options = {}) {
|
|
15
|
+
validateGifOptions(options);
|
|
16
|
+
const captured = { ...options };
|
|
17
|
+
checkAbort(captured.signal);
|
|
18
|
+
try {
|
|
19
|
+
createDiagram(input);
|
|
20
|
+
}
|
|
21
|
+
catch (cause) {
|
|
22
|
+
throw new GifExportError("document", "Invalid diagram document; correct the validation issues before exporting.", { cause });
|
|
23
|
+
}
|
|
24
|
+
const document = JSON.parse(JSON.stringify(input));
|
|
25
|
+
// GIF frames share the authored camera so changing live canvas height cannot stretch diagram pixels.
|
|
26
|
+
// Validate the original option above before omitting it from this private export snapshot.
|
|
27
|
+
const { fitHeight: _fitHeight, ...viewport } = document.viewport;
|
|
28
|
+
const diagram = createDiagram({ ...document, viewport });
|
|
29
|
+
const plan = planGif(document, diagram.totalDuration, captured);
|
|
30
|
+
if (typeof globalThis.document === "undefined" || typeof Worker !== "function")
|
|
31
|
+
throw new GifExportError("environment", "GIF export requires a modern browser with canvas and workers; use flowsvg/node in Node.");
|
|
32
|
+
const report = (phase, completed) => { checkAbort(captured.signal); captured.onProgress?.({ phase, completed, total: plan.frames.length }); checkAbort(captured.signal); };
|
|
33
|
+
report("preparing", 0);
|
|
34
|
+
const canvas = globalThis.document.createElement("canvas");
|
|
35
|
+
canvas.width = plan.width;
|
|
36
|
+
canvas.height = plan.height;
|
|
37
|
+
let worker;
|
|
38
|
+
let workerUrl;
|
|
39
|
+
const workerFailure = new AbortController();
|
|
40
|
+
const failWorker = (event) => workerFailure.abort(new GifExportError("encoding", `Worker failed: ${event.message || "startup or execution was blocked"}. Check Blob worker permissions and retry.`));
|
|
41
|
+
try {
|
|
42
|
+
const context = canvas.getContext("2d", { willReadFrequently: true });
|
|
43
|
+
if (!context)
|
|
44
|
+
throw new GifExportError("environment", "Canvas 2D is unavailable; enable browser canvas support.");
|
|
45
|
+
const captions = captured.includeCaptions === false ? [] : collectCaptions(diagram, plan.startMs, plan.endMs);
|
|
46
|
+
await waitForFonts([document.title ?? "中文", ...captions.map(({ text }) => text)].join("\n"), captured.signal);
|
|
47
|
+
preflightCaptions(context, captions, plan);
|
|
48
|
+
checkAbort(captured.signal);
|
|
49
|
+
workerUrl = URL.createObjectURL(new Blob([GIF_WORKER_SOURCE], { type: "text/javascript" }));
|
|
50
|
+
worker = createWorker(workerUrl);
|
|
51
|
+
// Startup errors can arrive while image decoding is pending, before the first frame request.
|
|
52
|
+
worker.addEventListener("error", failWorker);
|
|
53
|
+
for (const [index, frame] of plan.frames.entries()) {
|
|
54
|
+
report("sampling", index);
|
|
55
|
+
const caption = diagram.evaluateAt(frame.timeMs, plan).caption ?? "";
|
|
56
|
+
await paintFrame(context, canvas, diagram.renderSvg(frame.timeMs, plan), caption, plan, captured.signal, workerFailure.signal);
|
|
57
|
+
const pixels = readPixels(context, plan);
|
|
58
|
+
report("encoding", index);
|
|
59
|
+
await requestWorker(worker, { type: "frame", rgba: pixels.data.buffer, width: plan.width, height: plan.height, delayMs: frame.delayMs, repeat: plan.loopCount === 1 ? -1 : plan.loopCount === 0 ? 0 : plan.loopCount - 1 }, [pixels.data.buffer], captured.signal, workerFailure.signal);
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
61
|
+
}
|
|
62
|
+
const result = await requestWorker(worker, { type: "finish" }, [], captured.signal, workerFailure.signal);
|
|
63
|
+
if (!result.bytes)
|
|
64
|
+
throw new GifExportError("encoding", "Encoder returned no bytes; retry with a smaller export.");
|
|
65
|
+
report("complete", plan.frames.length);
|
|
66
|
+
return result.bytes;
|
|
67
|
+
}
|
|
68
|
+
finally {
|
|
69
|
+
worker?.removeEventListener("error", failWorker);
|
|
70
|
+
worker?.terminate();
|
|
71
|
+
if (workerUrl)
|
|
72
|
+
URL.revokeObjectURL(workerUrl);
|
|
73
|
+
canvas.width = 0;
|
|
74
|
+
canvas.height = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function checkAbort(signal, workerFailure) {
|
|
78
|
+
if (signal?.aborted)
|
|
79
|
+
throw new GifExportError("cancelled", "GIF export cancelled; start another export when ready.");
|
|
80
|
+
if (workerFailure?.aborted)
|
|
81
|
+
throw workerFailure.reason;
|
|
82
|
+
}
|
|
83
|
+
function requestWorker(worker, data, transfer, signal, workerFailure) {
|
|
84
|
+
checkAbort(signal, workerFailure);
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
const cleanup = () => { worker.removeEventListener("message", message); workerFailure.removeEventListener("abort", failure); signal?.removeEventListener("abort", abort); };
|
|
87
|
+
const abort = () => { cleanup(); reject(new GifExportError("cancelled", "GIF export cancelled; start another export when ready.")); };
|
|
88
|
+
const failure = () => { cleanup(); reject(workerFailure.reason); };
|
|
89
|
+
const message = (event) => { cleanup(); if (event.data.error)
|
|
90
|
+
reject(new GifExportError("encoding", `${event.data.error}; retry with a smaller export.`));
|
|
91
|
+
else
|
|
92
|
+
resolve(event.data); };
|
|
93
|
+
worker.addEventListener("message", message);
|
|
94
|
+
workerFailure.addEventListener("abort", failure, { once: true });
|
|
95
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
96
|
+
try {
|
|
97
|
+
worker.postMessage(data, transfer);
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
cleanup();
|
|
101
|
+
reject(new GifExportError("encoding", "Cannot send pixels to the encoder; retry the export.", { cause }));
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
async function paintFrame(context, canvas, svg, caption, plan, signal, workerFailure) {
|
|
106
|
+
checkAbort(signal, workerFailure);
|
|
107
|
+
const url = URL.createObjectURL(new Blob([svg], { type: "image/svg+xml" }));
|
|
108
|
+
const image = new Image();
|
|
109
|
+
try {
|
|
110
|
+
image.src = url;
|
|
111
|
+
await withAbort(image.decode(), signal, workerFailure);
|
|
112
|
+
checkAbort(signal, workerFailure);
|
|
113
|
+
const palette = resolveThemePalette(plan.theme);
|
|
114
|
+
context.fillStyle = palette.background;
|
|
115
|
+
context.fillRect(0, 0, canvas.width, canvas.height);
|
|
116
|
+
context.drawImage(image, 0, 0, plan.width, plan.diagramHeight);
|
|
117
|
+
if (!plan.captionHeight)
|
|
118
|
+
return;
|
|
119
|
+
context.font = CAPTION_FONT;
|
|
120
|
+
context.fillStyle = palette.text;
|
|
121
|
+
context.textBaseline = "top";
|
|
122
|
+
wrapCaption(context, caption, plan.width - CAPTION_PADDING_X * 2).forEach((line, index) => context.fillText(line, CAPTION_PADDING_X, plan.diagramHeight + CAPTION_PADDING_Y + index * CAPTION_LINE_HEIGHT));
|
|
123
|
+
}
|
|
124
|
+
catch (cause) {
|
|
125
|
+
if (cause instanceof GifExportError)
|
|
126
|
+
throw cause;
|
|
127
|
+
throw new GifExportError("rasterization", "Cannot rasterize the diagram; check browser SVG/canvas support and retry.", { cause });
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
image.src = "";
|
|
131
|
+
URL.revokeObjectURL(url);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Race browser services with cancellation and worker failure; detach both listeners on settlement. */
|
|
135
|
+
function withAbort(promise, signal, workerFailure) {
|
|
136
|
+
checkAbort(signal, workerFailure);
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
const cleanup = () => { signal?.removeEventListener("abort", abort); workerFailure?.removeEventListener("abort", failure); };
|
|
139
|
+
const abort = () => { cleanup(); reject(new GifExportError("cancelled", "GIF export cancelled; start another export when ready.")); };
|
|
140
|
+
const failure = () => { cleanup(); reject(workerFailure?.reason); };
|
|
141
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
142
|
+
workerFailure?.addEventListener("abort", failure, { once: true });
|
|
143
|
+
Promise.resolve(promise).then((value) => { cleanup(); resolve(value); }, (error) => { cleanup(); reject(error); });
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
async function waitForFonts(text, signal) {
|
|
147
|
+
try {
|
|
148
|
+
await withAbort(globalThis.document.fonts.load(CAPTION_FONT, text), signal);
|
|
149
|
+
await withAbort(globalThis.document.fonts.ready, signal);
|
|
150
|
+
}
|
|
151
|
+
catch (cause) {
|
|
152
|
+
if (cause instanceof GifExportError)
|
|
153
|
+
throw cause;
|
|
154
|
+
throw new GifExportError("environment", "Cannot prepare system fonts; check browser font support and retry.", { cause });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function createWorker(url) {
|
|
158
|
+
try {
|
|
159
|
+
return new Worker(url);
|
|
160
|
+
}
|
|
161
|
+
catch (cause) {
|
|
162
|
+
throw new GifExportError("environment", "Cannot start the GIF worker; allow Blob workers in the browser content security policy.", { cause });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function readPixels(context, plan) {
|
|
166
|
+
try {
|
|
167
|
+
return context.getImageData(0, 0, plan.width, plan.height);
|
|
168
|
+
}
|
|
169
|
+
catch (cause) {
|
|
170
|
+
throw new GifExportError("rasterization", "Cannot read rendered pixels; check browser canvas permissions and retry.", { cause });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** Browser caption layout shares system font fallback with diagram SVG and preserves explicit newlines. */
|
|
2
|
+
import type { Diagram } from "../core/diagram.js";
|
|
3
|
+
import type { GifPlan } from "./plan.js";
|
|
4
|
+
export declare const CAPTION_FONT = "16px system-ui, -apple-system, \"PingFang SC\", \"Microsoft YaHei\", \"Noto Sans CJK SC\", sans-serif";
|
|
5
|
+
export declare const CAPTION_LINE_HEIGHT = 22;
|
|
6
|
+
export declare const CAPTION_PADDING_X = 16;
|
|
7
|
+
export declare const CAPTION_PADDING_Y = 12;
|
|
8
|
+
/** Measure code-point wrapping at the available text width; callers must first set CAPTION_FONT. */
|
|
9
|
+
export declare function wrapCaption(context: CanvasRenderingContext2D, text: string, width: number): string[];
|
|
10
|
+
/** Effective plain text and the source location used in actionable overflow errors. */
|
|
11
|
+
export interface CaptionInterval {
|
|
12
|
+
text: string;
|
|
13
|
+
location: string;
|
|
14
|
+
}
|
|
15
|
+
/** Enumerate compiled half-open intervals rather than samples; core already resolves caption inheritance. */
|
|
16
|
+
export declare function collectCaptions(diagram: Diagram, startMs: number, endMs: number): CaptionInterval[];
|
|
17
|
+
/** Measure every effective caption with the same fixed font, wrapping and spacing used by painting. */
|
|
18
|
+
export declare function preflightCaptions(context: CanvasRenderingContext2D, captions: CaptionInterval[], plan: GifPlan): void;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { GifExportError } from "./types.js";
|
|
2
|
+
import { DIAGRAM_FONT_FAMILY } from "../core/theme.js";
|
|
3
|
+
export const CAPTION_FONT = `16px ${DIAGRAM_FONT_FAMILY}`;
|
|
4
|
+
export const CAPTION_LINE_HEIGHT = 22;
|
|
5
|
+
export const CAPTION_PADDING_X = 16;
|
|
6
|
+
export const CAPTION_PADDING_Y = 12;
|
|
7
|
+
/** Measure code-point wrapping at the available text width; callers must first set CAPTION_FONT. */
|
|
8
|
+
export function wrapCaption(context, text, width) {
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const paragraph of text.split("\n")) {
|
|
11
|
+
let line = "";
|
|
12
|
+
for (const character of paragraph) {
|
|
13
|
+
if (line && context.measureText(line + character).width > width) {
|
|
14
|
+
lines.push(line);
|
|
15
|
+
line = "";
|
|
16
|
+
}
|
|
17
|
+
line += character;
|
|
18
|
+
}
|
|
19
|
+
lines.push(line);
|
|
20
|
+
}
|
|
21
|
+
return lines;
|
|
22
|
+
}
|
|
23
|
+
/** Enumerate compiled half-open intervals rather than samples; core already resolves caption inheritance. */
|
|
24
|
+
export function collectCaptions(diagram, startMs, endMs) {
|
|
25
|
+
if (diagram.beats.length === 0)
|
|
26
|
+
return [{ text: diagram.evaluateAt(0).caption ?? "", location: "static document title" }];
|
|
27
|
+
return diagram.beats.filter((beat) => beat.startMs < endMs && beat.endMs > startMs).map((beat) => ({
|
|
28
|
+
text: beat.caption ?? "", location: `scene "${beat.sceneId}", beat ${beat.beatIndex + 1}`
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
/** Measure every effective caption with the same fixed font, wrapping and spacing used by painting. */
|
|
32
|
+
export function preflightCaptions(context, captions, plan) {
|
|
33
|
+
context.font = CAPTION_FONT;
|
|
34
|
+
const width = plan.width - CAPTION_PADDING_X * 2;
|
|
35
|
+
const height = plan.captionHeight - CAPTION_PADDING_Y * 2;
|
|
36
|
+
for (const { text, location } of captions) {
|
|
37
|
+
if (text === "")
|
|
38
|
+
continue;
|
|
39
|
+
const lines = wrapCaption(context, text, width);
|
|
40
|
+
if (lines.length * CAPTION_LINE_HEIGHT > height || lines.some((line) => context.measureText(line).width > width)) {
|
|
41
|
+
throw new GifExportError("caption-overflow", `Caption overflows at ${location}; increase captionHeight or width, shorten the text, or disable captions.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Pure export planning: reject computable limits before any browser resource is allocated. */
|
|
2
|
+
import type { DiagramDocument } from "../core/model.js";
|
|
3
|
+
import { type GifExportOptions } from "./types.js";
|
|
4
|
+
/** Source-time evaluation and its output delay, both in milliseconds. */
|
|
5
|
+
export interface GifFrame {
|
|
6
|
+
timeMs: number;
|
|
7
|
+
delayMs: number;
|
|
8
|
+
}
|
|
9
|
+
/** Fixed integer pixel dimensions and a bounded sampling schedule shared by browser and Node. */
|
|
10
|
+
export interface GifPlan {
|
|
11
|
+
width: number;
|
|
12
|
+
diagramHeight: number;
|
|
13
|
+
height: number;
|
|
14
|
+
captionHeight: number;
|
|
15
|
+
theme: "light" | "dark";
|
|
16
|
+
frames: GifFrame[];
|
|
17
|
+
startMs: number;
|
|
18
|
+
endMs: number;
|
|
19
|
+
reducedMotion: boolean;
|
|
20
|
+
loopCount: number;
|
|
21
|
+
}
|
|
22
|
+
/** Validate supplied fields before defaults or cancellation checks can hide malformed input. */
|
|
23
|
+
export declare function validateGifOptions(options: GifExportOptions): void;
|
|
24
|
+
/** Defaults export the complete timeline at 15 fps, capped at 1280px without upscaling, with a 1s final hold. */
|
|
25
|
+
export declare function planGif(document: DiagramDocument, duration: number, options: GifExportOptions): GifPlan;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { GifExportError } from "./types.js";
|
|
2
|
+
const MAX_DURATION_MS = 120_000;
|
|
3
|
+
const MAX_FRAMES = 1800;
|
|
4
|
+
const MAX_ENCODED_FRAMES = 1801;
|
|
5
|
+
const MAX_FRAME_DELAY_TICKS = 65_535;
|
|
6
|
+
const MAX_SIDE = 1920;
|
|
7
|
+
const DEFAULT_MAX_WIDTH = 1280;
|
|
8
|
+
const DEFAULT_FPS = 15;
|
|
9
|
+
const DEFAULT_CAPTION_HEIGHT = 80;
|
|
10
|
+
const DEFAULT_HOLD_MS = 1000;
|
|
11
|
+
const MIN_FPS = 1;
|
|
12
|
+
const MAX_FPS = 30;
|
|
13
|
+
const MAX_TOTAL_PLAYS = 65_536;
|
|
14
|
+
const GIF_TIME_UNIT_MS = 10;
|
|
15
|
+
const SECOND_MS = 1000;
|
|
16
|
+
/** Validate supplied fields before defaults or cancellation checks can hide malformed input. */
|
|
17
|
+
export function validateGifOptions(options) {
|
|
18
|
+
if (options === null || typeof options !== "object" || Array.isArray(options)) {
|
|
19
|
+
throw new GifExportError("options", "Provide GIF options as an object.");
|
|
20
|
+
}
|
|
21
|
+
for (const name of ["width", "fps", "captionHeight", "startMs", "endMs", "speed", "finalHoldMs", "loopCount"]) {
|
|
22
|
+
const value = options[name];
|
|
23
|
+
if (value !== undefined && !Number.isFinite(value))
|
|
24
|
+
throw new GifExportError("options", `${name} must be a finite number.`);
|
|
25
|
+
}
|
|
26
|
+
for (const name of ["includeCaptions", "reducedMotion"]) {
|
|
27
|
+
if (options[name] !== undefined && typeof options[name] !== "boolean")
|
|
28
|
+
throw new GifExportError("options", `${name} must be a boolean.`);
|
|
29
|
+
}
|
|
30
|
+
if (options.theme !== undefined && options.theme !== "light" && options.theme !== "dark")
|
|
31
|
+
throw new GifExportError("options", "theme must be light or dark.");
|
|
32
|
+
if (options.captionHeight !== undefined && options.captionHeight < 0)
|
|
33
|
+
throw new GifExportError("options", "captionHeight must be nonnegative.");
|
|
34
|
+
if (options.onProgress !== undefined && typeof options.onProgress !== "function")
|
|
35
|
+
throw new GifExportError("options", "onProgress must be a function.");
|
|
36
|
+
if (options.signal !== undefined && !(options.signal instanceof AbortSignal))
|
|
37
|
+
throw new GifExportError("options", "signal must be an AbortSignal from an AbortController.");
|
|
38
|
+
}
|
|
39
|
+
/** Defaults export the complete timeline at 15 fps, capped at 1280px without upscaling, with a 1s final hold. */
|
|
40
|
+
export function planGif(document, duration, options) {
|
|
41
|
+
validateGifOptions(options);
|
|
42
|
+
const width = options.width ?? Math.min(DEFAULT_MAX_WIDTH, document.viewport.width);
|
|
43
|
+
const fps = options.fps ?? DEFAULT_FPS;
|
|
44
|
+
const captionHeight = options.includeCaptions === false ? 0 : options.captionHeight ?? DEFAULT_CAPTION_HEIGHT;
|
|
45
|
+
const startMs = options.startMs ?? 0;
|
|
46
|
+
const endMs = options.endMs ?? duration;
|
|
47
|
+
const speed = options.speed ?? 1;
|
|
48
|
+
const hold = options.finalHoldMs ?? DEFAULT_HOLD_MS;
|
|
49
|
+
const loopCount = options.loopCount ?? 0;
|
|
50
|
+
for (const [name, value] of Object.entries({ width, fps, captionHeight, startMs, endMs, speed, finalHoldMs: hold, loopCount })) {
|
|
51
|
+
if (!Number.isFinite(value))
|
|
52
|
+
throw new GifExportError("options", `${name} must be finite; provide a valid number.`);
|
|
53
|
+
}
|
|
54
|
+
if (width <= 0 || speed <= 0 || hold < 0 || captionHeight < 0 || !Number.isInteger(fps) || fps < MIN_FPS || fps > MAX_FPS)
|
|
55
|
+
throw new GifExportError("options", "Use positive width/speed, nonnegative hold/caption height and an integer fps from 1 to 30.");
|
|
56
|
+
if (!Number.isInteger(loopCount) || loopCount < 0 || loopCount > MAX_TOTAL_PLAYS)
|
|
57
|
+
throw new GifExportError("options", "loopCount must be an integer from 0 to 65,536 (maximum total plays).");
|
|
58
|
+
if (!Number.isFinite(duration) || startMs < 0 || endMs > duration || (duration === 0 ? startMs !== 0 || endMs !== 0 : startMs >= endMs))
|
|
59
|
+
throw new GifExportError("options", "Choose a finite, nonempty range within the document timeline; static documents accept only zero.");
|
|
60
|
+
const outputWidth = Math.max(1, Math.round(width));
|
|
61
|
+
const diagramHeight = Math.max(1, Math.round(width * document.viewport.height / document.viewport.width));
|
|
62
|
+
const height = diagramHeight + Math.round(captionHeight);
|
|
63
|
+
const effectiveDuration = (endMs - startMs) / speed;
|
|
64
|
+
const proposedCount = duration === 0 ? 0 : Math.max(1, Math.ceil(effectiveDuration * fps / SECOND_MS));
|
|
65
|
+
// Rounding the division can otherwise add a zero-delay sample at the excluded endpoint.
|
|
66
|
+
const count = proposedCount > 1 && (proposedCount - 1) * SECOND_MS / fps >= effectiveDuration ? proposedCount - 1 : proposedCount;
|
|
67
|
+
if (effectiveDuration > MAX_DURATION_MS || count > MAX_FRAMES || Math.max(outputWidth, height) > MAX_SIDE)
|
|
68
|
+
throw new GifExportError("limit", "Reduce duration, fps or dimensions: limits are 120 seconds, 1800 samples and a 1920px final long edge.");
|
|
69
|
+
const holdTicks = duration === 0 ? Math.max(1, Math.round(hold / GIF_TIME_UNIT_MS)) : Math.round(hold / GIF_TIME_UNIT_MS);
|
|
70
|
+
const hasFinalFrame = duration === 0 || hold > 0 || endMs === duration;
|
|
71
|
+
// Each encoded GCE delay is 16-bit. Count worker splits before allocating or sampling.
|
|
72
|
+
const encodedFrames = count + (hasFinalFrame ? Math.max(1, Math.ceil(holdTicks / MAX_FRAME_DELAY_TICKS)) : 0);
|
|
73
|
+
if (encodedFrames > MAX_ENCODED_FRAMES)
|
|
74
|
+
throw new GifExportError("limit", "GIF requires more than 1801 encoded frames including final-hold splits; reduce finalHoldMs, duration or fps.");
|
|
75
|
+
const endLeftTime = duration === 0 ? 0 : previousTime(endMs);
|
|
76
|
+
const frames = [];
|
|
77
|
+
for (let index = 0; index < count; index++) {
|
|
78
|
+
const from = index * SECOND_MS / fps;
|
|
79
|
+
const until = Math.min(effectiveDuration, (index + 1) * SECOND_MS / fps);
|
|
80
|
+
frames.push({ timeMs: Math.min(endLeftTime, startMs + from * speed), delayMs: (Math.round(until / GIF_TIME_UNIT_MS) - Math.round(from / GIF_TIME_UNIT_MS)) * GIF_TIME_UNIT_MS });
|
|
81
|
+
}
|
|
82
|
+
const finalTime = endMs === duration ? endMs : endLeftTime;
|
|
83
|
+
// Preserve the global completed state even when it has no additional hold delay.
|
|
84
|
+
if (hasFinalFrame)
|
|
85
|
+
frames.push({ timeMs: finalTime, delayMs: holdTicks * GIF_TIME_UNIT_MS });
|
|
86
|
+
return { width: outputWidth, diagramHeight, height, captionHeight: Math.round(captionHeight), theme: options.theme ?? document.theme ?? "light", frames, startMs, endMs, reducedMotion: options.reducedMotion ?? false, loopCount };
|
|
87
|
+
}
|
|
88
|
+
/** Return the immediate predecessor of a positive finite time; epsilon subtraction can skip short beats. */
|
|
89
|
+
function previousTime(timeMs) {
|
|
90
|
+
const bits = new DataView(new ArrayBuffer(8));
|
|
91
|
+
bits.setFloat64(0, timeMs);
|
|
92
|
+
bits.setBigUint64(0, bits.getBigUint64(0) - 1n);
|
|
93
|
+
return bits.getFloat64(0);
|
|
94
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ThemeName } from "../core/model.js";
|
|
2
|
+
/** Stages belong to one independent export; counts represent acknowledged sampling frames. */
|
|
3
|
+
export interface GifExportProgress {
|
|
4
|
+
phase: "preparing" | "sampling" | "encoding" | "complete";
|
|
5
|
+
completed: number;
|
|
6
|
+
total: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Browser export settings, captured at invocation; times are milliseconds.
|
|
10
|
+
* Animation is limited to 120 effective seconds and 1800 samples. The final GIF
|
|
11
|
+
* permits at most 1801 encoded frames, including splits of long final holds.
|
|
12
|
+
* The final long edge, including captions, must not exceed 1920 pixels.
|
|
13
|
+
* Invalid values and computable limits fail before canvas allocation or sampling.
|
|
14
|
+
*/
|
|
15
|
+
export interface GifExportOptions {
|
|
16
|
+
/** Explicit theme; otherwise use the document theme, then light. Never reads system preferences. */
|
|
17
|
+
theme?: ThemeName;
|
|
18
|
+
/** Inclusive source start, default 0. Evaluates the full document to retain earlier scene state. */
|
|
19
|
+
startMs?: number;
|
|
20
|
+
/**
|
|
21
|
+
* Exclusive source end, default total duration. A segment holds the end-left picture;
|
|
22
|
+
* the global end includes its completed state, even with finalHoldMs: 0.
|
|
23
|
+
* Animated ranges require 0 <= startMs < endMs <= total duration; static ranges require both 0.
|
|
24
|
+
*/
|
|
25
|
+
endMs?: number;
|
|
26
|
+
/** Positive finite source-time multiplier, default 1. Output duration is source range / speed. */
|
|
27
|
+
speed?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Positive diagram width in pixels, rounded to an integer. Diagram height preserves the authored viewport ratio.
|
|
30
|
+
* GIF ignores viewport.fitHeight for all frames to keep pixel dimensions and diagram scale stable.
|
|
31
|
+
* Defaults to min(viewport.width, 1280), without upscaling; captions add height below the diagram.
|
|
32
|
+
*/
|
|
33
|
+
width?: number;
|
|
34
|
+
/** Integer samples per second from 1 to 30, default 15; cumulative GIF delay rounding avoids drift. */
|
|
35
|
+
fps?: number;
|
|
36
|
+
/** Include a fixed caption region below the diagram, default true. False removes that region. */
|
|
37
|
+
includeCaptions?: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Caption-region height in pixels, rounded to an integer; default 80, finite and nonnegative.
|
|
40
|
+
* Text uses 16px system font, 22px line height, 16px horizontal and 12px vertical padding.
|
|
41
|
+
* Every effective caption is measured before sampling; overflow reports its scene/beat without clipping or shrinking.
|
|
42
|
+
*/
|
|
43
|
+
captionHeight?: number;
|
|
44
|
+
/** Zero means infinite looping (default); integers 1–65,536 mean total plays, with 1 playing once. */
|
|
45
|
+
loopCount?: number;
|
|
46
|
+
/**
|
|
47
|
+
* Additional final-picture delay, finite and nonnegative; default 1000. Zero adds no delay.
|
|
48
|
+
* Long delays split into bounded GIF frames and count toward the 1801 encoded-frame limit.
|
|
49
|
+
* Static documents produce one picture with at least 10ms delay, even when this value is zero.
|
|
50
|
+
*/
|
|
51
|
+
finalHoldMs?: number;
|
|
52
|
+
/** Default false, independent of system preferences. True completes transitions and hides flow markers without changing duration. */
|
|
53
|
+
reducedMotion?: boolean;
|
|
54
|
+
/** Cancels this task with code cancelled; task-owned workers, images, canvas and object URLs are released on every exit. */
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
/** Reports preparing, sampling, encoding and complete. Cancellation never reports complete. Does not change player state. */
|
|
57
|
+
onProgress?: (progress: GifExportProgress) => void;
|
|
58
|
+
}
|
|
59
|
+
export type GifExportErrorCode = "options" | "limit" | "document" | "caption-overflow" | "environment" | "rasterization" | "encoding" | "cancelled";
|
|
60
|
+
/** Distinguishable export failure with a message describing the corrective action. */
|
|
61
|
+
export declare class GifExportError extends Error {
|
|
62
|
+
readonly code: GifExportErrorCode;
|
|
63
|
+
constructor(code: GifExportErrorCode, message: string, options?: ErrorOptions);
|
|
64
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Distinguishable export failure with a message describing the corrective action. */
|
|
2
|
+
export class GifExportError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
constructor(code, message, options) {
|
|
5
|
+
super(message, options);
|
|
6
|
+
this.code = code;
|
|
7
|
+
this.name = "GifExportError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const GIF_WORKER_SOURCE: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const GIF_WORKER_SOURCE = "(()=>{var se=Object.create;var W=Object.defineProperty;var le=Object.getOwnPropertyDescriptor;var ae=Object.getOwnPropertyNames;var fe=Object.getPrototypeOf,ue=Object.prototype.hasOwnProperty;var ge=(e,t)=>()=>(t||e((t={exports:{}}).exports,t),t.exports);var de=(e,t,o,n)=>{if(t&&typeof t==\"object\"||typeof t==\"function\")for(let r of ae(t))!ue.call(e,r)&&r!==o&&W(e,r,{get:()=>t[r],enumerable:!(n=le(t,r))||n.enumerable});return e};var he=(e,t,o)=>(o=e!=null?se(fe(e)):{},de(t||!e||!e.__esModule?W(o,\"default\",{value:e,enumerable:!0}):o,e));var ce=ge(H=>{var J=Object.defineProperty,we=e=>J(e,\"__esModule\",{value:!0}),ye=(e,t)=>{for(var o in t)J(e,o,{get:t[o],enumerable:!0})};we(H);ye(H,{GIFEncoder:()=>oe,applyPalette:()=>Ce,default:()=>qe,nearestColor:()=>Fe,nearestColorIndex:()=>ne,nearestColorIndexWithDistance:()=>re,prequantize:()=>ve,quantize:()=>xe,snapColorsToPalette:()=>Ee});var be={signature:\"GIF\",version:\"89a\",trailer:59,extensionIntroducer:33,applicationExtensionLabel:255,graphicControlExtensionLabel:249,imageSeparator:44,signatureSize:3,versionSize:3,globalColorTableFlagMask:128,colorResolutionMask:112,sortFlagMask:8,globalColorTableSizeMask:7,applicationIdentifierSize:8,applicationAuthCodeSize:3,disposalMethodMask:28,userInputFlagMask:2,transparentColorFlagMask:1,localColorTableFlagMask:128,interlaceFlagMask:64,idSortFlagMask:32,localColorTableSizeMask:7};function Q(e=256){let t=0,o=new Uint8Array(e);return{get buffer(){return o.buffer},reset(){t=0},bytesView(){return o.subarray(0,t)},bytes(){return o.slice(0,t)},writeByte(r){n(t+1),o[t]=r,t++},writeBytes(r,i=0,c=r.length){n(t+c);for(let l=0;l<c;l++)o[t++]=r[l+i]},writeBytesView(r,i=0,c=r.byteLength){n(t+c),o.set(r.subarray(i,i+c),t),t+=c}};function n(r){var i=o.length;if(i>=r)return;var c=1024*1024;r=Math.max(r,i*(i<c?2:1.125)>>>0),i!=0&&(r=Math.max(r,256));let l=o;o=new Uint8Array(r),t>0&&o.set(l.subarray(0,t),0)}}var V=12,Y=5003,pe=[0,1,3,7,15,31,63,127,255,511,1023,2047,4095,8191,16383,32767,65535];function Ae(e,t,o,n,r=Q(512),i=new Uint8Array(256),c=new Int32Array(Y),l=new Int32Array(Y)){let g=c.length,f=Math.max(2,n);i.fill(0),l.fill(0),c.fill(-1);let a=0,s=0,d=f+1,p=d,h=!1,w=p,B=(1<<w)-1,u=1<<d-1,I=u+1,M=u+2,y=0,x=o[0],F=0;for(let A=g;A<65536;A*=2)++F;F=8-F,r.writeByte(f),v(u);let b=o.length;for(let A=1;A<b;A++)e:{let k=o[A],E=(k<<V)+x,_=k<<F^x;if(c[_]===E){x=l[_];break e}let P=_===0?1:g-_;for(;c[_]>=0;)if(_-=P,_<0&&(_+=g),c[_]===E){x=l[_];break e}v(x),x=k,M<1<<V?(l[_]=M++,c[_]=E):(c.fill(-1),M=u+2,h=!0,v(u))}return v(x),v(I),r.writeByte(0),r.bytesView();function v(A){for(a&=pe[s],s>0?a|=A<<s:a=A,s+=w;s>=8;)i[y++]=a&255,y>=254&&(r.writeByte(y),r.writeBytesView(i,0,y),y=0),a>>=8,s-=8;if((M>B||h)&&(h?(w=p,B=(1<<w)-1,h=!1):(++w,B=w===V?1<<w:(1<<w)-1)),A==I){for(;s>0;)i[y++]=a&255,y>=254&&(r.writeByte(y),r.writeBytesView(i,0,y),y=0),a>>=8,s-=8;y>0&&(r.writeByte(y),r.writeBytesView(i,0,y),y=0)}}}var Me=Ae;function $(e,t,o){return e<<8&63488|t<<2&992|o>>3}function ee(e,t,o,n){return e>>4|t&240|(o&240)<<4|(n&240)<<8}function te(e,t,o){return e>>4<<8|t&240|o>>4}function q(e,t,o){return e<t?t:e>o?o:e}function S(e){return e*e}function Z(e,t,o){var n=0,r=1e100;let i=e[t],c=i.cnt,l=i.ac,g=i.rc,f=i.gc,a=i.bc;for(var s=i.fw;s!=0;s=e[s].fw){let p=e[s],h=p.cnt,w=c*h/(c+h);if(!(w>=r)){var d=0;o&&(d+=w*S(p.ac-l),d>=r)||(d+=w*S(p.rc-g),!(d>=r)&&(d+=w*S(p.gc-f),!(d>=r)&&(d+=w*S(p.bc-a),!(d>=r)&&(r=d,n=s))))}}i.err=r,i.nn=n}function O(){return{ac:0,rc:0,gc:0,bc:0,cnt:0,nn:0,fw:0,bk:0,tm:0,mtm:0,err:0}}function Be(e,t){let o=t===\"rgb444\"?4096:65536,n=new Array(o),r=e.length;if(t===\"rgba4444\")for(let i=0;i<r;++i){let c=e[i],l=c>>24&255,g=c>>16&255,f=c>>8&255,a=c&255,s=ee(a,f,g,l),d=s in n?n[s]:n[s]=O();d.rc+=a,d.gc+=f,d.bc+=g,d.ac+=l,d.cnt++}else if(t===\"rgb444\")for(let i=0;i<r;++i){let c=e[i],l=c>>16&255,g=c>>8&255,f=c&255,a=te(f,g,l),s=a in n?n[a]:n[a]=O();s.rc+=f,s.gc+=g,s.bc+=l,s.cnt++}else for(let i=0;i<r;++i){let c=e[i],l=c>>16&255,g=c>>8&255,f=c&255,a=$(f,g,l),s=a in n?n[a]:n[a]=O();s.rc+=f,s.gc+=g,s.bc+=l,s.cnt++}return n}function xe(e,t,o={}){let{format:n=\"rgb565\",clearAlpha:r=!0,clearAlphaColor:i=0,clearAlphaThreshold:c=0,oneBitAlpha:l=!1}=o;if(!e||!e.buffer)throw new Error(\"quantize() expected RGBA Uint8Array data\");if(!(e instanceof Uint8Array)&&!(e instanceof Uint8ClampedArray))throw new Error(\"quantize() expected RGBA Uint8Array data\");let g=new Uint32Array(e.buffer),f=o.useSqrt!==!1,a=n===\"rgba4444\",s=Be(g,n),d=s.length,p=d-1,h=new Uint32Array(d+1);for(var w=0,u=0;u<d;++u){let C=s[u];if(C!=null){var B=1/C.cnt;a&&(C.ac*=B),C.rc*=B,C.gc*=B,C.bc*=B,s[w++]=C}}S(t)/w<.022&&(f=!1);for(var u=0;u<w-1;++u)s[u].fw=u+1,s[u+1].bk=u,f&&(s[u].cnt=Math.sqrt(s[u].cnt));f&&(s[u].cnt=Math.sqrt(s[u].cnt));var I,M,y;for(u=0;u<w;++u){Z(s,u,!1);var x=s[u].err;for(M=++h[0];M>1&&(y=M>>1,!(s[I=h[y]].err<=x));M=y)h[M]=I;h[M]=u}var F=w-t;for(u=0;u<F;){for(var b;;){var v=h[1];if(b=s[v],b.tm>=b.mtm&&s[b.nn].mtm<=b.tm)break;b.mtm==p?v=h[1]=h[h[0]--]:(Z(s,v,!1),b.tm=u);var x=s[v].err;for(M=1;(y=M+M)<=h[0]&&(y<h[0]&&s[h[y]].err>s[h[y+1]].err&&y++,!(x<=s[I=h[y]].err));M=y)h[M]=I;h[M]=v}var A=s[b.nn],k=b.cnt,E=A.cnt,B=1/(k+E);a&&(b.ac=B*(k*b.ac+E*A.ac)),b.rc=B*(k*b.rc+E*A.rc),b.gc=B*(k*b.gc+E*A.gc),b.bc=B*(k*b.bc+E*A.bc),b.cnt+=A.cnt,b.mtm=++u,s[A.bk].fw=A.fw,s[A.fw].bk=A.bk,A.mtm=p}let _=[];var P=0;for(u=0;;++P){let R=q(Math.round(s[u].rc),0,255),C=q(Math.round(s[u].gc),0,255),L=q(Math.round(s[u].bc),0,255),T=255;a&&(T=q(Math.round(s[u].ac),0,255),l&&(T=T<=(typeof l==\"number\"?l:127)?0:255),r&&T<=c&&(R=C=L=i,T=0));let X=a?[R,C,L,T]:[R,C,L];if(_e(_,X)||_.push(X),(u=s[u].fw)==0)break}return _}function _e(e,t){for(let o=0;o<e.length;o++){let n=e[o],r=n[0]===t[0]&&n[1]===t[1]&&n[2]===t[2],i=n.length>=4&&t.length>=4?n[3]===t[3]:!0;if(r&&i)return!0}return!1}function G(e,t){var o=0,n;for(n=0;n<e.length;n++){let r=e[n]-t[n];o+=r*r}return o}function D(e,t){return t>1?Math.round(e/t)*t:e}function ve(e,{roundRGB:t=5,roundAlpha:o=10,oneBitAlpha:n=null}={}){let r=new Uint32Array(e.buffer);for(let i=0;i<r.length;i++){let c=r[i],l=c>>24&255,g=c>>16&255,f=c>>8&255,a=c&255;l=D(l,o),n&&(l=l<=(typeof n==\"number\"?n:127)?0:255),a=D(a,t),f=D(f,t),g=D(g,t),r[i]=l<<24|g<<16|f<<8|a<<0}}function Ce(e,t,o=\"rgb565\"){if(!e||!e.buffer)throw new Error(\"quantize() expected RGBA Uint8Array data\");if(!(e instanceof Uint8Array)&&!(e instanceof Uint8ClampedArray))throw new Error(\"quantize() expected RGBA Uint8Array data\");if(t.length>256)throw new Error(\"applyPalette() only works with 256 colors or less\");let n=new Uint32Array(e.buffer),r=n.length,i=o===\"rgb444\"?4096:65536,c=new Uint8Array(r),l=new Array(i),g=o===\"rgba4444\";if(o===\"rgba4444\")for(let f=0;f<r;f++){let a=n[f],s=a>>24&255,d=a>>16&255,p=a>>8&255,h=a&255,w=ee(h,p,d,s),B=w in l?l[w]:l[w]=Ie(h,p,d,s,t);c[f]=B}else{let f=o===\"rgb444\"?te:$;for(let a=0;a<r;a++){let s=n[a],d=s>>16&255,p=s>>8&255,h=s&255,w=f(h,p,d),B=w in l?l[w]:l[w]=ke(h,p,d,t);c[a]=B}}return c}function Ie(e,t,o,n,r){let i=0,c=1e100;for(let l=0;l<r.length;l++){let g=r[l],f=g[3],a=z(f-n);if(a>c)continue;let s=g[0];if(a+=z(s-e),a>c)continue;let d=g[1];if(a+=z(d-t),a>c)continue;let p=g[2];a+=z(p-o),!(a>c)&&(c=a,i=l)}return i}function ke(e,t,o,n){let r=0,i=1e100;for(let c=0;c<n.length;c++){let l=n[c],g=l[0],f=z(g-e);if(f>i)continue;let a=l[1];if(f+=z(a-t),f>i)continue;let s=l[2];f+=z(s-o),!(f>i)&&(i=f,r=c)}return r}function Ee(e,t,o=5){if(!e.length||!t.length)return;let n=e.map(c=>c.slice(0,3)),r=o*o,i=e[0].length;for(let c=0;c<t.length;c++){let l=t[c];l.length<i?l=[l[0],l[1],l[2],255]:l.length>i?l=l.slice(0,3):l=l.slice();let g=re(n,l.slice(0,3),G),f=g[0],a=g[1];a>0&&a<=r&&(e[f]=l)}}function z(e){return e*e}function ne(e,t,o=G){let n=1/0,r=-1;for(let i=0;i<e.length;i++){let c=e[i],l=o(t,c);l<n&&(n=l,r=i)}return r}function re(e,t,o=G){let n=1/0,r=-1;for(let i=0;i<e.length;i++){let c=e[i],l=o(t,c);l<n&&(n=l,r=i)}return[r,n]}function Fe(e,t,o=G){return e[ne(e,t,o)]}function oe(e={}){let{initialCapacity:t=4096,auto:o=!0}=e,n=Q(t),r=5003,i=new Uint8Array(256),c=new Int32Array(r),l=new Int32Array(r),g=!1;return{reset(){n.reset(),g=!1},finish(){n.writeByte(be.trailer)},bytes(){return n.bytes()},bytesView(){return n.bytesView()},get buffer(){return n.buffer},get stream(){return n},writeHeader:f,writeFrame(a,s,d,p={}){let{transparent:h=!1,transparentIndex:w=0,delay:B=0,palette:u=null,repeat:I=0,colorDepth:M=8,dispose:y=-1}=p,x=!1;if(o?g||(x=!0,f(),g=!0):x=!!p.first,s=Math.max(0,Math.floor(s)),d=Math.max(0,Math.floor(d)),x){if(!u)throw new Error(\"First frame must include a { palette } option\");ze(n,s,d,u,M),K(n,u),I>=0&&Te(n,I)}let F=Math.round(B/10);me(n,y,F,h,w);let b=!!u&&!x;Ue(n,s,d,b?u:null),b&&K(n,u),Se(n,a,s,d,M,i,c,l)}};function f(){ie(n,\"GIF89a\")}}function me(e,t,o,n,r){e.writeByte(33),e.writeByte(249),e.writeByte(4),r<0&&(r=0,n=!1);var i,c;n?(i=1,c=2):(i=0,c=0),t>=0&&(c=t&7),c<<=2,e.writeByte(0|c|0|i),m(e,o),e.writeByte(r||0),e.writeByte(0)}function ze(e,t,o,n,r=8){let l=j(n.length)-1,g=128|r-1<<4|0|l,f=0,a=0;m(e,t),m(e,o),e.writeBytes([g,f,a])}function Te(e,t){e.writeByte(33),e.writeByte(255),e.writeByte(11),ie(e,\"NETSCAPE2.0\"),e.writeByte(3),e.writeByte(1),m(e,t),e.writeByte(0)}function K(e,t){let o=1<<j(t.length);for(let n=0;n<o;n++){let r=[0,0,0];n<t.length&&(r=t[n]),e.writeByte(r[0]),e.writeByte(r[1]),e.writeByte(r[2])}}function Ue(e,t,o,n){if(e.writeByte(44),m(e,0),m(e,0),m(e,t),m(e,o),n){let c=j(n.length)-1;e.writeByte(128|c)}else e.writeByte(0)}function Se(e,t,o,n,r=8,i,c,l){Me(o,n,t,r,e,i,c,l)}function m(e,t){e.writeByte(t&255),e.writeByte(t>>8&255)}function ie(e,t){for(var o=0;o<t.length;o++)e.writeByte(t.charCodeAt(o))}function j(e){return Math.max(Math.ceil(Math.log2(e)),1)}var qe=oe});var U=he(ce(),1),N=(0,U.GIFEncoder)(),De=655350,Ge=256;self.onmessage=e=>{try{let t=e.data;if(t.type===\"finish\"){N.finish();let c=N.bytes();self.postMessage({bytes:c},{transfer:[c.buffer]});return}let o=new Uint8Array(t.rgba),n=(0,U.quantize)(o,Ge),r=(0,U.applyPalette)(o,n),i=t.delayMs;do{let c=Math.min(i,De);N.writeFrame(r,t.width,t.height,{palette:n,delay:c,repeat:t.repeat}),i-=c}while(i>0);self.postMessage({acknowledged:!0})}catch(t){self.postMessage({error:t instanceof Error?t.message:String(t)})}};})();\n";
|
package/dist/export.d.ts
ADDED
package/dist/export.js
ADDED
package/dist/index.d.ts
CHANGED
|
@@ -1 +1,7 @@
|
|
|
1
|
-
export
|
|
1
|
+
export { createDiagram, renderSvg, wrapText } from "./core/diagram.js";
|
|
2
|
+
export type { Diagram } from "./core/diagram.js";
|
|
3
|
+
export { validateDocument, DiagramValidationError } from "./core/validation.js";
|
|
4
|
+
export type { NodeKind, EdgeRoute, PortDirection, FlowDirection, ThemeName, Viewport, NodeStyle, EdgeStyle, DiagramNode, DiagramEdge, DiagramDocument, Scene, Beat, DiagramBeat, NodeVisualState, EdgeVisualState, Action, ValidationIssue, EvaluateOptions, TextLine, EvaluatedNode, EvaluatedEdge, EdgeFlowMarker, LayoutWarning, CameraState, HitObjectType, HitObject, DiagramPoint, DiagramFrameState } from "./core/model.js";
|
|
5
|
+
export type { NodeContent, ContentBlock, ContentListItem, ContentListStyle, ContentTag, ContentTagStyle, ContentTagBackground, ContentGraphBlock, ContentGraphNode, ContentGraphEdge, ContentGraphLayout, ContentText, ContentTextSpan, ContentSpanStyle, ContentSpan, ContentTextStyle, ContentAlignment, ContentSafeArea, ContentLine, NodeContentLayout } from "./core/node-content.js";
|
|
6
|
+
export { measureNodeContent } from "./core/node-content-measurement.js";
|
|
7
|
+
export type { NodeContentMeasurementInput, NodeContentMeasurement, NodeContentMeasurementDiagnostic } from "./core/node-content-measurement.js";
|
package/dist/index.js
CHANGED
package/dist/node/cli.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Node CLI: render completely, then atomically publish HTML/GIF; cancellation never publishes partial bytes. */
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { parseArgs } from "node:util";
|
|
6
|
+
import { renderHtml, assertNodeRuntime } from "./html.js";
|
|
7
|
+
import { exportGif } from "./gif.js";
|
|
8
|
+
import { publishOutput } from "./output.js";
|
|
9
|
+
const USAGE = `Usage: flowsvg INPUT.json --output OUTPUT --format html|gif [options]
|
|
10
|
+
Common: --theme light|dark --overwrite
|
|
11
|
+
HTML: --autoplay --no-controls --gif-export
|
|
12
|
+
GIF: --executable-path PATH --start-ms N --end-ms N --speed N --width N --fps N
|
|
13
|
+
--no-captions --caption-height N --loop-count N --final-hold-ms N --reduced-motion
|
|
14
|
+
GIF requires Node 20+ and preinstalled Chromium; browsers are never downloaded automatically.`;
|
|
15
|
+
const CLI_OPTIONS = {
|
|
16
|
+
output: { type: "string", short: "o" }, format: { type: "string" }, theme: { type: "string" },
|
|
17
|
+
autoplay: { type: "boolean" }, "gif-export": { type: "boolean" }, "no-controls": { type: "boolean" }, overwrite: { type: "boolean" },
|
|
18
|
+
"executable-path": { type: "string" }, "start-ms": { type: "string" }, "end-ms": { type: "string" },
|
|
19
|
+
speed: { type: "string" }, width: { type: "string" }, fps: { type: "string" },
|
|
20
|
+
"no-captions": { type: "boolean" }, "caption-height": { type: "string" }, "loop-count": { type: "string" },
|
|
21
|
+
"final-hold-ms": { type: "string" }, "reduced-motion": { type: "boolean" }, help: { type: "boolean", short: "h" }
|
|
22
|
+
};
|
|
23
|
+
function parseCli() { return parseArgs({ allowPositionals: true, options: CLI_OPTIONS }); }
|
|
24
|
+
async function main(signal) {
|
|
25
|
+
assertNodeRuntime();
|
|
26
|
+
const { values, positionals } = parseCli();
|
|
27
|
+
if (values.help) {
|
|
28
|
+
console.log(USAGE);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (positionals.length !== 1 || !values.output || !["html", "gif"].includes(values.format ?? ""))
|
|
32
|
+
throw new Error(USAGE);
|
|
33
|
+
if (values.theme !== undefined && values.theme !== "light" && values.theme !== "dark")
|
|
34
|
+
throw new Error("Theme must be light or dark.");
|
|
35
|
+
const input = JSON.parse(await readFile(resolve(positionals[0]), "utf8"));
|
|
36
|
+
signal.throwIfAborted();
|
|
37
|
+
const theme = values.theme;
|
|
38
|
+
const content = values.format === "gif"
|
|
39
|
+
? await exportGif(input, gifOptions(values, theme, signal))
|
|
40
|
+
: await renderHtml(input, { theme, autoplay: values.autoplay, controls: !values["no-controls"], gifExport: values["gif-export"] });
|
|
41
|
+
await publishOutput(resolve(values.output), content, values.overwrite === true, signal);
|
|
42
|
+
}
|
|
43
|
+
function gifOptions(values, theme, signal) {
|
|
44
|
+
return {
|
|
45
|
+
theme, signal, executablePath: values["executable-path"],
|
|
46
|
+
startMs: numericOption(values["start-ms"], "start-ms"), endMs: numericOption(values["end-ms"], "end-ms"),
|
|
47
|
+
speed: numericOption(values.speed, "speed"), width: numericOption(values.width, "width"), fps: numericOption(values.fps, "fps"),
|
|
48
|
+
includeCaptions: !values["no-captions"], captionHeight: numericOption(values["caption-height"], "caption-height"),
|
|
49
|
+
loopCount: numericOption(values["loop-count"], "loop-count"), finalHoldMs: numericOption(values["final-hold-ms"], "final-hold-ms"),
|
|
50
|
+
reducedMotion: values["reduced-motion"],
|
|
51
|
+
onProgress: (progress) => { console.error(`FlowSVG: ${progress.phase} ${progress.completed}/${progress.total}`); }
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function numericOption(value, name) {
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
const number = Number(value);
|
|
58
|
+
if (value.trim() === "" || !Number.isFinite(number))
|
|
59
|
+
throw new Error(`--${name} requires a finite number.`);
|
|
60
|
+
return number;
|
|
61
|
+
}
|
|
62
|
+
const controller = new AbortController();
|
|
63
|
+
const cancel = () => controller.abort(new Error("Export cancelled by signal."));
|
|
64
|
+
process.on("SIGINT", cancel);
|
|
65
|
+
process.on("SIGTERM", cancel);
|
|
66
|
+
main(controller.signal).catch((error) => {
|
|
67
|
+
console.error(`FlowSVG: ${error instanceof Error ? error.message : String(error)}`);
|
|
68
|
+
process.exitCode = 1;
|
|
69
|
+
}).finally(() => {
|
|
70
|
+
process.off("SIGINT", cancel);
|
|
71
|
+
process.off("SIGTERM", cancel);
|
|
72
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Browser } from "playwright-core";
|
|
2
|
+
import type { DiagramDocument } from "../core/model.js";
|
|
3
|
+
import { type GifExportOptions } from "../export/types.js";
|
|
4
|
+
/** Node-only browser selection. Choose browser or executablePath; a supplied browser remains caller-owned. */
|
|
5
|
+
export interface NodeGifExportOptions extends GifExportOptions {
|
|
6
|
+
/** Use an already installed Chromium executable. This function never downloads a browser. */
|
|
7
|
+
executablePath?: string;
|
|
8
|
+
/** Already connected/launched Playwright browser; only the task's isolated context is closed. */
|
|
9
|
+
browser?: Browser;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Return GIF bytes using the same exporter as flowsvg/export. Requires Node 20+ and preinstalled Chromium.
|
|
13
|
+
* Snapshot inputs before asynchronous work. Success, error and cancellation release task-owned resources;
|
|
14
|
+
* a supplied browser is never closed. Progress is forwarded from the browser exporter.
|
|
15
|
+
*/
|
|
16
|
+
export declare function exportGif(document: DiagramDocument, options?: NodeGifExportOptions): Promise<Uint8Array>;
|