@leviosa-ai/detail-page-editor 0.6.3 → 0.6.4

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.
@@ -9,6 +9,7 @@ import {
9
9
  REFERENCE_ACCEPT_ATTR,
10
10
  estimateBriefCredits,
11
11
  finalizeReferenceDataUri,
12
+ planReferenceTokens,
12
13
  readReferenceFile,
13
14
  referenceOrdinal,
14
15
  type DesignReferenceAspect,
@@ -46,6 +47,9 @@ type Picked = {
46
47
  aspects: DesignReferenceAspect[];
47
48
  /** 이 장의 비전 입력 토큰. 0 은 "아직 못 쟀다" — 값은 비싼 쪽으로 잡힌다. */
48
49
  inputTokens: number;
50
+ /** 이 장의 가로세로. 0 이면 아직 못 쟀다 — 세로로 긴 캡쳐의 값은 이 크기로 정해진다. */
51
+ width: number;
52
+ height: number;
49
53
  };
50
54
 
51
55
  export function DesignReferencePicker({
@@ -70,6 +74,9 @@ export function DesignReferencePicker({
70
74
  aspects: DesignReferenceAspect[];
71
75
  /** 앞 화면에서 이미 잰 크기. 0 이면 여기서도 비싼 쪽으로 잡는다. */
72
76
  inputTokens: number;
77
+ /** 앞 화면에서 잰 가로세로. 옛 임시저장에는 없어서 선택이다. */
78
+ width?: number;
79
+ height?: number;
73
80
  }[];
74
81
  disabled?: boolean;
75
82
  }) {
@@ -82,6 +89,8 @@ export function DesignReferencePicker({
82
89
  uri: entry.uri,
83
90
  aspects: [...entry.aspects],
84
91
  inputTokens: entry.inputTokens,
92
+ width: entry.width ?? 0,
93
+ height: entry.height ?? 0,
85
94
  })),
86
95
  );
87
96
  const [instruction, setInstruction] = useState("");
@@ -106,6 +115,8 @@ export function DesignReferencePicker({
106
115
  uri: await readReferenceFile(file),
107
116
  aspects: [],
108
117
  inputTokens: 0,
118
+ width: 0,
119
+ height: 0,
109
120
  });
110
121
  } catch (err) {
111
122
  setError(err instanceof Error ? err.message : "참고 사진을 붙이지 못했어요.");
@@ -128,7 +139,13 @@ export function DesignReferencePicker({
128
139
  setItems((prev) =>
129
140
  prev.map((entry) =>
130
141
  entry.id === item.id
131
- ? { ...entry, uri: result.uri, inputTokens: result.inputTokens }
142
+ ? {
143
+ ...entry,
144
+ uri: result.uri,
145
+ inputTokens: result.inputTokens,
146
+ width: result.width,
147
+ height: result.height,
148
+ }
132
149
  : entry,
133
150
  ),
134
151
  );
@@ -168,7 +185,10 @@ export function DesignReferencePicker({
168
185
 
169
186
  // 값은 장수가 아니라 **붙인 그림의 크기**로 정해진다. 서버와 같은 공식이라 여기 뜬 수가
170
187
  // 곧 청구될 수다(선차감이라 미리 맞아야 한다).
171
- const credits = estimateBriefCredits(items.map((item) => item.inputTokens));
188
+ //
189
+ // 크기를 그대로 넘기는 이유는 세로로 긴 캡쳐 때문이다 — 서버가 조각내 싣고, 조각 수는
190
+ // **몇 장을 붙였는지**에 따라 달라진다. 장마다 미리 굳혀 둔 토큰 수로는 못 센다.
191
+ const credits = estimateBriefCredits(planReferenceTokens(items));
172
192
 
173
193
  const analyze = useCallback(async () => {
174
194
  if (!items.length || busy) return;
@@ -130,14 +130,20 @@ export async function readReferenceFile(file: File): Promise<string> {
130
130
  * 번 더 잰다 — 줄이기가 못 돈 큰 그림이 그대로 서버까지 가서 422 로 돌아오면, 유저는
131
131
  * 붙이는 자리가 아니라 제출 자리에서 실패를 본다.
132
132
  *
133
- * 크기(``inputTokens``)도 여기서 함께 잰다. 판독 값이 그 크기로 정해지는데, 재는 자리를
134
- * 따로 두면 그림을 한 번 더 디코딩하게 된다. 못 쟀으면 0 이고, 값은 비싼 쪽으로 잡힌다.
133
+ * 크기도 여기서 함께 잰다. 판독 값이 그 크기로 정해지는데, 재는 자리를 따로 두면 그림을
134
+ * 한 번 더 디코딩하게 된다. 못 쟀으면 0 이고, 값은 비싼 쪽으로 잡힌다.
135
+ *
136
+ * **가로세로를 그대로 함께 돌려준다.** 세로로 긴 캡쳐는 서버가 조각내 싣기 때문에
137
+ * ``inputTokens`` 한 수로는 값을 못 낸다 — 조각 수가 **몇 장을 붙였는지**에 따라 달라져서,
138
+ * 붙이는 시점에는 아직 정할 수 없다({@link planReferenceTokens}).
135
139
  *
136
140
  * @returns 줄인 data URI, 또는 그래도 상한을 넘겼으면 이유.
137
141
  */
138
142
  export async function finalizeReferenceDataUri(
139
143
  original: string,
140
- ): Promise<{ uri: string; inputTokens: number } | { error: string }> {
144
+ ): Promise<
145
+ { uri: string; inputTokens: number; width: number; height: number } | { error: string }
146
+ > {
141
147
  const shrunk = await shrinkReferenceDataUri(original);
142
148
  const cap = shrunk.tall
143
149
  ? MAX_TALL_REFERENCE_UPLOAD_BYTES
@@ -151,6 +157,8 @@ export async function finalizeReferenceDataUri(
151
157
  return {
152
158
  uri: shrunk.uri,
153
159
  inputTokens: estimateImageInputTokens(shrunk.width, shrunk.height),
160
+ width: shrunk.width,
161
+ height: shrunk.height,
154
162
  };
155
163
  }
156
164
 
@@ -237,11 +245,146 @@ export function estimateImageInputTokens(width: number, height: number): number
237
245
  return IMAGE_TOKENS_BASE + IMAGE_TOKENS_PER_TILE * tiles;
238
246
  }
239
247
 
248
+ /* ---- 세로로 긴 캡쳐의 조각내기 ---------------------------------------------- *
249
+ *
250
+ * 상세페이지 전체 캡쳐는 긴 변이 **세로**라, 한 장으로 실으면 폭이 뭉개진다(실측
251
+ * 900×39418 → 36×1568). 그래서 서버가 위에서 아래로 조각내 여러 장으로 싣는다
252
+ * (``app/services/detail_page/design_reference.py`` 의 ``plan_bands``).
253
+ *
254
+ * 그 산수를 여기 옮겨 두는 이유는 **판독이 선차감**이기 때문이다. 조각이 늘면 입력
255
+ * 토큰도 그만큼 늘어나는데, 화면이 그것을 모르면 "1크레딧"이라고 써 놓고 8을 받는다.
256
+ * 서버가 그림을 열어 재는 것과 달리 여기서는 열 수 없으므로, **크기만으로 정해지는**
257
+ * 계획을 양쪽이 나눠 갖는다. 상수 하나라도 갈라지면 값이 어긋난다.
258
+ */
259
+
260
+ /** 조각내기를 시작하는 종횡비(세로/가로). 서버의 ``BAND_TRIGGER_RATIO``. */
261
+ const BAND_TRIGGER_RATIO = 2.5;
262
+ /** 조각 한 장의 폭 상한. 원본이 이보다 좁으면 키우지 않는다. */
263
+ const BAND_WIDTH_PX = 1024;
264
+ /** 조각 한 장의 높이. 폭 상한과 짝지어 2×3 타일이 되는 값이다. */
265
+ const BAND_HEIGHT_PX = 1536;
266
+ /** 조각끼리 겹치는 비율. 경계에 걸친 섹션이 반쪽씩만 보이면 두 블록으로 읽힌다. */
267
+ const BAND_OVERLAP = 0.08;
268
+ /** 한 요청에 실을 조각 총량. 장수로 나눠 쓴다. */
269
+ const MAX_REFERENCE_BANDS = 60;
270
+ /** 장당 최소 조각 수. 총량을 장수로 나눌 때의 바닥이다. */
271
+ const MIN_BANDS_PER_REFERENCE = 8;
272
+ /** 안 나뉘는 장의 긴 변 상한. 서버가 여기까지 줄인 **뒤** 토큰을 센다. */
273
+ const MAX_REFERENCE_EDGE_PX = 1568;
274
+
275
+ /**
276
+ * 이 요청에서 한 장에 허용되는 조각 수. 서버의 ``band_budget_for``.
277
+ *
278
+ * 총량을 장수로 나눈다 — 한 장이 예산을 다 쓰면 뒤 장이 굶는다. 그래서 **같은 그림도
279
+ * 몇 장을 함께 붙였는지에 따라 값이 달라진다.**
280
+ */
281
+ export function bandBudgetFor(referenceCount: number): number {
282
+ const n = Math.max(1, Math.floor(Number(referenceCount) || 1));
283
+ return Math.max(MIN_BANDS_PER_REFERENCE, Math.floor(MAX_REFERENCE_BANDS / n));
284
+ }
285
+
286
+ /** 조각 계획. ``tops`` 가 비어 있으면 안 나눈다는 뜻이다. */
287
+ export type BandPlan = { width: number; height: number; tops: number[] };
288
+
289
+ /**
290
+ * 세로로 긴 한 장을 어떻게 나눌지 — 서버의 ``plan_bands`` 를 그대로 옮긴 것.
291
+ *
292
+ * 픽셀을 안 만지므로 그림을 열지 않고도 답이 나온다. 반올림을 ``Math.round`` 로 두는
293
+ * 것이 계약이다: 파이썬 기본 ``round`` 는 0.5 를 짝수로 붙여서, 서버 쪽이 그 자리에서만
294
+ * 따로 반올림을 맞춰 두었다.
295
+ */
296
+ export function planBands(width: number, height: number, bandBudget: number): BandPlan {
297
+ const w = Math.max(0, Math.floor(Number(width) || 0));
298
+ const h = Math.max(0, Math.floor(Number(height) || 0));
299
+ if (bandBudget <= 1 || w <= 0 || h <= 0) return { width: w, height: h, tops: [] };
300
+ if (h < w * BAND_TRIGGER_RATIO) return { width: w, height: h, tops: [] };
301
+
302
+ const step = BAND_HEIGHT_PX * (1 - BAND_OVERLAP);
303
+ // 예산만큼의 조각으로 덮을 수 있는 세로 길이. 첫 장은 통째로 쓰고 그 뒤는 겹치는
304
+ // 만큼 덜 나간다.
305
+ const coverable = (bandBudget - 1) * step + BAND_HEIGHT_PX;
306
+ const scale = Math.min(1, BAND_WIDTH_PX / w, coverable / h);
307
+ const targetW = Math.max(1, Math.round(w * scale));
308
+ const targetH = Math.max(1, Math.round(h * scale));
309
+ if (targetH <= BAND_HEIGHT_PX) return { width: w, height: h, tops: [] };
310
+
311
+ const tops: number[] = [];
312
+ let top = 0;
313
+ for (;;) {
314
+ const cut = Math.round(top);
315
+ tops.push(cut);
316
+ if (cut + BAND_HEIGHT_PX >= targetH || tops.length >= bandBudget) break;
317
+ top += step;
318
+ }
319
+ return { width: targetW, height: targetH, tops };
320
+ }
321
+
322
+ /**
323
+ * 레퍼런스 한 장이 **실제로 실릴 때** 차지하는 입력 토큰. 서버의
324
+ * ``estimate_reference_input_tokens``.
325
+ *
326
+ * {@link estimateImageInputTokens} 와 갈리는 지점은 정규화를 반영한다는 것이다: 세로로
327
+ * 긴 캡쳐는 조각 수만큼 곱해지고, 안 나뉘는 장은 서버가 줄인 뒤의 크기로 센다.
328
+ */
329
+ export function estimateReferenceInputTokens(
330
+ width: number,
331
+ height: number,
332
+ bandBudget = 1,
333
+ ): number {
334
+ const plan = planBands(width, height, bandBudget);
335
+ if (plan.tops.length) {
336
+ return plan.tops.reduce(
337
+ (sum, top) =>
338
+ sum +
339
+ estimateImageInputTokens(
340
+ plan.width,
341
+ Math.min(plan.height, top + BAND_HEIGHT_PX) - top,
342
+ ),
343
+ 0,
344
+ );
345
+ }
346
+
347
+ let w = Math.max(0, Math.floor(Number(width) || 0));
348
+ let h = Math.max(0, Math.floor(Number(height) || 0));
349
+ if (w <= 0 || h <= 0) return UNKNOWN_IMAGE_TOKENS;
350
+ const longest = Math.max(w, h);
351
+ if (longest > MAX_REFERENCE_EDGE_PX) {
352
+ const scale = MAX_REFERENCE_EDGE_PX / longest;
353
+ w = Math.max(1, Math.round(w * scale));
354
+ h = Math.max(1, Math.round(h * scale));
355
+ }
356
+ return estimateImageInputTokens(w, h);
357
+ }
358
+
359
+ /**
360
+ * 붙여 둔 레퍼런스들의 장별 입력 토큰. {@link estimateBriefCredits} 에 그대로 넣는다.
361
+ *
362
+ * 장수를 여기서 알아야 하는 이유는 조각 예산이 장수로 나뉘기 때문이다 — 같은 캡쳐도
363
+ * 혼자면 28조각, 여섯 장이면 10조각이다. 그래서 붙이는 시점이 아니라 **값을 말하는
364
+ * 시점**에 센다.
365
+ *
366
+ * 크기를 못 잰 장(옛 임시저장 등)은 그 장이 들고 있던 ``inputTokens`` 를 쓰고, 그것도
367
+ * 없으면 비싼 쪽으로 잡는다.
368
+ */
369
+ export function planReferenceTokens(
370
+ items: readonly { width?: number; height?: number; inputTokens?: number }[],
371
+ ): number[] {
372
+ const budget = bandBudgetFor(items.length);
373
+ return items.map((item) => {
374
+ const w = Math.floor(Number(item.width) || 0);
375
+ const h = Math.floor(Number(item.height) || 0);
376
+ if (w > 0 && h > 0) return estimateReferenceInputTokens(w, h, budget);
377
+ const known = Math.floor(Number(item.inputTokens) || 0);
378
+ return known > 0 ? known : UNKNOWN_IMAGE_TOKENS;
379
+ });
380
+ }
381
+
240
382
  /**
241
383
  * 붙여 둔 레퍼런스를 읽는 데 드는 크레딧. 서버의 ``estimate_brief_credits`` 와 같은 값을
242
384
  * 낸다 — 다르면 화면이 말한 값과 청구가 어긋난다.
243
385
  *
244
- * @param imageTokens 장별 입력 토큰. 못 잰 장은 0 으로 넣으면 비싼 쪽으로 잡는다.
386
+ * @param imageTokens 장별 입력 토큰. 세로로 긴 캡쳐가 섞일 수 있으면
387
+ * {@link planReferenceTokens} 가 낸 값을 넣는다 — 조각내기를 안 세면 값이 모자란다.
245
388
  */
246
389
  export function estimateBriefCredits(imageTokens: number[]): number {
247
390
  if (!imageTokens.length) return 0;
@@ -68,6 +68,15 @@ export interface NewProductDraftReference {
68
68
  * 0 은 못 쟀다는 뜻이다.
69
69
  */
70
70
  inputTokens: number;
71
+ /**
72
+ * 줄인 뒤의 가로세로. 세로로 긴 캡쳐는 서버가 조각내 싣고 그 조각 수가 **몇 장을
73
+ * 붙였는지**에 따라 달라져서, 굳혀 둔 토큰 수 하나로는 값을 낼 수 없다.
74
+ *
75
+ * 이 필드가 없던 시절의 초안도 그대로 열려야 하므로 선택이다 — 그때는 위
76
+ * ``inputTokens`` 로 떨어진다(세로로 긴 장이면 값을 낮게 잡는다).
77
+ */
78
+ width?: number;
79
+ height?: number;
71
80
  }
72
81
 
73
82
  export interface NewProductDraft {
@@ -169,6 +178,8 @@ function references(value: unknown): NewProductDraftReference[] {
169
178
  uri,
170
179
  aspects,
171
180
  inputTokens: typeof item.inputTokens === "number" ? item.inputTokens : 0,
181
+ ...(typeof item.width === "number" ? { width: item.width } : {}),
182
+ ...(typeof item.height === "number" ? { height: item.height } : {}),
172
183
  },
173
184
  ];
174
185
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leviosa-ai/detail-page-editor",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
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",