nenae18 commited on
Commit
7d8cf5c
Β·
verified Β·
1 Parent(s): 04c34ec

Deploy SalonView AI (brand single-source)

Browse files
Dockerfile CHANGED
@@ -23,9 +23,12 @@ ENV PYTHONUNBUFFERED=1 \
23
  WORKDIR /app
24
 
25
  # Build + runtime libs: build-essential/cmake for insightface; libglib/libgomp for opencv/onnxruntime.
 
 
26
  RUN apt-get update && apt-get install -y --no-install-recommends \
27
  build-essential gcc g++ cmake \
28
  libpq-dev libjpeg-dev zlib1g-dev libglib2.0-0 libgomp1 libgl1 \
 
29
  && rm -rf /var/lib/apt/lists/*
30
 
31
  COPY requirements.txt .
 
23
  WORKDIR /app
24
 
25
  # Build + runtime libs: build-essential/cmake for insightface; libglib/libgomp for opencv/onnxruntime.
26
+ # fonts-nanum: AI κ³ μ§€ μ›Œν„°λ§ˆν¬κ°€ ν•œκΈ€μ΄λΌ CJK ν°νŠΈκ°€ μ—†μœΌλ©΄ β–‘β–‘β–‘(두뢀 κΈ€μž)둜 μ°νžŒλ‹€.
27
+ # κ³ μ§€κ°€ μ½νžˆμ§€ μ•ŠμœΌλ©΄ ν‘œμ‹œ 의무λ₯Ό μ§€ν‚€μ§€ λͺ»ν•œ 것과 κ°™μœΌλ―€λ‘œ 이미지에 λ°˜λ“œμ‹œ ν¬ν•¨ν•œλ‹€.
28
  RUN apt-get update && apt-get install -y --no-install-recommends \
29
  build-essential gcc g++ cmake \
30
  libpq-dev libjpeg-dev zlib1g-dev libglib2.0-0 libgomp1 libgl1 \
31
+ fonts-nanum \
32
  && rm -rf /var/lib/apt/lists/*
33
 
34
  COPY requirements.txt .
app/api/v1/endpoints/photos.py CHANGED
@@ -105,11 +105,16 @@ def _share_image_url(share_token: str, request: Request) -> str:
105
  )
106
 
107
 
108
- def _share_video_url(share_token: str, request: Request) -> str:
109
- """Customer-facing result VIDEO URL β€” share_token based, same rules as the image."""
 
 
 
 
 
110
  return (
111
  f"{_request_base_url(request)}{settings.api_v1_prefix}"
112
- f"/photos/share/{share_token}/video"
113
  )
114
 
115
 
@@ -202,9 +207,11 @@ def list_history(
202
  has_video = t.video_status == "SUCCEEDED" and bool(t.video_key)
203
  items.append(HistoryItem(
204
  token=t.share_token,
 
205
  created_at=t.created_at,
206
  image_url=_share_image_url(t.share_token, request),
207
- video_url=_share_video_url(t.share_token, request) if has_video else None,
 
208
  has_video=has_video,
209
  name=(consent.client_name if consent else None),
210
  ))
@@ -457,10 +464,9 @@ def get_share(
457
  status=task.status,
458
  # share_token based URL only β€” never expose internal task uuid / result path
459
  result_image_url=_share_image_url(task.share_token, request),
460
- video_url=(
461
- _share_video_url(task.share_token, request)
462
- if task.video_status == "SUCCEEDED" and task.video_key else None
463
- ),
464
  expires_at=task.result_expires_at,
465
  original_shared=task.original_shared,
466
  privacy=SharePrivacy(
@@ -663,7 +669,7 @@ def _video_response(task, request: Request) -> VideoResponse:
663
  task_id=task.task_id,
664
  video_status=status,
665
  video_url=(
666
- _share_video_url(task.share_token, request)
667
  if status == "SUCCEEDED" and task.video_key else None
668
  ),
669
  video_error=task.video_error,
@@ -741,39 +747,45 @@ def create_video(
741
 
742
  @router.get("/video/{task_id}", response_model=VideoResponse)
743
  def get_video_status(
744
- task_id: str, request: Request, db: Session = Depends(get_db)
 
 
 
745
  ) -> VideoResponse:
746
- """Operator poll: current video state (+ tokenized URL when done)."""
 
 
 
 
747
  task = task_repo.get_task(db, task_id)
748
  if task is None:
749
  raise HTTPException(404, "task not found")
750
  return _video_response(task, request)
751
 
752
 
753
- @router.get("/share/{share_token}/video")
754
- def get_share_video(
755
- share_token: str,
 
756
  range_header: str | None = Header(default=None, alias="Range"),
757
  db: Session = Depends(get_db),
758
  ) -> Response:
759
- """Serve the result video by share_token (customer-facing) β€” same gates as the image.
760
 
761
- Supports HTTP Range (206): iOS Safari's media stack probes with `Range: bytes=0-1`
762
- and REFUSES to play sources that answer a plain 200 β€” without this, the paid clip
763
- simply never plays on iPhones (audit finding). Also enables seeking everywhere."""
764
- task = task_repo.get_task_by_share_token(db, share_token)
 
 
 
 
765
  if task is None:
766
  raise HTTPException(404, "not found")
767
  if task.status in (TaskStatus.EXPIRED, TaskStatus.DELETED) or _is_expired(
768
  task, _utcnow()
769
  ):
770
  raise HTTPException(410, "result expired")
771
- if task.status != TaskStatus.SUCCEEDED:
772
- raise HTTPException(409, "result is not ready")
773
-
774
- consent = task_repo.get_consent(db, task.task_id)
775
- if not _qr_allowed(consent):
776
- raise HTTPException(403, "QR delivery is not allowed")
777
 
778
  if task.video_status != "SUCCEEDED" or not task.video_key:
779
  raise HTTPException(404, "video is not available")
 
105
  )
106
 
107
 
108
+ def _operator_video_url(task_id: str, request: Request) -> str:
109
+ """OWNER-ONLY promo video URL (access-code gated).
110
+
111
+ Video is a salon marketing tool, NOT a customer deliverable β€” so it is served
112
+ from an access-code-gated endpoint keyed by the internal task id, never from the
113
+ customer share_token path. The customer share page never receives a video URL.
114
+ """
115
  return (
116
  f"{_request_base_url(request)}{settings.api_v1_prefix}"
117
+ f"/photos/video-file/{task_id}"
118
  )
119
 
120
 
 
207
  has_video = t.video_status == "SUCCEEDED" and bool(t.video_key)
208
  items.append(HistoryItem(
209
  token=t.share_token,
210
+ task_id=t.task_id,
211
  created_at=t.created_at,
212
  image_url=_share_image_url(t.share_token, request),
213
+ # μ˜μƒμ€ 원μž₯ μ „μš©(μ ‘μ†μ½”λ“œ 게이트) 경둜둜만 β€” μ†λ‹˜ share κ²½λ‘œλ‘œλŠ” μ ˆλŒ€ μ•ˆ λ‚˜κ°„λ‹€.
214
+ video_url=_operator_video_url(t.task_id, request) if has_video else None,
215
  has_video=has_video,
216
  name=(consent.client_name if consent else None),
217
  ))
 
464
  status=task.status,
465
  # share_token based URL only β€” never expose internal task uuid / result path
466
  result_image_url=_share_image_url(task.share_token, request),
467
+ # μ˜μƒμ€ μ†λ‹˜μ—κ²Œ μ „λ‹¬ν•˜μ§€ μ•ŠλŠ”λ‹€(ν™λ³΄μš©, 원μž₯ μ „μš©). μ†λ‹˜ 곡유 νŽ˜μ΄μ§€μ—λŠ”
468
+ # μ–Έμ œλ‚˜ μ‚¬μ§„λ§Œ λ‚˜κ°„λ‹€ β€” 원μž₯이 λ‚˜μ€‘μ— 홍보 μ˜μƒμ„ λ§Œλ“€μ–΄λ„ 이 λ§ν¬μ—λŠ” μ•ˆ λœ¬λ‹€.
469
+ video_url=None,
 
470
  expires_at=task.result_expires_at,
471
  original_shared=task.original_shared,
472
  privacy=SharePrivacy(
 
669
  task_id=task.task_id,
670
  video_status=status,
671
  video_url=(
672
+ _operator_video_url(task.task_id, request)
673
  if status == "SUCCEEDED" and task.video_key else None
674
  ),
675
  video_error=task.video_error,
 
747
 
748
  @router.get("/video/{task_id}", response_model=VideoResponse)
749
  def get_video_status(
750
+ task_id: str,
751
+ request: Request,
752
+ access_code: str | None = Header(default=None, alias="X-Access-Code"),
753
+ db: Session = Depends(get_db),
754
  ) -> VideoResponse:
755
+ """Operator poll: current promo-video state (+ owner video URL when done).
756
+
757
+ Access-code gated like the rest of the operator surface β€” video is owner-only."""
758
+ if _shop_for_code(access_code) is None:
759
+ raise HTTPException(403, "invalid access code")
760
  task = task_repo.get_task(db, task_id)
761
  if task is None:
762
  raise HTTPException(404, "task not found")
763
  return _video_response(task, request)
764
 
765
 
766
+ @router.get("/video-file/{task_id}")
767
+ def get_video_file(
768
+ task_id: str,
769
+ access_code: str | None = Header(default=None, alias="X-Access-Code"),
770
  range_header: str | None = Header(default=None, alias="Range"),
771
  db: Session = Depends(get_db),
772
  ) -> Response:
773
+ """Serve the OWNER's promo video (access-code gated) β€” never the customer path.
774
 
775
+ Video is a salon marketing tool, so this endpoint is gated by the shop access code
776
+ (same as the work-history list) and keyed by the internal task id, which is only
777
+ handed to the operator through the gated /history response. The customer share
778
+ page and token never expose a video. Supports HTTP Range (206) so the operator's
779
+ <video> can seek and iOS can play it."""
780
+ if _shop_for_code(access_code) is None:
781
+ raise HTTPException(403, "invalid access code")
782
+ task = task_repo.get_task(db, task_id)
783
  if task is None:
784
  raise HTTPException(404, "not found")
785
  if task.status in (TaskStatus.EXPIRED, TaskStatus.DELETED) or _is_expired(
786
  task, _utcnow()
787
  ):
788
  raise HTTPException(410, "result expired")
 
 
 
 
 
 
789
 
790
  if task.video_status != "SUCCEEDED" or not task.video_key:
791
  raise HTTPException(404, "video is not available")
app/schemas/photo.py CHANGED
@@ -70,11 +70,17 @@ class VideoResponse(BaseModel):
70
 
71
 
72
  class HistoryItem(BaseModel):
73
- """운영자 μž‘μ—…λ‚΄μ—­ 1건 β€” share_token 기반 URL만 λ…ΈμΆœ(task_id/λ‚΄λΆ€κ²½λ‘œ μ—†μŒ)."""
 
 
 
 
 
74
 
75
  token: str
 
76
  created_at: datetime
77
  image_url: str
78
- video_url: str | None = None
79
  has_video: bool = False
80
  name: str | None = None
 
70
 
71
 
72
  class HistoryItem(BaseModel):
73
+ """운영자 μž‘μ—…λ‚΄μ—­ 1건.
74
+
75
+ 이미지 URL 은 μ†λ‹˜κ³Ό κ³΅μœ ν•˜λŠ” share_token κΈ°λ°˜μ΄μ§€λ§Œ, task_id λŠ” 원μž₯ μ „μš©
76
+ μž‘μ—…(홍보 μ˜μƒ 생성/μ €μž₯)에 쓰인닀 β€” μž‘μ—…λ‚΄μ—­μ€ μ ‘μ†μ½”λ“œλ‘œ κ²Œμ΄νŠΈλ˜λ―€λ‘œ
77
+ μš΄μ˜μžμ—κ²Œλ§Œ λ…ΈμΆœλœλ‹€. μ˜μƒμ€ μ†λ‹˜ 곡유 κ²½λ‘œκ°€ μ•„λ‹ˆλΌ μ ‘μ†μ½”λ“œλ‘œ λ³΄ν˜Έλ˜λŠ”
78
+ /photos/video-file/{task_id} 둜만 받을 수 μžˆλ‹€."""
79
 
80
  token: str
81
+ task_id: str
82
  created_at: datetime
83
  image_url: str
84
+ video_url: str | None = None # 원μž₯ μ „μš© μ˜μƒ 파일 URL(μ ‘μ†μ½”λ“œ ν•„μš”)
85
  has_video: bool = False
86
  name: str | None = None
app/services/watermark.py CHANGED
@@ -23,7 +23,12 @@ from app.core.config import settings
23
  logger = logging.getLogger(__name__)
24
 
25
  # Best-effort CJK-capable fonts (Windows / common Linux). Falls back to default.
 
 
26
  _FONT_CANDIDATES = (
 
 
 
27
  "malgun.ttf", "malgunbd.ttf", "NanumGothic.ttf",
28
  "NotoSansCJK-Regular.ttc", "NotoSansKR-Regular.otf", "arial.ttf",
29
  )
 
23
  logger = logging.getLogger(__name__)
24
 
25
  # Best-effort CJK-capable fonts (Windows / common Linux). Falls back to default.
26
+ # μ ˆλŒ€κ²½λ‘œλ₯Ό λ¨Όμ € λ‘”λ‹€: μ»¨ν…Œμ΄λ„ˆ(Debian)μ—μ„œλŠ” μ΄λ¦„λ§ŒμœΌλ‘œλŠ” 폰트λ₯Ό λͺ» μ°Ύμ•„ ν•œκΈ€μ΄ β–‘β–‘β–‘ 둜 μ°νžŒλ‹€.
27
+ # (Dockerfile 이 fonts-nanum 을 μ„€μΉ˜ν•œλ‹€.)
28
  _FONT_CANDIDATES = (
29
+ "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
30
+ "/usr/share/fonts/truetype/nanum/NanumBarunGothic.ttf",
31
+ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
32
  "malgun.ttf", "malgunbd.ttf", "NanumGothic.ttf",
33
  "NotoSansCJK-Regular.ttc", "NotoSansKR-Regular.otf", "arial.ttf",
34
  )
static/index.html CHANGED
@@ -150,15 +150,11 @@
150
  </div>
151
  </section>
152
 
153
- <!-- 5/7 RESULT -->
 
154
  <section class="screen" id="screen-result" data-step="4">
155
  <header class="topbar"><button class="icon-btn" data-go="capture" aria-label="λ’€λ‘œ">β€Ή</button><span class="topbar-title">κ²°κ³Ό</span><span class="icon-btn ghost"></span></header>
156
  <div class="screen-body">
157
- <!-- photo/video shown SEPARATELY (tabs appear only after a clip is made) -->
158
- <div class="share-tabs" id="result-tabs" hidden>
159
- <button type="button" class="share-tab active" id="result-tab-photo">사진</button>
160
- <button type="button" class="share-tab" id="result-tab-video">μ˜μƒ</button>
161
- </div>
162
  <div id="result-photo-wrap">
163
  <div class="ba" id="ba">
164
  <img class="ba-after" id="ba-after" alt="AI κ²°κ³Ό" />
@@ -169,9 +165,6 @@
169
  </div>
170
  <input type="range" id="ba-range" class="ba-range" min="0" max="100" value="50" aria-label="원본/AI 비ꡐ" />
171
  </div>
172
- <div class="share-frame" id="result-video-wrap" hidden>
173
- <video id="result-video" class="share-video" controls playsinline loop muted></video>
174
- </div>
175
  <div class="chips center">
176
  <span class="chip gold">ν—€μ–΄ μœ μ§€</span><span class="chip gold">λ°°κ²½ μœ μ§€</span><span class="chip gold">AI 가상 λͺ¨λΈ ν‘œμ‹œ</span>
177
  </div>
@@ -179,11 +172,7 @@
179
  </div>
180
  <div class="screen-foot">
181
  <button class="btn btn-primary" id="result-go">QR둜 μ „λ‹¬ν•˜κΈ°</button>
182
- <div class="action-row">
183
- <button class="btn btn-outline" id="btn-save">πŸ“₯ 사진 μ €μž₯</button>
184
- <button class="btn btn-outline" id="btn-video">✨ μ˜μƒ λ§Œλ“€κΈ°</button>
185
- </div>
186
- <p class="video-status" id="video-status" hidden></p>
187
  <button class="btn btn-text" data-go="capture">λ‹€μ‹œ 찍기</button>
188
  </div>
189
  </section>
@@ -225,20 +214,12 @@
225
  </div>
226
  </section>
227
 
228
- <!-- CUSTOMER SHARE (/result/{token}) -->
 
229
  <section class="screen" id="screen-share">
230
  <div class="share-head"><p class="brand-logo small">{{BRAND_WORDMARK}}</p></div>
231
  <div class="screen-body center-col">
232
- <!-- photo/video are SEPARATE views: tabs appear only when a video exists;
233
- the photo shows alone by default (clean, no frame decorations) -->
234
- <div class="share-tabs" id="share-tabs" hidden>
235
- <button type="button" class="share-tab active" id="tab-photo">사진</button>
236
- <button type="button" class="share-tab" id="tab-video">μ˜μƒ</button>
237
- </div>
238
  <div class="share-frame" id="share-photo-wrap"><img id="share-img" class="share-img" alt="AI κ²°κ³Ό" /></div>
239
- <div class="share-frame" id="share-video-wrap" hidden>
240
- <video id="share-video" class="share-video" controls playsinline loop muted></video>
241
- </div>
242
  <button class="btn btn-primary" id="share-save">πŸ“₯ 사진 μ €μž₯</button>
243
  <p class="disclosure">AI둜 μƒμ„±Β·νŽΈμ§‘λœ 가상 κ²°κ³Ό μ΄λ―Έμ§€μž…λ‹ˆλ‹€.</p>
244
  <p class="fineprint">μ €μž₯ λ²„νŠΌμ„ λˆ„λ₯΄λ©΄ κ°€λŸ¬λ¦¬μ— μ €μž₯λΌμš” Β· λ§ν¬λŠ” 7일 ν›„ λ§Œλ£Œλ©λ‹ˆλ‹€.</p>
@@ -276,7 +257,8 @@
276
  </div>
277
  </section>
278
 
279
- <!-- RECENT VIEW (tap a thumbnail β€” photo/video shown SEPARATELY, save either) -->
 
280
  <section class="screen" id="screen-recent-view">
281
  <header class="topbar"><button class="icon-btn" data-go="recent" aria-label="λ’€λ‘œ">β€Ή</button><span class="topbar-title">κ²°κ³Ό 보기</span><span class="icon-btn ghost"></span></header>
282
  <div class="screen-body center-col">
@@ -289,8 +271,12 @@
289
  <p class="disclosure">AI둜 μƒμ„±Β·νŽΈμ§‘λœ 가상 κ²°κ³Όμž…λ‹ˆλ‹€.</p>
290
  </div>
291
  <div class="screen-foot">
292
- <button class="btn btn-primary" id="rv-save">πŸ“₯ μ €μž₯</button>
 
 
 
293
  <div class="link-row"><span class="link-text" id="recent-link"></span><button class="btn-copy" id="recent-copy">볡사</button></div>
 
294
  </div>
295
  </section>
296
  </main>
 
150
  </div>
151
  </section>
152
 
153
+ <!-- 5/7 RESULT β€” μ†λ‹˜ 전달은 μ‚¬μ§„λ§Œ. 홍보 μ˜μƒμ€ 'μž‘μ—… λ‚΄μ—­'μ—μ„œ 원μž₯이 λ”°λ‘œ λ§Œλ“ λ‹€
154
+ (μ†λ‹˜μ„ κΈ°λ‹€λ¦¬κ²Œ ν•˜μ§€ μ•Šκ³ , μ˜μƒ λΉ„μš©μ€ 원μž₯이 κ³ λ₯Έ μ‚¬μ§„μ—λ§Œ λ“ λ‹€). -->
155
  <section class="screen" id="screen-result" data-step="4">
156
  <header class="topbar"><button class="icon-btn" data-go="capture" aria-label="λ’€λ‘œ">β€Ή</button><span class="topbar-title">κ²°κ³Ό</span><span class="icon-btn ghost"></span></header>
157
  <div class="screen-body">
 
 
 
 
 
158
  <div id="result-photo-wrap">
159
  <div class="ba" id="ba">
160
  <img class="ba-after" id="ba-after" alt="AI κ²°κ³Ό" />
 
165
  </div>
166
  <input type="range" id="ba-range" class="ba-range" min="0" max="100" value="50" aria-label="원본/AI 비ꡐ" />
167
  </div>
 
 
 
168
  <div class="chips center">
169
  <span class="chip gold">ν—€μ–΄ μœ μ§€</span><span class="chip gold">λ°°κ²½ μœ μ§€</span><span class="chip gold">AI 가상 λͺ¨λΈ ν‘œμ‹œ</span>
170
  </div>
 
172
  </div>
173
  <div class="screen-foot">
174
  <button class="btn btn-primary" id="result-go">QR둜 μ „λ‹¬ν•˜κΈ°</button>
175
+ <button class="btn btn-outline" id="btn-save">πŸ“₯ 사진 μ €μž₯</button>
 
 
 
 
176
  <button class="btn btn-text" data-go="capture">λ‹€μ‹œ 찍기</button>
177
  </div>
178
  </section>
 
214
  </div>
215
  </section>
216
 
217
+ <!-- CUSTOMER SHARE (/result/{token}) β€” μ†λ‹˜μ—κ²ŒλŠ” μ‚¬μ§„λ§Œ. μ˜μƒμ€ 원μž₯ ν™λ³΄μš©μ΄λΌ
218
+ μ†λ‹˜ 곡유 νŽ˜μ΄μ§€μ—λŠ” μ ˆλŒ€ λ‚˜μ˜€μ§€ μ•ŠλŠ”λ‹€(λŒ€κΈ°Β·λΉ„μš© μ—†μŒ). -->
219
  <section class="screen" id="screen-share">
220
  <div class="share-head"><p class="brand-logo small">{{BRAND_WORDMARK}}</p></div>
221
  <div class="screen-body center-col">
 
 
 
 
 
 
222
  <div class="share-frame" id="share-photo-wrap"><img id="share-img" class="share-img" alt="AI κ²°κ³Ό" /></div>
 
 
 
223
  <button class="btn btn-primary" id="share-save">πŸ“₯ 사진 μ €μž₯</button>
224
  <p class="disclosure">AI둜 μƒμ„±Β·νŽΈμ§‘λœ 가상 κ²°κ³Ό μ΄λ―Έμ§€μž…λ‹ˆλ‹€.</p>
225
  <p class="fineprint">μ €μž₯ λ²„νŠΌμ„ λˆ„λ₯΄λ©΄ κ°€λŸ¬λ¦¬μ— μ €μž₯λΌμš” Β· λ§ν¬λŠ” 7일 ν›„ λ§Œλ£Œλ©λ‹ˆλ‹€.</p>
 
257
  </div>
258
  </section>
259
 
260
+ <!-- RECENT VIEW β€” 원μž₯이 μ €μž₯된 κ²°κ³Όμ—μ„œ λ§ˆμŒμ— λ“œλŠ” 사진을 골라 홍보 μ˜μƒμ„ λ§Œλ“œλŠ” κ³³.
261
+ μ˜μƒμ€ 이 λ§€μž₯ ν™λ³΄μš©μ΄λ©° μ†λ‹˜μ—κ²ŒλŠ” μ „λ‹¬λ˜μ§€ μ•ŠλŠ”λ‹€. -->
262
  <section class="screen" id="screen-recent-view">
263
  <header class="topbar"><button class="icon-btn" data-go="recent" aria-label="λ’€λ‘œ">β€Ή</button><span class="topbar-title">κ²°κ³Ό 보기</span><span class="icon-btn ghost"></span></header>
264
  <div class="screen-body center-col">
 
271
  <p class="disclosure">AI둜 μƒμ„±Β·νŽΈμ§‘λœ 가상 κ²°κ³Όμž…λ‹ˆλ‹€.</p>
272
  </div>
273
  <div class="screen-foot">
274
+ <!-- 홍보 μ˜μƒ λ§Œλ“€κΈ°: 원μž₯ μ „μš©. 이 μ‚¬μ§„μœΌλ‘œ 5초 홍보 μ˜μƒμ„ λ§Œλ“ λ‹€(유료 AI 호좜이라 λ²„νŠΌμ„ λˆŒλŸ¬μ•Όλ§Œ 생성). -->
275
+ <button class="btn btn-primary" id="rv-make-video" hidden>✨ 이 μ‚¬μ§„μœΌλ‘œ 홍보 μ˜μƒ λ§Œλ“€κΈ°</button>
276
+ <p class="video-status" id="rv-video-status" hidden></p>
277
+ <button class="btn btn-outline" id="rv-save">πŸ“₯ μ €μž₯</button>
278
  <div class="link-row"><span class="link-text" id="recent-link"></span><button class="btn-copy" id="recent-copy">볡사</button></div>
279
+ <p class="fineprint">곡유 λ§ν¬λŠ” μ†λ‹˜μ—κ²Œ <b>μ‚¬μ§„λ§Œ</b> μ „λ‹¬λ©λ‹ˆλ‹€ Β· μ˜μƒμ€ λ§€μž₯ ν™λ³΄μš©μ΄μ—μš”</p>
280
  </div>
281
  </section>
282
  </main>
static/js/app.js CHANGED
@@ -17,7 +17,6 @@ const state = {
17
  accessCode: "",
18
  gender: "auto", // operator pick on capture screen: "auto" | "woman" | "man"
19
  retryTo: "capture",
20
- videoStatus: "NONE", // NONE | PROCESSING | SUCCEEDED | FAILED (per current task)
21
  };
22
  let CFG = { access_required: false, video_enabled: true };
23
 
@@ -352,110 +351,18 @@ function showResult() {
352
  range.oninput = apply;
353
  range.value = 50; apply();
354
  $("#result-go").disabled = !state.deliveryAvailable;
355
- resetVideoUi();
356
  showScreen("result");
357
  addRecent(tokenFromResultUrl(state.resultUrl)); // remember it in the local gallery
358
  }
359
 
360
- // --- result video (operator button -> 5s live clip; paid call, so explicit per customer) ---
361
- let videoPollTimer = null;
362
- function resetVideoUi() {
363
- state.videoStatus = "NONE";
364
- state.videoUrl = null;
365
- if (videoPollTimer) { clearTimeout(videoPollTimer); videoPollTimer = null; }
366
- const btn = $("#btn-video"), stat = $("#video-status");
367
- btn.hidden = !CFG.video_enabled;
368
- btn.disabled = false;
369
- btn.textContent = "✨ μ˜μƒ λ§Œλ“€κΈ°";
370
- stat.hidden = true; stat.textContent = "";
371
- // reset the result photo/video view back to photo for the next customer
372
- $("#result-tabs").hidden = true;
373
- const rv = $("#result-video"); rv.pause && rv.pause(); rv.removeAttribute("src");
374
- resultShow("photo");
375
- }
376
-
377
- // operator result screen: photo (before/after) vs the generated clip β€” one at a time
378
- function resultShow(which) {
379
- const photo = which === "photo";
380
- $("#result-photo-wrap").hidden = !photo;
381
- $("#result-video-wrap").hidden = photo;
382
- $("#result-tab-photo").classList.toggle("active", photo);
383
- $("#result-tab-video").classList.toggle("active", !photo);
384
- $("#btn-save").textContent = photo ? "πŸ“₯ 사진 μ €μž₯" : "πŸ“₯ μ˜μƒ μ €μž₯";
385
- const v = $("#result-video");
386
- if (photo) { try { v.pause(); } catch (e) { /* noop */ } }
387
- else {
388
- if (state.videoUrl) prefetchMedia(state.videoUrl); // warm for μ €μž₯
389
- try { v.play(); } catch (e) { /* needs a tap */ }
390
- }
391
- }
392
- $("#result-tab-photo").addEventListener("click", () => resultShow("photo"));
393
- $("#result-tab-video").addEventListener("click", () => resultShow("video"));
394
- // save the result image onto the OPERATOR's device (marketing use β€” independent of the
395
- // server's 7-day retention, so a wiped/expired server can't take the keepsake with it)
396
  $("#btn-save").addEventListener("click", () => {
397
- const showingVideo = !$("#result-video-wrap").hidden;
398
- if (showingVideo && state.videoUrl) {
399
- saveMediaToGallery(state.videoUrl, `${BRAND.slug}_${Date.now()}.mp4`, "μ˜μƒ");
400
- } else if (state.resultUrl) {
401
- saveMediaToGallery(state.resultUrl, `${BRAND.slug}_${Date.now()}.png`, "사진");
402
- } else { showToast("κ²°κ³Όκ°€ 아직 μ—†μ–΄μš”."); }
403
  });
