Deploy SalonView AI (brand single-source)
Browse files- Dockerfile +3 -0
- app/api/v1/endpoints/photos.py +37 -25
- app/schemas/photo.py +8 -2
- app/services/watermark.py +5 -0
- static/index.html +12 -26
- static/js/app.js +104 -161
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
|
| 109 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return (
|
| 111 |
f"{_request_base_url(request)}{settings.api_v1_prefix}"
|
| 112 |
-
f"/photos/
|
| 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 |
-
|
|
|
|
| 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 |
-
|
| 461 |
-
|
| 462 |
-
|
| 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 |
-
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 745 |
) -> VideoResponse:
|
| 746 |
-
"""Operator poll: current video state (+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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("/
|
| 754 |
-
def
|
| 755 |
-
|
|
|
|
| 756 |
range_header: str | None = Header(default=None, alias="Range"),
|
| 757 |
db: Session = Depends(get_db),
|
| 758 |
) -> Response:
|
| 759 |
-
"""Serve the
|
| 760 |
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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건
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 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
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 361 |
-
|
| 362 |
-
|
| 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 |
-
|
| 398 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 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 |
-
|
| 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 |
-
|
| 649 |
-
|
| 650 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 701 |
-
|
|
|
|
| 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`, "μ¬μ§");
|