@leviosa-ai/detail-page-editor 0.6.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -73,6 +73,19 @@ export const MAX_REFERENCE_FILE_BYTES = 12 * 1024 * 1024;
|
|
|
73
73
|
/** 줄인 **뒤** 서버가 받아 주는 한 장의 크기. 서버의 ``MAX_REFERENCE_BYTES`` 와 같다. */
|
|
74
74
|
export const MAX_REFERENCE_UPLOAD_BYTES = 4 * 1024 * 1024;
|
|
75
75
|
|
|
76
|
+
/**
|
|
77
|
+
* 세로로 긴 캡쳐 한 장의 상한. 위 값보다 크다.
|
|
78
|
+
*
|
|
79
|
+
* 서버의 한 장 상한(``MAX_REFERENCE_BYTES``)은 **안 나눈 한 장**에만 걸린다
|
|
80
|
+
* (``_normalize_data_uri`` 의 ``len(pieces) == 1``). 세로로 긴 캡쳐는 서버가 밴드로
|
|
81
|
+
* 나누므로 그 상한을 안 지나가고, 대신 나누기 전 상한(16MB)과 합계 상한(18MB)이
|
|
82
|
+
* 받아 준다. 여기서 4MB 로 막으면 **폭을 살려 보내려던 그림이 붙는 자리에서 거절된다** —
|
|
83
|
+
* 그러면 유저는 다시 긴 변을 줄인 띠를 넣게 되고, 사고가 그대로 돌아온다.
|
|
84
|
+
*
|
|
85
|
+
* 파일 상한과 같은 값이다: 붙이는 자리에서 이미 통과시킨 크기를 다시 막지 않는다.
|
|
86
|
+
*/
|
|
87
|
+
export const MAX_TALL_REFERENCE_UPLOAD_BYTES = MAX_REFERENCE_FILE_BYTES;
|
|
88
|
+
|
|
76
89
|
/** 붙일 수 없는 파일이면 이유를, 괜찮으면 null. */
|
|
77
90
|
export function referenceFileRejection(file: File): string | null {
|
|
78
91
|
const type = String(file.type || "").toLowerCase();
|
|
@@ -126,8 +139,11 @@ export async function finalizeReferenceDataUri(
|
|
|
126
139
|
original: string,
|
|
127
140
|
): Promise<{ uri: string; inputTokens: number } | { error: string }> {
|
|
128
141
|
const shrunk = await shrinkReferenceDataUri(original);
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
const cap = shrunk.tall
|
|
143
|
+
? MAX_TALL_REFERENCE_UPLOAD_BYTES
|
|
144
|
+
: MAX_REFERENCE_UPLOAD_BYTES;
|
|
145
|
+
if (dataUriByteLength(shrunk.uri) > cap) {
|
|
146
|
+
const limit = Math.round(cap / (1024 * 1024));
|
|
131
147
|
return {
|
|
132
148
|
error: `참고 사진을 ${limit}MB 아래로 줄이지 못했어요. 더 작은 그림을 써 주세요.`,
|
|
133
149
|
};
|
|
@@ -6,13 +6,52 @@
|
|
|
6
6
|
* 구성뿐이라 긴 변 1,024px 이면 충분하다. 비용도 여기서 갈린다 — 비전 입력은 픽셀이
|
|
7
7
|
* 아니라 타일 수로 계산되므로 줄인 만큼 그대로 싸진다.
|
|
8
8
|
*
|
|
9
|
+
* ## ⚠ 세로로 긴 캡쳐에 긴 변 상한을 쓰면 안 된다
|
|
10
|
+
*
|
|
11
|
+
* 위 문단은 **정사각에 가까운 그림에서만** 맞다. 상세페이지 전체 캡쳐는 긴 변이
|
|
12
|
+
* 세로다. 실측 900×39418 짜리 캡쳐에 긴 변 1,024 를 걸면 **23×1024** 가 나온다 —
|
|
13
|
+
* 폭 23픽셀에는 섹션 경계도 장식도 글자도 남지 않는다. 에러도 경고도 없이 통과하고,
|
|
14
|
+
* 서버는 그 띠를 정상 입력으로 받는다. 저작이 "레퍼런스를 안 닮는" 사고의 실제 원인이
|
|
15
|
+
* 이것이었다(2026-08-14, job e538ce45 — 명세·저작 두 턴이 모두 그 띠를 봤다).
|
|
16
|
+
*
|
|
17
|
+
* 서버는 세로로 긴 캡쳐를 폭을 살려 밴드로 나눠 싣는다
|
|
18
|
+
* (`app/services/detail_page/design_reference.py` 의 ``_slice_into_bands``). 그러려면
|
|
19
|
+
* **폭이 살아 있는 그림이 서버에 닿아야 한다.** 그래서 여기서는 세로로 긴 그림을
|
|
20
|
+
* 만나면 긴 변이 아니라 **폭만** 기준으로 삼고, 줄일 것이 없으면 원본을 손대지 않고
|
|
21
|
+
* 그대로 보낸다.
|
|
22
|
+
*
|
|
9
23
|
* JPEG 로 굳힌다. 레퍼런스는 사진이지 도형이 아니라서 무손실이 필요 없고, PNG 로 두면
|
|
10
24
|
* 같은 그림이 서너 배 무겁다.
|
|
11
25
|
*/
|
|
12
26
|
|
|
13
|
-
/** 긴 변 상한.
|
|
27
|
+
/** 긴 변 상한. **세로로 긴 캡쳐에는 쓰지 않는다**(머리말 참조). */
|
|
14
28
|
export const REFERENCE_MAX_EDGE = 1024;
|
|
15
29
|
|
|
30
|
+
/**
|
|
31
|
+
* 세로로 긴 캡쳐로 보는 종횡비(세로/가로). 서버의 ``BAND_TRIGGER_RATIO`` 와 같은 값이다 —
|
|
32
|
+
* 여기서 통과시킨 그림을 저쪽이 밴드로 나누므로, 두 문턱이 어긋나면 "폭은 살렸는데
|
|
33
|
+
* 서버는 안 나누는" 구간이 생긴다.
|
|
34
|
+
*/
|
|
35
|
+
export const TALL_TRIGGER_RATIO = 2.5;
|
|
36
|
+
|
|
37
|
+
/** 세로로 긴 캡쳐에서 지키는 폭. 서버의 밴드 폭(``BAND_WIDTH_PX``)과 같다. */
|
|
38
|
+
export const TALL_MAX_WIDTH = 1024;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 세로로 긴 캡쳐에서 더는 못 내려가는 폭. 서버의 ``BAND_MIN_WIDTH_PX`` 와 같다.
|
|
42
|
+
* 이 아래로 내려가면 밴드로 나눠도 글자를 못 읽으므로, 줄이는 대신 원본을 보낸다.
|
|
43
|
+
*/
|
|
44
|
+
export const TALL_MIN_WIDTH = 640;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 캔버스로 다시 그릴 수 있는 넓이 상한.
|
|
48
|
+
*
|
|
49
|
+
* iOS Safari 는 16,777,216px 를 넘는 캔버스에 그리면 **예외 없이 빈 그림**을 내놓는다.
|
|
50
|
+
* 세로로 긴 캡쳐는 이 상한을 쉽게 넘으므로(900×39418 = 3,548만), 넘을 것 같으면
|
|
51
|
+
* 다시 그리지 않고 원본을 그대로 보낸다 — 빈 그림을 보내는 것보다 무거운 원본이 낫다.
|
|
52
|
+
*/
|
|
53
|
+
export const MAX_CANVAS_PIXELS = 16_000_000;
|
|
54
|
+
|
|
16
55
|
const QUALITY = 0.82;
|
|
17
56
|
|
|
18
57
|
/**
|
|
@@ -59,12 +98,79 @@ export function readImageFileAsDataUri(file: File): Promise<string> {
|
|
|
59
98
|
return readAsDataUri(file);
|
|
60
99
|
}
|
|
61
100
|
|
|
101
|
+
/**
|
|
102
|
+
* 한 장을 어떻게 줄일지에 대한 판정. 캔버스 없이 **크기만으로** 정해진다.
|
|
103
|
+
*
|
|
104
|
+
* 순수 함수로 떼어 둔 이유는 이 판정이 사고가 났던 자리이기 때문이다 — 캔버스가 필요한
|
|
105
|
+
* 코드 안에 묻어 두면 jsdom 에서 못 재고, 못 재는 규칙은 조용히 다시 틀어진다.
|
|
106
|
+
*/
|
|
107
|
+
export type ResizePlan = {
|
|
108
|
+
/** 세로로 긴 캡쳐인가. 상한을 고르는 쪽(``finalizeReferenceDataUri``)이 읽는다. */
|
|
109
|
+
tall: boolean;
|
|
110
|
+
/** 다시 그리지 않고 원본을 그대로 보낼 것인가. */
|
|
111
|
+
passThrough: boolean;
|
|
112
|
+
/** 보낼 그림의 크기. ``passThrough`` 면 원본 크기 그대로다. */
|
|
113
|
+
width: number;
|
|
114
|
+
height: number;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 보낼 크기를 정한다.
|
|
119
|
+
*
|
|
120
|
+
* - **세로로 긴 캡쳐**(세로 ≥ 가로 × {@link TALL_TRIGGER_RATIO}): 폭만 기준으로 줄인다.
|
|
121
|
+
* 폭이 이미 {@link TALL_MAX_WIDTH} 안이면 손대지 않는다 — 다시 그릴 이유가 없고,
|
|
122
|
+
* 캔버스 넓이 상한도 안 건드린다. 줄여야 하는데 캔버스 넓이 상한을 넘거나 폭이
|
|
123
|
+
* {@link TALL_MIN_WIDTH} 아래로 떨어지면 **줄이기를 포기하고 원본을 보낸다**(서버가
|
|
124
|
+
* 밴드로 나누며 자기 예산에 맞춘다).
|
|
125
|
+
* - 그 밖의 그림: 예전처럼 긴 변 {@link REFERENCE_MAX_EDGE}.
|
|
126
|
+
*/
|
|
127
|
+
export function planReferenceResize(width: number, height: number): ResizePlan {
|
|
128
|
+
const w = Math.max(0, Math.floor(Number(width) || 0));
|
|
129
|
+
const h = Math.max(0, Math.floor(Number(height) || 0));
|
|
130
|
+
if (!w || !h) return { tall: false, passThrough: true, width: w, height: h };
|
|
131
|
+
|
|
132
|
+
const tall = h >= w * TALL_TRIGGER_RATIO;
|
|
133
|
+
if (!tall) {
|
|
134
|
+
const scale = Math.min(1, REFERENCE_MAX_EDGE / Math.max(w, h));
|
|
135
|
+
if (scale >= 1) return { tall, passThrough: true, width: w, height: h };
|
|
136
|
+
return {
|
|
137
|
+
tall,
|
|
138
|
+
passThrough: false,
|
|
139
|
+
width: Math.max(1, Math.round(w * scale)),
|
|
140
|
+
height: Math.max(1, Math.round(h * scale)),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
let scale = Math.min(1, TALL_MAX_WIDTH / w);
|
|
145
|
+
if (w * h * scale * scale > MAX_CANVAS_PIXELS) {
|
|
146
|
+
scale = Math.min(scale, Math.sqrt(MAX_CANVAS_PIXELS / (w * h)));
|
|
147
|
+
}
|
|
148
|
+
if (scale >= 1 || w * scale < TALL_MIN_WIDTH) {
|
|
149
|
+
// 줄일 것이 없거나, 줄이면 읽을 수 없어진다. 둘 다 원본이 답이다.
|
|
150
|
+
return { tall, passThrough: true, width: w, height: h };
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
tall,
|
|
154
|
+
passThrough: false,
|
|
155
|
+
width: Math.max(1, Math.round(w * scale)),
|
|
156
|
+
height: Math.max(1, Math.round(h * scale)),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
62
160
|
/**
|
|
63
161
|
* 줄이기 결과. ``width``/``height`` 는 **보낼 그림의 크기**다 — 판독 값이 그 크기로
|
|
64
162
|
* 정해지므로(비전 입력은 타일 수로 청구된다) 원본이 아니라 줄인 뒤 크기여야 한다.
|
|
65
163
|
* 디코드가 안 되면 0 이고, 그때는 부르는 쪽이 "모른다"로 다뤄야 한다.
|
|
164
|
+
*
|
|
165
|
+
* ``tall`` 은 세로로 긴 캡쳐라 **원본 폭을 지키고 보낸다**는 표시다. 그 그림은 서버가
|
|
166
|
+
* 밴드로 나누므로 한 장짜리 바이트 상한을 그대로 걸면 안 된다.
|
|
66
167
|
*/
|
|
67
|
-
export type ShrunkReference = {
|
|
168
|
+
export type ShrunkReference = {
|
|
169
|
+
uri: string;
|
|
170
|
+
width: number;
|
|
171
|
+
height: number;
|
|
172
|
+
tall: boolean;
|
|
173
|
+
};
|
|
68
174
|
|
|
69
175
|
/**
|
|
70
176
|
* 이미 읽어 둔 data URI 를 줄인다. 디코드나 캔버스가 안 되면 **원본을 그대로** 돌려준다 —
|
|
@@ -77,27 +183,34 @@ export async function shrinkReferenceDataUri(
|
|
|
77
183
|
// 순서를 뒤집으면 줄이지도 못할 그림을 몇 초씩 기다렸다가 포기하게 된다.
|
|
78
184
|
const canvas = document.createElement("canvas");
|
|
79
185
|
const ctx = canvas.getContext("2d");
|
|
80
|
-
if (!ctx) return { uri: original, width: 0, height: 0 };
|
|
186
|
+
if (!ctx) return { uri: original, width: 0, height: 0, tall: false };
|
|
81
187
|
|
|
82
188
|
try {
|
|
83
189
|
const img = await loadImage(original);
|
|
84
|
-
const
|
|
85
|
-
if (!
|
|
86
|
-
|
|
87
|
-
|
|
190
|
+
const plan = planReferenceResize(img.naturalWidth, img.naturalHeight);
|
|
191
|
+
if (!plan.width || !plan.height) {
|
|
192
|
+
return { uri: original, width: 0, height: 0, tall: false };
|
|
193
|
+
}
|
|
194
|
+
if (plan.passThrough) {
|
|
88
195
|
// 줄일 것이 없어도 크기는 안다 — 여기서 0 을 내면 안 줄인 그림만 값이 비싸진다.
|
|
89
|
-
return {
|
|
196
|
+
return {
|
|
197
|
+
uri: original,
|
|
198
|
+
width: plan.width,
|
|
199
|
+
height: plan.height,
|
|
200
|
+
tall: plan.tall,
|
|
201
|
+
};
|
|
90
202
|
}
|
|
91
203
|
|
|
92
|
-
canvas.width =
|
|
93
|
-
canvas.height =
|
|
204
|
+
canvas.width = plan.width;
|
|
205
|
+
canvas.height = plan.height;
|
|
94
206
|
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
95
207
|
return {
|
|
96
208
|
uri: canvas.toDataURL("image/jpeg", QUALITY),
|
|
97
209
|
width: canvas.width,
|
|
98
210
|
height: canvas.height,
|
|
211
|
+
tall: plan.tall,
|
|
99
212
|
};
|
|
100
213
|
} catch {
|
|
101
|
-
return { uri: original, width: 0, height: 0 };
|
|
214
|
+
return { uri: original, width: 0, height: 0, tall: false };
|
|
102
215
|
}
|
|
103
216
|
}
|
|
@@ -45,7 +45,7 @@ export const MAX_GIF_WIDTH = 512;
|
|
|
45
45
|
/**
|
|
46
46
|
* MP4 gets a wider cap than GIF/WebP. That 512 is a *transfer* limit: GIF pays
|
|
47
47
|
* for every pixel in a palette-coded frame, and WebP frames travel to the server
|
|
48
|
-
*
|
|
48
|
+
* one by one under an upload cap. MP4 is encoded right here by a hardware H.264
|
|
49
49
|
* encoder that eats resolution cheaply, so holding it at 512 would only make the
|
|
50
50
|
* video soft on a ~860px-wide detail page for no gain.
|
|
51
51
|
*/
|
|
@@ -131,12 +131,35 @@ function buildGlobalPalette(frames: HTMLCanvasElement[]): number[][] {
|
|
|
131
131
|
/** Output format for an animated section. WebP is the default. */
|
|
132
132
|
export type AnimationFormat = "webp" | "gif" | "mp4";
|
|
133
133
|
|
|
134
|
-
/**
|
|
135
|
-
|
|
134
|
+
/**
|
|
135
|
+
* Wire format for the frames that travel to the server for WebP encoding.
|
|
136
|
+
*
|
|
137
|
+
* They used to go as lossless PNG. That is what a section costs when nothing is
|
|
138
|
+
* thrown away: a 512×2644 composed frame is ~1.4MB as PNG, and a section is up
|
|
139
|
+
* to ``GIF_MAX_FRAMES`` of them — ~56MB of request body, which no edge in front
|
|
140
|
+
* of the API accepts (nginx answers 413 before the app sees a byte).
|
|
141
|
+
*
|
|
142
|
+
* The same frame is ~220KB as quality-92 WebP. Nothing visible is lost on the
|
|
143
|
+
* way: the server's animated WebP is itself encoded at quality 80, so a
|
|
144
|
+
* near-lossless intermediate is already below the floor the output sits on.
|
|
145
|
+
*/
|
|
146
|
+
const FRAME_WIRE_TYPE = "image/webp";
|
|
147
|
+
const FRAME_WIRE_QUALITY = 0.92;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Turn a canvas into the Blob that goes over the wire.
|
|
151
|
+
*
|
|
152
|
+
* A browser that cannot encode WebP falls back to PNG on its own — ``toBlob``
|
|
153
|
+
* is specified to use ``image/png`` for a type it does not support — and the
|
|
154
|
+
* server sniffs the bytes rather than trusting the name, so that path still
|
|
155
|
+
* encodes. It just pays the old size.
|
|
156
|
+
*/
|
|
157
|
+
export function canvasToFrameBlob(canvas: HTMLCanvasElement): Promise<Blob> {
|
|
136
158
|
return new Promise((resolve, reject) => {
|
|
137
159
|
canvas.toBlob(
|
|
138
160
|
(blob) => (blob ? resolve(blob) : reject(new Error("canvas toBlob failed"))),
|
|
139
|
-
|
|
161
|
+
FRAME_WIRE_TYPE,
|
|
162
|
+
FRAME_WIRE_QUALITY,
|
|
140
163
|
);
|
|
141
164
|
});
|
|
142
165
|
}
|
|
@@ -161,8 +184,8 @@ function encodeFramesAsGif(frames: HTMLCanvasElement[], delayMs: number): Blob {
|
|
|
161
184
|
* Encode composed frames as an animated WebP.
|
|
162
185
|
*
|
|
163
186
|
* The round trip is not an optimisation choice — no browser can encode animated
|
|
164
|
-
* WebP, so the frames have to leave the page.
|
|
165
|
-
*
|
|
187
|
+
* WebP, so the frames have to leave the page. See ``FRAME_WIRE_TYPE`` for why
|
|
188
|
+
* they leave as WebP rather than as the PNG they are composed in.
|
|
166
189
|
*/
|
|
167
190
|
async function encodeFramesAsWebp(
|
|
168
191
|
host: DetailPageHost,
|
|
@@ -170,7 +193,7 @@ async function encodeFramesAsWebp(
|
|
|
170
193
|
delayMs: number,
|
|
171
194
|
signal?: AbortSignal,
|
|
172
195
|
): Promise<Blob> {
|
|
173
|
-
const payload = await Promise.all(frames.map(
|
|
196
|
+
const payload = await Promise.all(frames.map(canvasToFrameBlob));
|
|
174
197
|
const fps = Math.max(1, Math.round(1000 / Math.max(1, delayMs)));
|
|
175
198
|
return host.api.encodeDetailPageAnimation(payload, { fps, format: "webp" }, signal);
|
|
176
199
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leviosa-ai/detail-page-editor",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"description": "Leviosa detail-page editor shell. Source-only: consumers compile it through Next transpilePackages. Every app coupling (sourcing server, brand store, cache keys, toast) arrives through the DetailPageHost interface.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "UNLICENSED",
|