404
 
405
- $("#btn-video").addEventListener("click", startVideo);
406
- async function startVideo() {
407
- if (!state.taskId || state.videoStatus === "PROCESSING") return;
408
- const tid = state.taskId; // same guard as pollVideo: ignore replies for a stale task
409
- const btn = $("#btn-video"), stat = $("#video-status");
410
- btn.disabled = true;
411
- stat.hidden = false;
412
- stat.textContent = "μ˜μƒμ„ λ§Œλ“€κ³  μžˆμ–΄μš”β€¦ λΉ λ₯΄λ©΄ 30초, 보톡 1λΆ„ μ •λ„μ˜ˆμš”";
413
- const data = new FormData();
414
- data.append("access_code", state.accessCode || "");
415
- try {
416
- const r = await api(`${API}/video/${tid}`, { method: "POST", body: data });
417
- if (state.taskId !== tid) return;
418
- state.videoStatus = r.video_status;
419
- if (r.video_status === "SUCCEEDED") { state.videoUrl = sameOriginPath(r.video_url); videoReady(); return; }
420
- pollVideo();
421
- } catch (e) {
422
- if (state.taskId !== tid) return;
423
- state.videoStatus = "FAILED";
424
- btn.disabled = false;
425
- stat.textContent = "μ˜μƒ 생성을 μ‹œμž‘ν•˜μ§€ λͺ»ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.";
426
- }
427
- }
428
- async function pollVideo() {
429
- if (!state.taskId) return;
430
- const tid = state.taskId; // guard: a reply landing after the operator moved to a NEW
431
- try { // customer must not touch the new screen's video UI
432
- const st = await api(`${API}/video/${tid}`);
433
- if (state.taskId !== tid) return;
434
- state.videoStatus = st.video_status;
435
- if (st.video_status === "SUCCEEDED") { state.videoUrl = sameOriginPath(st.video_url); videoReady(); return; }
436
- if (st.video_status === "FAILED") {
437
- $("#btn-video").disabled = false;
438
- $("#video-status").hidden = false;
439
- $("#video-status").textContent = "μ˜μƒ 생성에 μ‹€νŒ¨ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.";
440
- return;
441
- }
442
- } catch (e) { /* transient poll error -> keep trying */ }
443
- videoPollTimer = setTimeout(pollVideo, 5000);
444
- }
445
- function videoReady() {
446
- const btn = $("#btn-video"), stat = $("#video-status");
447
- btn.disabled = true;
448
- btn.textContent = "μ˜μƒ μ™„μ„± βœ“";
449
- stat.hidden = false;
450
- stat.textContent = "μ˜μƒμ΄ μ™„μ„±λμ–΄μš”! μ•„λž˜ 'μ˜μƒ' νƒ­μ—μ„œ λ°”λ‘œ ν™•μΈν•˜μ„Έμš”.";
451
- // show the clip RIGHT HERE on the result screen (no need to hand off to QR first)
452
- if (state.videoUrl) {
453
- $("#result-video").src = state.videoUrl;
454
- $("#result-tabs").hidden = false;
455
- resultShow("video");
456
- }
457
- showToast("μ˜μƒμ΄ μ™„μ„±λμ–΄μš”!");
458
- }
459
  $("#result-go").addEventListener("click", loadQr);
