flowsvg 0.1.1 → 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 +28 -0
- package/PATTERNS.md +61 -5
- package/README.md +149 -461
- package/README.zh-CN.md +149 -461
- package/dist/adapters/react.d.ts +7 -0
- package/dist/adapters/react.js +14 -2
- package/dist/adapters/vue.d.ts +29 -3
- package/dist/adapters/vue.js +20 -4
- package/dist/adapters/web-component.d.ts +14 -2
- package/dist/adapters/web-component.js +32 -3
- package/dist/browser/controls.d.ts +7 -3
- package/dist/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 +9 -1
- package/dist/browser/player.js +83 -24
- package/dist/browser/select-control.d.ts +33 -0
- package/dist/browser/select-control.js +171 -0
- package/dist/browser.d.ts +2 -1
- package/dist/browser.js +2 -1
- package/dist/core/diagram.d.ts +4 -2
- package/dist/core/diagram.js +111 -21
- package/dist/core/edge-geometry.d.ts +16 -3
- package/dist/core/edge-geometry.js +264 -37
- package/dist/core/model.d.ts +35 -9
- package/dist/core/node-content-measurement.d.ts +34 -0
- package/dist/core/node-content-measurement.js +66 -0
- package/dist/core/node-content.d.ts +17 -0
- package/dist/core/node-content.js +30 -6
- package/dist/core/timeline.d.ts +11 -3
- package/dist/core/timeline.js +29 -5
- package/dist/core/validation.d.ts +1 -1
- package/dist/core/validation.js +13 -3
- 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 +3 -1
- package/dist/index.js +1 -0
- 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/styles.css +194 -14
- package/docs/images/flowsvg-wordmark.gif +0 -0
- package/package.json +37 -6
|
@@ -43,26 +43,44 @@ function contentSafeArea(bounds, padding, kind) {
|
|
|
43
43
|
}
|
|
44
44
|
/** Lay out current content from the top left, without DOM font measurement or authored-size changes. */
|
|
45
45
|
export function layoutNodeContent(content, bounds, palette, kind) {
|
|
46
|
+
return layoutNodeContentWithMetrics(content, bounds, palette, kind).layout;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Internal layout shared by fixed-size evaluation and author-time measurement.
|
|
50
|
+
* contentHeight includes complete line boxes, padded tags, graph block heights and all authored gaps.
|
|
51
|
+
* overflows retain only width limits and graph-internal height limits; outer node height remains adjustable.
|
|
52
|
+
*/
|
|
53
|
+
export function layoutNodeContentWithMetrics(content, bounds, palette, kind) {
|
|
46
54
|
const safeArea = contentSafeArea(bounds, content.style?.padding ?? DEFAULT_PADDING, kind);
|
|
47
55
|
let cursorY = safeArea.y;
|
|
48
56
|
let widthOverflow = false;
|
|
49
57
|
let heightOverflow = false;
|
|
50
58
|
const tags = [];
|
|
51
59
|
const graphs = [];
|
|
60
|
+
const overflows = [];
|
|
52
61
|
const lines = content.blocks.flatMap((block, blockIndex) => {
|
|
53
62
|
const result = block.type === "graph"
|
|
54
63
|
? layoutContentGraph(block, safeArea, cursorY, palette, blockIndex, graphs)
|
|
55
64
|
: block.type === "list"
|
|
56
65
|
? layoutContentList(block, safeArea, cursorY, palette, blockIndex)
|
|
57
66
|
: layoutTextPart(block.text, block.type === "title" ? "title" : block.role ?? "body", block.style, safeArea, cursorY, palette, blockIndex);
|
|
58
|
-
widthOverflow
|
|
67
|
+
const blockWidthOverflow = result.widthOverflow || result.lines.some((line) => contentLineExceedsWidth(line, safeArea));
|
|
68
|
+
if (blockWidthOverflow)
|
|
69
|
+
overflows.push({ blockIndex, dimension: "width" });
|
|
70
|
+
if (result.heightOverflow)
|
|
71
|
+
overflows.push({ blockIndex, dimension: "height" });
|
|
72
|
+
widthOverflow ||= blockWidthOverflow;
|
|
59
73
|
heightOverflow ||= result.heightOverflow ?? false;
|
|
60
74
|
tags.push(...result.tags);
|
|
61
75
|
cursorY = result.bottom + (blockIndex < content.blocks.length - 1 ? content.style?.blockGap ?? DEFAULT_BLOCK_GAP : 0);
|
|
62
76
|
return result.lines;
|
|
63
77
|
});
|
|
64
|
-
return {
|
|
65
|
-
|
|
78
|
+
return { contentHeight: cursorY - safeArea.y, overflows, layout: {
|
|
79
|
+
safeArea, lines, tags, ...(graphs.length ? { graphs } : {}),
|
|
80
|
+
widthOverflow,
|
|
81
|
+
// Authored positions change accumulation rounding; use the same geometric tolerance as width checks.
|
|
82
|
+
heightOverflow: heightOverflow || cursorY > safeArea.y + safeArea.height + OVERFLOW_TOLERANCE
|
|
83
|
+
} };
|
|
66
84
|
}
|
|
67
85
|
/** Graph labels reuse text metrics; authored graph geometry is clipped instead of resized. */
|
|
68
86
|
function layoutContentGraph(block, area, top, palette, blockIndex, graphs) {
|
|
@@ -82,7 +100,9 @@ function layoutContentGraph(block, area, top, palette, blockIndex, graphs) {
|
|
|
82
100
|
graphs.push({ blockIndex, nodes, edges });
|
|
83
101
|
return { lines: parts.flatMap((part) => part.lines), tags: [],
|
|
84
102
|
bottom: top + block.height,
|
|
85
|
-
|
|
103
|
+
// Recovering a label top from its absolute baseline can round across the block boundary.
|
|
104
|
+
heightOverflow: parts.some((part) => part.lines.some((line) => line.y - line.fontSize < top - OVERFLOW_TOLERANCE)
|
|
105
|
+
|| part.bottom > top + block.height)
|
|
86
106
|
|| block.nodes.some((node) => node.y - GRAPH_NODE_RADIUS < 0 || node.y + GRAPH_NODE_RADIUS > block.height),
|
|
87
107
|
widthOverflow: parts.some((part) => part.widthOverflow) || block.nodes.some((node) => node.x < GRAPH_NODE_RADIUS
|
|
88
108
|
|| node.x + GRAPH_NODE_RADIUS > area.width) };
|
|
@@ -215,7 +235,8 @@ function layoutContentTag(tag, area, top, palette, blockIndex, itemIndex, tagInd
|
|
|
215
235
|
return { ...result, widthOverflow: result.widthOverflow || area.width < TAG_PADDING_X * 2,
|
|
216
236
|
lines: result.lines.map((line) => ({ ...line, tagIndex })), bottom: result.bottom + TAG_PADDING_Y,
|
|
217
237
|
tags: [{ x: area.x, y: top, width: area.width, height: result.bottom - top + TAG_PADDING_Y,
|
|
218
|
-
fill: tag.style?.fill ?? palette.tagFill, explicitFill: tag.style?.fill !== undefined,
|
|
238
|
+
fill: tag.style?.fill ?? palette.tagFill, explicitFill: tag.style?.fill !== undefined,
|
|
239
|
+
...(tag.style?.shape === undefined ? {} : { shape: tag.style.shape }), blockIndex, itemIndex, tagIndex }] };
|
|
219
240
|
}
|
|
220
241
|
/** Baseline shifts preserve line spacing and expand tag backgrounds instead of moving the row boundary. */
|
|
221
242
|
function shiftTagText(part, offset) {
|
|
@@ -473,10 +494,13 @@ function validateContentTags(value, path, issues) {
|
|
|
473
494
|
validateContentTextStyle(tag.style, `${tagPath}.style`, issues, true);
|
|
474
495
|
continue;
|
|
475
496
|
}
|
|
476
|
-
const { fill, ...textStyle } = tag.style;
|
|
497
|
+
const { fill, shape, ...textStyle } = tag.style;
|
|
477
498
|
validateContentTextStyle(textStyle, `${tagPath}.style`, issues, true);
|
|
478
499
|
if (fill !== undefined)
|
|
479
500
|
validateColor(fill, `${tagPath}.style.fill`, issues);
|
|
501
|
+
if (shape !== undefined && shape !== "rounded" && shape !== "pill") {
|
|
502
|
+
issues.push({ path: `${tagPath}.style.shape`, message: "must be rounded or pill" });
|
|
503
|
+
}
|
|
480
504
|
}
|
|
481
505
|
}
|
|
482
506
|
/** Validate inline source values before evaluation; unknown fields keep their exact source position. */
|
package/dist/core/timeline.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Deterministic global timeline: compile actions/snapshots and evaluate base styles, visibility, highlights and focus.
|
|
3
3
|
* Spatial layout and SVG output belong to the core; theme defaults are supplied only during evaluation.
|
|
4
4
|
*/
|
|
5
|
-
import type { DiagramDocument, EvaluateOptions, FlowDirection, NodeVisualState, EdgeVisualState } from "./model.js";
|
|
5
|
+
import type { DiagramBeat, DiagramDocument, EvaluateOptions, FlowDirection, NodeVisualState, EdgeVisualState } from "./model.js";
|
|
6
6
|
type VisualValues = NodeVisualState & EdgeVisualState;
|
|
7
7
|
/** Evaluation-time theme defaults; unspecified snapshot colors remain unresolved so compilation does not fix a theme. */
|
|
8
8
|
export interface TimelinePalette {
|
|
@@ -10,11 +10,12 @@ export interface TimelinePalette {
|
|
|
10
10
|
stroke: string;
|
|
11
11
|
edge: string;
|
|
12
12
|
}
|
|
13
|
-
/** One transfer
|
|
13
|
+
/** One beat-local transfer; single-flow movement may finish before its beat, and no flow persists in snapshots. */
|
|
14
14
|
export interface CompiledFlow {
|
|
15
15
|
target: string;
|
|
16
16
|
direction: FlowDirection;
|
|
17
17
|
text?: string;
|
|
18
|
+
highlightParticipants: boolean;
|
|
18
19
|
}
|
|
19
20
|
/** Object transition result; highlight is the overlay weight, while highlighted is the target logical state. */
|
|
20
21
|
export interface TimelineObject {
|
|
@@ -30,6 +31,12 @@ export interface TimelineFrame {
|
|
|
30
31
|
sceneId: string | null;
|
|
31
32
|
beatId: string | null;
|
|
32
33
|
beatProgress: number;
|
|
34
|
+
/** Linear movement completion, clamped at arrival while the beat retains temporary highlights. */
|
|
35
|
+
flowProgress: number;
|
|
36
|
+
/** Single-flow receiver paint after arrival; independent of logical highlights and opening transitions. */
|
|
37
|
+
flowArrivalProgress: number;
|
|
38
|
+
/** Single-flow automatic paint removal during the last 200 ms; logical flags and marker lifetime are unchanged. */
|
|
39
|
+
flowDepartureProgress: number;
|
|
33
40
|
transitionProgress: number;
|
|
34
41
|
focusBefore: string | null;
|
|
35
42
|
focusAfter: string | null;
|
|
@@ -44,10 +51,11 @@ export interface TimelineFrame {
|
|
|
44
51
|
*/
|
|
45
52
|
export declare class CompiledTimeline {
|
|
46
53
|
readonly totalDuration: number;
|
|
54
|
+
readonly schedule: readonly DiagramBeat[];
|
|
47
55
|
private readonly beats;
|
|
48
56
|
private readonly initial;
|
|
49
57
|
private readonly title?;
|
|
50
|
-
constructor(document: DiagramDocument);
|
|
58
|
+
constructor(document: DiagramDocument, movementDurations?: ReadonlyMap<string, number>);
|
|
51
59
|
/** Clamp finite times globally; reduced motion skips interpolation but preserves beat selection and total duration. */
|
|
52
60
|
evaluateAt(timeMs: number, options: EvaluateOptions, palette: TimelinePalette): TimelineFrame;
|
|
53
61
|
/** Select the next beat at endMs, retaining the last beat at total duration; static documents return undefined. */
|
package/dist/core/timeline.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { mixColor } from "./color.js";
|
|
2
|
+
import { DiagramValidationError } from "./validation.js";
|
|
2
3
|
const DEFAULT_TRANSITION_MS = 250;
|
|
4
|
+
const FLOW_ARRIVAL_TRANSITION_MS = 200;
|
|
5
|
+
const FLOW_FULL_HIGHLIGHT_HOLD_MS = 100;
|
|
6
|
+
const FLOW_DEPARTURE_TRANSITION_MS = 200;
|
|
7
|
+
const FLOW_ARRIVAL_HOLD_MS = FLOW_ARRIVAL_TRANSITION_MS + FLOW_FULL_HIGHLIGHT_HOLD_MS + FLOW_DEPARTURE_TRANSITION_MS;
|
|
3
8
|
const NUMERIC_VISUAL_FIELDS = ["x", "y", "width", "height", "opacity", "strokeWidth"];
|
|
4
9
|
const DEFAULT_STROKE_WIDTH = 2;
|
|
5
10
|
/**
|
|
@@ -9,10 +14,11 @@ const DEFAULT_STROKE_WIDTH = 2;
|
|
|
9
14
|
*/
|
|
10
15
|
export class CompiledTimeline {
|
|
11
16
|
totalDuration;
|
|
17
|
+
schedule;
|
|
12
18
|
beats;
|
|
13
19
|
initial;
|
|
14
20
|
title;
|
|
15
|
-
constructor(document) {
|
|
21
|
+
constructor(document, movementDurations = new Map()) {
|
|
16
22
|
this.initial = new Map([
|
|
17
23
|
...document.nodes.map((node) => [node.id, {
|
|
18
24
|
visible: node.visible !== false, highlighted: false, kind: "node", values: {
|
|
@@ -31,7 +37,7 @@ export class CompiledTimeline {
|
|
|
31
37
|
let focus = null;
|
|
32
38
|
let startMs = 0;
|
|
33
39
|
let caption = document.title;
|
|
34
|
-
for (const scene of document.scenes) {
|
|
40
|
+
for (const [sceneIndex, scene] of document.scenes.entries()) {
|
|
35
41
|
scene.beats.forEach((beat, index) => {
|
|
36
42
|
// Copy the index, then replace changed objects/values so earlier beat snapshots retain their original state.
|
|
37
43
|
const after = new Map(state);
|
|
@@ -39,7 +45,8 @@ export class CompiledTimeline {
|
|
|
39
45
|
const flows = [];
|
|
40
46
|
for (const action of beat.actions) {
|
|
41
47
|
if (action.type === "flow") {
|
|
42
|
-
flows.push({ target: action.target, direction: action.direction,
|
|
48
|
+
flows.push({ target: action.target, direction: action.direction, highlightParticipants: action.highlightParticipants ?? true,
|
|
49
|
+
...(action.text !== undefined ? { text: action.text } : {}) });
|
|
43
50
|
continue;
|
|
44
51
|
}
|
|
45
52
|
if (action.type === "focus") {
|
|
@@ -64,13 +71,25 @@ export class CompiledTimeline {
|
|
|
64
71
|
}
|
|
65
72
|
}
|
|
66
73
|
caption = beat.caption ?? caption;
|
|
67
|
-
const
|
|
68
|
-
|
|
74
|
+
const beatId = `${scene.id}:${index}`;
|
|
75
|
+
const movementMs = movementDurations.get(beatId) ?? beat.durationMs;
|
|
76
|
+
// Reference compilation keeps authored timing; effective single-flow beats own a fixed arrival hold.
|
|
77
|
+
const durationMs = movementDurations.has(beatId) ? movementMs + FLOW_ARRIVAL_HOLD_MS : beat.durationMs;
|
|
78
|
+
const endMs = startMs + durationMs;
|
|
79
|
+
// A shortened beat must finish its opening transition before the next snapshot takes over.
|
|
80
|
+
const transitionMs = Math.min(durationMs, beat.transitionMs ?? Math.min(DEFAULT_TRANSITION_MS, beat.durationMs));
|
|
81
|
+
if (!Number.isFinite(movementMs) || !Number.isFinite(endMs)) {
|
|
82
|
+
throw new DiagramValidationError([{ path: `scenes[${sceneIndex}].beats[${index}].durationMs`, message: "effective beat timing must be finite" }]);
|
|
83
|
+
}
|
|
84
|
+
beats.push({ startMs, endMs, movementMs, transitionMs, sceneId: scene.id, beatId, beatIndex: index, caption, focusBefore, focusAfter: focus, before: state, after, flows });
|
|
69
85
|
state = after;
|
|
70
86
|
startMs = endMs;
|
|
71
87
|
});
|
|
72
88
|
}
|
|
73
89
|
this.beats = beats;
|
|
90
|
+
this.schedule = Object.freeze(beats.map(({ sceneId, beatId, beatIndex, startMs, endMs, caption }) => Object.freeze({
|
|
91
|
+
sceneId, beatId, beatIndex, startMs, endMs, ...(caption !== undefined ? { caption } : {})
|
|
92
|
+
})));
|
|
74
93
|
this.totalDuration = startMs;
|
|
75
94
|
}
|
|
76
95
|
/** Clamp finite times globally; reduced motion skips interpolation but preserves beat selection and total duration. */
|
|
@@ -98,6 +117,11 @@ export class CompiledTimeline {
|
|
|
98
117
|
sceneId: beat?.sceneId ?? null,
|
|
99
118
|
beatId: beat?.beatId ?? null,
|
|
100
119
|
beatProgress: beat ? (time - beat.startMs) / (beat.endMs - beat.startMs) : 0,
|
|
120
|
+
flowProgress: beat ? Math.min(1, (time - beat.startMs) / beat.movementMs) : 0,
|
|
121
|
+
flowArrivalProgress: !beat || options.reducedMotion || beat.flows.length !== 1 ? 1
|
|
122
|
+
: easeInOutCubic(Math.max(0, Math.min(1, (time - beat.startMs - beat.movementMs) / FLOW_ARRIVAL_TRANSITION_MS))),
|
|
123
|
+
flowDepartureProgress: !beat || options.reducedMotion || beat.flows.length !== 1 ? 0
|
|
124
|
+
: easeInOutCubic(Math.max(0, Math.min(1, (time - beat.endMs + FLOW_DEPARTURE_TRANSITION_MS) / FLOW_DEPARTURE_TRANSITION_MS))),
|
|
101
125
|
transitionProgress: progress,
|
|
102
126
|
focusBefore: beat?.focusBefore ?? null,
|
|
103
127
|
focusAfter: beat?.focusAfter ?? null,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ValidationIssue } from "./model.js";
|
|
2
|
-
/**
|
|
2
|
+
/** Validation error for invalid documents or author-time measurement inputs; issues retains all affected field paths. */
|
|
3
3
|
export declare class DiagramValidationError extends Error {
|
|
4
4
|
readonly issues: readonly ValidationIssue[];
|
|
5
5
|
constructor(issues: ValidationIssue[]);
|
package/dist/core/validation.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Validate document fields, references and action conflicts without mutating input; content rules stay shared. */
|
|
2
2
|
import { supportsNodeContent, validateNodeContent } from "./node-content.js";
|
|
3
|
-
/**
|
|
3
|
+
/** Validation error for invalid documents or author-time measurement inputs; issues retains all affected field paths. */
|
|
4
4
|
export class DiagramValidationError extends Error {
|
|
5
5
|
issues;
|
|
6
6
|
constructor(issues) {
|
|
@@ -30,9 +30,17 @@ export function validateDocument(input) {
|
|
|
30
30
|
issues.push({ path: "viewport", message: "must be an object" });
|
|
31
31
|
}
|
|
32
32
|
else {
|
|
33
|
-
validateUnknownFields(viewport, ["width", "height"], "viewport", issues);
|
|
33
|
+
validateUnknownFields(viewport, ["width", "height", "fitHeight"], "viewport", issues);
|
|
34
34
|
validatePositiveFinite(viewport.width, "viewport.width", issues);
|
|
35
35
|
validatePositiveFinite(viewport.height, "viewport.height", issues);
|
|
36
|
+
if (viewport.fitHeight !== undefined) {
|
|
37
|
+
if (!isRecord(viewport.fitHeight))
|
|
38
|
+
issues.push({ path: "viewport.fitHeight", message: "must be an object" });
|
|
39
|
+
else {
|
|
40
|
+
validateUnknownFields(viewport.fitHeight, ["padding"], "viewport.fitHeight", issues);
|
|
41
|
+
validateNonNegativeFinite(viewport.fitHeight.padding, "viewport.fitHeight.padding", issues);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
36
44
|
}
|
|
37
45
|
if (!Array.isArray(input.nodes))
|
|
38
46
|
issues.push({ path: "nodes", message: "must be an array" });
|
|
@@ -298,7 +306,7 @@ function validateAction(action, path, allIds, nodeById, edgeById, assignments, i
|
|
|
298
306
|
const allowedByType = {
|
|
299
307
|
show: ["type", "target"], hide: ["type", "target"], highlight: ["type", "target"], unhighlight: ["type", "target"],
|
|
300
308
|
updateNode: ["type", "target", "value"], updateEdge: ["type", "target", "value"], focus: ["type", "target"],
|
|
301
|
-
flow: ["type", "target", "direction", "text"]
|
|
309
|
+
flow: ["type", "target", "direction", "text", "highlightParticipants"]
|
|
302
310
|
};
|
|
303
311
|
const allowed = Object.hasOwn(allowedByType, type) ? allowedByType[type] : undefined;
|
|
304
312
|
if (!allowed) {
|
|
@@ -332,6 +340,8 @@ function validateAction(action, path, allIds, nodeById, edgeById, assignments, i
|
|
|
332
340
|
issues.push({ path: `${path}.direction`, message: "must be forward or reverse" });
|
|
333
341
|
if (action.text !== undefined && typeof action.text !== "string")
|
|
334
342
|
issues.push({ path: `${path}.text`, message: "must be a string" });
|
|
343
|
+
if (action.highlightParticipants !== undefined && typeof action.highlightParticipants !== "boolean")
|
|
344
|
+
issues.push({ path: `${path}.highlightParticipants`, message: "must be a boolean" });
|
|
335
345
|
// Reserve the edge independently of other field errors so invalid directions cannot hide duplicate transfers.
|
|
336
346
|
if (isNonEmptyString(action.target) && edgeById.has(action.target))
|
|
337
347
|
addAssignment(`flow:${action.target}`, `${path}.target`, assignments, issues);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DiagramDocument } from "../core/model.js";
|
|
2
|
+
import { type GifExportOptions } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Return opaque GIF bytes without downloading or changing playback. Snapshot inputs synchronously
|
|
5
|
+
* and release task resources on success, failure or cancellation. Captions are measured before sampling.
|
|
6
|
+
* @throws {GifExportError} With a distinct code for invalid options/document, limits, caption overflow,
|
|
7
|
+
* unavailable browser services, rasterization, encoding or cancellation. See GifExportOptions for limits.
|
|
8
|
+
*/
|
|
9
|
+
export declare function exportGif(input: DiagramDocument, options?: GifExportOptions): Promise<Uint8Array>;
|
|
@@ -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
|
+
}
|