@leviosa-ai/detail-page-editor 0.6.2 → 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
|
-
? {
|
|
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
|
-
|
|
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
|
-
*
|
|
134
|
-
*
|
|
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<
|
|
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 장별 입력 토큰.
|
|
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
|
});
|
|
@@ -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.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",
|