460
 
461
  async function loadQr() {
@@ -490,27 +397,20 @@ $("#done-home").addEventListener("click", () => {
490
  showScreen("landing");
491
  });
492
 
493
- // --- customer share view (/result/{token}) ---
494
- let shareData = { token: null, imageUrl: null, videoUrl: null };
 
 
 
495
  async function loadSharedResult(token) {
496
  // A sleeping/waking server or a cell blip must NOT show a paying customer the terminal
497
  // "링크가 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€" screen β€” only a real 410 means expired. Retry transient failures.
498
  for (let attempt = 0; attempt < 20; attempt++) {
499
  try {
500
  const share = await api(`${API}/share/${encodeURIComponent(token)}`);
501
- shareData = { token, imageUrl: sameOriginPath(share.result_image_url),
502
- videoUrl: share.video_url ? sameOriginPath(share.video_url) : null };
503
  $("#share-img").src = shareData.imageUrl;
504
- // photo and video are SEPARATE views: the photo shows alone by default; when a clip
505
- // exists, tabs appear and each tab shows exactly one of them (no stacked clutter).
506
- if (shareData.videoUrl) {
507
- $("#share-video").src = shareData.videoUrl;
508
- $("#share-tabs").hidden = false;
509
- prefetchMedia(shareData.imageUrl);
510
- } else {
511
- pollShareVideo(token); // the operator may still be generating the clip
512
- }
513
- shareShow("photo");
514
  showScreen("share");
515
  return;
516
  } catch (e) {
@@ -525,47 +425,8 @@ async function loadSharedResult(token) {
525
  showScreen("expired");
526
  }
527
 
528
- // The realistic salon sequence is: customer scans the QR FIRST, then the operator taps
529
- // μ˜μƒ λ§Œλ“€κΈ° (1-3 min). Poll quietly so the clip appears on the already-open page.
530
- let shareVideoPollCount = 0;
531
- async function pollShareVideo(token) {
532
- if (shareVideoPollCount++ > 16 || shareData.videoUrl) return; // ~4 min budget
533
- setTimeout(async () => {
534
- try {
535
- const share = await api(`${API}/share/${encodeURIComponent(token)}`);
536
- if (share.video_url) {
537
- shareData.videoUrl = sameOriginPath(share.video_url);
538
- $("#share-video").src = shareData.videoUrl;
539
- $("#share-tabs").hidden = false;
540
- showToast("μ˜μƒμ΄ λ„μ°©ν–ˆμ–΄μš”! 'μ˜μƒ' νƒ­μ—μ„œ ν™•μΈν•˜μ„Έμš”.");
541
- return;
542
- }
543
- } catch (e) { /* transient β€” keep polling */ }
544
- pollShareVideo(token);
545
- }, 15000);
546
- }
547
-
548
- function shareShow(which) {
549
- const photo = which === "photo";
550
- $("#share-photo-wrap").hidden = !photo;
551
- $("#share-video-wrap").hidden = photo;
552
- $("#tab-photo").classList.toggle("active", photo);
553
- $("#tab-video").classList.toggle("active", !photo);
554
- $("#share-save").textContent = photo ? "πŸ“₯ 사진 μ €μž₯" : "πŸ“₯ μ˜μƒ μ €μž₯";
555
- const v = $("#share-video");
556
- if (photo) { try { v.pause(); } catch (e) { /* not loaded yet */ } }
557
- else {
558
- prefetchMedia(shareData.videoUrl); // warm the blob so μ €μž₯ works within the tap window
559
- try { v.play(); } catch (e) { /* browser wants a direct tap on controls */ }
560
- }
561
- }
562
- $("#tab-photo").addEventListener("click", () => shareShow("photo"));
563
- $("#tab-video").addEventListener("click", () => shareShow("video"));
564
  $("#share-save").addEventListener("click", () => {
565
- const showingVideo = !$("#share-video-wrap").hidden;
566
- if (showingVideo && shareData.videoUrl) {
567
- saveMediaToGallery(shareData.videoUrl, `${BRAND.slug}_${shareData.token}.mp4`, "μ˜μƒ");
568
- } else if (shareData.imageUrl) {
569
  saveMediaToGallery(shareData.imageUrl, `${BRAND.slug}_${shareData.token}.png`, "사진");
570
  }
571
  });
@@ -640,17 +501,47 @@ function renderHistoryCell(grid, empty, it) {
640
  grid.appendChild(cell);
641
  }
642
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  function openRecentView(it) {
644
  recentViewItem = it;
 
645
  const imgUrl = sameOriginPath(it.image_url) || recentImageUrl(it.token);
646
  $("#rv-img").src = imgUrl;
647
  prefetchMedia(imgUrl); // warm for μ €μž₯ (share-sheet must fire within the tap window)
648
- const vwrap = $("#rv-video"), hasVideo = !!(it.has_video && it.video_url);
649
- if (hasVideo) vwrap.src = sameOriginPath(it.video_url); else vwrap.removeAttribute("src");
650
- $("#rv-tabs").hidden = !hasVideo;
 
 
 
 
 
651
  rvShow("photo");
652
  $("#recent-link").textContent = recentShareUrl(it.token);
653
  showScreen("recent-view");
 
654
  }
655
  function rvShow(which) {
656
  const photo = which === "photo";
@@ -660,12 +551,63 @@ function rvShow(which) {
660
  $("#rv-tab-video").classList.toggle("active", !photo);
661
  const v = $("#rv-video");
662
  if (photo) { try { v.pause(); } catch (e) { /* not loaded */ } }
663
- else {
664
- if (recentViewItem && recentViewItem.video_url) prefetchMedia(sameOriginPath(recentViewItem.video_url));
665
- try { v.play(); } catch (e) { /* needs a tap */ }
666
- }
667
  $("#rv-save").textContent = photo ? "πŸ“₯ 사진 μ €μž₯" : "πŸ“₯ μ˜μƒ μ €μž₯";
668
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
669
  // Save a photo/video to the phone's GALLERY. Web Share API (navigator.share with a File)
670
  // gives the native sheet with "사진에 μ €μž₯ / Save to Photos" on both iOS and Android β€” the
671
  // only reliable way to reach the gallery from a web page (a plain <a download> lands in
@@ -697,8 +639,9 @@ async function saveMediaToGallery(url, filename, label) {
697
  $("#rv-save").addEventListener("click", () => {
698
  if (!recentViewItem) return;
699
  const showingVideo = !$("#rv-video-wrap").hidden;
700
- if (showingVideo && recentViewItem.video_url) {
701
- saveMediaToGallery(sameOriginPath(recentViewItem.video_url), `${BRAND.slug}_${recentViewItem.token}.mp4`, "μ˜μƒ");
 
702
  } else {
703
  saveMediaToGallery(sameOriginPath(recentViewItem.image_url) || recentImageUrl(recentViewItem.token),
704
  `${BRAND.slug}_${recentViewItem.token}.png`, "사진");
 
17
  accessCode: "",
18
  gender: "auto", // operator pick on capture screen: "auto" | "woman" | "man"
19
  retryTo: "capture",
 
20
  };
21
  let CFG = { access_required: false, video_enabled: true };
22
 
 
351
  range.oninput = apply;
352
  range.value = 50; apply();
353
  $("#result-go").disabled = !state.deliveryAvailable;
 
354
  showScreen("result");
355
  addRecent(tokenFromResultUrl(state.resultUrl)); // remember it in the local gallery
356
  }
357
 
358
+ // Result screen is PHOTO-ONLY: the customer is handed a photo (fast, no video wait/cost).
359
+ // Promo video is made later by the owner from 'μž‘μ—… λ‚΄μ—­' (recent-view). Save the result
360
+ // photo onto the OPERATOR's device (independent of the server's 7-day retention).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
361
  $("#btn-save").addEventListener("click", () => {
362
+ if (state.resultUrl) saveMediaToGallery(state.resultUrl, `${BRAND.slug}_${Date.now()}.png`, "사진");
363
+ else showToast("κ²°κ³Όκ°€ 아직 μ—†μ–΄μš”.");
 
 
 
 
364
  });
365
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  $("#result-go").addEventListener("click", loadQr);
367
 
368
  async function loadQr() {
 
397
  showScreen("landing");
398
  });
399
 
400
+ // --- customer share view (/result/{token}) β€” PHOTO ONLY ---
401
+ // The customer is never handed a video: video is the salon's promo tool, so it never
402
+ // appears here (no wait, no cost surprise). The server also returns no video URL for
403
+ // the customer share path β€” this is enforced on both ends.
404
+ let shareData = { token: null, imageUrl: null };
405
  async function loadSharedResult(token) {
406
  // A sleeping/waking server or a cell blip must NOT show a paying customer the terminal
407
  // "링크가 λ§Œλ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€" screen β€” only a real 410 means expired. Retry transient failures.
408
  for (let attempt = 0; attempt < 20; attempt++) {
409
  try {
410
  const share = await api(`${API}/share/${encodeURIComponent(token)}`);
411
+ shareData = { token, imageUrl: sameOriginPath(share.result_image_url) };
 
412
  $("#share-img").src = shareData.imageUrl;
413
+ prefetchMedia(shareData.imageUrl); // warm the blob so μ €μž₯ fires within the tap window
 
 
 
 
 
 
 
 
 
414
  showScreen("share");
415
  return;
416
  } catch (e) {
 
425
  showScreen("expired");
426
  }
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  $("#share-save").addEventListener("click", () => {
429
+ if (shareData.imageUrl) {
 
 
 
430
  saveMediaToGallery(shareData.imageUrl, `${BRAND.slug}_${shareData.token}.png`, "사진");
431
  }
432
  });
 
501
  grid.appendChild(cell);
502
  }
503
 
504
+ // promo video is OWNER-only: served access-code gated and keyed by task_id (never the
505
+ // customer share path). A custom header can't ride on a <video src>, so the bytes are
506
+ // fetched here with the code and played from a blob URL.
507
+ let rvVideoPollTimer = null;
508
+ async function fetchOperatorVideoBlob(taskId) {
509
+ const r = await fetch(`${API}/video-file/${encodeURIComponent(taskId)}`,
510
+ { headers: { "X-Access-Code": state.accessCode || "" } });
511
+ if (!r.ok) throw new Error(String(r.status));
512
+ return r.blob();
513
+ }
514
+ async function loadRecentVideo(taskId) {
515
+ try {
516
+ const blob = await fetchOperatorVideoBlob(taskId);
517
+ if (!recentViewItem || recentViewItem.task_id !== taskId) return false;
518
+ if (recentViewItem._videoObjUrl) URL.revokeObjectURL(recentViewItem._videoObjUrl);
519
+ const url = URL.createObjectURL(blob);
520
+ recentViewItem._videoObjUrl = url; // reused by μ €μž₯ without a second fetch
521
+ $("#rv-video").src = url;
522
+ $("#rv-tabs").hidden = false;
523
+ return true;
524
+ } catch (e) { return false; }
525
+ }
526
+
527
  function openRecentView(it) {
528
  recentViewItem = it;
529
+ if (rvVideoPollTimer) { clearTimeout(rvVideoPollTimer); rvVideoPollTimer = null; }
530
  const imgUrl = sameOriginPath(it.image_url) || recentImageUrl(it.token);
531
  $("#rv-img").src = imgUrl;
532
  prefetchMedia(imgUrl); // warm for μ €μž₯ (share-sheet must fire within the tap window)
533
+ $("#rv-video").removeAttribute("src");
534
+ $("#rv-tabs").hidden = true;
535
+ const hasVideo = !!(it.has_video && it.task_id);
536
+ // make-video button: shown only when video is enabled, we have a task_id, and none exists yet
537
+ const mk = $("#rv-make-video"), st = $("#rv-video-status");
538
+ st.hidden = true; st.textContent = "";
539
+ mk.disabled = false; mk.textContent = "✨ 이 μ‚¬μ§„μœΌλ‘œ 홍보 μ˜μƒ λ§Œλ“€κΈ°";
540
+ mk.hidden = !(CFG.video_enabled && it.task_id && !hasVideo);
541
  rvShow("photo");
542
  $("#recent-link").textContent = recentShareUrl(it.token);
543
  showScreen("recent-view");
544
+ if (hasVideo) loadRecentVideo(it.task_id); // fetch + play the existing promo clip
545
  }
546
  function rvShow(which) {
547
  const photo = which === "photo";
 
551
  $("#rv-tab-video").classList.toggle("active", !photo);
552
  const v = $("#rv-video");
553
  if (photo) { try { v.pause(); } catch (e) { /* not loaded */ } }
554
+ else { try { v.play(); } catch (e) { /* needs a tap */ } }
 
 
 
555
  $("#rv-save").textContent = photo ? "πŸ“₯ 사진 μ €μž₯" : "πŸ“₯ μ˜μƒ μ €μž₯";
556
  }
557
+
558
+ // --- make a promo video from THIS saved photo (owner action; paid fal call, explicit) ---
559
+ $("#rv-make-video").addEventListener("click", startRecentVideo);
560
+ async function startRecentVideo() {
561
+ const it = recentViewItem;
562
+ if (!it || !it.task_id) return;
563
+ const tid = it.task_id;
564
+ const mk = $("#rv-make-video"), st = $("#rv-video-status");
565
+ mk.disabled = true;
566
+ st.hidden = false;
567
+ st.textContent = "홍보 μ˜μƒμ„ λ§Œλ“€κ³  μžˆμ–΄μš”β€¦ λΉ λ₯΄λ©΄ 30초, 보톡 1λΆ„ μ •λ„μ˜ˆμš”";
568
+ const data = new FormData();
569
+ data.append("access_code", state.accessCode || "");
570
+ try {
571
+ const r = await api(`${API}/video/${encodeURIComponent(tid)}`, { method: "POST", body: data });
572
+ if (!recentViewItem || recentViewItem.task_id !== tid) return;
573
+ if (r.video_status === "SUCCEEDED") { recentVideoReady(tid); return; }
574
+ if (r.video_status === "FAILED") { rvVideoFailed(); return; }
575
+ pollRecentVideo(tid);
576
+ } catch (e) {
577
+ if (!recentViewItem || recentViewItem.task_id !== tid) return;
578
+ rvVideoFailed("μ˜μƒ 생성을 μ‹œμž‘ν•˜μ§€ λͺ»ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.");
579
+ }
580
+ }
581
+ async function pollRecentVideo(tid) {
582
+ try {
583
+ const r = await fetch(`${API}/video/${encodeURIComponent(tid)}`,
584
+ { headers: { "X-Access-Code": state.accessCode || "" } });
585
+ if (!r.ok) throw new Error(String(r.status));
586
+ const j = await r.json();
587
+ if (!recentViewItem || recentViewItem.task_id !== tid) return; // moved away: stop
588
+ if (j.video_status === "SUCCEEDED") { recentVideoReady(tid); return; }
589
+ if (j.video_status === "FAILED") { rvVideoFailed(); return; }
590
+ } catch (e) { /* transient poll error -> keep trying */ }
591
+ rvVideoPollTimer = setTimeout(() => pollRecentVideo(tid), 5000);
592
+ }
593
+ async function recentVideoReady(tid) {
594
+ const mk = $("#rv-make-video"), st = $("#rv-video-status");
595
+ const ok = await loadRecentVideo(tid);
596
+ if (recentViewItem && recentViewItem.task_id === tid) recentViewItem.has_video = true;
597
+ mk.hidden = true;
598
+ st.hidden = false;
599
+ st.textContent = ok
600
+ ? "홍보 μ˜μƒμ΄ μ™„μ„±λμ–΄μš”! 'μ˜μƒ' νƒ­μ—μ„œ ν™•μΈν•˜κ³  μ €μž₯ν•˜μ„Έμš”."
601
+ : "μ˜μƒμ΄ μ™„μ„±λμ–΄μš”. 'μ˜μƒ' νƒ­μ—μ„œ ν™•μΈν•˜μ„Έμš”.";
602
+ if (ok) rvShow("video");
603
+ showToast("홍보 μ˜μƒμ΄ μ™„μ„±λμ–΄μš”!");
604
+ }
605
+ function rvVideoFailed(msg) {
606
+ const mk = $("#rv-make-video"), st = $("#rv-video-status");
607
+ mk.disabled = false;
608
+ st.hidden = false;
609
+ st.textContent = msg || "μ˜μƒ 생성에 μ‹€νŒ¨ν–ˆμ–΄μš”. λ‹€μ‹œ μ‹œλ„ν•΄ μ£Όμ„Έμš”.";
610
+ }
611
  // Save a photo/video to the phone's GALLERY. Web Share API (navigator.share with a File)
612
  // gives the native sheet with "사진에 μ €μž₯ / Save to Photos" on both iOS and Android β€” the
613
  // only reliable way to reach the gallery from a web page (a plain <a download> lands in
 
639
  $("#rv-save").addEventListener("click", () => {
640
  if (!recentViewItem) return;
641
  const showingVideo = !$("#rv-video-wrap").hidden;
642
+ if (showingVideo && recentViewItem._videoObjUrl) {
643
+ // the promo clip was already fetched with the access code into a blob β€” save that
644
+ saveMediaToGallery(recentViewItem._videoObjUrl, `${BRAND.slug}_${recentViewItem.token}.mp4`, "μ˜μƒ");
645
  } else {
646
  saveMediaToGallery(sameOriginPath(recentViewItem.image_url) || recentImageUrl(recentViewItem.token),
647
  `${BRAND.slug}_${recentViewItem.token}.png`, "사진");