@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,763 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useState, type ChangeEvent } from "react";
|
|
4
|
+
import { ImagePlus, Info, Loader2, Pencil, Sparkles, X } from "lucide-react";
|
|
5
|
+
import { useTranslation } from "react-i18next";
|
|
6
|
+
|
|
7
|
+
import { AnnotationDialog } from "./annotation-dialog";
|
|
8
|
+
import { toDrawableDataUri } from "../../lib/detail-page/image-data-uri";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_IMAGE_TIER,
|
|
12
|
+
IMAGE_TIERS,
|
|
13
|
+
IMAGE_TIER_META,
|
|
14
|
+
isImageCreditBlocked,
|
|
15
|
+
imageCreditRequired,
|
|
16
|
+
type ImageTier,
|
|
17
|
+
} from "../../lib/detail-page/image-credit";
|
|
18
|
+
import {
|
|
19
|
+
Select,
|
|
20
|
+
SelectContent,
|
|
21
|
+
SelectItem,
|
|
22
|
+
SelectTrigger,
|
|
23
|
+
SelectValue,
|
|
24
|
+
} from "../ui/select";
|
|
25
|
+
import {
|
|
26
|
+
Tooltip,
|
|
27
|
+
TooltipContent,
|
|
28
|
+
TooltipTrigger,
|
|
29
|
+
} from "../ui/tooltip";
|
|
30
|
+
import { useDetailPageHost } from "./detail-page-host-context";
|
|
31
|
+
import type { DataGifRequestInput } from "../../lib/detail-page/data-gif-payload";
|
|
32
|
+
import { insertPersonalImage } from "../../lib/detail-page/insert-image";
|
|
33
|
+
import {
|
|
34
|
+
useFakeProgress,
|
|
35
|
+
GENERATION_ESTIMATE_MS,
|
|
36
|
+
} from "../../lib/detail-page/use-fake-progress";
|
|
37
|
+
import {
|
|
38
|
+
GenerationProgressFill,
|
|
39
|
+
progressPercent,
|
|
40
|
+
} from "./generation-progress-fill";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Custom Canvas side-panel section: AI 이미지 생성.
|
|
44
|
+
*
|
|
45
|
+
* the stock editor's stock ``ai-images-panel`` calls the stock editor's own AI behind their API
|
|
46
|
+
* key. We instead surface OUR generation pipeline: the editor injects an
|
|
47
|
+
* ``onGenerate`` callback (wired to ``generateDetailPageImage`` in the real app,
|
|
48
|
+
* stubbed in the dev harness) and ``uploadFile`` for reference images. The
|
|
49
|
+
* panel only knows prompt-in / image-URL-out, so it stays decoupled from the
|
|
50
|
+
* backend contract.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
export type GenerateImageInput = {
|
|
54
|
+
prompt: string;
|
|
55
|
+
referenceImages: string[];
|
|
56
|
+
mode: "new_reference" | "based_on_existing";
|
|
57
|
+
/**
|
|
58
|
+
* 유저가 밑그림 위에 직접 표시한 마킹본(PNG data URI).
|
|
59
|
+
*
|
|
60
|
+
* 그림은 **어디를**, 글은 **무엇을** 이다. 마킹이 있으면 ``prompt`` 는 비어도 된다 —
|
|
61
|
+
* 손글씨로 적을 수 있다.
|
|
62
|
+
*/
|
|
63
|
+
annotatedImage?: string;
|
|
64
|
+
/** 선택된 이미지 모델 티어(basic/pro/max). 미지정 시 백엔드 기본값(pro). */
|
|
65
|
+
tier: ImageTier;
|
|
66
|
+
/**
|
|
67
|
+
* 주면 서버가 결과를 브랜드 자산 버킷에 직접 쓴다. 예전처럼 결과 URL을 다시
|
|
68
|
+
* 내려받아 재업로드하지 않는다 — 그 왕복이 S3 CORS를 두 번 타서, 한 번만
|
|
69
|
+
* 막혀도 이미지는 만들어졌는데 브랜드 버킷엔 아무것도 안 남았다.
|
|
70
|
+
*/
|
|
71
|
+
brandId?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type GenerateImageFn = (input: GenerateImageInput) => Promise<string[]>;
|
|
75
|
+
|
|
76
|
+
export type GenerateGifInput = {
|
|
77
|
+
prompt: string;
|
|
78
|
+
referenceImages: string[];
|
|
79
|
+
/** 배경 투명 처리(마젠타 크로마키). 상세페이지 움짤 기본값. */
|
|
80
|
+
transparent: boolean;
|
|
81
|
+
/** 이미지와 같다. 저장 위치는 start 시점에 확정된다. */
|
|
82
|
+
brandId?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export type GenerateGifFn = (input: GenerateGifInput) => Promise<string[]>;
|
|
86
|
+
|
|
87
|
+
export type GenerateTextGifInput = {
|
|
88
|
+
/** GIF로 만들 텍스트(선택 텍스트 요소의 현재 내용). */
|
|
89
|
+
text: string;
|
|
90
|
+
/** 이펙트 id(blur_in|wave|shimmer|typewriter|bounce|glow_pulse|wobble|fade_up). */
|
|
91
|
+
effect: string;
|
|
92
|
+
/** 글자색 HEX. */
|
|
93
|
+
color?: string;
|
|
94
|
+
/** 강조색 HEX(shimmer/glow_pulse). */
|
|
95
|
+
accent?: string;
|
|
96
|
+
/** 합성 배경색 HEX(텍스트가 놓인 페이지/섹션 배경색 → 엣지 정합). */
|
|
97
|
+
background?: string;
|
|
98
|
+
/** 글자 크기(px, 요소 fontSize). */
|
|
99
|
+
fontSize?: number;
|
|
100
|
+
/** 글자 굵기(요소 fontWeight). */
|
|
101
|
+
fontWeight?: number;
|
|
102
|
+
/** 편집기에서 쓰는 font-family 이름. */
|
|
103
|
+
fontFamily?: string;
|
|
104
|
+
/**
|
|
105
|
+
* 여러 줄(위→아래). 그룹으로 묶인 텍스트를 통째로 GIF화할 때 쓴다. 요소 하나 안의
|
|
106
|
+
* 줄바꿈도 여기서 줄로 쪼개 보낸다(SVG ``<text>``는 개행을 공백으로 접는다).
|
|
107
|
+
*/
|
|
108
|
+
lines?: Array<{
|
|
109
|
+
text: string;
|
|
110
|
+
color: string;
|
|
111
|
+
fontSize: number;
|
|
112
|
+
fontWeight: number;
|
|
113
|
+
fontFamily: string;
|
|
114
|
+
/** 원본 상자 기준 앵커 x(px). 주면 서버가 추정 대신 그 자리에 찍는다. */
|
|
115
|
+
x?: number;
|
|
116
|
+
/** 원본 상자 기준 줄의 세로 중심 y(px). */
|
|
117
|
+
y?: number;
|
|
118
|
+
/** 가로 정렬 기준. */
|
|
119
|
+
anchor?: "start" | "middle" | "end";
|
|
120
|
+
}>;
|
|
121
|
+
/**
|
|
122
|
+
* 서버가 ``@font-face``로 주입할 폰트 파일 주소. 서버 컨테이너엔 우리 폰트가 없어서
|
|
123
|
+
* 이름만 보내면 시스템 폴백(픽셀 폰트)으로 그려진다.
|
|
124
|
+
*/
|
|
125
|
+
fonts?: Array<{ family: string; url: string; weight: number }>;
|
|
126
|
+
/**
|
|
127
|
+
* 원본 텍스트 상자 크기(px)와 여백. 주면 서버가 캔버스를 글자 수로 추정하지 않고 이
|
|
128
|
+
* 상자에 맞춰 그린다 — 편집기가 같은 상자에 되꽂아야 글자 크기가 원본과 같다.
|
|
129
|
+
*/
|
|
130
|
+
boxWidth?: number;
|
|
131
|
+
boxHeight?: number;
|
|
132
|
+
bleed?: number;
|
|
133
|
+
/**
|
|
134
|
+
* 활성 브랜드 id. 주면 서버가 결과 GIF를 브랜드 자산 버킷에 **직접** 쓴다.
|
|
135
|
+
* 예전에는 브라우저가 개인 버킷에서 다시 내려받아 재업로드했는데, 서버가 이미
|
|
136
|
+
* 들고 있는 바이트를 왕복시키는 구조라 S3 CORS를 두 번 탔다.
|
|
137
|
+
*/
|
|
138
|
+
brandId?: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type GenerateTextGifFn = (
|
|
142
|
+
input: GenerateTextGifInput,
|
|
143
|
+
) => Promise<string[]>;
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 수치를 GIF로(카운트업 · 셀 차오름). 입력은 `buildDataGifPayload`가 받는 그대로다 —
|
|
147
|
+
* 호스트(운영 편집기 / 데모)는 페이로드 조립을 그 함수에 맡기고 전송만 한다.
|
|
148
|
+
*/
|
|
149
|
+
export type GenerateDataGifFn = (
|
|
150
|
+
input: DataGifRequestInput,
|
|
151
|
+
) => Promise<string[]>;
|
|
152
|
+
|
|
153
|
+
export type GenerateImageGifInput = {
|
|
154
|
+
/** GIF로 만들 소스 이미지(선택 이미지 요소의 src, data URI 또는 http(s) URL). */
|
|
155
|
+
sourceImage: string;
|
|
156
|
+
/** 이펙트 id(ken_burns|pulse_zoom|holo_foil|…). */
|
|
157
|
+
effect: string;
|
|
158
|
+
/** 캔버스 밖을 채울 배경색 HEX(이미지가 놓인 페이지 배경 → 엣지 정합). */
|
|
159
|
+
background?: string;
|
|
160
|
+
/** 활성 브랜드 id. 주면 서버가 결과를 브랜드 자산 버킷에 직접 쓴다. */
|
|
161
|
+
brandId?: string;
|
|
162
|
+
/**
|
|
163
|
+
* 소스가 사진인지 도형인지. 렌더는 같고, 브랜드 GIF 패널에서 어느 구획에 놓일지가
|
|
164
|
+
* 갈린다 — 구워진 GIF만 봐서는 알 수 없어 만들 때 알려줘야 한다.
|
|
165
|
+
*/
|
|
166
|
+
assetKind?: "image" | "shape";
|
|
167
|
+
/**
|
|
168
|
+
* 진행 단계 보고. 홀로그램 포일은 물체 검출(배경제거)이 붙어 체감이 길기 때문에
|
|
169
|
+
* 버튼 문구를 단계별로 바꿔줘야 "멈춘 것 같다"는 인상을 주지 않는다.
|
|
170
|
+
*/
|
|
171
|
+
onProgress?: (progress: { stage: string; progress: number }) => void;
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
export type GenerateImageGifResult = {
|
|
175
|
+
urls: string[];
|
|
176
|
+
/** 물체를 특정하지 못해 이미지 전체에 적용됐는지(패널이 안내 문구를 띄운다). */
|
|
177
|
+
maskFallback?: boolean;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
export type GenerateImageGifFn = (
|
|
181
|
+
input: GenerateImageGifInput,
|
|
182
|
+
) => Promise<GenerateImageGifResult>;
|
|
183
|
+
|
|
184
|
+
export type RemoveBackgroundInput = {
|
|
185
|
+
/** 배경을 지울 소스 이미지(선택 이미지 요소의 src, data URI 또는 http(s) URL). */
|
|
186
|
+
sourceImage: string;
|
|
187
|
+
/** 활성 브랜드 id. 주면 서버가 컷아웃을 브랜드 자산 버킷에 직접 쓴다. */
|
|
188
|
+
brandId?: string;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 선택 이미지의 배경을 지워 컷아웃(투명 PNG) 주소를 돌려준다. 실패하면 null.
|
|
193
|
+
*
|
|
194
|
+
* GIF 계열과 달리 새 요소를 삽입하지 않고 **선택 요소의 src를 갈아 끼운다** — 누끼는
|
|
195
|
+
* 새 소재를 만드는 일이 아니라 지금 놓인 사진을 고치는 일이기 때문이다.
|
|
196
|
+
*/
|
|
197
|
+
export type RemoveBackgroundFn = (
|
|
198
|
+
input: RemoveBackgroundInput,
|
|
199
|
+
) => Promise<string | null>;
|
|
200
|
+
|
|
201
|
+
type AddElementOpts = Record<string, unknown>;
|
|
202
|
+
type PageLike = {
|
|
203
|
+
computedWidth: number;
|
|
204
|
+
computedHeight: number;
|
|
205
|
+
addElement: (opts: AddElementOpts) => unknown;
|
|
206
|
+
};
|
|
207
|
+
type StoreLike = {
|
|
208
|
+
activePage?: PageLike;
|
|
209
|
+
pages: PageLike[];
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
type AiGeneratePanelProps = {
|
|
213
|
+
store: unknown;
|
|
214
|
+
onGenerate?: GenerateImageFn;
|
|
215
|
+
/** 프롬프트 → 애니메이션 GIF URL 생성(개인 폴더 저장). 없으면 GIF 모드 안내만 표시. */
|
|
216
|
+
onGenerateGif?: GenerateGifFn;
|
|
217
|
+
/** AI GIF 1회 생성 비용(크레딧). 0/미지정이면 크레딧 UI 숨김. */
|
|
218
|
+
gifCreditCost?: number;
|
|
219
|
+
/** 초기 탭(image/gif). GIF 요소 편집 시 "gif"로 열어 재생성이 기본이 되게 한다. */
|
|
220
|
+
initialMode?: "image" | "gif";
|
|
221
|
+
/**
|
|
222
|
+
* 우측 인스펙터처럼 "이미 선택된 이미지"가 참조로 암묵 주입되는 경우 true. GIF는
|
|
223
|
+
* 참조 이미지가 필수라, false(좌측)면 업로드가 있어야 생성 가능하다.
|
|
224
|
+
*/
|
|
225
|
+
hasImplicitReference?: boolean;
|
|
226
|
+
/**
|
|
227
|
+
* 암묵 참조의 실제 src(현재 선택된 이미지). 주어지면 업로드 칸 대신 이 이미지를
|
|
228
|
+
* 읽기 전용 미리보기로 띄워 "지금 이 이미지를 편집/참조 중"임을 시각적으로 보여준다.
|
|
229
|
+
* 이미지 편집·GIF 재생성 모두 선택 이미지가 입력이므로, 예시 input이 항상 보이게 한다.
|
|
230
|
+
*/
|
|
231
|
+
implicitReferenceSrc?: string;
|
|
232
|
+
/**
|
|
233
|
+
* 주면 "그림으로 지시" 진입점이 뜬다. 이 src 위에 유저가 표시한 마킹본이 원본과
|
|
234
|
+
* **함께** 생성기로 간다. 지금은 이미지 모드에서만 의미가 있다(GIF 는 첫 프레임을
|
|
235
|
+
* 그대로 쓰므로 마킹이 결과에 그려질 위험이 있다).
|
|
236
|
+
*/
|
|
237
|
+
annotateBaseSrc?: string;
|
|
238
|
+
uploadFile?: (file: File) => Promise<string>;
|
|
239
|
+
/**
|
|
240
|
+
* 결과 처리 오버라이드. 주어지면 새 이미지를 페이지에 삽입하는 대신 이 콜백을
|
|
241
|
+
* 호출한다(예: 우측 인스펙터에서 선택된 이미지의 src 교체). 버튼 라벨도 "교체"로.
|
|
242
|
+
*/
|
|
243
|
+
onResult?: (src: string) => void;
|
|
244
|
+
/**
|
|
245
|
+
* 티어별 크레딧 단가(중앙 feature_costs). 주어지면 선택된 티어의 값으로 비용/게이트를
|
|
246
|
+
* 계산한다. 단일 값 `creditCost`보다 우선. 데모(dev-canvas)는 대체 단가 맵을 넘긴다.
|
|
247
|
+
*/
|
|
248
|
+
costByTier?: Partial<Record<ImageTier, number>>;
|
|
249
|
+
/**
|
|
250
|
+
* (레거시) 단일 티어 크레딧. `costByTier` 미주입 시 선택 티어 관계없이 이 값을 쓴다.
|
|
251
|
+
* 0/미지정이면 크레딧 UI 숨김.
|
|
252
|
+
*/
|
|
253
|
+
creditCost?: number;
|
|
254
|
+
/** 현재 보유 크레딧 잔액. 1.5× 안전 마진 게이트에 사용. */
|
|
255
|
+
creditBalance?: number;
|
|
256
|
+
/** 크레딧 부족 시 "크레딧 추가하기" 목적지(레비오사 pricing). 없으면 CTA 비활성. */
|
|
257
|
+
onBuyCredits?: () => void;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** Drop the generated image onto the active page, sized to ~62% of its width. */
|
|
261
|
+
function insertImage(store: unknown, src: string) {
|
|
262
|
+
const s = store as StoreLike;
|
|
263
|
+
const page = s.activePage ?? s.pages[0];
|
|
264
|
+
if (!page) return;
|
|
265
|
+
const width = Math.round(page.computedWidth * 0.62);
|
|
266
|
+
const height = Math.round(width);
|
|
267
|
+
page.addElement({
|
|
268
|
+
type: "image",
|
|
269
|
+
src,
|
|
270
|
+
x: Math.round((page.computedWidth - width) / 2),
|
|
271
|
+
y: Math.round((page.computedHeight - height) / 2),
|
|
272
|
+
width,
|
|
273
|
+
height,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function AiGeneratePanel({
|
|
278
|
+
store,
|
|
279
|
+
onGenerate,
|
|
280
|
+
onGenerateGif,
|
|
281
|
+
gifCreditCost = 0,
|
|
282
|
+
initialMode = "image",
|
|
283
|
+
hasImplicitReference = false,
|
|
284
|
+
implicitReferenceSrc,
|
|
285
|
+
annotateBaseSrc,
|
|
286
|
+
uploadFile,
|
|
287
|
+
onResult,
|
|
288
|
+
costByTier,
|
|
289
|
+
creditCost = 0,
|
|
290
|
+
creditBalance = 0,
|
|
291
|
+
onBuyCredits,
|
|
292
|
+
}: AiGeneratePanelProps) {
|
|
293
|
+
const { t } = useTranslation("branding");
|
|
294
|
+
const { brand } = useDetailPageHost();
|
|
295
|
+
const [mode, setMode] = useState<"image" | "gif">(
|
|
296
|
+
// GIF 생성이 배선된 경우에만 gif 초기값을 존중(미배선이면 토글이 없어 갇힘).
|
|
297
|
+
initialMode === "gif" && onGenerateGif ? "gif" : "image",
|
|
298
|
+
);
|
|
299
|
+
const [tier, setTier] = useState<ImageTier>(DEFAULT_IMAGE_TIER);
|
|
300
|
+
const [transparent, setTransparent] = useState(true);
|
|
301
|
+
const [refUrl, setRefUrl] = useState<string | null>(null);
|
|
302
|
+
const [refUploading, setRefUploading] = useState(false);
|
|
303
|
+
const [prompt, setPrompt] = useState("");
|
|
304
|
+
const [loading, setLoading] = useState(false);
|
|
305
|
+
const [error, setError] = useState<string | null>(null);
|
|
306
|
+
const [insufficient, setInsufficient] = useState(false);
|
|
307
|
+
|
|
308
|
+
const isGif = mode === "gif";
|
|
309
|
+
|
|
310
|
+
// 생성 중 가짜 진행률(0~1). GIF는 이미지보다 오래 걸리므로 예상시간을 달리 준다.
|
|
311
|
+
const progress = useFakeProgress(
|
|
312
|
+
loading,
|
|
313
|
+
isGif ? GENERATION_ESTIMATE_MS.gif : GENERATION_ESTIMATE_MS.image,
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
// 활성 단가: GIF 모드는 단일 gifCreditCost, 이미지 모드는 선택 티어 단가.
|
|
317
|
+
const tierCost = costByTier?.[tier] ?? creditCost;
|
|
318
|
+
const activeCost = isGif ? gifCreditCost : tierCost;
|
|
319
|
+
|
|
320
|
+
// 잔액이 비용의 1.5배 미만이면 생성 차단(비용 0=미구성이면 게이트 비활성).
|
|
321
|
+
const blocked = isImageCreditBlocked(activeCost, creditBalance);
|
|
322
|
+
const required = imageCreditRequired(activeCost);
|
|
323
|
+
|
|
324
|
+
// GIF는 참조 이미지가 필수(image→video). 우측 인스펙터는 선택 이미지가 암묵 참조.
|
|
325
|
+
const hasReference = Boolean(refUrl) || hasImplicitReference;
|
|
326
|
+
const gifMissingRef = isGif && !hasReference;
|
|
327
|
+
|
|
328
|
+
const handleRef = useCallback(
|
|
329
|
+
async (e: ChangeEvent<HTMLInputElement>) => {
|
|
330
|
+
const file = e.target.files?.[0];
|
|
331
|
+
e.target.value = "";
|
|
332
|
+
if (!file) return;
|
|
333
|
+
if (!uploadFile) {
|
|
334
|
+
setError(t("detailPage.aiGenerate.uploadNotWired"));
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
setError(null);
|
|
338
|
+
setRefUploading(true);
|
|
339
|
+
try {
|
|
340
|
+
setRefUrl(await uploadFile(file));
|
|
341
|
+
} catch (err) {
|
|
342
|
+
setError(err instanceof Error ? err.message : t("detailPage.aiGenerate.uploadFailed"));
|
|
343
|
+
} finally {
|
|
344
|
+
setRefUploading(false);
|
|
345
|
+
}
|
|
346
|
+
},
|
|
347
|
+
[uploadFile, t],
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
// 프롬프트와 마킹본은 서로를 대신할 수 있다 — 손글씨로만 지시하는 것이 요점이라
|
|
351
|
+
// 둘 중 하나만 있어도 생성이 돌아야 한다. 대신 둘 다 없으면 지시가 없는 요청이다.
|
|
352
|
+
const runGenerate = useCallback(
|
|
353
|
+
async (override?: { prompt?: string; annotatedImage?: string }) => {
|
|
354
|
+
const finalPrompt = (override?.prompt ?? prompt).trim();
|
|
355
|
+
const annotatedImage = override?.annotatedImage;
|
|
356
|
+
if ((!finalPrompt && !annotatedImage) || loading || blocked) return;
|
|
357
|
+
const generator = isGif ? onGenerateGif : onGenerate;
|
|
358
|
+
if (!generator) {
|
|
359
|
+
setError(t("detailPage.aiGenerate.generateNotWired"));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (gifMissingRef) {
|
|
363
|
+
setError(t("detailPage.aiGenerate.gifNeedsReference"));
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
setLoading(true);
|
|
367
|
+
setError(null);
|
|
368
|
+
setInsufficient(false);
|
|
369
|
+
try {
|
|
370
|
+
const referenceImages = refUrl ? [refUrl] : [];
|
|
371
|
+
const brandId = brand.getStoredActiveBrandId() ?? undefined;
|
|
372
|
+
const urls = isGif
|
|
373
|
+
? await onGenerateGif!({
|
|
374
|
+
prompt: finalPrompt,
|
|
375
|
+
referenceImages,
|
|
376
|
+
transparent,
|
|
377
|
+
brandId,
|
|
378
|
+
})
|
|
379
|
+
: await onGenerate!({
|
|
380
|
+
prompt: finalPrompt,
|
|
381
|
+
referenceImages,
|
|
382
|
+
mode: refUrl ? "new_reference" : "based_on_existing",
|
|
383
|
+
tier,
|
|
384
|
+
brandId,
|
|
385
|
+
annotatedImage,
|
|
386
|
+
});
|
|
387
|
+
const url = urls[0];
|
|
388
|
+
if (url) {
|
|
389
|
+
// GIF는 항상 페이지에 삽입(우측 이미지 교체 대상 아님) + 애니 태깅.
|
|
390
|
+
if (isGif) insertPersonalImage(store, url, { isGif: true });
|
|
391
|
+
else if (onResult) onResult(url);
|
|
392
|
+
else insertImage(store, url);
|
|
393
|
+
} else
|
|
394
|
+
setError(
|
|
395
|
+
isGif
|
|
396
|
+
? t("detailPage.aiGenerate.noGif")
|
|
397
|
+
: t("detailPage.aiGenerate.noImage"),
|
|
398
|
+
);
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// 생성기가 크레딧 부족을 알리면(422/402 등) 별도로 표기해 CTA를 띄운다.
|
|
401
|
+
const maybe = err as { insufficientCredits?: boolean };
|
|
402
|
+
if (maybe && maybe.insufficientCredits) {
|
|
403
|
+
setInsufficient(true);
|
|
404
|
+
setError(t("detailPage.aiGenerate.insufficientCredits"));
|
|
405
|
+
} else {
|
|
406
|
+
setError(err instanceof Error ? err.message : t("detailPage.aiGenerate.generateFailed"));
|
|
407
|
+
}
|
|
408
|
+
} finally {
|
|
409
|
+
setLoading(false);
|
|
410
|
+
}
|
|
411
|
+
}, [prompt, loading, blocked, isGif, gifMissingRef, transparent, onGenerate, onGenerateGif, refUrl, store, onResult, tier, t]);
|
|
412
|
+
|
|
413
|
+
const handleGenerate = useCallback(() => {
|
|
414
|
+
void runGenerate();
|
|
415
|
+
}, [runGenerate]);
|
|
416
|
+
|
|
417
|
+
// "그림으로 지시": 선택 이미지를 밑그림으로 띄우고, 표시한 마킹본과 글을 함께 보낸다.
|
|
418
|
+
// 밑그림은 캔버스에 그릴 수 있는 바이트여야 해서(교차 출처면 합성이 터진다) 먼저
|
|
419
|
+
// data URI 로 바꾼다 — 실패하면 진입점 자체를 열지 않는다.
|
|
420
|
+
const [annotateBase, setAnnotateBase] = useState<string | null>(null);
|
|
421
|
+
const [annotateOpen, setAnnotateOpen] = useState(false);
|
|
422
|
+
const [annotatePreparing, setAnnotatePreparing] = useState(false);
|
|
423
|
+
|
|
424
|
+
const openAnnotate = useCallback(async () => {
|
|
425
|
+
if (!annotateBaseSrc) return;
|
|
426
|
+
setAnnotatePreparing(true);
|
|
427
|
+
setError(null);
|
|
428
|
+
try {
|
|
429
|
+
const dataUri = await toDrawableDataUri(annotateBaseSrc);
|
|
430
|
+
if (!dataUri) {
|
|
431
|
+
setError(
|
|
432
|
+
t("detailPage.annotate.baseUnavailable", {
|
|
433
|
+
defaultValue: "이 이미지 위에는 그릴 수 없어요. 프롬프트로 지시해 주세요.",
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
setAnnotateBase(dataUri);
|
|
439
|
+
setAnnotateOpen(true);
|
|
440
|
+
} finally {
|
|
441
|
+
setAnnotatePreparing(false);
|
|
442
|
+
}
|
|
443
|
+
}, [annotateBaseSrc, t]);
|
|
444
|
+
|
|
445
|
+
const submitAnnotation = useCallback(
|
|
446
|
+
({
|
|
447
|
+
instruction,
|
|
448
|
+
annotatedImage,
|
|
449
|
+
}: {
|
|
450
|
+
instruction: string;
|
|
451
|
+
annotatedImage: string | null;
|
|
452
|
+
}) => {
|
|
453
|
+
setAnnotateOpen(false);
|
|
454
|
+
void runGenerate({
|
|
455
|
+
prompt: instruction,
|
|
456
|
+
annotatedImage: annotatedImage ?? undefined,
|
|
457
|
+
});
|
|
458
|
+
},
|
|
459
|
+
[runGenerate],
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
return (
|
|
463
|
+
<div className="flex h-full flex-col gap-4 overflow-y-auto p-4">
|
|
464
|
+
{/* 이미지 / GIF 토글 — GIF 생성이 배선된 경우에만 노출(미배선 패널에서 "연결 안 됨"
|
|
465
|
+
막다른 길 방지). 좌측 자유 생성은 항상, 우측 편집 패널은 onGenerateGif 있을 때만. */}
|
|
466
|
+
{onGenerateGif ? (
|
|
467
|
+
<div className="grid grid-cols-2 gap-2">
|
|
468
|
+
<button
|
|
469
|
+
type="button"
|
|
470
|
+
onClick={() => setMode("image")}
|
|
471
|
+
className={
|
|
472
|
+
mode === "image"
|
|
473
|
+
? "flex h-10 items-center justify-center gap-2 rounded-md bg-neutral-900 text-sm font-semibold text-white"
|
|
474
|
+
: "flex h-10 items-center justify-center gap-2 rounded-md border border-neutral-200 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
|
475
|
+
}
|
|
476
|
+
>
|
|
477
|
+
<Sparkles aria-hidden="true" size={15} />
|
|
478
|
+
{t("detailPage.aiGenerate.image")}
|
|
479
|
+
</button>
|
|
480
|
+
<button
|
|
481
|
+
type="button"
|
|
482
|
+
onClick={() => setMode("gif")}
|
|
483
|
+
className={
|
|
484
|
+
isGif
|
|
485
|
+
? "flex h-10 items-center justify-center gap-2 rounded-md bg-neutral-900 text-sm font-semibold text-white"
|
|
486
|
+
: "flex h-10 items-center justify-center gap-2 rounded-md border border-neutral-200 text-sm font-medium text-neutral-600 hover:bg-neutral-50"
|
|
487
|
+
}
|
|
488
|
+
>
|
|
489
|
+
<Sparkles aria-hidden="true" size={15} />
|
|
490
|
+
{t("detailPage.aiGenerate.gif")}
|
|
491
|
+
</button>
|
|
492
|
+
</div>
|
|
493
|
+
) : null}
|
|
494
|
+
|
|
495
|
+
{/* GIF 모드: 배경 투명 토글(기본 ON). 상세페이지 움짤은 대체로 배경 투명. */}
|
|
496
|
+
{isGif ? (
|
|
497
|
+
<label className="flex cursor-pointer items-center justify-between rounded-md border border-neutral-200 px-3 py-2.5">
|
|
498
|
+
<span className="flex flex-col">
|
|
499
|
+
<span className="text-sm font-medium text-neutral-800">
|
|
500
|
+
{t("detailPage.aiGenerate.transparentBg")}
|
|
501
|
+
</span>
|
|
502
|
+
<span className="text-[11px] leading-4 text-neutral-500">
|
|
503
|
+
{t("detailPage.aiGenerate.transparentBgHint")}
|
|
504
|
+
</span>
|
|
505
|
+
</span>
|
|
506
|
+
<input
|
|
507
|
+
type="checkbox"
|
|
508
|
+
checked={transparent}
|
|
509
|
+
onChange={(e) => setTransparent(e.target.checked)}
|
|
510
|
+
className="h-4 w-4 accent-neutral-900"
|
|
511
|
+
/>
|
|
512
|
+
</label>
|
|
513
|
+
) : null}
|
|
514
|
+
|
|
515
|
+
{/* 이미지 모델(티어) 선택 — GIF 모드는 단일 모델이라 숨긴다. */}
|
|
516
|
+
<div hidden={isGif}>
|
|
517
|
+
<div className="mb-2 flex items-center gap-1">
|
|
518
|
+
<p className="text-xs font-medium text-neutral-500">{t("detailPage.aiGenerate.imageModel")}</p>
|
|
519
|
+
<Tooltip>
|
|
520
|
+
<TooltipTrigger asChild>
|
|
521
|
+
<button
|
|
522
|
+
type="button"
|
|
523
|
+
className="flex h-4 w-4 items-center justify-center rounded-full text-neutral-400 hover:text-neutral-600"
|
|
524
|
+
aria-label={t("detailPage.aiGenerate.modelQualityHelp")}
|
|
525
|
+
>
|
|
526
|
+
<Info aria-hidden="true" size={13} />
|
|
527
|
+
</button>
|
|
528
|
+
</TooltipTrigger>
|
|
529
|
+
<TooltipContent side="left" className="max-w-[240px] text-left">
|
|
530
|
+
<p className="mb-1 font-semibold">{t("detailPage.aiGenerate.modelTooltipTitle")}</p>
|
|
531
|
+
<ul className="space-y-1">
|
|
532
|
+
{IMAGE_TIERS.map((tk) => (
|
|
533
|
+
<li key={tk}>
|
|
534
|
+
<span className="font-medium">{IMAGE_TIER_META[tk].label}</span>
|
|
535
|
+
<span className="opacity-80"> · {IMAGE_TIER_META[tk].quality}</span>
|
|
536
|
+
</li>
|
|
537
|
+
))}
|
|
538
|
+
</ul>
|
|
539
|
+
</TooltipContent>
|
|
540
|
+
</Tooltip>
|
|
541
|
+
</div>
|
|
542
|
+
<Select value={tier} onValueChange={(v) => setTier(v as ImageTier)}>
|
|
543
|
+
<SelectTrigger className="h-10 w-full text-sm">
|
|
544
|
+
<SelectValue />
|
|
545
|
+
</SelectTrigger>
|
|
546
|
+
<SelectContent>
|
|
547
|
+
{IMAGE_TIERS.map((tk) => {
|
|
548
|
+
const meta = IMAGE_TIER_META[tk];
|
|
549
|
+
const cost = costByTier?.[tk];
|
|
550
|
+
return (
|
|
551
|
+
<SelectItem key={tk} value={tk} className="items-start py-2">
|
|
552
|
+
<div className="flex flex-col gap-0.5 pr-2">
|
|
553
|
+
<span className="flex items-center gap-1.5 text-sm font-medium text-neutral-900">
|
|
554
|
+
{meta.label}
|
|
555
|
+
{meta.badge ? (
|
|
556
|
+
<span className="rounded-full bg-neutral-900 px-1.5 py-px text-[10px] font-semibold text-white">
|
|
557
|
+
{meta.badge}
|
|
558
|
+
</span>
|
|
559
|
+
) : null}
|
|
560
|
+
{typeof cost === "number" && cost > 0 ? (
|
|
561
|
+
<span className="text-[11px] font-normal text-neutral-500">
|
|
562
|
+
· {cost}cr
|
|
563
|
+
</span>
|
|
564
|
+
) : null}
|
|
565
|
+
</span>
|
|
566
|
+
<span className="text-[11px] leading-4 text-neutral-500">
|
|
567
|
+
{meta.quality}
|
|
568
|
+
</span>
|
|
569
|
+
</div>
|
|
570
|
+
</SelectItem>
|
|
571
|
+
);
|
|
572
|
+
})}
|
|
573
|
+
</SelectContent>
|
|
574
|
+
</Select>
|
|
575
|
+
<p className="mt-1.5 text-[11px] leading-4 text-neutral-500">
|
|
576
|
+
{IMAGE_TIER_META[tier].description}
|
|
577
|
+
</p>
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
{/* 참조 이미지.
|
|
581
|
+
- 우측 인스펙터(hasImplicitReference + implicitReferenceSrc): 현재 선택된
|
|
582
|
+
이미지가 곧 입력이므로, 업로드 칸 대신 그 이미지를 읽기 전용 예시로 띄운다.
|
|
583
|
+
이미지 편집·GIF 재생성 둘 다 동일하게 선택 이미지가 참조로 들어간다.
|
|
584
|
+
- 좌측 자유 생성: GIF는 참조 필수(image→video), 이미지는 선택 업로드. */}
|
|
585
|
+
{hasImplicitReference && implicitReferenceSrc ? (
|
|
586
|
+
<div>
|
|
587
|
+
<p className="mb-2 text-xs font-medium text-neutral-500">
|
|
588
|
+
{t("detailPage.aiGenerate.currentImageLabel")}
|
|
589
|
+
</p>
|
|
590
|
+
<div className="relative overflow-hidden rounded-lg border border-neutral-200 bg-neutral-50">
|
|
591
|
+
{/* crossOrigin: S3 이미지를 Origin 없이 그리면 ACAO 없는 응답이 캐시돼
|
|
592
|
+
캔버스 crossOrigin 로드를 오염시킨다. 미리보기도 Origin을 실어 로드한다. */}
|
|
593
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
594
|
+
<img
|
|
595
|
+
src={implicitReferenceSrc}
|
|
596
|
+
crossOrigin="anonymous"
|
|
597
|
+
alt={t("detailPage.aiGenerate.referenceAlt")}
|
|
598
|
+
className="h-36 w-full object-contain"
|
|
599
|
+
/>
|
|
600
|
+
</div>
|
|
601
|
+
<p className="mt-1.5 text-[11px] leading-4 text-neutral-500">
|
|
602
|
+
{isGif
|
|
603
|
+
? t("detailPage.aiGenerate.currentImageGifHint")
|
|
604
|
+
: t("detailPage.aiGenerate.currentImageHint")}
|
|
605
|
+
</p>
|
|
606
|
+
{/* 그림으로 지시 — 이미지 모드에서만. GIF 는 이 이미지를 첫 프레임으로 그대로
|
|
607
|
+
쓰므로 마킹이 결과에 남는다. */}
|
|
608
|
+
{annotateBaseSrc && !isGif ? (
|
|
609
|
+
<button
|
|
610
|
+
type="button"
|
|
611
|
+
onClick={() => void openAnnotate()}
|
|
612
|
+
disabled={annotatePreparing || loading}
|
|
613
|
+
className="mt-2 inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-md border border-neutral-200 text-xs font-medium text-neutral-700 hover:bg-neutral-50 disabled:cursor-not-allowed disabled:opacity-40"
|
|
614
|
+
>
|
|
615
|
+
{annotatePreparing ? (
|
|
616
|
+
<Loader2 aria-hidden="true" className="animate-spin" size={14} />
|
|
617
|
+
) : (
|
|
618
|
+
<Pencil aria-hidden="true" size={14} />
|
|
619
|
+
)}
|
|
620
|
+
{t("detailPage.annotate.open", { defaultValue: "그림으로 지시하기" })}
|
|
621
|
+
</button>
|
|
622
|
+
) : null}
|
|
623
|
+
</div>
|
|
624
|
+
) : (
|
|
625
|
+
<div hidden={isGif && hasImplicitReference}>
|
|
626
|
+
<p className="mb-2 text-xs font-medium text-neutral-500">
|
|
627
|
+
{t("detailPage.aiGenerate.referenceLabel")}{" "}
|
|
628
|
+
{isGif ? (
|
|
629
|
+
<span className="text-red-500">*</span>
|
|
630
|
+
) : (
|
|
631
|
+
t("detailPage.aiGenerate.referenceOptional")
|
|
632
|
+
)}
|
|
633
|
+
</p>
|
|
634
|
+
{refUrl ? (
|
|
635
|
+
<div className="relative overflow-hidden rounded-lg border border-neutral-200">
|
|
636
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
637
|
+
<img src={refUrl} crossOrigin="anonymous" alt={t("detailPage.aiGenerate.referenceAlt")} className="h-36 w-full object-cover" />
|
|
638
|
+
<button
|
|
639
|
+
type="button"
|
|
640
|
+
onClick={() => setRefUrl(null)}
|
|
641
|
+
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80"
|
|
642
|
+
aria-label={t("detailPage.aiGenerate.removeReference")}
|
|
643
|
+
>
|
|
644
|
+
<X aria-hidden="true" size={15} />
|
|
645
|
+
</button>
|
|
646
|
+
</div>
|
|
647
|
+
) : (
|
|
648
|
+
<label className="flex h-36 cursor-pointer flex-col items-center justify-center gap-2 rounded-lg border border-dashed border-neutral-300 text-neutral-400 hover:border-neutral-400 hover:text-neutral-500">
|
|
649
|
+
{refUploading ? (
|
|
650
|
+
<Loader2 aria-hidden="true" className="animate-spin" size={22} />
|
|
651
|
+
) : (
|
|
652
|
+
<ImagePlus aria-hidden="true" size={22} />
|
|
653
|
+
)}
|
|
654
|
+
<span className="text-xs">{t("detailPage.aiGenerate.attachHint")}</span>
|
|
655
|
+
<input
|
|
656
|
+
type="file"
|
|
657
|
+
accept="image/*"
|
|
658
|
+
className="hidden"
|
|
659
|
+
onChange={handleRef}
|
|
660
|
+
/>
|
|
661
|
+
</label>
|
|
662
|
+
)}
|
|
663
|
+
</div>
|
|
664
|
+
)}
|
|
665
|
+
|
|
666
|
+
{/* 프롬프트 */}
|
|
667
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
668
|
+
<label className="mb-2 text-xs font-medium text-neutral-500">
|
|
669
|
+
{t("detailPage.aiGenerate.prompt")} <span className="text-red-500">*</span>
|
|
670
|
+
</label>
|
|
671
|
+
<textarea
|
|
672
|
+
value={prompt}
|
|
673
|
+
onChange={(e) => setPrompt(e.target.value)}
|
|
674
|
+
placeholder={t("detailPage.aiGenerate.promptPlaceholder")}
|
|
675
|
+
className="min-h-[160px] flex-1 resize-none rounded-lg border border-neutral-200 p-3 text-sm text-neutral-800 outline-none placeholder:text-neutral-400 focus:border-neutral-400"
|
|
676
|
+
/>
|
|
677
|
+
</div>
|
|
678
|
+
|
|
679
|
+
{error ? (
|
|
680
|
+
<p className="text-xs font-medium text-red-600">{error}</p>
|
|
681
|
+
) : null}
|
|
682
|
+
|
|
683
|
+
{/* 1.5× 안전 마진 미달(또는 생성 중 크레딧 부족) → 차단 안내 + 추가구매 CTA */}
|
|
684
|
+
{activeCost > 0 && (blocked || insufficient) ? (
|
|
685
|
+
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2.5 text-center">
|
|
686
|
+
<p className="text-[13px] font-medium text-amber-800">
|
|
687
|
+
{t("detailPage.aiGenerate.insufficientCreditsTitle")}
|
|
688
|
+
</p>
|
|
689
|
+
<p className="mt-0.5 text-[11px] leading-4 text-amber-700">
|
|
690
|
+
{t("detailPage.aiGenerate.insufficientCreditsHint", { required, balance: creditBalance })}
|
|
691
|
+
</p>
|
|
692
|
+
<button
|
|
693
|
+
type="button"
|
|
694
|
+
onClick={onBuyCredits}
|
|
695
|
+
disabled={!onBuyCredits}
|
|
696
|
+
className="mt-2 inline-flex items-center gap-1.5 rounded-md bg-neutral-900 px-3 py-1.5 text-xs font-medium text-white disabled:opacity-40"
|
|
697
|
+
>
|
|
698
|
+
<Sparkles aria-hidden="true" size={13} />
|
|
699
|
+
{t("detailPage.aiGenerate.buyCredits")}
|
|
700
|
+
</button>
|
|
701
|
+
</div>
|
|
702
|
+
) : null}
|
|
703
|
+
|
|
704
|
+
{gifMissingRef ? (
|
|
705
|
+
<p className="text-xs font-medium text-neutral-500">
|
|
706
|
+
{t("detailPage.aiGenerate.gifNeedsReference")}
|
|
707
|
+
</p>
|
|
708
|
+
) : null}
|
|
709
|
+
|
|
710
|
+
<button
|
|
711
|
+
type="button"
|
|
712
|
+
onClick={handleGenerate}
|
|
713
|
+
disabled={loading || !prompt.trim() || blocked || gifMissingRef}
|
|
714
|
+
className={
|
|
715
|
+
"relative flex h-11 items-center justify-center gap-2 overflow-hidden rounded-lg bg-neutral-900 text-sm font-semibold text-white " +
|
|
716
|
+
(loading
|
|
717
|
+
? "cursor-progress"
|
|
718
|
+
: "disabled:cursor-not-allowed disabled:opacity-40")
|
|
719
|
+
}
|
|
720
|
+
>
|
|
721
|
+
{loading ? <GenerationProgressFill progress={progress} /> : null}
|
|
722
|
+
<span className="relative z-10 flex items-center gap-2">
|
|
723
|
+
{loading ? (
|
|
724
|
+
<Loader2 aria-hidden="true" className="animate-spin" size={16} />
|
|
725
|
+
) : (
|
|
726
|
+
<Sparkles aria-hidden="true" size={16} />
|
|
727
|
+
)}
|
|
728
|
+
{loading
|
|
729
|
+
? (isGif
|
|
730
|
+
? t("detailPage.aiGenerate.generatingGif")
|
|
731
|
+
: t("detailPage.aiGenerate.generating")) +
|
|
732
|
+
" " +
|
|
733
|
+
progressPercent(progress) +
|
|
734
|
+
"%"
|
|
735
|
+
: isGif
|
|
736
|
+
? t("detailPage.aiGenerate.generateGif")
|
|
737
|
+
: onResult
|
|
738
|
+
? t("detailPage.aiGenerate.replaceImage")
|
|
739
|
+
: t("detailPage.aiGenerate.generate")}
|
|
740
|
+
{activeCost > 0 && !loading ? (
|
|
741
|
+
<span className="text-xs font-normal opacity-80">· {activeCost}cr</span>
|
|
742
|
+
) : null}
|
|
743
|
+
</span>
|
|
744
|
+
</button>
|
|
745
|
+
|
|
746
|
+
<AnnotationDialog
|
|
747
|
+
open={annotateOpen}
|
|
748
|
+
imageUrl={annotateBase}
|
|
749
|
+
title={t("detailPage.annotate.imageTitle", {
|
|
750
|
+
defaultValue: "그림으로 지시해 이미지 고치기",
|
|
751
|
+
})}
|
|
752
|
+
description={t("detailPage.annotate.imageDescription", {
|
|
753
|
+
defaultValue:
|
|
754
|
+
"바꿀 자리를 표시하고 어떻게 바꿀지 적어주세요. 표시하지 않은 곳은 그대로 둡니다.",
|
|
755
|
+
})}
|
|
756
|
+
submitLabel={t("detailPage.aiGenerate.replaceImage")}
|
|
757
|
+
busy={loading}
|
|
758
|
+
onSubmit={submitAnnotation}
|
|
759
|
+
onClose={() => setAnnotateOpen(false)}
|
|
760
|
+
/>
|
|
761
|
+
</div>
|
|
762
|
+
);
|
|
763
|
+
}
|