@leviosa-ai/detail-page-editor 0.1.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/components/cardnews/color-input.tsx +380 -0
- package/components/detail-page/ai-generate-panel.tsx +763 -0
- package/components/detail-page/annotation-canvas.tsx +860 -0
- package/components/detail-page/annotation-dialog.tsx +397 -0
- package/components/detail-page/bubble-tail-overlay.tsx +209 -0
- package/components/detail-page/canvas-context-menu.tsx +234 -0
- package/components/detail-page/canvas-observer.tsx +68 -0
- package/components/detail-page/canvas-overlay-host.tsx +44 -0
- package/components/detail-page/canvas-store-facade.ts +51 -0
- package/components/detail-page/chart-inspector.tsx +531 -0
- package/components/detail-page/design-reference-picker.tsx +383 -0
- package/components/detail-page/detail-page-basic-shapes.tsx +180 -0
- package/components/detail-page/detail-page-brand-gifs-panel.tsx +261 -0
- package/components/detail-page/detail-page-brand-kit-panel.tsx +77 -0
- package/components/detail-page/detail-page-brand-panel-header.tsx +44 -0
- package/components/detail-page/detail-page-charts-panel.tsx +104 -0
- package/components/detail-page/detail-page-decorations-panel.tsx +125 -0
- package/components/detail-page/detail-page-download-dialog.tsx +524 -0
- package/components/detail-page/detail-page-editor.tsx +974 -0
- package/components/detail-page/detail-page-elements-panel.tsx +117 -0
- package/components/detail-page/detail-page-font-picker.tsx +231 -0
- package/components/detail-page/detail-page-history-buttons.tsx +87 -0
- package/components/detail-page/detail-page-host-context.tsx +465 -0
- package/components/detail-page/detail-page-icons-panel.tsx +251 -0
- package/components/detail-page/detail-page-konva-json-preview.tsx +474 -0
- package/components/detail-page/detail-page-layers-panel.tsx +618 -0
- package/components/detail-page/detail-page-my-images-panel.tsx +179 -0
- package/components/detail-page/detail-page-my-shapes-panel.tsx +174 -0
- package/components/detail-page/detail-page-page-toolbar.tsx +171 -0
- package/components/detail-page/detail-page-pages-panel.tsx +299 -0
- package/components/detail-page/detail-page-pages-timeline.tsx +79 -0
- package/components/detail-page/detail-page-photos-panel.tsx +257 -0
- package/components/detail-page/detail-page-properties-panel.tsx +2680 -0
- package/components/detail-page/detail-page-qr-panel.tsx +137 -0
- package/components/detail-page/detail-page-references-panel.tsx +166 -0
- package/components/detail-page/detail-page-selection.ts +44 -0
- package/components/detail-page/detail-page-shapes-panel.tsx +16 -0
- package/components/detail-page/detail-page-sidebar-sections.tsx +324 -0
- package/components/detail-page/detail-page-tables-panel.tsx +106 -0
- package/components/detail-page/detail-page-text-panel.tsx +179 -0
- package/components/detail-page/detail-page-thumbnail-bus.ts +42 -0
- package/components/detail-page/edit-quota-ui.tsx +150 -0
- package/components/detail-page/editor-history.ts +56 -0
- package/components/detail-page/editor-hotkeys.tsx +165 -0
- package/components/detail-page/element-recents-strip.tsx +105 -0
- package/components/detail-page/element-rects.ts +82 -0
- package/components/detail-page/fill-control.tsx +261 -0
- package/components/detail-page/find-replace-panel.tsx +252 -0
- package/components/detail-page/generation-progress-fill.tsx +57 -0
- package/components/detail-page/gif-animator.tsx +184 -0
- package/components/detail-page/gif-effect-picker.tsx +136 -0
- package/components/detail-page/group-drill-in.tsx +219 -0
- package/components/detail-page/group-prompt-edit-panel.tsx +154 -0
- package/components/detail-page/hover-highlight-overlay.tsx +111 -0
- package/components/detail-page/hovered-layer.ts +34 -0
- package/components/detail-page/inspector-controls.tsx +175 -0
- package/components/detail-page/leviosa-canvas-workspace.tsx +313 -0
- package/components/detail-page/nested-selection-overlay.tsx +364 -0
- package/components/detail-page/panel-search-input.tsx +49 -0
- package/components/detail-page/prompt-edit-panel.tsx +146 -0
- package/components/detail-page/section-height-handle.tsx +276 -0
- package/components/detail-page/section-reauthor-controller.tsx +210 -0
- package/components/detail-page/spec-prompt-edit-panel.tsx +178 -0
- package/components/detail-page/spec-resize-absorber.tsx +149 -0
- package/components/detail-page/svg-prompt-edit-panel.tsx +139 -0
- package/components/detail-page/table-canvas-overlay.tsx +707 -0
- package/components/detail-page/table-inspector.tsx +586 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/command.tsx +175 -0
- package/components/ui/dialog.tsx +167 -0
- package/components/ui/label.tsx +24 -0
- package/components/ui/select.tsx +190 -0
- package/components/ui/slider.tsx +63 -0
- package/components/ui/switch.tsx +35 -0
- package/components/ui/tooltip.tsx +57 -0
- package/config/detail-page-bundle-font-tags.json +39 -0
- package/config/detail-page-fonts.json +373 -0
- package/index.ts +24 -0
- package/lib/cardnews/font-loader.ts +306 -0
- package/lib/detail-page/animation-sniff.ts +101 -0
- package/lib/detail-page/asset-bytes-url.ts +70 -0
- package/lib/detail-page/basic-shapes.ts +603 -0
- package/lib/detail-page/canvas-menu.ts +182 -0
- package/lib/detail-page/chart/defaults.ts +283 -0
- package/lib/detail-page/chart/format.ts +66 -0
- package/lib/detail-page/chart/normalize.ts +149 -0
- package/lib/detail-page/chart/palette.ts +85 -0
- package/lib/detail-page/chart/paste.ts +82 -0
- package/lib/detail-page/chart/preview.ts +78 -0
- package/lib/detail-page/chart/render.ts +188 -0
- package/lib/detail-page/chart/renderers/bar-h.ts +103 -0
- package/lib/detail-page/chart/renderers/bar-v.ts +213 -0
- package/lib/detail-page/chart/renderers/donut.ts +186 -0
- package/lib/detail-page/chart/renderers/gauge.ts +96 -0
- package/lib/detail-page/chart/renderers/line.ts +143 -0
- package/lib/detail-page/chart/renderers/shared.ts +263 -0
- package/lib/detail-page/chart/renderers/stack.ts +121 -0
- package/lib/detail-page/chart/sync.ts +92 -0
- package/lib/detail-page/chart/types.ts +145 -0
- package/lib/detail-page/css-color.ts +34 -0
- package/lib/detail-page/data-gif-payload.ts +167 -0
- package/lib/detail-page/design-reference.ts +244 -0
- package/lib/detail-page/distribute.ts +89 -0
- package/lib/detail-page/editor-routes.ts +10 -0
- package/lib/detail-page/element-recents.ts +167 -0
- package/lib/detail-page/find-replace.ts +120 -0
- package/lib/detail-page/format-painter.ts +159 -0
- package/lib/detail-page/gif-frames.ts +216 -0
- package/lib/detail-page/gif-timeline.ts +65 -0
- package/lib/detail-page/group-action.ts +62 -0
- package/lib/detail-page/hangul-roman.ts +58 -0
- package/lib/detail-page/icon-curated.ts +290 -0
- package/lib/detail-page/icon-fuzzy.ts +250 -0
- package/lib/detail-page/icon-keywords.generated.ts +3766 -0
- package/lib/detail-page/icon-keywords.ko.ts +524 -0
- package/lib/detail-page/icon-search.ts +257 -0
- package/lib/detail-page/icons.ts +67 -0
- package/lib/detail-page/image-credit.ts +121 -0
- package/lib/detail-page/image-data-uri.ts +29 -0
- package/lib/detail-page/insert-image.ts +75 -0
- package/lib/detail-page/insert-shape.ts +49 -0
- package/lib/detail-page/korean-dictionary.ts +187 -0
- package/lib/detail-page/layer-move.ts +257 -0
- package/lib/detail-page/new-product-draft-store.ts +105 -0
- package/lib/detail-page/new-product-draft.ts +283 -0
- package/lib/detail-page/personal-images-refresh.ts +30 -0
- package/lib/detail-page/product-source.ts +587 -0
- package/lib/detail-page/qr-code.ts +214 -0
- package/lib/detail-page/reference-image.ts +103 -0
- package/lib/detail-page/reference-upload.ts +181 -0
- package/lib/detail-page/replace-page.ts +35 -0
- package/lib/detail-page/replace-with-gif.ts +251 -0
- package/lib/detail-page/section-height.ts +131 -0
- package/lib/detail-page/section-reauthor-bus.ts +61 -0
- package/lib/detail-page/shape-to-image.ts +219 -0
- package/lib/detail-page/spec-group/clipboard.ts +35 -0
- package/lib/detail-page/spec-group/sync.ts +377 -0
- package/lib/detail-page/spec-group/text-metrics.ts +48 -0
- package/lib/detail-page/stock-photos.ts +88 -0
- package/lib/detail-page/svg-colors.ts +74 -0
- package/lib/detail-page/table/defaults.ts +344 -0
- package/lib/detail-page/table/edit.ts +312 -0
- package/lib/detail-page/table/harvest.ts +103 -0
- package/lib/detail-page/table/normalize.ts +125 -0
- package/lib/detail-page/table/paste.ts +49 -0
- package/lib/detail-page/table/preview.ts +66 -0
- package/lib/detail-page/table/render.ts +388 -0
- package/lib/detail-page/table/sync.ts +107 -0
- package/lib/detail-page/table/types.ts +102 -0
- package/lib/detail-page/text-gif-layout.ts +245 -0
- package/lib/detail-page/text-gif-payload.ts +67 -0
- package/lib/detail-page/text-presets.ts +230 -0
- package/lib/detail-page/use-fake-progress.ts +67 -0
- package/lib/detail-page/z-order.ts +82 -0
- package/lib/detail-page-canvas/bubble-path.ts +190 -0
- package/lib/detail-page-canvas/canvas-key.ts +22 -0
- package/lib/detail-page-canvas/clip-rect.ts +65 -0
- package/lib/detail-page-canvas/custom-props-adapter.ts +219 -0
- package/lib/detail-page-canvas/editor-fonts.ts +161 -0
- package/lib/detail-page-canvas/export/ai.ts +413 -0
- package/lib/detail-page-canvas/export/color.ts +35 -0
- package/lib/detail-page-canvas/export/document-model.ts +78 -0
- package/lib/detail-page-canvas/export/export-files.ts +290 -0
- package/lib/detail-page-canvas/export/font-subset.ts +264 -0
- package/lib/detail-page-canvas/export/gif-export.ts +310 -0
- package/lib/detail-page-canvas/export/gif-plan.ts +95 -0
- package/lib/detail-page-canvas/export/mp4-encode.ts +158 -0
- package/lib/detail-page-canvas/export/mp4-support.ts +22 -0
- package/lib/detail-page-canvas/export/pdf/geometry.ts +309 -0
- package/lib/detail-page-canvas/export/pdf/inline-svg.ts +399 -0
- package/lib/detail-page-canvas/export/pdf/resources.ts +443 -0
- package/lib/detail-page-canvas/export/pdf/text-ops.ts +81 -0
- package/lib/detail-page-canvas/export/pdf/writer.ts +109 -0
- package/lib/detail-page-canvas/export/psd.ts +354 -0
- package/lib/detail-page-canvas/export/raster.ts +255 -0
- package/lib/detail-page-canvas/export/svg.ts +479 -0
- package/lib/detail-page-canvas/export/text-layout.ts +142 -0
- package/lib/detail-page-canvas/font-catalog.ts +177 -0
- package/lib/detail-page-canvas/font-tags.ts +47 -0
- package/lib/detail-page-canvas/gif-web-fonts.ts +90 -0
- package/lib/detail-page-canvas/konva-fallback.ts +477 -0
- package/lib/detail-page-canvas/text-background-plan.ts +124 -0
- package/lib/detail-page-canvas/text-highlight-bands.ts +127 -0
- package/lib/utils.ts +10 -0
- package/package.json +68 -0
- package/types/commerce.ts +171 -0
- package/types/detail-page-api.ts +200 -0
- package/types/detail-page-canvas.ts +20 -0
- package/types/fontkit.d.ts +42 -0
- package/types/gifenc.d.ts +51 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Animation-aware raster export: builds a ZIP where contiguous still sections
|
|
3
|
+
* are stacked into PNGs and every animated section is encoded as its own
|
|
4
|
+
* animated WebP (default) or GIF.
|
|
5
|
+
*
|
|
6
|
+
* An animated section is rendered by driving each source animation element
|
|
7
|
+
* frame-by-frame (swap ``src`` → ``store.toDataURL`` composites background +
|
|
8
|
+
* text + that frame) across a shared timeline. **That composition step is the
|
|
9
|
+
* same for both formats** — only the final encode differs:
|
|
10
|
+
*
|
|
11
|
+
* - **WebP** posts the composed frames to the server. Browsers cannot encode
|
|
12
|
+
* animated WebP at all (``canvas.toBlob`` gives one still, WebCodecs
|
|
13
|
+
* ``VideoEncoder`` gives video, not WebP), so this is the one step we cannot
|
|
14
|
+
* keep client-side. See ``docs/detail_page_animated_webp_plan.md`` in the
|
|
15
|
+
* server repo.
|
|
16
|
+
* - **GIF** quantizes to one global palette and encodes here via ``gifenc``, so
|
|
17
|
+
* picking GIF keeps the export fully offline.
|
|
18
|
+
* - **MP4** encodes here too, via WebCodecs ``VideoEncoder`` + an MP4 muxer.
|
|
19
|
+
* ``mp4-encode.ts`` is dynamic-imported so browsers that never pick MP4 do not
|
|
20
|
+
* pay for the muxer.
|
|
21
|
+
*
|
|
22
|
+
* Heavy deps (gifenc, jszip, gifuct) live here and this module is
|
|
23
|
+
* dynamic-imported from the download dialog only when an animation is present.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { applyPalette, GIFEncoder, quantize } from "gifenc";
|
|
27
|
+
|
|
28
|
+
import type { DetailPageHost } from "../../../components/detail-page/detail-page-host-context";
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
decodeAnimation,
|
|
32
|
+
frameAtTime,
|
|
33
|
+
type DecodedAnimation,
|
|
34
|
+
type DecodedFrame,
|
|
35
|
+
} from "../../detail-page/gif-frames";
|
|
36
|
+
import { buildGifTimeline } from "../../detail-page/gif-timeline";
|
|
37
|
+
import { isGifSrc, planGifExport } from "./gif-plan";
|
|
38
|
+
|
|
39
|
+
/** Cap GIF output width — a full-res 4× animated section would be many MB. */
|
|
40
|
+
export const MAX_GIF_WIDTH = 512;
|
|
41
|
+
/**
|
|
42
|
+
* MP4 gets a wider cap than GIF/WebP. That 512 is a *transfer* limit: GIF pays
|
|
43
|
+
* for every pixel in a palette-coded frame, and WebP frames travel to the server
|
|
44
|
+
* as PNG under an upload cap. MP4 is encoded right here by a hardware H.264
|
|
45
|
+
* encoder that eats resolution cheaply, so holding it at 512 would only make the
|
|
46
|
+
* video soft on a ~860px-wide detail page for no gain.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_MP4_WIDTH = 1080;
|
|
49
|
+
/** Palette sampling stride across frames to keep quantize input bounded. */
|
|
50
|
+
const PALETTE_SAMPLE_PIXELS = 60_000;
|
|
51
|
+
|
|
52
|
+
type ElementLike = {
|
|
53
|
+
id?: string;
|
|
54
|
+
type?: string;
|
|
55
|
+
src?: string;
|
|
56
|
+
custom?: Record<string, unknown> | null;
|
|
57
|
+
children?: ElementLike[];
|
|
58
|
+
set: (patch: Record<string, unknown>) => void;
|
|
59
|
+
};
|
|
60
|
+
type PageLike = {
|
|
61
|
+
id: string;
|
|
62
|
+
computedWidth: number;
|
|
63
|
+
computedHeight: number;
|
|
64
|
+
children: ElementLike[];
|
|
65
|
+
};
|
|
66
|
+
type StoreLike = {
|
|
67
|
+
pages: PageLike[];
|
|
68
|
+
toDataURL: (opts: { pageId?: string; pixelRatio?: number; mimeType?: string }) => Promise<string>;
|
|
69
|
+
waitLoading?: () => Promise<void>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function loadImage(src: string): Promise<HTMLImageElement> {
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const img = new Image();
|
|
75
|
+
img.onload = () => resolve(img);
|
|
76
|
+
img.onerror = () => reject(new Error("image decode failed"));
|
|
77
|
+
img.src = src;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function collectGifElements(children: ElementLike[], out: ElementLike[]): void {
|
|
82
|
+
for (const el of children ?? []) {
|
|
83
|
+
const tagged = el.custom && (el.custom as { detailPageGif?: unknown }).detailPageGif;
|
|
84
|
+
if (tagged || ((el.type === "image" || el.type === "svg") && isGifSrc(el.src))) {
|
|
85
|
+
out.push(el);
|
|
86
|
+
}
|
|
87
|
+
if (el.children) collectGifElements(el.children, out);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function drawToCanvas(img: HTMLImageElement): HTMLCanvasElement {
|
|
92
|
+
const canvas = document.createElement("canvas");
|
|
93
|
+
canvas.width = img.naturalWidth;
|
|
94
|
+
canvas.height = img.naturalHeight;
|
|
95
|
+
canvas.getContext("2d")?.drawImage(img, 0, 0);
|
|
96
|
+
return canvas;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function imageDataOf(canvas: HTMLCanvasElement): ImageData {
|
|
100
|
+
const ctx = canvas.getContext("2d");
|
|
101
|
+
if (!ctx) throw new Error("2d context unavailable");
|
|
102
|
+
return ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build one global 256-color palette from a strided sample across all composed
|
|
107
|
+
* frames, so a section that barely changes reuses colors instead of paying a
|
|
108
|
+
* per-frame local color table.
|
|
109
|
+
*/
|
|
110
|
+
function buildGlobalPalette(frames: HTMLCanvasElement[]): number[][] {
|
|
111
|
+
const perFrame = Math.max(1, Math.floor(PALETTE_SAMPLE_PIXELS / frames.length));
|
|
112
|
+
const sample = new Uint8Array(frames.length * perFrame * 4);
|
|
113
|
+
let o = 0;
|
|
114
|
+
for (const frame of frames) {
|
|
115
|
+
const data = imageDataOf(frame).data;
|
|
116
|
+
const stride = Math.max(4, Math.floor(data.length / 4 / perFrame) * 4);
|
|
117
|
+
for (let i = 0; i < data.length && o < sample.length - 4; i += stride) {
|
|
118
|
+
sample[o++] = data[i];
|
|
119
|
+
sample[o++] = data[i + 1];
|
|
120
|
+
sample[o++] = data[i + 2];
|
|
121
|
+
sample[o++] = data[i + 3];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return quantize(sample.subarray(0, o), 256, { format: "rgb444" });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Output format for an animated section. WebP is the default. */
|
|
128
|
+
export type AnimationFormat = "webp" | "gif" | "mp4";
|
|
129
|
+
|
|
130
|
+
/** Turn a canvas into a PNG Blob — the wire format for server-side encoding. */
|
|
131
|
+
function canvasToPngBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
canvas.toBlob(
|
|
134
|
+
(blob) => (blob ? resolve(blob) : reject(new Error("canvas toBlob failed"))),
|
|
135
|
+
"image/png",
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Encode composed frames as an animated GIF, entirely in the browser. */
|
|
141
|
+
function encodeFramesAsGif(frames: HTMLCanvasElement[], delayMs: number): Blob {
|
|
142
|
+
const palette = buildGlobalPalette(frames);
|
|
143
|
+
const encoder = GIFEncoder();
|
|
144
|
+
const delay = Math.round(delayMs);
|
|
145
|
+
for (const frame of frames) {
|
|
146
|
+
const { data, width, height } = imageDataOf(frame);
|
|
147
|
+
const index = applyPalette(data, palette, "rgb444");
|
|
148
|
+
encoder.writeFrame(index, width, height, { palette, delay });
|
|
149
|
+
}
|
|
150
|
+
encoder.finish();
|
|
151
|
+
return new Blob([encoder.bytes() as unknown as Uint8Array<ArrayBuffer>], {
|
|
152
|
+
type: "image/gif",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Encode composed frames as an animated WebP.
|
|
158
|
+
*
|
|
159
|
+
* The round trip is not an optimisation choice — no browser can encode animated
|
|
160
|
+
* WebP, so the frames have to leave the page. They go as lossless PNG so the
|
|
161
|
+
* server compresses the original pixels once rather than compounding losses.
|
|
162
|
+
*/
|
|
163
|
+
async function encodeFramesAsWebp(
|
|
164
|
+
host: DetailPageHost,
|
|
165
|
+
frames: HTMLCanvasElement[],
|
|
166
|
+
delayMs: number,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
): Promise<Blob> {
|
|
169
|
+
const payload = await Promise.all(frames.map(canvasToPngBlob));
|
|
170
|
+
const fps = Math.max(1, Math.round(1000 / Math.max(1, delayMs)));
|
|
171
|
+
return host.api.encodeDetailPageAnimation(payload, { fps, format: "webp" }, signal);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Render a single animated section to an animated WebP (default) or GIF Blob. */
|
|
175
|
+
export async function encodeSectionGif(
|
|
176
|
+
store: unknown,
|
|
177
|
+
pageId: string,
|
|
178
|
+
/** WebP 인코딩은 서버가 한다 — 브라우저에 애니메이션 WebP 인코더가 없다. */
|
|
179
|
+
host: DetailPageHost,
|
|
180
|
+
format: AnimationFormat = "webp",
|
|
181
|
+
signal?: AbortSignal,
|
|
182
|
+
): Promise<Blob> {
|
|
183
|
+
const s = store as StoreLike;
|
|
184
|
+
const page = s.pages.find((p) => p.id === pageId);
|
|
185
|
+
if (!page) throw new Error(`page not found: ${pageId}`);
|
|
186
|
+
|
|
187
|
+
const gifEls: ElementLike[] = [];
|
|
188
|
+
collectGifElements(page.children, gifEls);
|
|
189
|
+
if (gifEls.length === 0) throw new Error("no GIF element on page");
|
|
190
|
+
|
|
191
|
+
// Decode each source GIF and remember its original src to restore later.
|
|
192
|
+
const decoded = new Map<ElementLike, DecodedAnimation>();
|
|
193
|
+
const original = new Map<ElementLike, string | undefined>();
|
|
194
|
+
for (const el of gifEls) {
|
|
195
|
+
original.set(el, el.src);
|
|
196
|
+
if (el.src) decoded.set(el, await decodeAnimation(el.src));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const timeline = buildGifTimeline([...decoded.values()].map((g) => g.durationMs));
|
|
200
|
+
const maxWidth = format === "mp4" ? MAX_MP4_WIDTH : MAX_GIF_WIDTH;
|
|
201
|
+
const ratio = Math.min(1, maxWidth / Math.max(1, page.computedWidth));
|
|
202
|
+
const frameSrcCache = new WeakMap<DecodedFrame, string>();
|
|
203
|
+
const frameSrc = (frame: DecodedFrame): string => {
|
|
204
|
+
let url = frameSrcCache.get(frame);
|
|
205
|
+
if (!url) {
|
|
206
|
+
url = frame.canvas.toDataURL("image/png");
|
|
207
|
+
frameSrcCache.set(frame, url);
|
|
208
|
+
}
|
|
209
|
+
return url;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const composed: HTMLCanvasElement[] = [];
|
|
213
|
+
try {
|
|
214
|
+
for (const t of timeline.times) {
|
|
215
|
+
for (const el of gifEls) {
|
|
216
|
+
const gif = decoded.get(el);
|
|
217
|
+
if (gif) el.set({ src: frameSrc(frameAtTime(gif, t)) });
|
|
218
|
+
}
|
|
219
|
+
await s.waitLoading?.();
|
|
220
|
+
const url = await s.toDataURL({ pageId, pixelRatio: ratio, mimeType: "image/png" });
|
|
221
|
+
composed.push(drawToCanvas(await loadImage(url)));
|
|
222
|
+
}
|
|
223
|
+
} finally {
|
|
224
|
+
for (const el of gifEls) el.set({ src: original.get(el) ?? "" });
|
|
225
|
+
await s.waitLoading?.();
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (format === "gif") return encodeFramesAsGif(composed, timeline.frameDelayMs);
|
|
229
|
+
if (format === "mp4") {
|
|
230
|
+
const { encodeFramesAsMp4 } = await import("./mp4-encode");
|
|
231
|
+
return encodeFramesAsMp4(composed, timeline.frameDelayMs, signal);
|
|
232
|
+
}
|
|
233
|
+
return encodeFramesAsWebp(host, composed, timeline.frameDelayMs, signal);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Stack the given selected-page indices into one vertical PNG/JPG Blob. */
|
|
237
|
+
async function stackPngRun(
|
|
238
|
+
store: StoreLike,
|
|
239
|
+
pageIds: string[],
|
|
240
|
+
indices: number[],
|
|
241
|
+
pixelRatio: number,
|
|
242
|
+
mimeType: string,
|
|
243
|
+
): Promise<Blob> {
|
|
244
|
+
const urls: string[] = [];
|
|
245
|
+
for (const i of indices) {
|
|
246
|
+
urls.push(await store.toDataURL({ pageId: pageIds[i], pixelRatio, mimeType }));
|
|
247
|
+
}
|
|
248
|
+
const images = await Promise.all(urls.map(loadImage));
|
|
249
|
+
const width = Math.max(...images.map((img) => img.width));
|
|
250
|
+
const height = images.reduce((acc, img) => acc + img.height, 0);
|
|
251
|
+
const canvas = document.createElement("canvas");
|
|
252
|
+
canvas.width = width;
|
|
253
|
+
canvas.height = height;
|
|
254
|
+
const ctx = canvas.getContext("2d");
|
|
255
|
+
if (!ctx) throw new Error("2d context unavailable");
|
|
256
|
+
if (mimeType === "image/jpeg") {
|
|
257
|
+
ctx.fillStyle = "#ffffff";
|
|
258
|
+
ctx.fillRect(0, 0, width, height);
|
|
259
|
+
}
|
|
260
|
+
let y = 0;
|
|
261
|
+
for (const img of images) {
|
|
262
|
+
ctx.drawImage(img, 0, y);
|
|
263
|
+
y += img.height;
|
|
264
|
+
}
|
|
265
|
+
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, mimeType, 0.95));
|
|
266
|
+
if (!blob) throw new Error("canvas toBlob failed");
|
|
267
|
+
return blob;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export type GifZipOptions = {
|
|
271
|
+
pageIds: string[];
|
|
272
|
+
gifFlags: boolean[];
|
|
273
|
+
pixelRatio: number;
|
|
274
|
+
mimeType: string;
|
|
275
|
+
ext: string;
|
|
276
|
+
/** Animated section format. Defaults to WebP. */
|
|
277
|
+
animationFormat?: AnimationFormat;
|
|
278
|
+
/** Optional progress callback: (done, total) units. */
|
|
279
|
+
onProgress?: (done: number, total: number) => void;
|
|
280
|
+
signal?: AbortSignal;
|
|
281
|
+
/** 소싱 서버 접근(애니메이션 WebP 인코딩). */
|
|
282
|
+
host: DetailPageHost;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Build the full ZIP for a document that contains at least one animated
|
|
287
|
+
* section. Contiguous still sections stack into one ``label.ext`` PNG/JPG; each
|
|
288
|
+
* animated section is a standalone ``label.webp`` (or ``.gif``).
|
|
289
|
+
*/
|
|
290
|
+
export async function exportGifZip(store: unknown, opts: GifZipOptions): Promise<Blob> {
|
|
291
|
+
const s = store as StoreLike;
|
|
292
|
+
const units = planGifExport(opts.gifFlags);
|
|
293
|
+
const animationFormat = opts.animationFormat ?? "webp";
|
|
294
|
+
const JSZip = (await import("jszip")).default;
|
|
295
|
+
const zip = new JSZip();
|
|
296
|
+
let done = 0;
|
|
297
|
+
for (const unit of units) {
|
|
298
|
+
if (unit.kind === "gif") {
|
|
299
|
+
zip.file(
|
|
300
|
+
`${unit.label}.${animationFormat}`,
|
|
301
|
+
await encodeSectionGif(s, opts.pageIds[unit.page], opts.host, animationFormat, opts.signal),
|
|
302
|
+
);
|
|
303
|
+
} else {
|
|
304
|
+
const blob = await stackPngRun(s, opts.pageIds, unit.pages, opts.pixelRatio, opts.mimeType);
|
|
305
|
+
zip.file(`${unit.label}.${opts.ext}`, blob);
|
|
306
|
+
}
|
|
307
|
+
opts.onProgress?.(++done, units.length);
|
|
308
|
+
}
|
|
309
|
+
return zip.generateAsync({ type: "blob" });
|
|
310
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure planning for GIF-aware raster export. When any selected section
|
|
3
|
+
* (Canvas page) contains an animated GIF, the download splits into multiple
|
|
4
|
+
* files inside a ZIP: contiguous still sections are stacked into one PNG, and
|
|
5
|
+
* each GIF section becomes its own ``.gif`` — e.g. sections 1..16 with GIFs at
|
|
6
|
+
* 4, 8, 10 produce ``1-3.png, 4.gif, 5-7.png, 8.gif, 9.png, 10.gif, 11-16.png``.
|
|
7
|
+
*
|
|
8
|
+
* Everything here is DOM-free so the slicing/naming is unit-testable; the canvas
|
|
9
|
+
* encoding glue lives in ``gif-export.ts`` (dynamic-imported, heavy deps).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ExportDocument, ExportElement } from "./document-model";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether a source URL alone proves the element is animated.
|
|
16
|
+
*
|
|
17
|
+
* **Only GIF can be judged this way.** The default output format is animated
|
|
18
|
+
* WebP now, but ``.webp`` is shared by stills (references, covers, generated
|
|
19
|
+
* photos) — matching on it would tag most of the library as animated. So the
|
|
20
|
+
* authority is ``custom.detailPageGif``, set wherever an animation is inserted
|
|
21
|
+
* (``insert-image.ts``, ``replace-with-gif.ts``); this suffix check only exists
|
|
22
|
+
* to keep pre-existing GIFs and outside URLs working.
|
|
23
|
+
*/
|
|
24
|
+
export function isGifSrc(src: unknown): boolean {
|
|
25
|
+
if (typeof src !== "string" || !src) return false;
|
|
26
|
+
if (src.startsWith("data:image/gif")) return true;
|
|
27
|
+
// Strip query/hash so presigned S3 URLs (``...x.gif?X-Amz-...``) still match.
|
|
28
|
+
return /\.gif(?:[?#]|$)/i.test(src);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function elementIsGif(el: ExportElement): boolean {
|
|
32
|
+
if (el.custom && (el.custom as { detailPageGif?: unknown }).detailPageGif) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return (el.type === "image" || el.type === "svg") && isGifSrc(el.src);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function anyGifChild(children: ExportElement[] | undefined): boolean {
|
|
39
|
+
for (const el of children ?? []) {
|
|
40
|
+
if (elementIsGif(el)) return true;
|
|
41
|
+
if (anyGifChild(el.children)) return true;
|
|
42
|
+
}
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* For the selected pages (in ``pageIds`` order, or document order when omitted),
|
|
48
|
+
* report whether each contains at least one GIF element. The result is aligned
|
|
49
|
+
* to ``pageIds`` so callers can index the two lists together.
|
|
50
|
+
*/
|
|
51
|
+
export function detectGifPages(doc: ExportDocument, pageIds?: string[]): boolean[] {
|
|
52
|
+
const pages = doc.pages ?? [];
|
|
53
|
+
if (!pageIds?.length) return pages.map((p) => anyGifChild(p.children));
|
|
54
|
+
const byId = new Map(pages.filter((p) => p.id).map((p) => [p.id, p] as const));
|
|
55
|
+
return pageIds.map((id) => anyGifChild(byId.get(id)?.children));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ExportUnit =
|
|
59
|
+
| { kind: "png"; pages: number[]; label: string }
|
|
60
|
+
| { kind: "gif"; page: number; label: string };
|
|
61
|
+
|
|
62
|
+
/** Zero-pad a 1-based section number to the width of the largest number. */
|
|
63
|
+
function label(n: number, total: number): string {
|
|
64
|
+
return String(n).padStart(String(total).length, "0");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Slice an ordered GIF-flag list into export units: runs of contiguous stills
|
|
69
|
+
* become one stacked PNG (``start-end`` or a bare ``n`` for a single page), and
|
|
70
|
+
* each GIF page stands alone. Indices are 0-based into the selected-page list;
|
|
71
|
+
* labels are 1-based and zero-padded for stable filesystem ordering.
|
|
72
|
+
*/
|
|
73
|
+
export function planGifExport(isGif: boolean[]): ExportUnit[] {
|
|
74
|
+
const total = isGif.length;
|
|
75
|
+
const units: ExportUnit[] = [];
|
|
76
|
+
let run: number[] = [];
|
|
77
|
+
const flush = () => {
|
|
78
|
+
if (run.length === 0) return;
|
|
79
|
+
const start = run[0] + 1;
|
|
80
|
+
const end = run[run.length - 1] + 1;
|
|
81
|
+
const name = start === end ? label(start, total) : `${label(start, total)}-${label(end, total)}`;
|
|
82
|
+
units.push({ kind: "png", pages: [...run], label: name });
|
|
83
|
+
run = [];
|
|
84
|
+
};
|
|
85
|
+
for (let i = 0; i < isGif.length; i++) {
|
|
86
|
+
if (isGif[i]) {
|
|
87
|
+
flush();
|
|
88
|
+
units.push({ kind: "gif", page: i, label: label(i + 1, total) });
|
|
89
|
+
} else {
|
|
90
|
+
run.push(i);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
flush();
|
|
94
|
+
return units;
|
|
95
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 합성 프레임 → H.264 MP4, 브라우저 안에서.
|
|
3
|
+
*
|
|
4
|
+
* WebP 와 정반대다. 움직이는 WebP 는 브라우저가 굽지 못해 서버로 보내지만, MP4 는
|
|
5
|
+
* 브라우저가 `VideoEncoder` 로 진짜 H.264 를 뽑을 수 있어서 프레임이 페이지를 떠날
|
|
6
|
+
* 이유가 없다. 서버로 옮기면 이미지에 ffmpeg 를 넣어야 하는데(우리가 쓰는 opencv
|
|
7
|
+
* 휠은 리눅스에서 H.264 인코더가 빠져 있어 `avc1` 이 아예 안 열린다), 그렇게 얻는
|
|
8
|
+
* 게 없다.
|
|
9
|
+
*
|
|
10
|
+
* `VideoEncoder` 는 청크만 뱉고 컨테이너는 만들지 않아서 mp4-muxer 로 감싼다.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { ArrayBufferTarget, Muxer } from "mp4-muxer";
|
|
14
|
+
|
|
15
|
+
import { isMp4EncodeSupported } from "./mp4-support";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 시도해 볼 코덱 문자열. 뒤로 갈수록 낮은 레벨이다.
|
|
19
|
+
*
|
|
20
|
+
* 레벨은 해상도 상한을 뜻해서, 세로로 긴 섹션은 낮은 레벨에서 거절당한다. 그래서
|
|
21
|
+
* 하나를 고정하지 않고 `isConfigSupported` 로 물어보며 내려간다. 전부 Baseline/High
|
|
22
|
+
* 프로파일이라 어디서든 재생된다.
|
|
23
|
+
*/
|
|
24
|
+
const CODEC_CANDIDATES = [
|
|
25
|
+
"avc1.640034", // High 5.2
|
|
26
|
+
"avc1.42003c", // Baseline 6.0
|
|
27
|
+
"avc1.420034", // Baseline 5.2
|
|
28
|
+
"avc1.42002a", // Baseline 4.2
|
|
29
|
+
"avc1.42001f", // Baseline 3.1
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
/** 화면에 담기는 화질 대비 파일이 커지지 않는 선. */
|
|
33
|
+
const BITS_PER_PIXEL_PER_SECOND = 0.12;
|
|
34
|
+
/** 앞으로 감기가 되도록 키프레임을 이 간격으로 박는다. */
|
|
35
|
+
const KEYFRAME_INTERVAL_SEC = 2;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* yuv420p 는 가로·세로가 짝수여야 한다. 홀수면 인코더가 거절하거나 한 줄이 뭉개져서,
|
|
39
|
+
* 짝수로 내린 크기에 맞춰 다시 그린다(잘리는 건 최대 1픽셀).
|
|
40
|
+
*/
|
|
41
|
+
function toEvenSized(frame: HTMLCanvasElement): HTMLCanvasElement {
|
|
42
|
+
const width = frame.width - (frame.width % 2);
|
|
43
|
+
const height = frame.height - (frame.height % 2);
|
|
44
|
+
if (width === frame.width && height === frame.height) return frame;
|
|
45
|
+
const canvas = document.createElement("canvas");
|
|
46
|
+
canvas.width = width;
|
|
47
|
+
canvas.height = height;
|
|
48
|
+
canvas.getContext("2d")?.drawImage(frame, 0, 0);
|
|
49
|
+
return canvas;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 영상에는 알파가 없다. 투명한 채로 넘기면 인코더가 검게 깔아서, 흰 바탕에 얹는다 —
|
|
54
|
+
* 상세페이지가 실제로 놓이는 바탕색이다.
|
|
55
|
+
*/
|
|
56
|
+
function flattenOnWhite(frame: HTMLCanvasElement): HTMLCanvasElement {
|
|
57
|
+
const canvas = document.createElement("canvas");
|
|
58
|
+
canvas.width = frame.width;
|
|
59
|
+
canvas.height = frame.height;
|
|
60
|
+
const ctx = canvas.getContext("2d");
|
|
61
|
+
if (!ctx) throw new Error("2d context unavailable");
|
|
62
|
+
ctx.fillStyle = "#ffffff";
|
|
63
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
64
|
+
ctx.drawImage(frame, 0, 0);
|
|
65
|
+
return canvas;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 이 크기를 받아 주는 첫 코덱. 전부 거절하면 null. */
|
|
69
|
+
async function pickCodec(
|
|
70
|
+
width: number,
|
|
71
|
+
height: number,
|
|
72
|
+
framerate: number,
|
|
73
|
+
bitrate: number,
|
|
74
|
+
): Promise<string | null> {
|
|
75
|
+
for (const codec of CODEC_CANDIDATES) {
|
|
76
|
+
try {
|
|
77
|
+
const support = await VideoEncoder.isConfigSupported({
|
|
78
|
+
codec,
|
|
79
|
+
width,
|
|
80
|
+
height,
|
|
81
|
+
framerate,
|
|
82
|
+
bitrate,
|
|
83
|
+
});
|
|
84
|
+
if (support.supported) return codec;
|
|
85
|
+
} catch {
|
|
86
|
+
// 이 코덱 문자열 자체를 모르는 브라우저 — 다음 후보로 넘어간다.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 합성 프레임을 H.264 MP4 로 굽는다.
|
|
94
|
+
*
|
|
95
|
+
* `fastStart: "in-memory"` 로 moov 를 앞에 둔다 — 뒤에 있으면 올린 곳에서 파일을 다
|
|
96
|
+
* 받기 전엔 재생을 시작하지 못한다.
|
|
97
|
+
*/
|
|
98
|
+
export async function encodeFramesAsMp4(
|
|
99
|
+
frames: HTMLCanvasElement[],
|
|
100
|
+
delayMs: number,
|
|
101
|
+
signal?: AbortSignal,
|
|
102
|
+
): Promise<Blob> {
|
|
103
|
+
if (!isMp4EncodeSupported()) {
|
|
104
|
+
throw new Error("MP4_ENCODE_UNSUPPORTED");
|
|
105
|
+
}
|
|
106
|
+
if (frames.length === 0) throw new Error("no frames to encode");
|
|
107
|
+
|
|
108
|
+
const prepared = frames.map((frame) => flattenOnWhite(toEvenSized(frame)));
|
|
109
|
+
const { width, height } = prepared[0];
|
|
110
|
+
if (width === 0 || height === 0) throw new Error("frame too small to encode");
|
|
111
|
+
|
|
112
|
+
const fps = Math.max(1, Math.round(1000 / Math.max(1, delayMs)));
|
|
113
|
+
const bitrate = Math.round(width * height * fps * BITS_PER_PIXEL_PER_SECOND);
|
|
114
|
+
const codec = await pickCodec(width, height, fps, bitrate);
|
|
115
|
+
if (!codec) throw new Error("MP4_ENCODE_UNSUPPORTED");
|
|
116
|
+
|
|
117
|
+
const muxer = new Muxer({
|
|
118
|
+
target: new ArrayBufferTarget(),
|
|
119
|
+
video: { codec: "avc", width, height, frameRate: fps },
|
|
120
|
+
fastStart: "in-memory",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
let failure: Error | null = null;
|
|
124
|
+
const encoder = new VideoEncoder({
|
|
125
|
+
output: (chunk, meta) => muxer.addVideoChunk(chunk, meta),
|
|
126
|
+
error: (error) => {
|
|
127
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
encoder.configure({ codec, width, height, framerate: fps, bitrate });
|
|
131
|
+
|
|
132
|
+
const frameDurationUs = Math.round(delayMs * 1000);
|
|
133
|
+
const keyEvery = Math.max(1, Math.round(fps * KEYFRAME_INTERVAL_SEC));
|
|
134
|
+
try {
|
|
135
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
136
|
+
if (signal?.aborted) throw signal.reason ?? new Error("aborted");
|
|
137
|
+
if (failure) throw failure;
|
|
138
|
+
// 타임스탬프는 마이크로초다. 밀리초를 그대로 넣으면 1000배 느린 영상이 나온다.
|
|
139
|
+
const videoFrame = new VideoFrame(prepared[i], {
|
|
140
|
+
timestamp: i * frameDurationUs,
|
|
141
|
+
duration: frameDurationUs,
|
|
142
|
+
});
|
|
143
|
+
try {
|
|
144
|
+
encoder.encode(videoFrame, { keyFrame: i % keyEvery === 0 });
|
|
145
|
+
} finally {
|
|
146
|
+
// VideoFrame 은 GC 를 기다리지 않는다 — 안 닫으면 프레임 수만큼 메모리를 문다.
|
|
147
|
+
videoFrame.close();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
await encoder.flush();
|
|
151
|
+
if (failure) throw failure;
|
|
152
|
+
} finally {
|
|
153
|
+
if (encoder.state !== "closed") encoder.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
muxer.finalize();
|
|
157
|
+
return new Blob([muxer.target.buffer as ArrayBuffer], { type: "video/mp4" });
|
|
158
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MP4 를 구울 수 있는 브라우저인지만 본다.
|
|
3
|
+
*
|
|
4
|
+
* 인코더(`mp4-encode.ts`)와 갈라 둔 이유는 하나다 — 거기엔 muxer 가 딸려 있어서,
|
|
5
|
+
* 형식 목록을 그리려고 그 파일을 import 하면 MP4 를 고르지도 않은 사람이 muxer 를
|
|
6
|
+
* 내려받는다. 여기는 의존성이 없어야 한다.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 이 브라우저가 MP4 를 구울 수 있는가.
|
|
11
|
+
*
|
|
12
|
+
* 거짓이 되는 경우가 둘이다. 하나는 정말 WebCodecs 가 없는 브라우저, 다른 하나는
|
|
13
|
+
* **보안 컨텍스트가 아닐 때**다 — `VideoEncoder` 는 https 와 localhost 에만 있어서,
|
|
14
|
+
* 평문 http 로 편집기를 열면 이름조차 없다. 그래서 "코덱 문제" 처럼 보이지 않고
|
|
15
|
+
* 그냥 MP4 항목이 사라진다.
|
|
16
|
+
*/
|
|
17
|
+
export function isMp4EncodeSupported(): boolean {
|
|
18
|
+
return (
|
|
19
|
+
typeof globalThis.VideoEncoder === "function" &&
|
|
20
|
+
typeof globalThis.VideoFrame === "function"
|
|
21
|
+
);
|
|
22
|
+
}
|