Compare commits

..

No commits in common. "main" and "feature-imageMatch" have entirely different histories.

53 changed files with 510 additions and 4212 deletions

7
.gitignore vendored
View File

@ -32,11 +32,8 @@ media/
*.ipynb_checkpoint*
# Static files (공유 기본 이미지는 예외로 추적)
static/*
!static/images/
static/images/*
!static/images/ado2_image.png
# Static files
static/
# Log files
*.log

View File

@ -69,9 +69,6 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
PROJECT_VERSION=0.1.0 # 프로젝트 버전
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
SHARE_FRONTEND_URL=https://ado2.o2osolution.ai # 공유 페이지 → 영상 상세 이동 프론트 URL (로컬: http://localhost:3000, 테스트: https://dev.castad.net)
SHARE_API_BASE_URL= # 공유 OG 페이지의 외부 공개 API URL (예: https://dev-ssul.castad.net/api). 프록시가 /api 를 떼면 필수
SHARE_DEFAULT_IMAGE_URL= # 포스터 없을 때 OG 이미지 (비우면 API /static/images/ado2_image.png)
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
# ================================
@ -179,66 +176,6 @@ fastapi dev main.py
fastapi run main.py
```
### 운영 업로드 및 메모리 한도
`POST /api/image/upload/blob`은 애플리케이션에서 파일당 15 MiB까지만 허용합니다.
운영 Nginx에서는 multipart 오버헤드를 고려해 이 엔드포인트의 요청 본문을
25 MiB로 제한합니다. 앱의 요청당 파일 합계 상한은 20 MiB이며, 나머지 5 MiB는
multipart 헤더와 `images_json`을 위한 여유입니다. 한 task에는 최대 100개
이미지만 누적할 수 있습니다. 200 MiB 이상의 요청을
허용하도록 Nginx 한도를 올리지 마세요. 프론트엔드는 이미지를 압축한 뒤 파일
한 개씩 전송해야 합니다.
운영 Nginx 설정은 이 저장소에서 관리되지 않으므로, 기존
`location = /api/image/upload/blob` 블록 안에서 다음 스니펫을 include합니다.
```nginx
include /배포경로/deploy/nginx/ado2-image-upload-limit.conf;
```
기존 설정이 prefix location만 사용한다면 그 블록의 `proxy_pass` 및 헤더 설정을
그대로 유지한 채, exact location을 추가하고 동일한 프록시 설정을 적용해야
합니다. 반영 전후에 실제 로드된 설정과 문법을 확인합니다.
```bash
sudo nginx -T | grep -n -E 'server_name|image/upload/blob|client_max_body_size'
sudo nginx -t
sudo systemctl reload nginx
```
`proxy_request_buffering off`는 이 스니펫에 포함하지 않았습니다. 이 옵션만으로
FastAPI의 multipart 파싱이 Azure 청크 스트리밍으로 바뀌지는 않으며, 느린
클라이언트 연결이 애플리케이션을 직접 점유하는 시간이 늘어날 수 있습니다.
Compose로 API를 실행하는 서버에서는 리소스 override를 함께 적용합니다.
이 override는 API 포트를 기본적으로 `127.0.0.1:8000`에만 바인딩해 외부
클라이언트가 Nginx의 요청 크기 제한을 우회하지 못하게 합니다. 운영 Nginx가
별도 컨테이너라면 호스트 포트를 공개하는 대신 두 서비스를 같은 내부 Docker
네트워크에 연결하세요. 부득이하게 `APP_BIND_ADDRESS`를 바꿀 때도 방화벽에서
8000 포트의 외부 접근을 차단해야 합니다. `!override` 구문을 위해 Docker
Compose 2.24.4 이상이 필요합니다.
```bash
docker compose -f docker-compose.yml -f compose.resources.yaml config --quiet
docker compose -f docker-compose.yml -f compose.resources.yaml up -d --force-recreate app
docker inspect castad-app \
--format 'memory={{.HostConfig.Memory}} reservation={{.HostConfig.MemoryReservation}} swap={{.HostConfig.MemorySwap}}'
```
기본값은 hard limit 2 GiB, reservation 512 MiB이며 추가 swap은 허용하지
않습니다. 호스트 용량과 실제 렌더링 부하를 측정한 뒤
`APP_MEMORY_LIMIT`/`APP_MEMORY_RESERVATION`으로 조정할 수 있습니다. 예를 들어
`APP_MEMORY_LIMIT=3g`를 설정하면 hard limit와 swap limit가 함께 3 GiB로
변경됩니다.
주의: 현재 저장소의 Dockerfile은 Uvicorn을 실행하지만 운영 로그 파일명에는
Gunicorn이 나타납니다. 운영 프로세스가 호스트의 systemd/Gunicorn으로 직접
실행 중이라면 이 Compose 제한은 적용되지 않습니다. 배포 전에 실제 실행
주체를 확인하고, Compose 컨테이너가 아니라면 Gunicorn을 loopback 또는 Unix
socket에만 bind하고 해당 서비스 관리자의 메모리 제한을 별도로 설정해야
합니다. 외부에서 앱 포트로 직접 접근할 수 있으면 Nginx의 25 MiB 제한을
우회할 수 있습니다.
## API 문서
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
@ -340,4 +277,4 @@ socket에만 bind하고 해당 서비스 관리자의 메모리 제한을 별도
│ │ │ │
```
testAc
testAc

View File

@ -16,13 +16,8 @@ from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from app.utils.pagination import PaginatedResponse
from app.utils.upload_blob_as_request import to_playback_url
from app.comment.models import Comment
from app.database.like_cache import (
bulk_is_user_liked,
get_like_counts,
mset_like_counts,
)
from app.database.like_cache import get_like_counts, mset_like_counts
from app.video.models import Video, VideoReaction
from app.video.schemas.video_schema import VideoListItem
@ -154,24 +149,6 @@ async def get_videos(
if vid not in db_found_ids:
like_count_map[vid] = 0
# is_liked_by_me: Redis user-set 기준, 캐시 미스 시 현재 사용자 상태만 DB 조회
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
needs_db_lookup = [
vid for vid, liked in raw_liked.items()
if liked is None and like_count_map.get(vid, 0) > 0
]
if needs_db_lookup:
liked_video_ids = set((await session.execute(
select(VideoReaction.video_id).where(
VideoReaction.video_id.in_(needs_db_lookup),
VideoReaction.user_uuid == current_user.user_uuid,
)
)).scalars().all())
for vid in needs_db_lookup:
raw_liked[vid] = vid in liked_video_ids
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
# VideoListItem으로 변환
items = [
VideoListItem(
@ -179,15 +156,10 @@ async def get_videos(
store_name=project.store_name,
region=project.region,
task_id=video.task_id,
result_movie_url=to_playback_url(video.result_movie_url),
poster_url=video.poster_url,
title=video.title,
description=video.description,
hashtags=video.hashtags,
result_movie_url=video.result_movie_url,
created_at=video.created_at,
like_count=like_count_map.get(video.id) or 0,
comment_count=comment_count or 0,
is_liked_by_me=liked_map.get(video.id, False),
)
for video, project, comment_count in rows
]

View File

@ -46,7 +46,7 @@ router = APIRouter(prefix="/comment", tags=["Comment"])
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
## 참고
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가).
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
""",
response_model=CommentCreateResponse,
@ -71,7 +71,7 @@ async def post_comment(
session=session,
video_id=video_id,
user_uuid=current_user.user_uuid,
nickname=current_user.nickname,
nickname=body.nickname,
content=body.content,
parent_id=body.parent_id,
)
@ -79,7 +79,6 @@ async def post_comment(
return CommentCreateResponse(
id=comment.id,
nickname=comment.nickname or "익명",
profile_image_url=current_user.profile_image_url,
parent_id=comment.parent_id,
content=comment.content,
created_at=comment.created_at,
@ -102,7 +101,7 @@ async def post_comment(
## 참고
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다.
- 작성자 정보는 노출되지 않으며, is_mine으로 본인 댓글 여부만 확인 가능합니다.
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
""",
response_model=PaginatedResponse[CommentItem],

View File

@ -17,8 +17,7 @@ class Comment(Base):
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
"""
__tablename__ = "comment"
@ -55,7 +54,7 @@ class Comment(Base):
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
)
nickname: Mapped[Optional[str]] = mapped_column(
String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)"
String(50), nullable=True, comment="댓글 작성자 닉네임 (null이면 익명)"
)
content: Mapped[str] = mapped_column(
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"

View File

@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
class CommentCreateRequest(BaseModel):
nickname: Optional[str] = Field(None, min_length=1, max_length=50, description="작성자 닉네임 (미입력 시 익명)")
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
@ -13,8 +14,7 @@ class ReplyItem(BaseModel):
"""대댓글 응답"""
id: int = Field(..., description="댓글 고유 ID")
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
is_deleted: bool = Field(..., description="삭제 여부")
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
@ -25,8 +25,7 @@ class CommentItem(BaseModel):
"""최상위 댓글 응답 — replies 포함"""
id: int = Field(..., description="댓글 고유 ID")
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
is_deleted: bool = Field(..., description="삭제 여부")
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
@ -36,8 +35,7 @@ class CommentItem(BaseModel):
class CommentCreateResponse(BaseModel):
id: int = Field(..., description="생성된 댓글 고유 ID")
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)")
nickname: str = Field(..., description="작성자 닉네임 (미입력 시 '익명')")
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
content: str = Field(..., description="댓글 본문")
created_at: datetime = Field(..., description="작성 일시")

View File

@ -7,7 +7,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.comment.models import Comment
from app.comment.schemas.comment_schema import CommentItem, ReplyItem
from app.user.models import User
from app.utils.pagination import PaginatedResponse
from app.video.models import Video
@ -38,7 +37,6 @@ def _build_comment_items(
parents: list,
replies_map: dict,
current_user_uuid: Optional[str],
profile_image_map: dict,
) -> List[CommentItem]:
items = []
for c in parents:
@ -47,7 +45,6 @@ def _build_comment_items(
ReplyItem(
id=r.id,
nickname=r.nickname or "익명",
profile_image_url=profile_image_map.get(r.user_uuid),
content=None if r.is_deleted else r.content,
is_deleted=r.is_deleted,
is_mine=(current_user_uuid == r.user_uuid) if current_user_uuid else False,
@ -59,7 +56,6 @@ def _build_comment_items(
CommentItem(
id=c.id,
nickname=c.nickname or "익명",
profile_image_url=profile_image_map.get(c.user_uuid),
content=None if c.is_deleted else c.content,
is_deleted=c.is_deleted,
is_mine=(current_user_uuid == c.user_uuid) if current_user_uuid else False,
@ -74,7 +70,7 @@ async def create_comment(
session: AsyncSession,
video_id: int,
user_uuid: str,
nickname: Optional[str],
nickname: str,
content: str,
parent_id: Optional[int],
) -> Comment:
@ -147,7 +143,6 @@ async def list_comments(
parents = (await session.execute(parents_q)).scalars().all()
replies_map: dict = defaultdict(list)
replies: list = []
if parents:
parent_ids = [c.id for c in parents]
replies_q = (
@ -162,16 +157,7 @@ async def list_comments(
for r in replies:
replies_map[r.parent_id].append(r)
# 작성자 프로필 이미지는 스냅샷을 저장하지 않고, 응답 시 User 테이블을 조인해 최신값을 조회한다.
user_uuids = {c.user_uuid for c in parents} | {r.user_uuid for r in replies}
profile_image_map: dict = {}
if user_uuids:
profile_q = select(User.user_uuid, User.profile_image_url).where(
User.user_uuid.in_(user_uuids)
)
profile_image_map = {uuid: url for uuid, url in (await session.execute(profile_q)).all()}
items = _build_comment_items(list(parents), replies_map, current_user_uuid, profile_image_map)
items = _build_comment_items(list(parents), replies_map, current_user_uuid)
return PaginatedResponse.create(
items=items,

View File

@ -433,10 +433,6 @@ class YouTubeAnalyticsService:
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
return result
# 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응)
_MAX_RETRIES = 3
_RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
async def _call_api(
self,
params: dict[str, str],
@ -457,67 +453,51 @@ class YouTubeAnalyticsService:
Raises:
YouTubeQuotaExceededError: 할당량 초과 (429)
YouTubeAuthError: 인증 실패 (401, 403)
YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생)
YouTubeAPIError: 기타 API 오류
Note:
- 타임아웃: 30초
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
- 인증 실패 시 자동으로 YouTubeAuthError 발생
- 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도
"""
headers = {"Authorization": f"Bearer {access_token}"}
last_error: Exception | None = None
for attempt in range(self._MAX_RETRIES + 1):
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.BASE_URL,
params=params,
headers=headers,
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(
self.BASE_URL,
params=params,
headers=headers,
)
# 할당량 초과 체크
if response.status_code == 429:
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
raise YouTubeQuotaExceededError()
# 인증 실패 체크
if response.status_code in (401, 403):
logger.warning(
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
)
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
# 할당량 초과 체크
if response.status_code == 429:
logger.warning("[YouTubeAnalyticsService._call_api] QUOTA_EXCEEDED")
raise YouTubeQuotaExceededError()
# HTTP 에러 체크
response.raise_for_status()
# 인증 실패 체크
if response.status_code in (401, 403):
logger.warning(
f"[YouTubeAnalyticsService._call_api] AUTH_FAILED - status={response.status_code}"
)
raise YouTubeAuthError(f"YouTube 인증 실패: {response.status_code}")
return response.json()
# HTTP 에러 체크
response.raise_for_status()
return response.json()
except (YouTubeAuthError, YouTubeQuotaExceededError):
raise # 이미 처리된 예외는 재시도 없이 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
f"status={e.response.status_code}, body={e.response.text[:500]}"
)
# 4xx는 재시도해도 동일하게 실패하므로 즉시 전파, 5xx만 재시도
if e.response.status_code < 500:
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
last_error = YouTubeAPIError(f"HTTP {e.response.status_code}")
except httpx.RequestError as e:
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
last_error = YouTubeAPIError(f"네트워크 오류: {e}")
except Exception as e:
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
raise YouTubeAPIError(f"알 수 없는 오류: {e}")
if attempt < self._MAX_RETRIES:
backoff = self._RETRY_BACKOFF_SECONDS[attempt]
logger.warning(
f"[YouTubeAnalyticsService._call_api] RETRY {attempt + 1}/{self._MAX_RETRIES} "
f"in {backoff}s - {last_error}"
)
await asyncio.sleep(backoff)
raise last_error
except (YouTubeAuthError, YouTubeQuotaExceededError):
raise # 이미 처리된 예외는 그대로 전파
except httpx.HTTPStatusError as e:
logger.error(
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
f"status={e.response.status_code}, body={e.response.text[:500]}"
)
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
except httpx.RequestError as e:
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
raise YouTubeAPIError(f"네트워크 오류: {e}")
except Exception as e:
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
raise YouTubeAPIError(f"알 수 없는 오류: {e}")

View File

@ -6,8 +6,6 @@ from fastapi import HTTPException
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from app.dashboard.exceptions import DashboardException
from app.social.exceptions import SocialException
from app.utils.logger import get_logger
from config import db_settings
@ -80,8 +78,7 @@ async def create_db_tables():
from app.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
from app.lyric.models import Lyric # noqa: F401
from app.song.models import Song, SongTimestamp # noqa: F401
from app.video.models import Video, VideoReaction # noqa: F401
from app.comment.models import Comment # noqa: F401
from app.video.models import Video # noqa: F401
from app.sns.models import SNSUploadTask # noqa: F401
from app.social.models import SocialUpload # noqa: F401
from app.dashboard.models import Dashboard # noqa: F401
@ -99,8 +96,6 @@ async def create_db_tables():
Song.__table__,
SongTimestamp.__table__,
Video.__table__,
VideoReaction.__table__,
Comment.__table__,
SNSUploadTask.__table__,
SocialUpload.__table__,
MarketingIntel.__table__,
@ -142,30 +137,13 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
yield session
except HTTPException:
raise
except (SocialException, DashboardException) as e:
# 전역 exception handler가 응답으로 변환하는 도메인 예외.
# 정상 플로우이므로 ERROR traceback 없이 롤백만 수행.
await session.rollback()
logger.warning(
f"[get_session] ROLLBACK - handled domain error: "
f"{type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise
except Exception as e:
await session.rollback()
# status_code < 500인 도메인 예외(계정 미연동 등)는 정상적인 비즈니스 흐름이므로 ERROR로 남기지 않음
if getattr(e, "status_code", 500) < 500:
logger.warning(
f"[get_session] ROLLBACK - client error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
else:
logger.error(traceback.format_exc())
logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
logger.error(traceback.format_exc())
logger.error(
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
f"duration: {(time.perf_counter() - start_time)*1000:.1f}ms"
)
raise
finally:
total_time = time.perf_counter() - start_time

View File

@ -1,14 +1,13 @@
import asyncio
import json
import secrets
import time
from collections.abc import AsyncIterator
from datetime import date
from pathlib import Path
from typing import Literal, Optional
from pydantic import BaseModel
from urllib.parse import unquote, urlparse
import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
@ -26,29 +25,14 @@ from app.home.schemas.home_schema import (
CrawlingResponse,
ErrorResponse,
ImageUploadResponse,
ImageUploadResultItem,
ImageUrlItem,
ManualMarketingRequest,
ProcessedInfo,
# MarketingAnalysis,
)
from app.home.services.naver_search import naver_search_client
from app.home.services.image_upload import (
ALLOWED_IMAGE_EXTENSIONS,
BlobReferenceState,
ImageUploadLockTimeoutError,
assert_continuation_owner as _assert_continuation_owner,
compensate_failed_upload_blobs,
image_result_item as _image_result_item,
image_upload_task_lock,
inspect_upload_file as _inspect_upload_file,
is_valid_image_extension as _is_valid_image_extension,
normalize_continuation_task_id as _normalize_continuation_task_id,
validate_task_image_count,
)
from app.utils.upload_blob_as_request import (
AzureBlobUploader,
BlobUploadTooLargeError,
)
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
from app.utils.common import generate_task_id
from app.utils.logger import get_logger
@ -59,36 +43,13 @@ from app.utils.address_parser import extract_region_from_address
from app.utils.autotag import autotag_images
from app.utils.image_filter import filter_marketing_images, assemble_images
from app.video.services.video import get_image_tags_by_task_id
from config import azure_blob_settings
from config import MEDIA_ROOT
logger = get_logger("home")
router = APIRouter()
async def _continuation_image_upload_lock(
task_id: Optional[str] = Form(default=None),
current_user: User = Depends(get_current_user),
) -> AsyncIterator[None]:
"""continuation 요청 전체를 task 단위로 직렬화하는 yield dependency입니다."""
# 인증 완료 자체가 lock 획득의 전제이며 endpoint와 dependency cache를 공유합니다.
del current_user
requested_task_id = task_id.strip() if task_id and task_id.strip() else None
if requested_task_id is None:
yield
return
normalized_task_id = _normalize_continuation_task_id(requested_task_id)
try:
async with image_upload_task_lock(normalized_task_id):
yield
except ImageUploadLockTimeoutError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="동일 이미지 작업이 처리 중입니다. 잠시 후 다시 시도해주세요.",
)
@router.get(
"/search/accommodation",
summary="장소 자동완성 검색 (숙박/음식점 등)",
@ -131,23 +92,13 @@ async def search_accommodation(
)
def _extract_region_from_address(
road_address: str | None, jibun_address: str | None = None
) -> str:
def _extract_region_from_address(road_address: str | None, jibun_address: str | None = None) -> str:
return extract_region_from_address(road_address, jibun_address)
class _IndustryOutput(BaseModel):
industry: Literal[
"stay",
"restaurant",
"cafe",
"salon",
"clinic",
"fitness",
"academy",
"attraction",
"general",
"stay", "restaurant", "cafe", "salon", "clinic", "fitness", "academy", "attraction", "general"
]
@ -169,7 +120,7 @@ async def _resolve_industry(category: str, customer_name: str = "") -> str:
"위 정보를 바탕으로 이 업체를 다음 업종 중 가장 적합한 하나로 분류하세요: "
"stay(숙박/펜션/호텔), restaurant(음식점), cafe(카페/디저트), "
"salon(미용실/네일/뷰티), clinic(병원/의원/치과), fitness(헬스/필라테스/요가), "
"academy(학원/교습소), attraction(관광/체험/액티비티/축제/행사). "
"academy(학원/교습소), attraction(관광/체험/액티비티). "
"위 8개 중 어느 것에도 명확히 해당하지 않는 경우에만 general(기타/범용 업종)로 분류하세요. "
"유사한 업종이 있으면 general 대신 그 업종을 우선 선택하세요."
)
@ -213,11 +164,10 @@ async def _resolve_industry(category: str, customer_name: str = "") -> str:
tags=["Crawling"],
)
async def crawling(
request_body: CrawlingRequest, session: AsyncSession = Depends(get_session)
):
request_body: CrawlingRequest,
session: AsyncSession = Depends(get_session)):
return await _crawling_logic(request_body.url, session)
@router.post(
"/autocomplete",
summary="네이버 자동완성 크롤링",
@ -250,30 +200,21 @@ async def crawling(
tags=["Crawling"],
)
async def autocomplete_crawling(
request_body: AutoCompleteRequest, session: AsyncSession = Depends(get_session)
):
request_body: AutoCompleteRequest,
session: AsyncSession = Depends(get_session)):
url = await _autocomplete_logic(request_body.model_dump())
return await _crawling_logic(url, session)
async def _crawling_logic(url: str, session: AsyncSession):
async def _crawling_logic(
url:str,
session: AsyncSession):
request_start = time.perf_counter()
# 요청당 1줄 요약 로그에 쓰이는 값들. 각 Step 이 실제로 실행될 때 채워진다.
customer_name = ""
region = ""
category = ""
industry = ""
owner_count = 0
extra_count = 0
filter_summary = "n/a"
gpt_status = "completed"
step2_elapsed = 0.0
step3_elapsed = 0.0
step4_elapsed = 0.0
logger.info("[crawling] ========== START ==========")
logger.info(f"[crawling] URL: {url[:80]}...")
# ========== Step 1: 네이버 지도 크롤링 ==========
step1_start = time.perf_counter()
logger.info("[crawling] Step 1: 네이버 지도 크롤링 시작...")
try:
scraper = NvMapScraper(url)
@ -308,11 +249,14 @@ async def _crawling_logic(url: str, session: AsyncSession):
)
step1_elapsed = (time.perf_counter() - step1_start) * 1000
owner_count = len(scraper.owner_images or [])
extra_count = len(scraper.extra_photo_urls or [])
logger.info(
f"[crawling] Step 1 완료 - 업체사진 {len(scraper.owner_images or [])}개, "
f"보충사진 {len(scraper.extra_photo_urls or [])}개 ({step1_elapsed:.1f}ms)"
)
# ========== Step 2: 정보 가공 (industry 선행 계산) ==========
step2_start = time.perf_counter()
logger.info("[crawling] Step 2: 정보 가공 시작...")
processed_info = None
marketing_analysis = None
@ -347,6 +291,10 @@ async def _crawling_logic(url: str, session: AsyncSession):
)
step2_elapsed = (time.perf_counter() - step2_start) * 1000
logger.info(
f"[crawling] Step 2 완료 - {customer_name}, {region}, "
f"category={category!r}, industry={industry!r} ({step2_elapsed:.1f}ms)"
)
# ========== Step 3: 이미지 마케팅 적합성 필터링 ==========
# 업체 사진이 SUPPLEMENT_THRESHOLD(30장) 이상이면 보충이 불필요하므로
@ -356,39 +304,39 @@ async def _crawling_logic(url: str, session: AsyncSession):
extra_photo_urls = scraper.extra_photo_urls or []
if len(owner_images) >= NvMapScraper.SUPPLEMENT_THRESHOLD:
# 업체 제공 사진은 필터링 면제 대상이므로 MAX_IMAGES 상한 없이 수집분을 전부 사용한다
# (MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용.
# 수집 자체는 NvMapScraper.BIZ_MAX_PAGES가 상한이며 도달 시 scraper가 warning을 남긴다).
scraper.image_link_list = list(owner_images)
scraper.image_link_list = owner_images[: NvMapScraper.MAX_IMAGES]
step3_elapsed = (time.perf_counter() - step3_start) * 1000
filter_summary = f"skip(owner>={NvMapScraper.SUPPLEMENT_THRESHOLD})"
logger.info(
f"[crawling] Step 3 SKIP - 업체 사진 {len(owner_images)}장 "
f"≥ {NvMapScraper.SUPPLEMENT_THRESHOLD}장 → 방문자 사진 필터링 생략"
)
else:
try:
extra_pass_flags = await filter_marketing_images(
[img["original"] for img in extra_photo_urls], industry
)
except Exception:
except Exception as e:
# logger.error(f"[crawling] Step 3 FAILED - 이미지 필터링 중 오류: {e}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="이미지 마케팅 적합성 필터링 중 오류가 발생했습니다.",
)
scraper.image_link_list = assemble_images(
owner_images,
extra_photo_urls,
extra_pass_flags,
NvMapScraper.MAX_IMAGES,
owner_images, extra_photo_urls, extra_pass_flags, NvMapScraper.MAX_IMAGES
)
passed_count = sum(extra_pass_flags)
step3_elapsed = (time.perf_counter() - step3_start) * 1000
filter_summary = f"{len(extra_photo_urls)}→{passed_count}"
logger.info(
f"[crawling] Step 3 완료 - 방문자 사진 {len(extra_photo_urls)}장 중 "
f"{passed_count}장 통과 → 최종 이미지 {len(scraper.image_link_list)}장 "
f"({step3_elapsed:.1f}ms)"
)
if not scraper.image_link_list:
logger.warning(
"[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다."
)
logger.warning("[crawling] Step 3 - 필터링 후 사용 가능 이미지가 0장입니다.")
# ========== Step 4: ChatGPT 마케팅 분석 ==========
step4_start = time.perf_counter()
logger.info("[crawling] Step 4: ChatGPT 마케팅 분석 시작...")
try:
# Step 4-1: ChatGPT 서비스 초기화 및 입력 데이터 구성
@ -417,27 +365,20 @@ async def _crawling_logic(url: str, session: AsyncSession):
)
# Step 4-3: 분석 결과 DB 저장 (industry는 Project로 흐르므로 여기엔 미저장)
# 공식 링크: 플레이스 홈페이지 항목 우선, 없으면 유저가 입력한 크롤링 소스 URL
# (컬럼 길이를 넘는 긴 검색 URL은 place_id 기반 표준 플레이스 URL로 대체)
official_site_url = scraper.official_site_url or url
if len(official_site_url) > 2048:
official_site_url = (
f"https://map.naver.com/p/entry/place/{scraper.place_id[2:]}"
)
marketing_intel = MarketingIntel(
place_id=scraper.place_id,
official_site_url=official_site_url,
intel_result=marketing_analysis.model_dump(),
)
session.add(marketing_intel)
await session.commit()
await session.refresh(marketing_intel)
m_id = marketing_intel.id
logger.debug(
f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}"
)
logger.debug(f"[MarketingPrompt] INSERT place_id={marketing_intel.place_id} id={marketing_intel.id}")
step4_elapsed = (time.perf_counter() - step4_start) * 1000
logger.info(
f"[crawling] Step 4 완료 - 마케팅 분석 성공 ({step4_elapsed:.1f}ms)"
)
except ChatGPTResponseError as e:
step4_elapsed = (time.perf_counter() - step4_start) * 1000
@ -464,26 +405,28 @@ async def _crawling_logic(url: str, session: AsyncSession):
# ========== 완료 ==========
total_elapsed = (time.perf_counter() - request_start) * 1000
logger.info(
f"[crawling] SUCCESS - url: {url[:80]}, name: {customer_name!r}, "
f"region: {region!r}, category: {category!r}, industry: {industry!r}, "
f"owner: {owner_count}, extra: {extra_count}, filter: {filter_summary}, "
f"images: {len(scraper.image_link_list or [])}, gpt: {gpt_status}, "
f"timing(ms): s1={step1_elapsed:.1f} s2={step2_elapsed:.1f} "
f"s3={step3_elapsed:.1f} s4={step4_elapsed:.1f} total={total_elapsed:.1f}"
)
logger.info("[crawling] ========== COMPLETE ==========")
logger.info(f"[crawling] 총 소요시간: {total_elapsed:.1f}ms")
logger.info(f"[crawling] - Step 1 (크롤링): {step1_elapsed:.1f}ms")
if scraper.base_info:
logger.info(f"[crawling] - Step 2 (정보가공): {step2_elapsed:.1f}ms")
if "step3_elapsed" in locals():
logger.info(f"[crawling] - Step 3 (이미지 필터링): {step3_elapsed:.1f}ms")
if "step4_elapsed" in locals():
logger.info(f"[crawling] - Step 4 (GPT 분석): {step4_elapsed:.1f}ms")
return {
"status": gpt_status,
"status": gpt_status if 'gpt_status' in locals() else "completed",
"image_list": scraper.image_link_list,
"image_count": len(scraper.image_link_list) if scraper.image_link_list else 0,
"processed_info": processed_info,
"marketing_analysis": marketing_analysis,
"m_id": m_id,
"industry": industry if "industry" in locals() else "",
"industry": industry if 'industry' in locals() else "",
}
@router.post(
"/marketing",
summary="업체명+주소 직접 입력 마케팅 분석",
@ -494,7 +437,6 @@ async def _crawling_logic(url: str, session: AsyncSession):
- **customer_name**: 업체명 / 브랜드명 (필수)
- **address**: 도로명 또는 지번 주소 (필수)
- **category**: 업종/카테고리 자유 입력 (선택, 예: 펜션, 카페). 비우면 업체명 기반 AI 분류
- **official_site_url**: 업체 공식 홈페이지 링크 (선택, http/https만 허용, 최대 2048자). 영상 응답의 official_site_url로 노출
## 반환 정보
- **processed_info**: 가공된 장소 정보 (customer_name, region, detail_region_info)
@ -538,7 +480,6 @@ async def manual_marketing(
# Step 3: 분석 결과 DB 저장 (place_id=None — 네이버 장소와 연결되지 않음)
marketing_intel = MarketingIntel(
place_id=None,
official_site_url=request_body.official_site_url,
intel_result=marketing_analysis.model_dump(),
)
session.add(marketing_intel)
@ -571,7 +512,7 @@ async def manual_marketing(
)
async def _autocomplete_logic(autocomplete_item: dict):
async def _autocomplete_logic(autocomplete_item:dict):
step1_start = time.perf_counter()
try:
async with NvMapPwScraper() as pw_scraper:
@ -599,7 +540,6 @@ async def _autocomplete_logic(autocomplete_item: dict):
return new_url
def _extract_image_name(url: str, index: int) -> str:
"""URL에서 이미지 이름 추출 또는 기본 이름 생성"""
try:
@ -612,6 +552,30 @@ def _extract_image_name(url: str, index: int) -> str:
return f"image_{index + 1:03d}"
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
def _is_valid_image_extension(filename: str | None) -> bool:
"""파일명의 확장자가 유효한 이미지 확장자인지 확인"""
if not filename:
return False
ext = Path(filename).suffix.lower()
return ext in ALLOWED_IMAGE_EXTENSIONS
def _get_file_extension(filename: str) -> str:
"""파일명에서 확장자 추출 (소문자)"""
return Path(filename).suffix.lower()
async def _save_upload_file(file: UploadFile, save_path: Path) -> None:
"""업로드 파일을 지정된 경로에 저장"""
save_path.parent.mkdir(parents=True, exist_ok=True)
async with aiofiles.open(save_path, "wb") as f:
content = await file.read()
await f.write(content)
IMAGES_JSON_EXAMPLE = """[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_189/1715688030436xT14o_JPEG/1.jpg"},
{"url": "https://naverbooking-phinf.pstatic.net/20240514_48/1715688030574wTtQd_JPEG/2.jpg"},
@ -620,13 +584,12 @@ IMAGES_JSON_EXAMPLE = """[
{"url": "https://naverbooking-phinf.pstatic.net/20240514_259/17156880311809wCnY_JPEG/5.jpg", "name": "외관"}
]"""
@router.post(
"/image/upload/blob",
summary="이미지 업로드 (Azure Blob Storage)",
description="""
이미지를 Azure Blob Storage에 업로드하고 task_id를 생성하거나 기존 작업에 이어 붙입니다.
바이너리 파일은 로컬 서버 경로에 복사하지 않고 Azure Blob에 청크 업로드됩니다.
이미지를 Azure Blob Storage에 업로드하고 새로운 task_id를 생성합니다.
바이너리 파일은 로컬 서버에 저장하지 않고 Azure Blob에 직접 업로드됩니다.
## 인증
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
@ -637,16 +600,8 @@ multipart/form-data 형식으로 전송합니다.
## 요청 필드
- **images_json**: 외부 이미지 URL 목록 (JSON 문자열, 선택)
- **files**: 이미지 바이너리 파일 목록 (선택)
- **task_id**: 분할 업로드를 이어갈 기존 task_id (선택, UUID7)
- **finalize**: 누적 이미지 태깅 실행 여부 (기본값 true)
**주의**:
- 기존 단일 요청은 `finalize=true` 기본값으로 이전과 동일하게 동작합니다.
- 분할 업로드 첫 요청은 `finalize=false`와 최소 1개 파일을 보내고, 응답 task_id를 다음 요청에 전달합니다.
- 중간 요청은 `task_id`와 `finalize=false`, 마지막 요청만 `finalize=true`로 보냅니다.
- 파일 1개는 최대 15 MiB, 한 요청의 파일 합계는 최대 20 MiB입니다.
- 한 task에는 기본 최대 100개의 이미지를 누적할 수 있습니다(서버 설정 가능).
- `finalize=true`일 때 해당 task_id에 누적된 전체 이미지를 한 번에 태깅합니다.
**주의**: images_json 또는 files 중 최소 하나는 반드시 전달해야 합니다.
## 지원 이미지 확장자
jpg, jpeg, png, webp, heic, heif
@ -674,19 +629,6 @@ curl -X POST "http://localhost:8000/image/upload/blob" \\
-H "Authorization: Bearer {access_token}" \\
-F 'images_json=[{"url":"https://example.com/image.jpg"}]' \\
-F "files=@/path/to/local_image.jpg"
# 분할 업로드 첫 요청 (응답의 task_id 보관)
curl -X POST "http://localhost:8000/image/upload/blob" \
-H "Authorization: Bearer {access_token}" \
-F "files=@/path/to/image1.jpg" \
-F "finalize=false"
# 분할 업로드 마지막 요청
curl -X POST "http://localhost:8000/image/upload/blob" \
-H "Authorization: Bearer {access_token}" \
-F "task_id={task_id}" \
-F "files=@/path/to/image2.jpg" \
-F "finalize=true"
```
## 반환 정보
@ -700,17 +642,14 @@ curl -X POST "http://localhost:8000/image/upload/blob" \
- **image_urls**: Image 테이블에 저장된 현재 task_id의 이미지 URL 목록
## 저장 경로
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{user_uuid}/{task_id}/image/{파일명})
- 바이너리 파일: Azure Blob Storage ({BASE_URL}/{task_id}/image/{파일명})
- URL 이미지: 외부 URL 그대로 Image 테이블에 저장
""",
response_model=ImageUploadResponse,
responses={
200: {"description": "이미지 업로드 성공"},
400: {"description": "입력 이미지가 유효하지 않음", "model": ErrorResponse},
400: {"description": "이미지가 제공되지 않음", "model": ErrorResponse},
401: {"description": "인증 실패 (토큰 없음/만료)"},
403: {"description": "continuation task 소유권 검증 실패"},
413: {"description": "파일 또는 요청 크기 제한 초과"},
502: {"description": "Azure Blob 업로드 실패"},
},
tags=["Image-Blob"],
openapi_extra={
@ -733,20 +672,11 @@ async def upload_images_blob(
default=None,
description="이미지 바이너리 파일 목록",
),
task_id: Optional[str] = Form(
default=None,
description="분할 업로드를 이어갈 기존 task_id (UUID7)",
),
finalize: bool = Form(
default=True,
description="true일 때 누적 이미지 태깅을 실행해 업로드를 완료",
),
industry: str = Form(
default="",
description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 크롤링 응답의 industry 값을 그대로 전달",
),
current_user: User = Depends(get_current_user),
_upload_lock: None = Depends(_continuation_image_upload_lock),
) -> ImageUploadResponse:
"""이미지 업로드 (URL + Azure Blob Storage)
@ -755,288 +685,126 @@ async def upload_images_blob(
- Stage 2: Azure Blob 업로드 (세션 없음)
- Stage 3: DB 저장 (새 세션으로 빠르게 처리)
"""
del _upload_lock
request_start = time.perf_counter()
requested_task_id = task_id.strip() if task_id and task_id.strip() else None
is_continuation = requested_task_id is not None
if requested_task_id:
task_id = _normalize_continuation_task_id(requested_task_id)
async with AsyncSessionLocal() as session:
existing_result = await session.execute(
select(Image)
.where(
Image.task_id == task_id,
Image.is_deleted.is_(False),
)
.order_by(Image.img_order, Image.id)
)
existing_images = list(existing_result.scalars().all())
_assert_continuation_owner(
existing_images,
current_user.user_uuid,
task_id,
)
else:
task_id = await generate_task_id()
existing_images = []
# task_id 생성
task_id = await generate_task_id()
logger.info(f"[upload_images_blob] START - task_id: {task_id}")
# ========== Stage 1: 입력 검증 (세션 없음, 파일 전체 메모리 적재 없음) ==========
# ========== Stage 1: 입력 검증 및 파일 데이터 준비 (세션 없음) ==========
has_images_json = images_json is not None and images_json.strip() != ""
has_files = files is not None and len(files) > 0
if not has_images_json and not has_files and not (is_continuation and finalize):
if not has_images_json and not has_files:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="images_json 또는 files 중 하나는 반드시 제공해야 합니다.",
)
# images_json 파싱
url_images: list[ImageUrlItem] = []
if has_images_json and images_json:
try:
parsed = json.loads(images_json)
if not isinstance(parsed, list):
raise ValueError("JSON 최상위 값은 배열이어야 합니다.")
url_images = [ImageUrlItem(**item) for item in parsed if item]
if isinstance(parsed, list):
url_images = [ImageUrlItem(**item) for item in parsed if item]
except (json.JSONDecodeError, TypeError, ValueError) as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"images_json 파싱 오류: {str(e)}",
)
upload_files = files or []
declared_total_size = sum(
file.size for file in upload_files if file.size is not None and file.size > 0
)
max_request_size = azure_blob_settings.IMAGE_UPLOAD_MAX_REQUEST_SIZE_BYTES
if declared_total_size > max_request_size:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
"한 요청의 파일 합계가 최대 크기 "
f"{max_request_size // (1024 * 1024)} MiB를 초과합니다."
),
)
valid_files_data: list[tuple[UploadFile, str, str, int]] = []
# 유효한 파일만 필터링 및 파일 내용 미리 읽기
valid_files_data: list[tuple[str, str, bytes]] = [] # (original_name, ext, content)
skipped_files: list[str] = []
actual_total_size = 0
for file in upload_files:
is_real_file = bool(file.filename and file.filename != "filename")
if not is_real_file or not _is_valid_image_extension(file.filename):
skipped_files.append(file.filename or "unknown")
continue
if has_files and files:
for f in files:
is_valid_ext = _is_valid_image_extension(f.filename)
is_not_empty = f.size is None or f.size > 0
is_real_file = f.filename and f.filename != "filename"
original_name, extension, actual_size = await _inspect_upload_file(file)
actual_total_size += actual_size
if actual_total_size > max_request_size:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
"한 요청의 실제 파일 합계가 최대 크기 "
f"{max_request_size // (1024 * 1024)} MiB를 초과합니다."
),
)
valid_files_data.append((file, original_name, extension, actual_size))
if f and is_real_file and is_valid_ext and is_not_empty:
# 파일 내용을 미리 읽어둠
content = await f.read()
ext = _get_file_extension(f.filename) # type: ignore[arg-type]
valid_files_data.append((f.filename or "image", ext, content))
else:
skipped_files.append(f.filename or "unknown")
provided_new_input = has_images_json or has_files
if provided_new_input and not url_images and not valid_files_data:
if not url_images and not valid_files_data:
detail = (
f"유효한 이미지가 없습니다. "
f"지원 확장자: {', '.join(ALLOWED_IMAGE_EXTENSIONS)}. "
f"건너뛴 파일: {skipped_files}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"유효한 이미지가 없습니다. "
f"지원 확장자: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}. "
f"건너뛴 파일: {skipped_files}"
),
detail=detail,
)
if not is_continuation and not finalize and not valid_files_data:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="분할 업로드의 첫 요청에는 소유권 확인용 이미지 파일이 필요합니다.",
)
validate_task_image_count(
existing_count=len(existing_images),
incoming_count=len(url_images) + len(valid_files_data),
)
stage1_time = time.perf_counter()
logger.info(
f"[upload_images_blob] Stage 1 done - urls: {len(url_images)}, "
f"files: {len(valid_files_data)}, "
f"elapsed: {(stage1_time - request_start) * 1000:.1f}ms"
)
# ========== Stage 2: Azure Blob 청크 업로드 (세션 없음) ==========
# (원본명, 공개 URL, Azure 저장 파일명)
blob_upload_results: list[tuple[str, str, str]] = []
uploader: AzureBlobUploader | None = None
order_hint = max((image.img_order for image in existing_images), default=-1) + 1
order_hint += len(url_images)
async def cleanup_current_request_blobs() -> None:
if uploader is None:
return
for _, _, stored_name in blob_upload_results:
await uploader.delete_image(stored_name)
async def compensate_failed_db_write(
commit_started: bool,
error: BaseException,
) -> None:
"""DB 반영 여부가 불명확하면 Blob 보존을 우선합니다."""
if not blob_upload_results:
return
blob_urls = {blob_url for _, blob_url, _ in blob_upload_results}
reference_state = await compensate_failed_upload_blobs(
task_id=task_id,
blob_urls=blob_urls,
commit_started=commit_started,
cleanup=cleanup_current_request_blobs,
)
if not commit_started:
return
if reference_state == BlobReferenceState.NONE:
logger.warning(
f"[upload_images_blob] Commit failed and independent DB check "
f"confirmed no Blob references; cleaning up - task_id: {task_id}"
)
return
logger.error(
f"[upload_images_blob] Preserving Blob after ambiguous commit - "
f"task_id: {task_id}, reference_state: {reference_state}, "
f"error: {type(error).__name__}: {error}"
)
async def compensate_after_cancellation(
commit_started: bool,
error: asyncio.CancelledError,
) -> None:
compensation_task = asyncio.create_task(
compensate_failed_db_write(commit_started, error)
)
try:
await asyncio.shield(compensation_task)
except asyncio.CancelledError:
# 반복 취소가 와도 확인/정리가 끝나도록 하며, 실패 시에는 Blob을 보존합니다.
try:
await compensation_task
except BaseException as compensation_error:
logger.error(
f"[upload_images_blob] Cancellation compensation failed; "
f"preserving Blob - task_id: {task_id}, "
f"{type(compensation_error).__name__}: {compensation_error}"
)
# ========== Stage 2: Azure Blob 업로드 (세션 없음) ==========
# 업로드 결과를 저장할 리스트 (나중에 DB에 저장)
blob_upload_results: list[tuple[str, str]] = [] # (img_name, blob_url)
img_order = len(url_images) # URL 이미지 다음 순서부터 시작
if valid_files_data:
uploader = AzureBlobUploader(user_uuid=current_user.user_uuid, task_id=task_id)
total_files = len(valid_files_data)
for idx, (file, original_name, extension, actual_size) in enumerate(
valid_files_data
):
name_without_ext = Path(original_name).stem
unique_suffix = secrets.token_hex(4)
stored_name = (
f"{name_without_ext}_{order_hint + idx:03d}_{unique_suffix}{extension}"
for idx, (original_name, ext, file_content) in enumerate(valid_files_data):
name_without_ext = (
original_name.rsplit(".", 1)[0]
if "." in original_name
else original_name
)
filename = f"{name_without_ext}_{img_order:03d}{ext}"
logger.debug(
f"[upload_images_blob] Uploading file {idx + 1}/{total_files}: "
f"{stored_name} ({actual_size} bytes)"
f"{filename} ({len(file_content)} bytes)"
)
try:
upload_success = await uploader.upload_image_stream(
file,
stored_name,
expected_size_bytes=actual_size,
max_size_bytes=(
azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
),
)
except BlobUploadTooLargeError as exc:
await cleanup_current_request_blobs()
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"파일 '{original_name}'이 최대 크기 "
f"{exc.max_size_bytes // (1024 * 1024)} MiB를 초과합니다."
),
)
except asyncio.CancelledError:
await asyncio.shield(cleanup_current_request_blobs())
raise
# Azure Blob Storage에 직접 업로드
upload_success = await uploader.upload_image_bytes(file_content, filename)
if upload_success:
blob_upload_results.append(
(original_name, uploader.public_url, stored_name)
)
blob_url = uploader.public_url
blob_upload_results.append((original_name, blob_url))
img_order += 1
logger.debug(
f"[upload_images_blob] File {idx + 1}/{total_files} SUCCESS"
)
else:
skipped_files.append(stored_name)
skipped_files.append(filename)
logger.warning(
f"[upload_images_blob] File {idx + 1}/{total_files} FAILED"
)
if valid_files_data and not blob_upload_results and not url_images:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Azure Blob Storage 이미지 업로드에 실패했습니다.",
)
if not is_continuation and not finalize and not blob_upload_results:
# URL row만 남으면 다음 요청에서 소유권을 증명할 수 없으므로 저장하지 않습니다.
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="분할 업로드를 시작할 이미지 파일 업로드에 실패했습니다.",
)
stage2_time = time.perf_counter()
logger.info(
f"[upload_images_blob] Stage 2 done - blob uploads: "
f"{len(blob_upload_results)}, skipped: {len(skipped_files)}, "
f"elapsed: {(stage2_time - stage1_time) * 1000:.1f}ms"
)
# ========== Stage 3: DB 저장 (새 세션으로 빠르게 처리) ==========
all_images: list[Image] = []
# 요약 로그용. 커밋이 끝난 뒤 실제 값으로 덮어쓴다.
stage3_time = stage2_time
added_count = 0
commit_started = False
logger.info("[upload_images_blob] Stage 3 starting - DB save...")
result_images: list[ImageUploadResultItem] = []
img_order = 0
try:
async with AsyncSessionLocal() as session:
# 같은 task의 append 요청을 직렬화해 img_order 충돌을 줄입니다.
locked_result = await session.execute(
select(Image)
.where(
Image.task_id == task_id,
Image.is_deleted.is_(False),
)
.order_by(Image.img_order, Image.id)
.with_for_update()
)
locked_images = list(locked_result.scalars().all())
if is_continuation:
_assert_continuation_owner(
locked_images,
current_user.user_uuid,
task_id,
)
elif locked_images:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="이미 사용 중인 task_id가 생성되었습니다. 다시 시도해주세요.",
)
validate_task_image_count(
existing_count=len(locked_images),
incoming_count=len(url_images) + len(blob_upload_results),
)
img_order = (
max(
(image.img_order for image in locked_images),
default=-1,
)
+ 1
)
new_images: list[Image] = []
# URL 이미지 저장
for url_item in url_images:
img_name = url_item.name or _extract_image_name(url_item.url, img_order)
image = Image(
task_id=task_id,
img_name=img_name,
@ -1044,10 +812,21 @@ async def upload_images_blob(
img_order=img_order,
)
session.add(image)
new_images.append(image)
await session.flush()
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=url_item.url,
img_order=img_order,
source="url",
)
)
img_order += 1
for img_name, blob_url, _ in blob_upload_results:
# Blob 업로드 결과 저장
for img_name, blob_url in blob_upload_results:
image = Image(
task_id=task_id,
img_name=img_name,
@ -1055,27 +834,28 @@ async def upload_images_blob(
img_order=img_order,
)
session.add(image)
new_images.append(image)
await session.flush()
result_images.append(
ImageUploadResultItem(
id=image.id,
img_name=img_name,
img_url=blob_url,
img_order=img_order,
source="blob",
)
)
img_order += 1
await session.flush()
all_images = sorted(
[*locked_images, *new_images],
key=lambda image: (image.img_order, image.id),
)
commit_started = True
await session.commit()
stage3_time = time.perf_counter()
added_count = len(new_images)
logger.info(
f"[upload_images_blob] Stage 3 done - "
f"saved: {len(result_images)}, "
f"elapsed: {(stage3_time - stage2_time) * 1000:.1f}ms"
)
except asyncio.CancelledError as e:
await compensate_after_cancellation(commit_started, e)
raise
except HTTPException as e:
await compensate_failed_db_write(commit_started, e)
raise
except SQLAlchemyError as e:
await compensate_failed_db_write(commit_started, e)
logger.error(f"[upload_images_blob] DB Error - task_id: {task_id}, error: {e}")
logger.exception("[upload_images_blob] DB 상세 오류:")
raise HTTPException(
@ -1083,7 +863,6 @@ async def upload_images_blob(
detail="이미지 저장 중 데이터베이스 오류가 발생했습니다.",
)
except Exception as e:
await compensate_failed_db_write(commit_started, e)
logger.error(
f"[upload_images_blob] Stage 3 EXCEPTION - "
f"task_id: {task_id}, error: {type(e).__name__}: {e}"
@ -1094,48 +873,36 @@ async def upload_images_blob(
detail="이미지 업로드 중 오류가 발생했습니다.",
)
result_images = [_image_result_item(image) for image in all_images]
saved_count = len(result_images)
image_urls = [img.img_url for img in result_images]
tagging_summary = "deferred"
if finalize:
await tagging_images(image_urls, industry=industry, clear_old_tags=True)
logger.info(f"[image_tagging] START - task_id: {task_id}")
await tagging_images(image_urls, industry=industry, clear_old_tags=True)
logger.info(f"[image_tagging] Done - task_id: {task_id}")
# 마지막 분할 요청에서 누적된 전체 이미지의 적합성을 확인합니다.
taged_image_list = await get_image_tags_by_task_id(task_id)
tagging_summary = f"tagged={len(taged_image_list)}"
if not taged_image_list:
logger.error(
f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}"
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"영상 생성에 적합한 이미지가 없습니다. "
"다른 이미지로 다시 업로드해주세요."
),
)
# 태깅 직후 영상 생성에 사용 가능한 이미지가 하나도 없으면 조기에 실패시킨다.
# (여기서 걸러지지 않으면 훨씬 나중인 영상 생성 단계에서야 슬롯 미배정으로 발견됨)
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 여기서는 재필터링하지 않는다.
taged_image_list = await get_image_tags_by_task_id(task_id)
logger.info(f"태깅된 이미지: {len(taged_image_list)}개 - task_id: {task_id}")
if not taged_image_list:
logger.error(f"[image_tagging] 영상 생성에 적합한 이미지가 없음 - task_id: {task_id}")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="영상 생성에 적합한 이미지가 없습니다. 다른 이미지로 다시 업로드해주세요.",
)
total_time = time.perf_counter() - request_start
logger.info(
f"[upload_images_blob] SUCCESS - task_id: {task_id}, "
f"cont: {is_continuation}, finalize: {finalize}, "
f"urls: {len(url_images)}, files: {len(valid_files_data)}, "
f"bytes: {actual_total_size}, blobs: {len(blob_upload_results)}, "
f"skipped: {len(skipped_files)}, added: {added_count}, "
f"task_total: {saved_count}, tagging: {tagging_summary}, "
f"timing(ms): s1={(stage1_time - request_start) * 1000:.1f} "
f"blob={(stage2_time - stage1_time) * 1000:.1f} "
f"db={(stage3_time - stage2_time) * 1000:.1f} "
f"total={total_time * 1000:.1f}"
f"total: {saved_count}, total_time: {total_time * 1000:.1f}ms"
)
return ImageUploadResponse(
task_id=task_id,
total_count=saved_count,
url_count=sum(image.source == "url" for image in result_images),
file_count=sum(image.source == "blob" for image in result_images),
total_count=len(result_images),
url_count=len(url_images),
file_count=len(blob_upload_results),
saved_count=saved_count,
images=result_images,
image_urls=image_urls,
@ -1143,8 +910,10 @@ async def upload_images_blob(
async def tagging_images(
image_urls: list[str], industry: str = "", clear_old_tags: bool = False
) -> None:
image_urls : list[str],
industry: str = "",
clear_old_tags : bool = False
) -> None:
# 1. 조회
async with AsyncSessionLocal() as session:
stmt = (
@ -1166,21 +935,14 @@ async def tagging_images(
session.add_all(new_imt)
null_imts = [imt for imt in image_tags if imt.img_tag is None] + new_imt
await session.commit()
if null_imts:
tag_datas = await autotag_images(
[img.img_url for img in null_imts], industry=industry
)
tag_datas = await autotag_images([img.img_url for img in null_imts], industry=industry)
# print(tag_datas)
async with AsyncSessionLocal() as session:
for tag, tag_data in zip(null_imts, tag_datas):
if isinstance(tag_data, Exception):
# 태깅 실패 이미지는 img_tag가 NULL로 남아 영상 생성 풀에서 제외됨
logger.warning(
f"[tagging_images] 이미지 태깅 최종 실패 - url: {tag.img_url}, "
f"error: {type(tag_data).__name__}: {tag_data}"
)
continue
tag.img_tag = tag_data.model_dump(mode="json")
session.add(tag)

View File

@ -274,7 +274,6 @@ class MarketingIntel(Base):
Attributes:
id: 고유 식별자 (자동 증가)
place_id : 데이터 소스별 식별자
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
intel_result : 마케팅 분석 결과물 json
created_at: 생성 일시 (자동 설정)
"""
@ -303,12 +302,6 @@ class MarketingIntel(Base):
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
)
official_site_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)",
)
intel_result : Mapped[dict[str, Any]] = mapped_column(
JSON,
nullable=False,
@ -377,10 +370,8 @@ class ImageTag(Base):
)
img_tag: Mapped[dict[str, Any]] = mapped_column(
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
JSON(none_as_null=True),
JSON,
nullable=True,
default=None,
default=False,
comment="태그 JSON",
)

View File

@ -1,6 +1,6 @@
from typing import Literal, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field
from app.utils.prompts.schemas import MarketingPromptOutput
class CrawlingRequest(BaseModel):
@ -261,7 +261,6 @@ class ManualMarketingRequest(BaseModel):
"store_name": "스테이 머뭄",
"address": "전북특별자치도 군산시 절골길 18",
"category": "펜션",
"official_site_url": "https://www.staymeomoom.com",
}
}
)
@ -269,23 +268,6 @@ class ManualMarketingRequest(BaseModel):
store_name: str = Field(..., description="업체명 / 브랜드명")
address: str = Field(..., description="도로명 또는 지번 주소")
category: str = Field(default="", description="업체 업종/카테고리 자유 입력 (예: 펜션, 카페). 크롤링 경로와 동일하게 AI가 8개 industry enum으로 자동 분류하는 데 사용. 비우면 업체명 기반 AI 분류")
official_site_url: Optional[str] = Field(
default=None,
max_length=2048,
description="업체 공식 홈페이지 링크 (선택, http/https만 허용). 영상 응답의 official_site_url로 노출됨",
)
@field_validator("official_site_url")
@classmethod
def _validate_official_site_url(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return None
v = v.strip()
if not v:
return None
if not v.startswith(("http://", "https://")):
raise ValueError("official_site_url은 http:// 또는 https://로 시작해야 합니다.")
return v
class ErrorResponse(BaseModel):

View File

@ -1,380 +0,0 @@
"""이미지 업로드 입력 검증과 continuation 소유권 검사 유틸리티."""
import asyncio
import time
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from enum import StrEnum
from pathlib import Path
from typing import Literal
from uuid import UUID
from fastapi import HTTPException, UploadFile, status
from sqlalchemy import select, text
from app.database.session import AsyncSessionLocal, engine
from app.home.models import Image
from app.home.schemas.home_schema import ImageUploadResultItem
from app.utils.logger import get_logger
from config import azure_blob_settings
logger = get_logger("image_upload")
_image_upload_lock_slots = asyncio.Semaphore(
azure_blob_settings.IMAGE_UPLOAD_MAX_CONCURRENT_LOCKS
)
ALLOWED_IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"}
_HEIF_BRANDS = {
b"heic",
b"heix",
b"hevc",
b"hevx",
b"heim",
b"heis",
b"mif1",
b"msf1",
}
_IMAGE_SIGNATURE_BYTES = 64
class ImageUploadLockTimeoutError(TimeoutError):
"""동일 task 이미지 변경 락을 제한 시간 내 획득하지 못했습니다."""
class BlobReferenceState(StrEnum):
"""불명확한 DB commit 뒤 Blob URL 참조 확인 결과."""
ALL = "all"
NONE = "none"
MIXED = "mixed"
UNKNOWN = "unknown"
def should_cleanup_failed_upload_blobs(
*,
commit_started: bool,
reference_state: BlobReferenceState = BlobReferenceState.UNKNOWN,
) -> bool:
"""DB commit 시도 후에는 참조가 없다고 확정된 경우에만 Blob을 삭제합니다."""
return not commit_started or reference_state == BlobReferenceState.NONE
def classify_blob_references(
expected_urls: set[str], found_urls: set[str]
) -> BlobReferenceState:
"""예상 Blob URL과 독립 조회 결과를 보존 우선 상태로 분류합니다."""
if not expected_urls:
return BlobReferenceState.NONE
matched_urls = expected_urls & found_urls
if matched_urls == expected_urls:
return BlobReferenceState.ALL
if not matched_urls:
return BlobReferenceState.NONE
return BlobReferenceState.MIXED
async def inspect_blob_references(
task_id: str,
blob_urls: set[str],
) -> BlobReferenceState:
"""독립 세션에서 이번 요청 Blob URL의 DB 반영 여부를 확인합니다.
commit 응답 유실 직후의 짧은 가시성 경합을 피하려고 NONE 결과만 세 번
재확인합니다. 존재/혼재/조회 실패는 즉시 보존 쪽으로 판정합니다.
"""
if not blob_urls:
return BlobReferenceState.NONE
for attempt in range(3):
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(Image.img_url).where(
Image.task_id == task_id,
Image.img_url.in_(blob_urls),
)
)
found_urls = set(result.scalars().all())
state = classify_blob_references(blob_urls, found_urls)
if state != BlobReferenceState.NONE:
return state
if attempt < 2:
await asyncio.sleep(0.1 * (attempt + 1))
except Exception as exc:
logger.error(
f"[inspect_blob_references] DB verification failed - task_id: "
f"{task_id}, {type(exc).__name__}: {exc}"
)
return BlobReferenceState.UNKNOWN
return BlobReferenceState.NONE
async def compensate_failed_upload_blobs(
*,
task_id: str,
blob_urls: set[str],
commit_started: bool,
cleanup: Callable[[], Awaitable[None]],
) -> BlobReferenceState:
"""DB 쓰기 실패 후 안전하다고 확인된 Blob만 보상 삭제합니다."""
reference_state = BlobReferenceState.UNKNOWN
if commit_started:
reference_state = await inspect_blob_references(task_id, blob_urls)
if should_cleanup_failed_upload_blobs(
commit_started=commit_started,
reference_state=reference_state,
):
await cleanup()
return reference_state
def validate_task_image_count(*, existing_count: int, incoming_count: int) -> int:
"""한 task에 누적 가능한 이미지 수를 검증하고 예상 총 개수를 반환합니다."""
total_count = existing_count + incoming_count
max_task_images = azure_blob_settings.IMAGE_UPLOAD_MAX_TASK_IMAGES
if total_count > max_task_images:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"한 작업에는 이미지를 최대 {max_task_images}개까지 추가할 수 있습니다.",
)
return total_count
@asynccontextmanager
async def _image_upload_lock_slot(
lock_name: str, timeout_seconds: int
) -> AsyncIterator[None]:
"""DB 락 연결 슬롯 대기에도 동일한 제한 시간을 적용합니다."""
try:
await asyncio.wait_for(
_image_upload_lock_slots.acquire(),
timeout=timeout_seconds,
)
except TimeoutError as exc:
raise ImageUploadLockTimeoutError(lock_name) from exc
try:
yield
finally:
_image_upload_lock_slots.release()
@asynccontextmanager
async def image_upload_task_lock(task_id: str) -> AsyncIterator[None]:
"""동일 task 요청 전체를 MySQL advisory lock으로 직렬화합니다.
yield dependency가 Azure 업로드부터 최종 태깅까지 connection을 점유하므로,
worker별 semaphore로 main DB pool의 나머지 connection을 보존합니다.
"""
lock_name = f"image_upload:{task_id}"
timeout_seconds = azure_blob_settings.IMAGE_UPLOAD_LOCK_TIMEOUT_SECONDS
lock_started = time.perf_counter()
async with _image_upload_lock_slot(lock_name, timeout_seconds):
async with engine.connect() as connection:
lock_result = await connection.execute(
text("SELECT GET_LOCK(:lock_name, :timeout_seconds)"),
{
"lock_name": lock_name,
"timeout_seconds": timeout_seconds,
},
)
if lock_result.scalar_one_or_none() != 1:
raise ImageUploadLockTimeoutError(lock_name)
logger.info(
f"[image_upload_task_lock] ACQUIRED - task_id: {task_id}, "
f"wait_ms: {(time.perf_counter() - lock_started) * 1000:.1f}"
)
try:
yield
finally:
release_task = asyncio.create_task(
connection.execute(
text("SELECT RELEASE_LOCK(:lock_name)"),
{"lock_name": lock_name},
)
)
try:
release_result = await asyncio.shield(release_task)
if release_result.scalar_one_or_none() != 1:
raise RuntimeError(f"RELEASE_LOCK failed: {lock_name}")
except asyncio.CancelledError:
# shield 바깥 task가 다시 취소돼도 release query는 끝까지 기다립니다.
try:
await release_task
except BaseException as release_exc:
await connection.invalidate(release_exc)
raise
except BaseException as exc:
# 락이 남은 connection이 pool로 복귀하지 않도록 폐기합니다.
await connection.invalidate(exc)
logger.error(
f"[image_upload_task_lock] RELEASE_LOCK failed - "
f"{type(exc).__name__}: {exc}"
)
else:
logger.info(
f"[image_upload_task_lock] RELEASED - task_id: {task_id}, "
f"held_ms: {(time.perf_counter() - lock_started) * 1000:.1f}"
)
def is_valid_image_extension(filename: str | None) -> bool:
"""파일명의 확장자가 지원 이미지 확장자인지 확인합니다."""
if not filename:
return False
return Path(filename).suffix.lower() in ALLOWED_IMAGE_EXTENSIONS
def _detect_image_format(header: bytes) -> str | None:
"""신뢰할 수 없는 파일명/MIME 대신 파일 시그니처로 형식을 판별합니다."""
if header.startswith(b"\xff\xd8\xff"):
return "jpeg"
if header.startswith(b"\x89PNG\r\n\x1a\n"):
return "png"
if len(header) >= 12 and header.startswith(b"RIFF") and header[8:12] == b"WEBP":
return "webp"
if len(header) >= 12 and header[4:8] == b"ftyp":
brands = {header[8:12]}
brands.update(
header[index : index + 4] for index in range(16, len(header) - 3, 4)
)
if brands & _HEIF_BRANDS:
return "heif"
return None
def _extension_matches_format(extension: str, detected_format: str) -> bool:
"""동일 포맷의 별칭을 고려해 확장자와 시그니처 일치 여부를 확인합니다."""
expected_formats = {
".jpg": "jpeg",
".jpeg": "jpeg",
".png": "png",
".webp": "webp",
".heic": "heif",
".heif": "heif",
}
return expected_formats.get(extension) == detected_format
async def inspect_upload_file(file: UploadFile) -> tuple[str, str, int]:
"""UploadFile을 상수 메모리로 검사하고 실제 바이트 크기를 반환합니다."""
original_name = file.filename or ""
if len(original_name) > 255:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="파일명은 255자를 초과할 수 없습니다.",
)
extension = Path(original_name).suffix.lower()
max_file_size = azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
validation_chunk_size = min(
azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES,
1024 * 1024,
)
total_size = 0
header = bytearray()
await file.seek(0)
try:
while chunk := await file.read(validation_chunk_size):
total_size += len(chunk)
if total_size > max_file_size:
raise HTTPException(
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
detail=(
f"파일 '{original_name}'이 최대 크기 "
f"{max_file_size // (1024 * 1024)} MiB를 초과합니다."
),
)
if len(header) < _IMAGE_SIGNATURE_BYTES:
remaining = _IMAGE_SIGNATURE_BYTES - len(header)
header.extend(chunk[:remaining])
finally:
await file.seek(0)
if total_size == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"빈 파일은 업로드할 수 없습니다: {original_name}",
)
detected_format = _detect_image_format(bytes(header))
if detected_format is None or not _extension_matches_format(
extension, detected_format
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
f"파일 내용과 확장자가 일치하는 지원 이미지가 아닙니다: {original_name}"
),
)
return original_name, extension, total_size
def normalize_continuation_task_id(task_id: str) -> str:
"""continuation task_id를 canonical UUID7 문자열로 검증합니다."""
try:
parsed = UUID(task_id)
except (ValueError, AttributeError):
parsed = None
if (
parsed is None
or len(task_id) != 36
or parsed.version != 7
or str(parsed) != task_id.lower()
):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="task_id는 올바른 UUID7 형식이어야 합니다.",
)
return str(parsed)
def _blob_url_prefix(user_uuid: str, task_id: str) -> str:
"""현재 사용자와 task에 허용된 Azure 이미지 URL prefix를 반환합니다."""
base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/")
return f"{base_url}/{user_uuid}/{task_id}/image/"
def assert_continuation_owner(
images: list[Image], user_uuid: str, task_id: str
) -> None:
"""Image에 owner 컬럼이 없어 Blob 경로로 continuation 소유권을 검증합니다."""
if not images:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="이어 올릴 이미지 작업을 찾을 수 없습니다.",
)
base_prefix = f"{azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip('/')}/"
owner_prefix = _blob_url_prefix(user_uuid, task_id)
internal_urls = [
image.img_url for image in images if image.img_url.startswith(base_prefix)
]
if not internal_urls or any(
not image_url.startswith(owner_prefix) for image_url in internal_urls
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="이 이미지 업로드 작업을 이어서 수정할 권한이 없습니다.",
)
def image_result_item(image: Image) -> ImageUploadResultItem:
"""DB Image를 기존 응답 아이템으로 변환합니다."""
base_prefix = f"{azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip('/')}/"
source: Literal["url", "blob"] = (
"blob" if image.img_url.startswith(base_prefix) else "url"
)
return ImageUploadResultItem(
id=image.id,
img_name=image.img_name,
img_url=image.img_url,
img_order=image.img_order,
source=source,
)

View File

@ -70,11 +70,11 @@ class GenerateLyricRequest(BaseModel):
language: str = Field(
default="Korean",
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
)
),
orientation: Literal["horizontal", "vertical"] = Field(
default="vertical",
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
)
),
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
@ -83,6 +83,11 @@ class GenerateLyricRequest(BaseModel):
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
)
genre: Optional[str] = Field(
None,
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
)
class GenerateLyricResponse(BaseModel):

View File

@ -33,5 +33,5 @@ async def youtube_seo_description(
session: AsyncSession = Depends(get_session),
) -> YoutubeDescriptionResponse:
return await seo_service.get_youtube_seo_description(
request_body.video_id, current_user, session
request_body.task_id, current_user, session
)

View File

@ -95,6 +95,8 @@ YOUTUBE_SCOPES = [
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
]
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
# =============================================================================
# Instagram/Facebook OAuth Scopes (추후 구현)
# =============================================================================

View File

@ -8,12 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
class YoutubeDescriptionRequest(BaseModel):
"""유튜브 SEO Description 제안 요청"""
video_id: int = Field(..., description="영상 고유 ID")
task_id: str = Field(..., description="작업 고유 식별자")
model_config = ConfigDict(
json_schema_extra={
"example": {
"video_id": 123
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
}
}
)

View File

@ -1,132 +1,88 @@
"""
유튜브 SEO 서비스
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
"""
import json
import logging
from fastapi import HTTPException, status
from fastapi import HTTPException
from redis.asyncio import Redis
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from config import db_settings
from app.home.models import MarketingIntel, Project
from app.social.constants import YOUTUBE_SEO_HASH
from app.social.schemas import YoutubeDescriptionResponse
from app.social.services.sns_metadata import apply_sns_metadata, has_stored_sns_metadata
from app.user.models import User
from app.video.models import Video
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
logger = logging.getLogger(__name__)
redis_seo_client = Redis(
host=db_settings.REDIS_HOST,
port=db_settings.REDIS_PORT,
db=0,
decode_responses=True,
)
class SeoService:
"""유튜브 SEO 비즈니스 로직 서비스"""
async def get_youtube_seo_description(
self,
video_id: int,
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""
저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
유튜브 SEO description 생성
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
"""
logger.info(
f"[SEO_SERVICE] Load metadata - user: {current_user.user_uuid} / video_id: {video_id}"
f"[SEO_SERVICE] Try Cache - user: {current_user.user_uuid} / task_id: {task_id}"
)
video = await self._get_owned_video(video_id, current_user.user_uuid, session)
if video is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
)
cached = await self._get_from_redis(task_id)
if cached:
return cached
if has_stored_sns_metadata(video):
return self._response_from_video(video)
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
result = await self._generate_seo_description(task_id, current_user, session)
await self._set_to_redis(task_id, result)
result = await self.generate_and_save_for_video(video.id, session)
if result is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
)
await session.commit()
return result
async def generate_and_save_for_video(
self,
video_id: int,
session: AsyncSession,
) -> YoutubeDescriptionResponse | None:
"""GPT로 SEO를 생성해 지정한 video 행에 저장합니다. 워커/온디맨드 공용."""
video_result = await session.execute(select(Video).where(Video.id == video_id))
video = video_result.scalar_one_or_none()
if video is None:
logger.warning(f"[SEO_SERVICE] Video NOT FOUND - video_id: {video_id}")
return None
if has_stored_sns_metadata(video):
return self._response_from_video(video)
result = await self._generate_seo_description(video.task_id, session)
apply_sns_metadata(video, result.title, result.description, result.keywords)
await session.flush()
logger.info(f"[SEO_SERVICE] Saved metadata - video_id: {video_id}")
return result
async def _get_owned_video(
self,
video_id: int,
user_uuid: str,
session: AsyncSession,
) -> Video | None:
result = await session.execute(
select(Video)
.join(Project, Project.id == Video.project_id)
.where(
Video.id == video_id,
Project.user_uuid == user_uuid,
Video.is_deleted.is_(False),
)
)
return result.scalar_one_or_none()
async def _generate_seo_description(
self,
task_id: str,
current_user: User,
session: AsyncSession,
) -> YoutubeDescriptionResponse:
"""GPT를 사용하여 SEO description 생성"""
from app.utils.prompts.chatgpt_prompt import ChatgptService
from app.utils.prompts.prompts import yt_upload_prompt
logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}")
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
try:
project_result = await session.execute(
select(Project)
.where(Project.task_id == task_id)
.where(
Project.task_id == task_id,
Project.user_uuid == current_user.user_uuid,
)
.order_by(Project.created_at.desc())
.limit(1)
)
project = project_result.scalar_one_or_none()
if project is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
)
marketing_result = await session.execute(
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
)
marketing_intelligence = marketing_result.scalar_one_or_none()
if marketing_intelligence is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="마케팅 인텔리전스를 찾을 수 없습니다.",
)
hashtags = marketing_intelligence.intel_result["target_keywords"]
@ -138,13 +94,12 @@ class SeoService:
),
"language": project.language,
"target_keywords": hashtags,
"industry": project.industry or "",
"industry": project.industry or "", # 크롤 시 분류해 Project에 저장한 업종 enum
}
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
chatgpt = ChatgptService(timeout=180)
yt_seo_output = await chatgpt.generate_structured_output(
yt_upload_prompt, yt_seo_input_data
)
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
return YoutubeDescriptionResponse(
title=yt_seo_output.title,
@ -152,8 +107,6 @@ class SeoService:
keywords=hashtags,
)
except HTTPException:
raise
except Exception as e:
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
raise HTTPException(
@ -161,12 +114,18 @@ class SeoService:
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
)
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
return YoutubeDescriptionResponse(
title=video.title or "",
description=video.description or "",
keywords=list(video.hashtags or []),
)
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
field = f"task_id:{task_id}"
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
if yt_seo_info:
return YoutubeDescriptionResponse(**json.loads(yt_seo_info))
return None
async def _set_to_redis(self, task_id: str, yt_seo: YoutubeDescriptionResponse) -> None:
field = f"task_id:{task_id}"
yt_seo_info = json.dumps(yt_seo.model_dump(), ensure_ascii=False)
await redis_seo_client.hset(YOUTUBE_SEO_HASH, field, yt_seo_info)
await redis_seo_client.expire(YOUTUBE_SEO_HASH, 3600)
seo_service = SeoService()

View File

@ -1,36 +0,0 @@
"""SNS 업로드용 영상 메타데이터 비교/반영 헬퍼."""
from app.video.models import Video
def has_stored_sns_metadata(video: Video) -> bool:
"""video 행에 SNS 제목이 이미 저장되어 있는지 확인합니다."""
return bool(video.title)
def sns_metadata_changed(
video: Video,
title: str,
description: str | None,
tags: list[str] | None,
) -> bool:
"""게시 폼 값이 저장된 SNS 메타데이터와 다른지 비교합니다."""
stored_tags = list(video.hashtags or [])
incoming_tags = list(tags or [])
return (
(video.title or "") != title
or (video.description or "") != (description or "")
or stored_tags != incoming_tags
)
def apply_sns_metadata(
video: Video,
title: str,
description: str | None,
hashtags: list[str] | None,
) -> None:
"""video 행에 SNS 메타데이터를 반영합니다."""
video.title = title
video.description = description
video.hashtags = list(hashtags or [])

View File

@ -26,7 +26,6 @@ from app.social.schemas import (
SocialUploadRequest,
)
from app.social.services.account_service import SocialAccountService
from app.social.services.sns_metadata import apply_sns_metadata, sns_metadata_changed
from app.social.worker.upload_task import process_social_upload
from app.user.models import User
from app.video.models import Video
@ -77,12 +76,6 @@ class SocialUploadService:
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
)
if sns_metadata_changed(video, body.title, body.description, body.tags):
apply_sns_metadata(video, body.title, body.description, body.tags)
logger.info(
f"[UPLOAD_SERVICE] video SNS 메타데이터 갱신 - video_id: {body.video_id}"
)
# 2. 소셜 계정 조회 및 소유권 검증
account = await self._account_service.get_account_by_id(
user_uuid=current_user.user_uuid,

View File

@ -35,7 +35,7 @@ logger = get_logger("song")
router = APIRouter(prefix="/song", tags=["Song"])
TARGET_SONG_DURATION_SECONDS = 40.0
TARGET_SONG_DURATION_SECONDS = 60.0
def _select_clip_by_duration(
@ -99,7 +99,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
```
## 참고
- 생성되는 노래는 약 40초 내외 길이입니다.
- 생성되는 노래는 약 1분 이내 길이입니다.
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
""",

View File

@ -158,12 +158,10 @@ async def kakao_callback(
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
# 프론트엔드로 토큰과 함께 리다이렉트
# is_new_user: 프론트가 신규 가입 시에만 Meta CompleteRegistration 전환 추적을 호출하도록 전달
redirect_url = (
f"{prj_settings.PROJECT_DOMAIN}"
f"?access_token={result.access_token}"
f"&refresh_token={result.refresh_token}"
f"&is_new_user={str(result.is_new_user).lower()}"
)
logger.info(
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."

View File

@ -219,51 +219,6 @@ class User(Base):
comment="마지막 로그인 일시",
)
first_video_created_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="첫 영상 생성 완료 일시 (Meta FirstVideoCreated 전환 이벤트 1회 발화 판정용)",
)
registration_tracked_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="가입 전환 추적 일시 (Meta CompleteRegistration 전환 이벤트 1회 발화 판정용)",
)
# ==========================================================================
# 광고 유입 경로 (UTM, 가입 시점 first-touch)
# ==========================================================================
utm_source: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="유입 매체 (예: meta, google, naver)",
)
utm_medium: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="유입 방식 (예: paid_social, cpc)",
)
utm_campaign: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="캠페인 이름",
)
utm_content: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="광고 소재 구분 (A/B 테스트용)",
)
utm_term: Mapped[Optional[str]] = mapped_column(
String(255),
nullable=True,
comment="검색 키워드",
)
credits: Mapped[int] = mapped_column(
Integer,
nullable=False,

View File

@ -6,12 +6,8 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
import asyncio
# medium 추론은 출력 비용의 80%를 차지하고, minimal은 A/B 비교(4회)에서 narrative 점수가
# welcome 단계로 편향되고 태그를 과다 선택하는 패턴이 반복돼 low로 고정한다.
IMAGE_TAG_REASONING_EFFORT = "low"
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
chatgpt = ChatgptService(model_type="gpt")
chatgpt = ChatgptService(model_type="gemini")
image_input_data = {
"img_url" : image_url,
"industry" : industry,
@ -21,11 +17,11 @@ async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_
"motion_recommended" : list(MotionRecommended)
}
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT)
image_result = await chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_url, False)
return image_result
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
chatgpt = ChatgptService(model_type="gpt")
chatgpt = ChatgptService(model_type="gemini")
image_input_data_list = [{
"img_url" : image_url,
"industry" : industry,
@ -35,7 +31,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
"motion_recommended" : list(MotionRecommended)
}for image_url in image_url_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], True, silent = True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for image_input_data in image_input_data_list]
image_result_tasks = [chatgpt.generate_structured_output(image_autotag_prompt, image_input_data, image_input_data['img_url'], False, silent = True) for image_input_data in image_input_data_list]
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
MAX_RETRY = 2
for _ in range(MAX_RETRY):
@ -44,7 +40,7 @@ async def autotag_images(image_url_list : list[str], industry: str = "") -> list
if not failed_idx:
break
retried = await asyncio.gather(
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], True, silent=True, reasoning_effort=IMAGE_TAG_REASONING_EFFORT) for i in failed_idx],
*[chatgpt.generate_structured_output(image_autotag_prompt, image_input_data_list[i], image_input_data_list[i]['img_url'], False, silent=True) for i in failed_idx],
return_exceptions=True
)
for i, result in zip(failed_idx, retried):

View File

@ -228,98 +228,34 @@ autotext_template_h_1 = {
DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
DVAT0001 = "14d4fa22-49b8-49ca-a233-ac0556436f60"
DVSL0001 = "20c526a7-b184-46f3-9138-9dfaff2fa342"
DVCL0001 = "b57dca80-167d-430a-8905-30f4674ff72b"
DVFT0001 = "03b6d521-4864-4e69-8e6c-1deab9f0ce6e"
DVAC0001 = "4db8f5a3-4fd1-42cf-86b3-d997f0713d99"
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
HST_LIST = [DHST0001]
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001]
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
HST_LIST = [DHST0001,DHST0002,DHST0003]
VST_LIST = [DVST0001,DVRT0001,DVCF0001]
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
"stay": DVST0001,
"restaurant": DVRT0001,
"cafe": DVCF0001,
"attraction": DVAT0001,
"salon": DVSL0001,
"clinic": DVCL0001,
"fitness": DVFT0001,
"academy": DVAC0001,
}
# 매핑에 없는 업종(general)의 폴백 템플릿
# 매핑에 없는 업종(salon, clinic 등)의 폴백 템플릿
DEFAULT_VERTICAL_TEMPLATE = DVST0001
# 템플릿 슬롯명의 모션 동의어 → MotionRecommended 표준값 정규화 맵.
# 동의어를 enum에 추가하면 LLM 태그 후보가 갈라져 매칭 정확도가 떨어지므로,
# enum은 표준 어휘만 유지하고 슬롯명 파싱 경계에서 흡수한다.
MOTION_TOKEN_NORMALIZATION: dict[str, MotionRecommended] = {
"zoom_in": MotionRecommended.slow_zoom_in,
"zoom_out": MotionRecommended.slow_zoom_out,
"pan_left": MotionRecommended.slow_pan,
"pan_right": MotionRecommended.slow_pan,
"tilt_up": MotionRecommended.slow_pan,
"tilt_down": MotionRecommended.slow_pan,
"push_diag": MotionRecommended.dolly,
"montage": MotionRecommended.static,
}
SCENE_TRACK = 1
AUDIO_TRACK = 2
SUBTITLE_TRACK = 3
KEYWORD_TRACK = 4
# 썸네일 컴포지션의 이미지 슬롯명 접미사 (8개 업종 템플릿 전수 확인, 예:
# "exterior_front-architecture_detail-wide_angle-slow_zoom_in-intro-core-9999").
# 일반 씬 이미지는 "-0001"~"-0030"류 4자리 순번을 쓰는 반면 썸네일만 "-9999"라
# 슬롯명 접미사만으로 안전하게 식별 가능(가로 템플릿처럼 썸네일 컴포지션이
# 없는 템플릿도 있음).
THUMBNAIL_SLOT_MARKER = "-9999"
def is_fixed_slot_name(name: str) -> bool:
"""이름이 '-fixed'로 끝나는 요소인지 판별합니다.
템플릿 명명 규칙상 마지막 토큰이 'fixed'인 요소(예: 'brand-cta-bi_logo-
factual-fixed', 'brand-cta-contact_phone-factual-fixed')는 회사 연락처
문구·로고 등 매 영상마다 절대 바뀌면 안 되는 고정 콘텐츠다. 이런 요소는
이미지 배정/자막 생성 대상에서 제외하고 템플릿에 이미 설정된 값을 그대로
보존해야 한다.
"""
if not name:
return False
return name.rsplit("-", 1)[-1] == "fixed"
# 언어별 대체 폰트 (Google Fonts — Creatomate 기본 지원).
# 템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라 여기 있는
# 언어는 렌더 직전 전체 텍스트 폰트를 교체한다. 매핑에 없는 언어(Korean,
# English 등)는 템플릿 원본 폰트를 유지한다. 중국어는 간체(SC) 기준.
LANGUAGE_FONT_MAP = {
"Japanese": "Noto Sans JP",
"Chinese": "Noto Sans SC",
"Thai": "Noto Sans Thai",
"Vietnamese": "Noto Sans",
}
def select_template(
orientation: OrientationType,
industry: str | None = None,
project_id: int | None = None,
) -> str:
def select_template(orientation: OrientationType, industry: str | None = None) -> str:
"""orientation과 industry에 따라 Creatomate 템플릿 ID를 선택합니다.
Args:
orientation: 영상 방향 ("horizontal" 또는 "vertical")
industry: 업종 분류 (stay|restaurant|cafe|attraction 등).
세로형에서 매핑에 있으면 전용 템플릿을 반환합니다.
project_id: 미매핑 업종(general 등)의 세로형 분배용 키.
project_id % len(VST_LIST)로 결정적으로 선택하므로, 같은
프로젝트는 몇 번 호출해도 동일 템플릿을 받아 사전/렌더 단계가
일치한다. 없으면 DEFAULT_VERTICAL_TEMPLATE로 폴백한다.
industry: 업종 분류 (stay|restaurant|cafe 등).
세로형에서만 사용되며, 매핑에 없으면 기본 템플릿으로 폴백합니다.
Returns:
선택된 템플릿 ID
@ -327,17 +263,10 @@ def select_template(
if orientation == "horizontal":
template_id = DHST0001
elif orientation == "vertical":
mapped = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry)
if mapped is not None:
template_id = mapped
elif project_id is not None:
# 미매핑 업종: project_id 나머지로 VST_LIST 결정적 분배 (공유 상태 없음)
template_id = VST_LIST[project_id % len(VST_LIST)]
else:
template_id = DEFAULT_VERTICAL_TEMPLATE
template_id = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry, DEFAULT_VERTICAL_TEMPLATE)
else:
raise
logger.info(f"[select_template] orientation={orientation}, industry={industry}, project_id={project_id}, template_id={template_id}")
logger.info(f"[select_template] orientation={orientation}, industry={industry}, template_id={template_id}")
return template_id
async def get_shared_client() -> httpx.AsyncClient:
@ -388,23 +317,20 @@ class CreatomateService:
api_key: str | None = None,
orientation: OrientationType = "vertical",
industry: str | None = None,
project_id: int | None = None,
):
"""
Args:
api_key: Creatomate API 키 (Bearer token으로 사용)
None일 경우 config에서 자동으로 가져옴
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
industry: 업종 분류 (stay|restaurant|cafe|attraction 등). 세로형 템플릿 선택에 사용되며,
매핑에 있으면 전용 템플릿을 사용
project_id: 미매핑 업종(general 등)의 세로형 분배용 키. 매핑에 없을 때
project_id % len(VST_LIST)로 결정적 선택
industry: 업종 분류 (stay|restaurant|cafe 등). 세로형 템플릿 선택에 사용되며,
매핑에 없거나 None이면 기본 세로형 템플릿으로 폴백
"""
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
self.orientation = orientation
# orientation·industry에 따른 템플릿 설정 가져오기
self.template_id = select_template(orientation, industry=industry, project_id=project_id)
self.template_id = select_template(orientation, industry=industry)
self.headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {self.api_key}",
@ -540,126 +466,22 @@ class CreatomateService:
template : dict,
target_template_type : str
) -> list:
"""target_template_type 요소 개수를 셉니다.
target_template_type이 "image"인 경우, 고정 자산('-fixed' 명명) 및
5토큰 명명 규칙을 따르지 않는 요소(고정 워터마크 등)는 콘텐츠 슬롯이
아니므로 카운트에서 제외합니다 (template_matching_taged_image의
image_slots 필터링과 동일 기준).
"""
source_elements = template["source"]["elements"]
template_component_data = self.parse_template_component_name(source_elements)
count = 0
for name, template_type in template_component_data.items():
if template_type != target_template_type:
continue
if target_template_type == "image" and (is_fixed_slot_name(name) or self.parse_slot_name_to_tag(name) is None):
continue
count += 1
for _, (_, template_type) in enumerate(template_component_data.items()):
if template_type == target_template_type:
count += 1
return count
def _slot_scores_with_fitness(
self,
pool_subset: list[dict],
slot: str,
thumbnail_fitness_map: dict | None,
) -> list[float]:
"""슬롯 점수에 썸네일 픽셀 적합도를 결합합니다.
썸네일 슬롯(-9999)에 한해 태그 점수에 적합도를 반영한다:
- reject(저해상도/극단 가로형) → 0점 (하드 배제)
- 정상 → 태그 점수 × 적합도 배율(0.25~1.0)
- 적합도 데이터 없음(헤더 확보/파싱 실패) → 태그 점수 그대로(중립).
다운로드 실패를 배제로 오판해 풀 전체가 날아가는 것을 막기 위함.
일반 씬 슬롯은 태그 점수를 그대로 반환한다.
"""
is_thumbnail = slot.endswith(THUMBNAIL_SLOT_MARKER)
if is_thumbnail and self.parse_slot_name_to_tag(slot) is None:
# 슬롯명이 명명 규칙을 어겨 태그 매칭이 불가능한 썸네일 슬롯.
# 태그 점수를 0으로 두면 pool 첫 컷이 뽑혀 사실상 무작위가 되므로
# 전 이미지 중립(1.0)으로 두고 아래 픽셀 적합도만으로 순위를 가른다.
scores = [1.0] * len(pool_subset)
else:
scores = self.calculate_image_slot_score_multi(pool_subset, slot)
if not thumbnail_fitness_map or not is_thumbnail:
return scores
adjusted = []
for item, score in zip(pool_subset, scores):
fitness = thumbnail_fitness_map.get(item.get("image_url"))
if fitness is None:
adjusted.append(score)
elif fitness["reject"]:
adjusted.append(0.0)
else:
adjusted.append(score * fitness["score"])
return adjusted
def _collect_thumbnail_slots(self, template_component_data: dict) -> list[str]:
"""배정 대상 썸네일 슬롯(-9999)을 수집합니다.
일반 씬 슬롯과 달리 슬롯명 파싱에 실패해도 제외하지 않는다. 썸네일은
노출 면적이 가장 큰 표면이라, 미배정 시 modify_element가 템플릿 원본
(샘플 이미지)을 그대로 남겨 완성 영상에 그대로 나가기 때문이다. 태그
매칭이 불가능한 슬롯은 _slot_scores_with_fitness가 픽셀 적합도만으로
고른다. 단 '-fixed' 고정 자산은 여기서도 제외한다.
파싱 실패는 템플릿 슬롯명 오타이므로 ERROR로 남겨 드러나게 한다
(조용히 넘어가면 샘플 이미지가 나가도 아무도 알아채지 못한다).
"""
slots = [
name for name, t in template_component_data.items()
if t == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
and not is_fixed_slot_name(name)
]
for name in slots:
if self.parse_slot_name_to_tag(name) is None:
logger.error(
f"[_collect_thumbnail_slots] 썸네일 슬롯명이 명명 규칙 위반 — "
f"'{name}' — 템플릿 슬롯명 수정 필요. 픽셀 적합도 기준으로 폴백 배정합니다."
)
return slots
def rank_thumbnail_candidates(
self,
template: dict,
taged_image_list: list,
thumbnail_fitness_map: dict | None = None,
top_n: int = 3,
) -> dict[str, list[dict]]:
"""썸네일 슬롯별로 상위 후보(태그×픽셀 점수 내림차순)를 반환합니다.
비전 LLM 최종 선택(Phase 2)에 넘길 후보 압축용. 읽기 전용 — pool을
변형하지 않으며 배정도 하지 않는다. 점수 0 이하(픽셀 reject 포함)는
제외한다.
Returns:
{슬롯명: [pool item, ...]} — 슬롯당 최대 top_n개, 점수 내림차순.
후보가 없는 슬롯은 키 자체를 포함하지 않는다.
"""
component = self.parse_template_component_name(template["source"]["elements"])
thumbnail_slots = self._collect_thumbnail_slots(component)
result: dict[str, list[dict]] = {}
for slot in thumbnail_slots:
scores = self._slot_scores_with_fitness(taged_image_list, slot, thumbnail_fitness_map)
ranked = sorted(
range(len(scores)), key=lambda i: scores[i], reverse=True
)
candidates = [taged_image_list[i] for i in ranked if scores[i] > 0][:top_n]
if candidates:
result[slot] = candidates
return result
def template_matching_taged_image(
self,
template: dict,
taged_image_list: list, # [{"image_url": str, "image_tag": dict}] — 이미 마케팅 적합성 필터를 통과한 이미지만 전달할 것
music_url: str,
address: str,
duplicate: bool = False,
thumbnail_fitness_map: dict | None = None, # {image_url: {"score", "reject"}} — 썸네일 슬롯 픽셀 적합도
thumbnail_choice: dict | None = None, # {슬롯명: image_url} — 비전 LLM 최종 선택(있으면 결정론 최고점 대신 사용)
duplicate: bool = False
) -> tuple[dict, dict]:
"""템플릿 슬롯에 이미지를 배정합니다.
@ -693,53 +515,9 @@ class CreatomateService:
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
# 이미지 슬롯과 텍스트 슬롯 분리
# 고정 자산(이름이 '-fixed'로 끝나는 로고 등) 및 5토큰 명명 규칙을 따르지
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
# 무작위 이미지로 덮어써진다.
# 썸네일 슬롯(-9999)은 파싱 실패해도 배정해야 하므로 여기서 제외하고
# _collect_thumbnail_slots가 별도로 수집한다.
image_slots = [
name for name, t in template_component_data.items()
if t == "image" and not is_fixed_slot_name(name)
and not name.endswith(THUMBNAIL_SLOT_MARKER)
and self.parse_slot_name_to_tag(name) is not None
]
image_slots = [name for name, t in template_component_data.items() if t == "image"]
text_slots = [(name, t) for name, t in template_component_data.items() if t == "text"]
# ── 썸네일 슬롯(-9999) 우선 배정 ─────────────────────────────────
# 썸네일은 영상에서 가장 노출이 큰 표면이므로 씬 슬롯과 다르게 취급한다:
# 1) 씬 슬롯이 pool에서 좋은 컷을 pop해 가기 전에 먼저 배정 (같은 태그
# 계열 씬 슬롯이 여러 개면 썸네일 차례에 적합 컷이 소진되는 문제 방지)
# 2) "상위 3장 가중 랜덤" 없이 결정론적 최고점 선택 — 랜덤 다양화가
# 저점수 컷(예: space_type 불일치 ×0.1 페널티 컷)을 확률적으로
# 썸네일에 앉히는 사고 방지
# duplicate(이미지 부족) 시에는 pop하지 않아 씬 커버리지를 깎지 않는다.
# thumbnail_choice(비전 LLM 최종 선택)가 해당 슬롯에 있으면 그 이미지를,
# 없으면(선택 실패/미제공) 결정론적 최고점 컷을 사용한다 — 폴백 안전.
thumbnail_choice = thumbnail_choice or {}
thumbnail_slots = self._collect_thumbnail_slots(template_component_data)
for slot in thumbnail_slots:
if not pool:
logger.warning(f"[template_matching_taged_image] 이미지 풀 없음 — 썸네일 슬롯 배정 불가: {slot}")
break
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
chosen_url = thumbnail_choice.get(slot)
chosen_idx = next(
(i for i, item in enumerate(pool) if item["image_url"] == chosen_url), None
) if chosen_url else None
if chosen_idx is not None:
sel_idx, sel_source = chosen_idx, "vision"
else:
sel_idx, sel_source = max(range(len(scores)), key=lambda i: scores[i]), "결정론"
selected = pool[sel_idx] if duplicate else pool.pop(sel_idx)
modifications[slot] = selected["image_url"]
assigned[slot] = selected["image_tag"]
logger.info(
f"[template_matching_taged_image] 썸네일 배정({sel_source}) — slot: {slot}, "
f"score: {scores[sel_idx]:.3f}, url: {selected['image_url']}"
)
if not pool:
logger.warning("[template_matching_taged_image] 태그된 이미지가 없음 — 이미지 슬롯 배정 불가")
elif duplicate:
@ -750,7 +528,7 @@ class CreatomateService:
def _best_slot_for_image(image: dict, slots: list) -> tuple[str | None, float]:
best_slot, best_score = None, -1.0
for slot in slots:
score = self._slot_scores_with_fitness([image], slot, thumbnail_fitness_map)[0]
score = self.calculate_image_slot_score_multi([image], slot)[0]
if score > best_score:
best_slot, best_score = slot, score
return best_slot, best_score
@ -778,7 +556,7 @@ class CreatomateService:
# Step 2: 커버리지 배정 후 남은 슬롯은 pool 전체에서 최고점 이미지 배정 (재사용 불가피)
for slot in remaining_slots:
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
scores = self.calculate_image_slot_score_multi(pool, slot)
if not scores:
continue
best_idx = scores.index(max(scores))
@ -788,7 +566,7 @@ class CreatomateService:
else:
# 이미지 충분(슬롯 수 <= 이미지 수): 난이도 우선 greedy 배정
# Step 1: 정렬용 사전 점수 계산 (최고 달성 점수가 낮을수록 배정이 어려운 슬롯)
prelim = {slot: self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map) for slot in image_slots}
prelim = {slot: self.calculate_image_slot_score_multi(pool, slot) for slot in image_slots}
ordered_slots = sorted(
image_slots,
key=lambda s: max(prelim[s]) if prelim[s] else 0.0
@ -801,7 +579,7 @@ class CreatomateService:
logger.warning(f"[template_matching_taged_image] 이미지 풀 소진 — 남은 슬롯 배정 불가: {slot}")
break
# pop 후 인덱스 변동이 있으므로 현재 pool로 재계산 (사전 점수 재사용 금지)
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
scores = self.calculate_image_slot_score_multi(pool, slot)
if not scores:
continue
@ -831,20 +609,14 @@ class CreatomateService:
_NARR_BONUS = 1.5 # narrative 독립 보너스: base=0이어도 narrative 신호가 점수에 남음
_NARR_MIN = 0.0 # narrative_preference 값 clamp 하한 (무경계 스키마 방어)
_NARR_MAX = 100.0 # narrative_preference 값 clamp 상한. NarrativePreference 스키마(0.0~100.0)와 스케일 일치
_SPACE_TYPE_MISMATCH_PENALTY = 0.1 # 슬롯이 요구하는 space_type과 다른 이미지에 적용하는 페널티 배수 (하드 필터에 가깝게)
def calculate_image_slot_score_multi(self, taged_image_list : list[dict], slot_name : str):
"""이미지 슬롯에 대한 각 이미지의 매칭 점수를 계산합니다.
점수 = (base * narr_mod + NARR_BONUS * narr) * space_type_penalty
- base: 태그 카테고리 매칭 합산 (최대 6.5 = space_type 2.0 + subject 3.0 + camera 1.0 + motion 0.5)
점수 = base * narr_mod + NARR_BONUS * narr
- base: 태그 카테고리 매칭 합산 (최대 5.5)
- narr_mod: [NARR_FLOOR, 1.0] 구간으로 완화된 narrative 계수
- NARR_BONUS * narr: narrative 독립 보너스 (base=0이어도 신호 유지)
- space_type_penalty: 슬롯이 요구하는 space_type과 이미지가 불일치하면
SPACE_TYPE_MISMATCH_PENALTY(0.1)를 곱해 사실상 탈락시킴. narrative_preference
점수가 아무리 높아도(예: bathroom 이미지가 intro narr=100) 슬롯이 지정한
space_type(예: exterior_front)과 다르면 우선 배정되지 않도록 하기 위함 —
같은 space_type 이미지가 pool에 하나도 없을 때만 상대적으로 선택됨(fallback 유지).
이 구조로 "base=0, narrative가 높음"과 "완전히 틀린 이미지(0,0)"를 구별할 수 있음.
"""
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
@ -855,25 +627,6 @@ class CreatomateService:
base_score_list = [0.0] * len(image_tag_list)
# slot_tag_narrative = NarrativePhase.accent # 기본값 (narrative 토큰 없는 슬롯 대비)
slot_space_type = slot_tag_dict.get("space_type")
space_type_match_list = [
slot_space_type is not None and slot_space_type.value in image_tag.get("space_type", [])
for image_tag in image_tag_list
]
# 썸네일 슬롯 한정: subject가 슬롯 요구와 일치하면 space_type 불일치
# 페널티를 면제한다. 배경: 일부 업종(예: 레스토랑 dining_hall-food_dish)은
# 태깅 관행상 슬롯이 요구하는 subject(음식 클로즈업)가 실제로는 다른
# space_type(table_setting/detail_plating 등)으로 붙는다 — space_type과
# subject가 한 사진에 공존하기 어려운 조합. 이런 슬롯은 space_type 하드
# 필터가 정작 원하는 subject 이미지를 전부 걸러내는 역효과를 낸다.
# 템플릿 슬롯명을 바꾸지 않고 여기서 흡수(썸네일 한정이라 일반 씬 슬롯의
# space_type 하드 필터는 그대로 유지).
slot_subject = slot_tag_dict.get("subject")
if slot_name.endswith(THUMBNAIL_SLOT_MARKER) and slot_subject is not None:
for idx, image_tag in enumerate(image_tag_list):
if slot_subject.value in image_tag.get("subject", []):
space_type_match_list[idx] = True
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
if slot_tag_cate == "narrative_preference":
@ -884,7 +637,7 @@ class CreatomateService:
case "space_type":
weight = 2.0
case "subject":
weight = 3.0
weight = 2.0
case "camera":
weight = 1.0
case "motion_recommended":
@ -906,8 +659,6 @@ class CreatomateService:
narr = clamped_narr / self._NARR_MAX # 0.0~1.0로 정규화
narr_mod = self._NARR_FLOOR + (1.0 - self._NARR_FLOOR) * narr
score = base_score_list[idx] * narr_mod + self._NARR_BONUS * narr
if not space_type_match_list[idx]:
score *= self._SPACE_TYPE_MISMATCH_PENALTY
image_score_list.append(score)
return image_score_list
@ -916,13 +667,7 @@ class CreatomateService:
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
위치 기반 파싱에 실패하면 토큰 위치를 무시한 대조로 한 번 더 시도한다
(_parse_slot_name_loosely). 슬롯명 오타로 슬롯이 배정에서 빠지면
modify_element가 템플릿 원본(샘플 이미지)을 그대로 남겨 완성 영상에
그대로 나가기 때문이다.
둘 다 실패하면 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
파싱 실패 시 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
"""
try:
tag_list = slot_name.split("-")
@ -932,8 +677,7 @@ class CreatomateService:
space_type = SpaceType(tag_list[0])
subject = Subject(tag_list[1])
camera = Camera(tag_list[2])
# 모션 동의어(zoom_in, pan_left 등)는 표준 enum 값으로 정규화 후 변환
motion = MOTION_TOKEN_NORMALIZATION.get(tag_list[3]) or MotionRecommended(tag_list[3])
motion = MotionRecommended(tag_list[3])
narrative = NarrativePhase(tag_list[4])
tag_dict = {
"space_type": space_type,
@ -944,64 +688,9 @@ class CreatomateService:
}
return tag_dict
except (ValueError, IndexError) as e:
loose = self._parse_slot_name_loosely(tag_list)
if loose is not None:
logger.warning(
f"[parse_slot_name_to_tag] 슬롯명이 명명 규칙 위반: '{slot_name}' — {e} — "
f"위치 무시 대조로 복구: { {k: v.value for k, v in loose.items()} } — 템플릿 슬롯명 수정 권장"
)
return loose
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 파싱 실패: '{slot_name}' — {e} — 슬롯 skip")
return None
def _parse_slot_name_loosely(self, tag_list: list[str]) -> dict[str, StrEnum] | None:
"""토큰 위치를 무시하고 각 enum에 대조해 태그를 복구합니다.
위치 기반 파싱이 실패했을 때만 호출한다. 토큰 하나는 한 카테고리에만
쓰이며, 앞선 토큰부터 순서대로 소비한다(중복 후보가 있으면 앞선 것 채택
— 위치 기반과 같은 값을 고르게 된다).
space_type/subject/narrative는 필수다. 이 셋을 못 채우면 슬롯명이 아닌
것으로 보고 None을 반환한다(고정 자산·비규칙 요소가 배정 대상에 섞여
무작위 이미지로 덮어써지는 것을 막기 위함). camera/motion은 선택이며
못 찾으면 키 자체를 넣지 않는다 — 점수 계산은 태그 딕셔너리를 순회하므로
없는 키는 자연히 가중치에서 빠진다.
"""
used: set[int] = set()
def take(converter) -> StrEnum | None:
for idx, token in enumerate(tag_list):
if idx in used:
continue
try:
value = converter(token)
except ValueError:
continue
if value is not None:
used.add(idx)
return value
return None
space_type = take(SpaceType)
subject = take(Subject)
narrative = take(NarrativePhase)
if space_type is None or subject is None or narrative is None:
return None
camera = take(Camera)
motion = take(lambda t: MOTION_TOKEN_NORMALIZATION.get(t) or MotionRecommended(t))
tag_dict: dict[str, StrEnum] = {
"space_type": space_type,
"subject": subject,
"narrative_preference": narrative,
}
if camera is not None:
tag_dict["camera"] = camera
if motion is not None:
tag_dict["motion_recommended"] = motion
return tag_dict
def elements_connect_resource_blackbox(
self,
elements: list,
@ -1031,26 +720,20 @@ class CreatomateService:
return modifications
def modify_element(self, elements: list, modification: dict) -> list:
"""elements의 source를 modification에 따라 수정합니다.
modification에 없는 image/text 요소(예: '-fixed' 명명의 고정 로고·
연락처 문구처럼 배정/자막 생성 대상에서 제외된 고정 콘텐츠)는 템플릿에
이미 설정된 source/text를 그대로 보존한다 (KeyError·빈 문자열로
렌더가 깨지지 않도록).
"""
"""elements의 source를 modification에 따라 수정합니다."""
def recursive_modify(element: dict) -> None:
if "name" in element:
match element["type"]:
case "image":
element["source"] = modification.get(element["name"], element.get("source"))
element["source"] = modification[element["name"]]
case "audio":
element["source"] = modification.get(element["name"], "")
case "video":
element["source"] = modification[element["name"]]
case "text":
#element["source"] = modification[element["name"]]
element["text"] = modification.get(element["name"], element.get("text", ""))
element["text"] = modification.get(element["name"], "")
case "composition":
for minor in element["elements"]:
recursive_modify(minor)
@ -1388,38 +1071,12 @@ class CreatomateService:
case "horizontal":
return autotext_template_h_1
def apply_language_font(self, template: dict, language: str) -> dict:
"""출력 언어에 맞춰 템플릿 내 모든 텍스트 요소의 폰트를 교체합니다.
템플릿 기본 폰트(GyeonggiTitleOTF/Pretendard)는 한글·라틴 전용이라
CJK/태국어 글리프가 없다. LANGUAGE_FONT_MAP에 있는 언어는 전체 텍스트
폰트를 해당 언어 지원 폰트(Google Fonts)로 교체하고, 매핑에 없는
언어(Korean, English 등)는 템플릿 원본 폰트를 유지한다.
"""
font_family = LANGUAGE_FONT_MAP.get(language)
if not font_family:
return template
def recursive_apply(element: dict) -> None:
if element.get("type") == "text":
element["font_family"] = font_family
for minor in element.get("elements") or []:
recursive_apply(minor)
for elem in template["source"]["elements"]:
recursive_apply(elem)
logger.info(
f"[apply_language_font] 텍스트 폰트 교체 완료 - language: {language}, font: {font_family}"
)
return template
def extract_text_format_from_template(self, template:dict):
keyword_list = []
subtitle_list = []
for elem in template["source"]["elements"]:
try: #최상위 내 텍스트만 검사
if elem["type"] == "text" and not is_fixed_slot_name(elem["name"]):
if elem["type"] == "text":
if elem["track"] == SUBTITLE_TRACK:
subtitle_list.append(elem["name"])
elif elem["track"] == KEYWORD_TRACK:
@ -1433,41 +1090,21 @@ class CreatomateService:
assert(len(keyword_list)==len(subtitle_list))
except Exception as E:
logger.error("this template does not have same amount of keyword and subtitle.")
# 썸네일 텍스트(thumb-*)는 전용 트랙이 아닌 thumbnail-composition 내부에
# 중첩되어 있어 위 최상위 트랙 스캔에 잡히지 않는다. 다국어 자막 생성에
# 함께 포함되도록 재귀 파싱으로 수집한다.
component_data = self.parse_template_component_name(
template["source"]["elements"]
)
thumbnail_list = [
name
for name, elem_type in component_data.items()
if elem_type == "text" and name.startswith("thumb-")
]
pitching_list = keyword_list + subtitle_list + thumbnail_list
pitching_list = keyword_list + subtitle_list
return pitching_list
def make_thumbnail_modification(self, brand_name : str, region : str, category_definition : str, target_keywords : list[str], detail_region_info : str = ""):
def make_thumbnail_modification(self, brand_name : str, region : str, brand_concept : str, category_definition : str, target_keywords : list[str]):
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[:len_keywords]]
# 지역 표기: 상세주소의 공백 기준 앞 두 마디 사용
# (예: "전북 군산시 절골길 18" → "전북 군산시").
# 상세주소가 없으면 기존 region 정규화 표기로 폴백.
if detail_region_info and detail_region_info.strip():
region_display = " ".join(detail_region_info.split()[:2])
else:
region_display = normalize_location(region)
mod_dict = {
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
"thumb-headline-brand_name-factual" : brand_name,
"thumb-subheadline-local_info-factual" : region_display,
"thumb-brand-wordmark" : brand_name,
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
"thumb-headline-hook_claim-aspirational" : brand_concept,
"thumb-badge-category" : category_definition,
}
return mod_dict

View File

@ -21,7 +21,7 @@ from app.utils.prompts.schemas import MarketingFilterOutput
logger = get_logger("image_filter")
# 크롤링 단계 필터링에 사용할 Gemini 모델. 시트(prompts.py)와 달리 코드에 고정한다.
MARKETING_FILTER_MODEL = "gpt-5-mini"
MARKETING_FILTER_MODEL = "gemini-3.5-flash"
MAX_RETRY = 2
@ -61,7 +61,7 @@ async def filter_marketing_images(image_url_list: list[str], industry: str = "")
if not image_url_list:
return []
chatgpt = ChatgptService(model_type="gpt")
chatgpt = ChatgptService(model_type="gemini")
prompt_text = _build_prompt(industry)
async def _call(url: str):
@ -70,7 +70,7 @@ async def filter_marketing_images(image_url_list: list[str], industry: str = "")
output_format=MarketingFilterOutput,
model=MARKETING_FILTER_MODEL,
img_url=url,
image_detail_high=True,
image_detail_high=False,
)
results: list[MarketingFilterOutput | BaseException] = await asyncio.gather(

View File

@ -1,156 +0,0 @@
"""
Meta Conversions API (CAPI) 클라이언트
Meta 픽셀과 병행하여 서버 사이드 전환 이벤트를 전송합니다.
브라우저 픽셀(fbq)과 동일한 event_id를 사용하여 Meta가 중복 이벤트를
제거(deduplication)할 수 있도록 합니다.
전송 규칙 (Meta 요구사항):
- external_id 등 개인 식별 정보: SHA-256 해시 후 전송
- fbc/fbp 쿠키, client_ip_address, client_user_agent: 원문 그대로 전송
- event_time: 유닉스 타임스탬프 (7일 이내)
- action_source: "website" 고정
참고: https://developers.facebook.com/docs/marketing-api/conversions-api
"""
import hashlib
import time
import httpx
from app.utils.logger import get_logger
from config import meta_conversion_settings
logger = get_logger("meta_capi")
# Meta Graph API 버전 및 요청 타임아웃
GRAPH_API_VERSION = "v21.0"
REQUEST_TIMEOUT = 10.0
def sha256_hash(value: str) -> str:
"""개인 식별 정보를 Meta 매칭 규격에 맞게 SHA-256 해시합니다.
Meta는 소문자 변환 + 공백 제거 후 해싱을 요구합니다.
Args:
value: 해시할 원문 문자열 (예: user_uuid, 이메일)
Returns:
str: SHA-256 해시 (16진수 소문자)
"""
normalized = value.strip().lower()
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
async def send_capi_event(
event_name: str,
event_id: str,
external_id: str,
client_ip: str | None = None,
client_user_agent: str | None = None,
fbc: str | None = None,
fbp: str | None = None,
event_source_url: str | None = None,
custom_data: dict | None = None,
test_event_code: str | None = None,
) -> bool:
"""Meta Conversions API로 서버 이벤트를 전송합니다.
전송 실패는 로깅만 하고 예외를 전파하지 않습니다 (전환 추적 실패가
서비스 기능에 영향을 주지 않도록 fire-and-forget 처리).
Args:
event_name: 이벤트 이름 (브라우저 fbq와 철자/대소문자 일치 필수)
event_id: 중복제거용 이벤트 ID (브라우저 fbq의 eventID와 동일해야 함)
external_id: 사용자 식별자 원문 (user_uuid). 내부에서 SHA-256 해시됨
client_ip: 클라이언트 IP 주소 (원문)
client_user_agent: 클라이언트 User-Agent (원문)
fbc: Meta 클릭 ID 쿠키(_fbc) 원문
fbp: Meta 브라우저 ID 쿠키(_fbp) 원문
event_source_url: 이벤트가 발생한 페이지 URL
custom_data: 추가 데이터 (예: Purchase의 value/currency)
test_event_code: 이벤트 관리자 "테스트 이벤트" 검증용 코드.
미지정 시 FACEBOOK_TEST_EVENT_CODE 환경변수 값을 사용 (운영 시 빈 값 유지)
Returns:
bool: 전송 성공 여부
"""
pixel_id = meta_conversion_settings.FACEBOOK_PIXEL_ID
access_token = meta_conversion_settings.FACEBOOK_ACCESS_TOKEN
if not pixel_id or not access_token:
logger.warning(
"[MetaCAPI] SKIP - FACEBOOK_PIXEL_ID/FACEBOOK_ACCESS_TOKEN 미설정 "
f"(event_name: {event_name}, event_id: {event_id})"
)
return False
user_data: dict = {
"external_id": [sha256_hash(external_id)],
}
if client_ip:
user_data["client_ip_address"] = client_ip
if client_user_agent:
user_data["client_user_agent"] = client_user_agent
if fbc:
user_data["fbc"] = fbc
if fbp:
user_data["fbp"] = fbp
event: dict = {
"event_name": event_name,
"event_time": int(time.time()),
"event_id": event_id,
"action_source": "website",
"user_data": user_data,
}
if event_source_url:
event["event_source_url"] = event_source_url
if custom_data:
event["custom_data"] = custom_data
payload: dict = {"data": [event]}
effective_test_code = test_event_code or meta_conversion_settings.FACEBOOK_TEST_EVENT_CODE
if effective_test_code:
payload["test_event_code"] = effective_test_code
logger.info(f"[MetaCAPI] TEST MODE - test_event_code: {effective_test_code}")
url = f"https://graph.facebook.com/{GRAPH_API_VERSION}/{pixel_id}/events"
try:
async with httpx.AsyncClient() as client:
response = await client.post(
url,
json=payload,
params={"access_token": access_token},
timeout=REQUEST_TIMEOUT,
)
if response.status_code == 200:
logger.info(
f"[MetaCAPI] SUCCESS - event_name: {event_name}, "
f"event_id: {event_id}, response: {response.json()}"
)
return True
logger.error(
f"[MetaCAPI] FAILED - event_name: {event_name}, event_id: {event_id}, "
f"status: {response.status_code}, body: {response.text}"
)
return False
except httpx.HTTPError as e:
logger.error(
f"[MetaCAPI] HTTP ERROR - event_name: {event_name}, "
f"event_id: {event_id}, error: {e}"
)
return False
except Exception as e:
logger.error(
f"[MetaCAPI] UNEXPECTED ERROR - event_name: {event_name}, "
f"event_id: {event_id}, error: {e}",
exc_info=True,
)
return False

View File

@ -48,30 +48,6 @@ class NvMapPwScraper():
]
_current_profile = None
# 헤드리스 자동화 탐지 신호(navigator.webdriver, 빈 plugins/languages, window.chrome 부재,
# permissions.query 이상 동작)를 정상 브라우저처럼 위장하는 스텔스 패치.
# create_page()와 fetch_graphql() 양쪽에서 생성하는 모든 page에 반드시 적용해야 한다 —
# 한쪽이라도 빠지면 그 경로만 헤드리스로 노출되어 안티봇 캡차 트리거 확률이 올라간다.
_STEALTH_INIT_SCRIPT = '''
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: () => false,
});
Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3, 4, 5] });
Object.defineProperty(navigator, "languages", { get: () => ["ko-KR", "ko"] });
window.chrome = window.chrome || { runtime: {} };
const originalQuery = window.navigator.permissions && window.navigator.permissions.query;
if (originalQuery) {
window.navigator.permissions.query = (parameters) => (
parameters.name === "notifications"
? Promise.resolve({ state: Notification.permission })
: originalQuery(parameters)
);
}
'''
# instance var
page = None
@ -113,17 +89,6 @@ if (originalQuery) {
if old_context:
await old_context.close()
@classmethod
async def _new_stealth_page(cls):
"""스텔스 패치(webdriver 위장 등)와 sec-ch-ua 헤더가 적용된 새 page를 생성한다."""
page = await cls._context.new_page()
await page.add_init_script(cls._STEALTH_INIT_SCRIPT)
if cls._current_profile:
await page.set_extra_http_headers({
'sec-ch-ua': cls._current_profile['sec_ch_ua']
})
return page
GRAPHQL_URL = "https://pcmap-api.place.naver.com/graphql"
@classmethod
@ -131,8 +96,7 @@ if (originalQuery) {
cls,
place_id: str,
payloads: list[dict],
capture_apollo: bool = False,
) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]:
) -> list[dict | None] | None:
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
네이버 pcmap GraphQL은 두 헤더를 검사한다:
@ -145,21 +109,14 @@ if (originalQuery) {
Args:
place_id: 네이버 place ID
payloads: GraphQL POST 본문 목록
capture_apollo: True면 place 페이지에 인라인된 __APOLLO_STATE__
JSON 문자열도 함께 캡처해 (results, apollo_json) 튜플로 반환.
(GraphQL base가 노출하지 않는 homepages 등 SSR 캐시 전용 필드용)
Returns:
payload별 파싱 JSON 목록(실패 항목은 None). 토큰 캡처 실패 시 None.
capture_apollo=True면 (위 결과, apollo_json 또는 None) 튜플.
"""
def _ret(results, apollo=None):
return (results, apollo) if capture_apollo else results
if not cls.is_ready:
logger.warning("[NvMapPwScraper] fetch_graphql: scraper가 초기화되지 않았습니다")
return _ret(None)
return None
page = await cls._new_stealth_page()
page = await cls._context.new_page()
captured: dict = {}
def on_request(req):
@ -193,17 +150,7 @@ if (originalQuery) {
if not captured.get("tok"):
logger.warning("[NvMapPwScraper] WTM 토큰 캡처 실패")
return _ret(None)
apollo_json: str | None = None
if capture_apollo:
try:
apollo_json = await page.evaluate(
"() => { try { return JSON.stringify(window.__APOLLO_STATE__ || null); }"
" catch (e) { return null; } }"
)
except Exception as e:
logger.warning(f"[NvMapPwScraper] __APOLLO_STATE__ 캡처 실패: {e}")
return None
headers = {
"Content-Type": "application/json",
@ -238,11 +185,11 @@ if (originalQuery) {
results.append(None)
else:
results.append(r)
return _ret(results, apollo_json)
return results
except Exception as e:
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
return _ret(None)
return None
finally:
await page.close()
@ -258,7 +205,35 @@ if (originalQuery) {
await self.page.close()
async def create_page(self):
self.page = await self._new_stealth_page()
self.page = await self._context.new_page()
await self.page.add_init_script(
'''const defaultGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
defaultGetter.apply(navigator);
defaultGetter.toString();
Object.defineProperty(Navigator.prototype, "webdriver", {
set: undefined,
enumerable: true,
configurable: true,
get: new Proxy(defaultGetter, {
apply: (target, thisArg, args) => {
Reflect.apply(target, thisArg, args);
return false;
},
}),
});
const patchedGetter = Object.getOwnPropertyDescriptor(
Navigator.prototype,
"webdriver"
).get;
patchedGetter.apply(navigator);
patchedGetter.toString();''')
await self.page.set_extra_http_headers({
'sec-ch-ua': self._current_profile['sec_ch_ua']
})
await self.page.goto("http://google.com")
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
@ -440,24 +415,23 @@ if (originalQuery) {
return best['place_url']
# 2순위(안전망): allSearch 캡처 실패 시 기존 iframe HTML 파싱 방식으로 폴백
# ── 잠시 비활성화 ──
# logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
# iframe_candidates = []
# for _ in range(3): # 500ms × 3 = 최대 1.5초
# if "/place/" in self.page.url:
# return self.page.url
# iframe_candidates = await self._extract_candidates_from_list_page()
# if iframe_candidates:
# break
# await self.page.wait_for_timeout(500)
#
# if iframe_candidates:
# best = self._select_best_candidate(iframe_candidates, title, address)
# logger.info(
# f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
# f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
# )
# return best['place_url']
logger.warning("[FALLBACK] allSearch 캡처 실패 → iframe 파싱 방식 시도")
iframe_candidates = []
for _ in range(3): # 500ms × 3 = 최대 1.5초
if "/place/" in self.page.url:
return self.page.url
iframe_candidates = await self._extract_candidates_from_list_page()
if iframe_candidates:
break
await self.page.wait_for_timeout(500)
if iframe_candidates:
best = self._select_best_candidate(iframe_candidates, title, address)
logger.info(
f"[AUTO-SELECT-IFRAME] '{title}' → '{best['title']}' "
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
)
return best['place_url']
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")

View File

@ -36,12 +36,7 @@ class NvMapScraper:
REQUEST_TIMEOUT = 120 # 초
data_source_identifier = "nv"
SUPPLEMENT_THRESHOLD = 30 # 업체 사진이 이 수 미만일 때 방문자 사진으로 보충
# 방문자 사진 보충 시의 합산 상한 (필터링·정책상 제한).
# 업체 제공 사진은 필터링 면제 대상이므로 이 상한과 무관하게 수집분을 전부 사용한다
# (수집 자체는 BIZ_MAX_PAGES가 상한 — 초과 시 warning 로그 발생).
MAX_IMAGES = 50
BIZ_PAGE_SIZE = 20 # getPhotoViewerItems 'biz' 커서의 페이지당 사진 수
BIZ_MAX_PAGES = 5 # 업체 사진 수집 상한 (5×20=100장, 도달 시 warning)
MAX_IMAGES = 50 # 업체+방문자 사진 합산 최대 장수
OVERVIEW_QUERY: str = """
query getAccommodation($id: String!, $deviceType: String) {
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
@ -112,7 +107,6 @@ query getVisitorReviewStats($id: String!) {
self.facility_info: str | None = None
self.voted_keyword_stats: list[dict] | None = None # 키워드 투표 집계 (displayName, count)
self.menu_info: list[dict] | None = None # 메뉴 목록 (name, price, description, recommend)
self.official_site_url: str | None = None # 업체 공식 링크 (base.homepages 대표 URL)
def _get_request_headers(self) -> dict:
headers = self.DEFAULT_HEADERS.copy()
@ -157,42 +151,6 @@ query getVisitorReviewStats($id: String!) {
if p.get("originalUrl") and not NvMapScraper._is_gif_url(p["originalUrl"])
]
@classmethod
def _build_biz_payload(cls, place_id: str, page: int) -> dict:
"""'biz' 커서(업체 등록 사진)의 page번째(0-base) 페이지 요청 payload를 만든다."""
start_index = page * cls.BIZ_PAGE_SIZE
cursor: dict = {"id": "biz"}
if start_index > 0:
cursor.update({
"startIndex": start_index,
"hasNext": True,
"lastCursor": str(start_index),
})
return {
"operationName": "getPhotoViewerItems",
"variables": {
"input": {
"businessId": place_id,
"cursors": [cursor],
"dateRange": "",
"excludeAuthorIds": [],
"excludeClipIds": [],
"excludeSection": [],
}
},
"query": cls.PHOTO_VIEWER_QUERY,
}
@staticmethod
def _raw_photo_count(photo_viewer_raw: dict | None) -> int:
"""getPhotoViewerItems 응답의 사진 수(gif 필터링 전 원본 기준)를 반환한다.
페이지네이션 계속 여부 판단용 — gif가 걸러진 뒤의 수로 판단하면
마지막 페이지가 아닌데도 조기 종료할 수 있어 원본 개수를 사용한다.
"""
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
return len(photos.get("photos") or [])
@staticmethod
def _dedup_by_original(images: list[dict]) -> list[dict]:
"""{"preview","original"} dict 리스트를 original URL 기준으로 순서를 유지한 채 중복 제거한다."""
@ -257,11 +215,9 @@ query getVisitorReviewStats($id: String!) {
# self.scrap_type = "GraphQL-Browser"
# ── 실제 브라우저로 WTM 캡차 우회 ──
data, stats_data, extra_photo_urls, biz_photo_urls, homepage_url = await self._scrap_via_browser(place_id)
data, stats_data, extra_photo_urls = await self._scrap_via_browser(place_id)
# 편의시설은 HTML 페이지 파싱이라 GraphQL(WTM) 차단과 별개로 직접 시도 (best-effort, 실패 시 None)
# 홈페이지 링크는 브라우저 캡처가 실패한 경우에만 HTML 경로로 보충한다.
fac_data, html_homepage = await self._get_facility_and_homepage(place_id)
homepage_url = homepage_url or html_homepage
fac_data = await self._get_facility_string(place_id)
self.scrap_type = "GraphQL-Browser"
self.rawdata = data
@ -270,11 +226,8 @@ query getVisitorReviewStats($id: String!) {
self.rawdata["facilities"] = fac_data
business = data["data"]["business"]
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다.
self.owner_images = self._dedup_by_original(
self._extract_origins(business.get("images") or {}) + biz_photo_urls
)
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup)
self.owner_images = self._dedup_by_original(self._extract_origins(business.get("images") or {}))
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
owner_originals = {img["original"] for img in self.owner_images}
@ -285,58 +238,28 @@ query getVisitorReviewStats($id: String!) {
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
# owner_images / extra_photo_urls를 직접 사용해 image_filter.assemble_images로 재조립할 것.)
# MAX_IMAGES 상한은 방문자 사진이 섞이는 보충 경로에만 적용하며(필터링·정책상 제한),
# 업체 제공 사진만으로 구성되는 경우는 수집분(최대 BIZ_MAX_PAGES 페이지)을 전부 사용한다.
if len(self.owner_images) < self.SUPPLEMENT_THRESHOLD:
combined = self._dedup_by_original(self.owner_images + self.extra_photo_urls)
logger.info(
f"[NvMapScraper] 업체 사진 {len(self.owner_images)}장 < {self.SUPPLEMENT_THRESHOLD}장 "
f"→ 보충 사진 {len(self.extra_photo_urls)}장 추가 (합산 {len(combined)}장, 상한 {self.MAX_IMAGES}장)"
)
self.image_link_list = combined[: self.MAX_IMAGES]
else:
self.image_link_list = list(self.owner_images)
combined = self.owner_images
self.image_link_list = combined[: self.MAX_IMAGES]
self.base_info = data["data"]["business"]["base"]
self.facility_info = fac_data
self.voted_keyword_stats = stats_data
self.menu_info = business.get("menus") or None
self.official_site_url = homepage_url
return
@staticmethod
def _extract_homepage_from_html(html: str) -> str | None:
"""플레이스 페이지 HTML의 __APOLLO_STATE__에서 홈페이지 링크를 추출한다.
GraphQL placeDetail(base)는 homepages 필드를 노출하지 않으므로(400),
SSR 페이지에 인라인된 Apollo 캐시의 "homepages" 객체를 직접 파싱한다.
대표(repr) 링크 우선, 죽은 링크(isDeadUrl)는 제외. 홈페이지 항목은
자체 홈페이지 외에 인스타그램/블로그 등일 수도 있다 — 업체가 대표로
등록한 링크를 그대로 신뢰한다.
"""
decoder = json.JSONDecoder()
search_from = 0
while True:
idx = html.find('"homepages":', search_from)
if idx == -1:
return None
search_from = idx + 1
try:
homepages, _ = decoder.raw_decode(html, idx + len('"homepages":'))
except ValueError:
continue
if not isinstance(homepages, dict):
continue
candidates = [homepages.get("repr"), *(homepages.get("etc") or [])]
for item in candidates:
if isinstance(item, dict) and item.get("url") and not item.get("isDeadUrl"):
return item["url"]
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[dict], list[dict], str | None]:
async def _scrap_via_browser(self, place_id: str) -> tuple[dict, list[dict] | None, list[str]]:
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
Returns:
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls, homepage_url)
(overview_data, review_stats_details, extra_photo_urls)
Raises:
GraphQLException: 브라우저 폴백마저 실패한 경우
@ -399,29 +322,14 @@ query getVisitorReviewStats($id: String!) {
},
"query": self.PHOTO_VIEWER_QUERY,
}
# 업체 등록 사진. placeDetail.images는 대표 사진 일부(1~수 장)만 반환하는
# 경우가 있어, 사진 탭이 실제 사용하는 'biz' 커서로 별도 조회해 보강한다.
# biz 커서는 페이지당 BIZ_PAGE_SIZE(20)장이며 lastCursor가 단순 인덱스 문자열
# ("20", "40")이라 이전 응답 없이도 모든 페이지를 미리 만들 수 있고, 범위를
# 벗어난 페이지는 빈 목록을 반환하므로 블라인드 요청해도 안전하다.
# 따라서 상한(BIZ_MAX_PAGES)까지의 전 페이지를 첫 배치에 한꺼번에 실어 보낸다
# — 추가 왕복(페이지 재탐색 + WTM 토큰 재캡처)이 없고, 개별 페이지 실패가
# 이후 페이지 수집을 막지 못한다.
biz_payloads = [
self._build_biz_payload(place_id, page) for page in range(self.BIZ_MAX_PAGES)
]
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload, *biz_payloads]
payloads = [overview_payload, stats_payload, interior_payload, exterior_payload, review_payload]
MAX_RETRY = 3
results = None
apollo_json: str | None = None
last_error: Exception | None = None
for attempt in range(1, MAX_RETRY + 1):
try:
results, captured_apollo = await NvMapPwScraper.fetch_graphql(
place_id, payloads, capture_apollo=True
)
apollo_json = apollo_json or captured_apollo
results = await NvMapPwScraper.fetch_graphql(place_id, payloads)
except Exception as e:
last_error = e
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
@ -452,40 +360,13 @@ query getVisitorReviewStats($id: String!) {
interior_urls = self._extract_photo_viewer_urls(results[2] if len(results) > 2 else None)
exterior_urls = self._extract_photo_viewer_urls(results[3] if len(results) > 3 else None)
review_urls = self._extract_photo_viewer_urls(results[4] if len(results) > 4 else None)
biz_pages = results[5:]
biz_urls = [url for page_result in biz_pages for url in self._extract_photo_viewer_urls(page_result)]
# 개별 페이지 실패(None)는 해당 20장만 누락되고 나머지 페이지는 영향 없다 (best-effort).
failed_biz_pages = sum(1 for r in biz_pages if r is None)
if failed_biz_pages:
logger.warning(
f"[NvMapScraper] 업체 사진 {failed_biz_pages}개 페이지 응답 실패 "
f"— 페이지당 최대 {self.BIZ_PAGE_SIZE}장 누락 가능 (수집분으로 진행)"
)
# 마지막 페이지까지 가득 차 있으면 상한 밖에 사진이 더 있을 수 있다.
if biz_pages and self._raw_photo_count(biz_pages[-1]) >= self.BIZ_PAGE_SIZE:
logger.warning(
f"[NvMapScraper] 업체 사진이 수집 상한(BIZ_MAX_PAGES={self.BIZ_MAX_PAGES}, "
f"{self.BIZ_MAX_PAGES * self.BIZ_PAGE_SIZE}장)까지 가득 참 — 초과분은 수집되지 않음"
)
extra_photo_urls = self._interleave(interior_urls, exterior_urls) + review_urls
logger.info(
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} "
f"리뷰:{len(review_urls)} / 업체(biz):{len(biz_urls)}"
f"[NvMapScraper] 보충 이미지 - 내부:{len(interior_urls)} 외부:{len(exterior_urls)} 리뷰:{len(review_urls)}"
)
# 홈페이지 링크: GraphQL base는 homepages를 노출하지 않으므로(400),
# 브라우저가 로드한 place 페이지의 __APOLLO_STATE__ JSON에서 추출한다.
homepage_url = (
self._extract_homepage_from_html(apollo_json) if apollo_json else None
)
logger.info(
f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}, "
f"homepage: {homepage_url or '없음'}"
)
return data, stats_data, extra_photo_urls, biz_urls, homepage_url
logger.info(f"[NvMapScraper] 브라우저 폴백 SUCCESS - place_id: {place_id}")
return data, stats_data, extra_photo_urls
async def _call_get_accommodation(self, place_id: str) -> dict:
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
@ -566,39 +447,29 @@ query getVisitorReviewStats($id: String!) {
logger.warning(f"[NvMapScraper] Failed to get review stats: {e}")
return None
async def _get_facility_and_homepage(self, place_id: str) -> tuple[str | None, str | None]:
"""장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
async def _get_facility_string(self, place_id: str) -> str | None:
"""장소 페이지에서 편의시설 정보를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
Args:
place_id: 네이버 지도 장소 ID
Returns:
(편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
편의시설 정보 문자열 또는 None
"""
facility: str | None = None
homepage: str | None = None
place_types = ["place", "accommodation", "restaurant"]
try:
async with aiohttp.ClientSession() as session:
for place_type in place_types:
url = f"https://pcmap.place.naver.com/{place_type}/{place_id}/home"
async with session.get(url, headers=self._get_request_headers()) as response:
raw = await response.read()
if homepage is None:
homepage = self._extract_homepage_from_html(
raw.decode("utf-8", errors="ignore")
)
if facility is None:
soup = bs4.BeautifulSoup(raw, "html.parser")
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
c_elem = soup.find("span", "place_blind", string="편의")
if c_elem:
facility = c_elem.parent.parent.find("div").string
if facility is not None and homepage is not None:
break
return facility, homepage
return c_elem.parent.parent.find("div").string
return None
except Exception as e:
logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
return facility, homepage
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
return None
# if __name__ == "__main__":

View File

@ -46,20 +46,7 @@ class ChatgptService:
)
case _:
raise NotImplementedError(f"Unknown Provider : {model_type}")
def _log_usage(self, response, model: str, output_format: type[BaseModel]) -> None:
usage = getattr(response, "usage", None)
if usage is None:
return
# 토큰 소모량 로깅 (필요 시 주석 해제)
# cached = getattr(getattr(usage, "prompt_tokens_details", None), "cached_tokens", None) or 0
# reasoning = getattr(getattr(usage, "completion_tokens_details", None), "reasoning_tokens", None) or 0
# logger.info(
# f"[ChatgptService({self.model_type})] usage model={model} output={output_format.__name__} "
# f"prompt={usage.prompt_tokens} cached={cached} "
# f"completion={usage.completion_tokens} reasoning={reasoning} total={usage.total_tokens}"
# )
async def _call_pydantic_output(
self,
prompt : str,
@ -126,10 +113,9 @@ class ChatgptService:
self,
prompt : str,
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
model : str,
model : str,
img_url : str,
image_detail_high : bool,
reasoning_effort : Optional[str] = None) -> BaseModel:
image_detail_high : bool) -> BaseModel:
content = []
if img_url:
content.append({
@ -143,16 +129,13 @@ class ChatgptService:
"type": "text",
"text": prompt
})
# gpt-5.4 계열/Gemini 호환 엔드포인트는 허용 값이 다르거나 파라미터를 거부하므로 지정된 경우에만 전달
extra_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {}
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format,
**extra_kwargs,
response_format=output_format
)
except (ValidationError, json.JSONDecodeError) as e:
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
@ -165,7 +148,6 @@ class ChatgptService:
if attempt < self.max_retries:
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
continue
self._log_usage(response, model, output_format)
# Response 디버그 로깅
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
@ -200,69 +182,13 @@ class ChatgptService:
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
raise last_error
async def generate_structured_output_multi_image(
self,
prompt_text: str,
output_format: BaseModel,
model: str,
img_urls: List[str],
image_detail_high: bool = True,
) -> BaseModel:
"""여러 이미지를 한 번에 보고 구조화 출력을 생성합니다 (썸네일 비전 선택용).
sheet 기반 Prompt를 거치지 않고 코드에서 조립한 프롬프트 텍스트와
이미지 URL 리스트를 직접 받는다. 이미지들은 프롬프트에 나열된 순서와
동일하게 첨부되므로, 프롬프트에서 "N번째 이미지"로 지칭할 수 있다.
"""
content = []
for url in img_urls:
content.append({
"type": "image_url",
"image_url": {
"url": url,
"detail": "high" if image_detail_high else "low",
},
})
content.append({"type": "text", "text": prompt_text})
last_error = None
for attempt in range(self.max_retries + 1):
try:
response = await self.client.beta.chat.completions.parse(
model=model,
messages=[{"role": "user", "content": content}],
response_format=output_format,
)
except (ValidationError, json.JSONDecodeError) as e:
logger.warning(
f"[ChatgptService({self.model_type})] multi-image parse failed "
f"(attempt {attempt + 1}/{self.max_retries + 1}): {e}"
)
last_error = ChatGPTResponseError("parse_error", type(e).__name__, str(e))
if attempt < self.max_retries:
continue
raise last_error
self._log_usage(response, model, output_format)
choice = response.choices[0]
if choice.finish_reason == "stop":
return choice.message.parsed
logger.warning(
f"[ChatgptService({self.model_type})] multi-image unexpected finish_reason "
f"(attempt {attempt + 1}/{self.max_retries + 1}): {choice.finish_reason}"
)
last_error = ChatGPTResponseError("failed", choice.finish_reason, "multi-image call failed")
raise last_error
async def generate_structured_output(
self,
prompt : Prompt,
input_data : dict,
img_url : Optional[str] = None,
img_detail_high : bool = False,
silent : bool = True,
reasoning_effort : Optional[str] = None,
silent : bool = True
) -> BaseModel:
prompt_text = prompt.build_prompt(input_data, silent)
@ -273,5 +199,5 @@ class ChatgptService:
# GPT API 호출
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
# parsed = await self._call_pydantic_output(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high, reasoning_effort)
parsed = await self._call_pydantic_output_chat_completion(prompt_text, prompt.prompt_output_class, prompt.prompt_model, img_url, img_detail_high)
return parsed

View File

@ -42,61 +42,6 @@ class SpaceType(StrEnum):
detail_lighting = auto()
detail_decor = auto()
detail_tableware = auto()
# 레스토랑(DVRT0001) 템플릿 어휘
dining_hall = auto()
table_setting = auto()
private_room = auto()
detail_cooking = auto()
detail_plating = auto()
detail_menu = auto()
detail_dish_main = auto()
detail_dish_side = auto()
brand_summary = auto()
# 카페(DVCF0001) 템플릿 어휘
hall = auto()
counter_bar = auto()
seating_window = auto()
seating_lounge = auto()
signage = auto()
detail_dessert = auto()
detail_signature_drink = auto()
# 관광지(DVAT0001) 템플릿 어휘
entrance_gate = auto()
exhibition_hall = auto()
landmark_main = auto()
landmark_detail = auto()
night_view = auto()
panorama_view = auto()
photo_spot = auto()
seasonal_scene = auto()
walking_path = auto()
# 미용실(DVSL0001) 템플릿 어휘
mirror_detail = auto()
styling_zone = auto()
waiting_lounge = auto()
# 병원(DVCL0001) 템플릿 어휘
consultation_room = auto()
detail_amenity = auto()
equipment_zone = auto()
recovery_room = auto()
treatment_room = auto()
waiting_area = auto()
# 피트니스(DVFT0001) 템플릿 어휘
apparatus_zone = auto()
brand_sign = auto()
detail_equipment = auto()
locker_room = auto()
powder_room = auto()
pt_zone = auto()
reformer_zone = auto()
# 학원(DVAC0001) 템플릿 어휘
classroom = auto()
counseling_room = auto()
detail_facility = auto()
detail_interior_prop = auto()
detail_materials = auto()
library_corner = auto()
study_room = auto()
class Subject(StrEnum):
"""이미지 내 주요 피사체 유형. 화면에 무엇이 담겨 있는지를 분류하는 태그."""
@ -110,28 +55,6 @@ class Subject(StrEnum):
signage = auto()
amenity_item = auto()
person = auto()
# 레스토랑(DVRT0001) 템플릿 어휘
cooking_action = auto()
menu_board = auto()
triptych = auto()
# 카페(DVCF0001) 템플릿 어휘
beverage = auto()
brewing_action = auto()
dessert = auto()
# 관광지(DVAT0001) 템플릿 어휘
scenery = auto()
structure = auto()
# 병원(DVCL0001) 템플릿 어휘
interior_clean = auto()
medical_equipment = auto()
# 피트니스(DVFT0001) 템플릿 어휘
interior_scale = auto()
pilates_apparatus = auto()
training_action = auto()
# 학원(DVAC0001) 템플릿 어휘
facility_equipment = auto()
learning_material = auto()
study_scene = auto()
class Camera(StrEnum):
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""

View File

@ -1,5 +1,4 @@
import copy
import re
import time
import json
from typing import Literal, Any
@ -13,31 +12,10 @@ from app.utils.prompts.prompts import *
logger = get_logger("subtitle")
# 비한국어 출력에서 번역 누락(한글 잔존)을 탐지하기 위한 패턴
_HANGUL_RE = re.compile(r"[가-힣]")
# 한글 잔존 시 GPT 재호출 최대 횟수 (최초 호출 포함)
_MAX_LANGUAGE_ATTEMPTS = 3
class SubtitleContentsGenerator():
def __init__(self):
self.chatgpt_service = ChatgptService(timeout=60.0)
@staticmethod
def _find_hangul_leftovers(output_data: SubtitlePromptOutput) -> list[str]:
"""비한국어 출력에서 한글이 남아 있는 pitching_tag 목록을 반환합니다.
프롬프트가 '한국어로 생성 후 {language}로 번역'하는 2단계 구조라서,
항목 수가 많으면 일부가 번역되지 않은 채(한국어/혼합 문장) 돌아오는
사례가 있어 코드 레벨에서 검증한다.
"""
return [
result.pitching_tag
for result in output_data.pitching_results
if _HANGUL_RE.search(result.pitching_data)
]
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str, language : str = "Korean", industry: str = "") -> SubtitlePromptOutput:
start = time.perf_counter()
logger.info(
@ -63,39 +41,7 @@ class SubtitleContentsGenerator():
logger.info(
f"[SubtitleContentsGenerator] GPT 호출 시작 - model: {dynamic_subtitle_prompt.prompt_model}"
)
# 비한국어 언어는 번역 누락(한글 잔존) 검증 후 필요 시 재호출.
# 전부 실패하면 잔존 건수가 가장 적은 시도를 채택한다 (영상 생성 자체는 진행).
output_data = None
best_output = None
best_leftover_count: int | None = None
for lang_attempt in range(1, _MAX_LANGUAGE_ATTEMPTS + 1):
candidate = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
if language == "Korean":
output_data = candidate
break
leftovers = self._find_hangul_leftovers(candidate)
if not leftovers:
output_data = candidate
break
logger.warning(
f"[SubtitleContentsGenerator] 번역 누락(한글 잔존) {len(leftovers)}건 "
f"(attempt {lang_attempt}/{_MAX_LANGUAGE_ATTEMPTS}) - language: {language}, "
f"tags: {leftovers}"
)
if best_leftover_count is None or len(leftovers) < best_leftover_count:
best_output = candidate
best_leftover_count = len(leftovers)
if output_data is None:
logger.error(
f"[SubtitleContentsGenerator] 모든 시도에서 한글 잔존 - "
f"최소 잔존 {best_leftover_count}건 결과 채택 - language: {language}"
)
output_data = best_output
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
elapsed = (time.perf_counter() - start) * 1000
logger.info(

View File

@ -113,11 +113,11 @@ class SunoService:
Args:
prompt: 가사 (customMode=true일 때 가사로 사용)
40초 이내 길이의 노래에 적합한 가사여야 함
1분 이내 길이의 노래에 적합한 가사여야 함
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz")
None일 경우 style 파라미터를 전송하지 않음
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
instrumental: True이면 BGM 전용 — 더미 가사로 40초 길이를 유도하고 보컬 없이 생성
instrumental: True이면 BGM 전용 — 더미 가사로 60초 길이를 유도하고 보컬 없이 생성
Returns:
task_id: 작업 추적용 ID
@ -125,7 +125,7 @@ class SunoService:
Note:
- 스트림 URL: 30-40초 내 생성
- 다운로드 URL: 2-3분 내 생성
- 생성되는 노래는 약 40초 이내의 길이
- 생성되는 노래는 약 1분 이내의 길이
"""
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
@ -133,11 +133,11 @@ class SunoService:
if instrumental:
bgm_lyrics = get_bgm_lyrics(genre)
formatted_prompt = f"[Song Duration: Around 40 seconds]\n{bgm_lyrics}"
formatted_prompt = f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{bgm_lyrics}"
logger.info(f"[Suno] BGM 더미 가사 장르 {normalized_genre} 선택됨")
else:
formatted_prompt = (
f"[Song Duration: Around 40 seconds]\n{prompt}"
f"[Song Duration: Around 1 minute - Must be around 60 seconds]\n{prompt}"
)
payload: dict[str, Any] = {
@ -148,7 +148,7 @@ class SunoService:
"callBackUrl": actual_callback_url,
}
if normalized_genre:
payload["style"] = f"{normalized_genre}, around 40 seconds" if instrumental else normalized_genre
payload["style"] = f"{normalized_genre}, around 60 seconds" if instrumental else normalized_genre
last_error: Exception | None = None

View File

@ -1,231 +0,0 @@
# -*- coding: utf-8 -*-
"""
썸네일 슬롯 한정 픽셀(헤더) 룰 스코어러 — Pillow/numpy 의존성 없이 구현.
배경 (zzz/ado2-thumbnail-selection 파일럿 대비 축소 이식):
기존 태그 매칭(calculate_image_slot_score_multi)은 의미 태그 교집합만 보고
실제 화질·프레이밍을 보지 않는다. 파일럿(수원화성, 2026-07-20)에서 검증된
6축 스코어러 중 "실질 가치는 최고 컷 찾기보다 명백히 나쁜 컷의 자동 배제"
(참고: references/scoring-logic.md)라는 결론에 따라, 하드 리젝트에 직결되는
두 축(해상도 업스케일 배율, 종횡비)만 이식한다.
이 두 축은 실제 픽셀 색상이 필요 없고 원본 가로/세로 픽셀 수만 알면 되므로,
이미지를 전체 디코드하지 않고 파일 헤더 몇십 KB만 읽어(HTTP Range) 포맷별
고정 위치에서 크기를 파싱한다(JPEG는 SOF 마커 스캔). Pillow의 픽셀 디코드가
없으므로 서버(Standard_B2als_v2, 2 vCPU 버스터블) CPU 부담이 사실상 0에
가깝다.
텍스트 안전영역 밀도·명암 대비(원 스코어러의 safe_area/contrast 축)는 실제
픽셀 값이 있어야 계산되는 축이라 이 방식으로는 커버하지 못한다 — 의도적으로
범위 밖으로 남겨둔 트레이드오프.
"""
from __future__ import annotations
import asyncio
import struct
import httpx
from app.utils.logger import get_logger
logger = get_logger("thumbnail_fitness")
# ── 커버 스펙 (thumbnail-composition 렌더 기준) ──────────────────────────
TARGET_H = 1920 # 9:16 출력 캔버스 기준 세로 px
_CROP_RATIO = 9 / 16 # tight_crop(fit=cover) 목표 종횡비
REJECT_UPSCALE = 2.5 # 9:16 크롭 후 업스케일 배율 초과 → 배제 (OG 1200×630 ≈ 3.05배)
REJECT_ASPECT = 2.2 # 원본 종횡비(w/h) 초과 극단 가로형 → 배제
_RANGE_BYTES = 131072 # 헤더만 읽기 위한 최대 다운로드 크기 (128KB, SOF 스캔 여유분)
_DOWNLOAD_TIMEOUT = 8.0
_DEFAULT_CONCURRENCY = 4 # 헤더만 받으므로 CPU 부담 없음 — I/O 대기 기준으로 넉넉히
# ── 포맷별 헤더 파싱 (순수 struct, 전체 디코드 없음) ──────────────────────
def parse_image_size(data: bytes) -> tuple[int, int] | None:
"""이미지 바이트(파일 앞부분)에서 원본 (width, height)를 추출합니다.
지원: JPEG(SOF 마커 스캔), PNG(IHDR), GIF(고정 오프셋), WebP(VP8X/VP8L).
단순 손실 WebP(VP8)는 비트 단위 파싱이 필요해 범위 밖 — 실패 시 None
(호출자가 중립 처리, 배제하지 않음).
"""
if len(data) < 12:
return None
if data[:2] == b"\xff\xd8":
return _parse_jpeg_size(data)
if data[:8] == b"\x89PNG\r\n\x1a\n":
return _parse_png_size(data)
if data[:6] in (b"GIF87a", b"GIF89a"):
return _parse_gif_size(data)
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return _parse_webp_size(data)
return None
def _parse_png_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 24:
return None
width, height = struct.unpack(">II", data[16:24])
return width, height
def _parse_gif_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 10:
return None
width, height = struct.unpack("<HH", data[6:10])
return width, height
_JPEG_SOF_MARKERS = {
0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7,
0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF,
}
_JPEG_NO_LENGTH_MARKERS = {0xD8, 0xD9, 0x01} | set(range(0xD0, 0xD8)) # SOI/EOI/RST0-7/TEM
def _parse_jpeg_size(data: bytes) -> tuple[int, int] | None:
n = len(data)
pos = 2 # SOI(0xFFD8) 이후부터 마커 스캔
while pos < n:
if data[pos] != 0xFF:
pos += 1
continue
# 0xFF 뒤 연속된 fill byte(0xFF) 스킵 후 실제 마커 바이트 탐색
marker_pos = pos
while marker_pos < n and data[marker_pos] == 0xFF:
marker_pos += 1
if marker_pos >= n:
return None
marker = data[marker_pos]
pos = marker_pos + 1
if marker in _JPEG_NO_LENGTH_MARKERS:
continue
if pos + 2 > n:
return None
seg_len = struct.unpack(">H", data[pos:pos + 2])[0]
if marker in _JPEG_SOF_MARKERS:
if pos + 7 > n:
return None # SOF가 헤더 범위 밖에 있음 — 파싱 실패(중립 처리)
height, width = struct.unpack(">HH", data[pos + 3:pos + 7])
return width, height
pos += seg_len
return None
def _parse_webp_size(data: bytes) -> tuple[int, int] | None:
if len(data) < 30:
return None
fourcc = data[12:16]
if fourcc == b"VP8X":
width = 1 + (data[24] | (data[25] << 8) | (data[26] << 16))
height = 1 + (data[27] | (data[28] << 8) | (data[29] << 16))
return width, height
if fourcc == b"VP8L":
if len(data) < 25 or data[20] != 0x2F:
return None
b0, b1, b2, b3 = data[21:25]
bits = b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)
width = (bits & 0x3FFF) + 1
height = ((bits >> 14) & 0x3FFF) + 1
return width, height
# 단순 손실 WebP(VP8) — 비트 단위 파싱 범위 밖, 중립 처리
return None
# ── 점수화 (실제 픽셀 없이 w,h만으로 산출) ─────────────────────────────
def score_pixel_fitness(width: int, height: int) -> dict:
"""해상도(업스케일 배율)·종횡비만으로 썸네일 적합도를 판정합니다.
Returns:
{"score": 0.0~1.0 (배율, 태그 점수에 곱해 쓴다), "reject": str}
reject가 비어있지 않으면 하드 배제 대상.
"""
if width <= 0 or height <= 0:
return {"score": 0.0, "reject": "invalid_dimensions"}
ratio = width / height
if ratio > REJECT_ASPECT:
return {"score": 0.0, "reject": f"극단 가로형({ratio:.2f}:1)"}
cropped_h = height if ratio > _CROP_RATIO else int(width / _CROP_RATIO)
if cropped_h <= 0:
return {"score": 0.0, "reject": "invalid_dimensions"}
upscale = TARGET_H / cropped_h
if upscale > REJECT_UPSCALE:
return {"score": 0.0, "reject": f"저해상도(업스케일 {upscale:.2f}x)"}
soft_score = 1.0 if upscale <= 1.0 else max(0.0, 1.0 - (upscale - 1.0) / 2.0)
return {"score": round(soft_score, 4), "reject": ""}
# ── 네트워크: 헤더 바이트만 받기 (HTTP Range, 미지원 서버도 조기 종료로 방어) ──
async def _fetch_header_bytes(
client: httpx.AsyncClient, url: str, max_bytes: int = _RANGE_BYTES, timeout: float = _DOWNLOAD_TIMEOUT
) -> bytes | None:
"""이미지 URL에서 앞부분 max_bytes만 받아옵니다.
Range 헤더를 무시하고 전체를 돌려주는 서버가 있어도, 스트리밍을 max_bytes
수신 즉시 중단해 실제 다운로드량을 상한선 이내로 강제한다.
"""
try:
async with client.stream(
"GET", url, headers={"Range": f"bytes=0-{max_bytes - 1}"}, timeout=timeout
) as response:
if response.status_code not in (200, 206):
return None
buf = bytearray()
async for chunk in response.aiter_bytes():
buf.extend(chunk)
if len(buf) >= max_bytes:
break
return bytes(buf[:max_bytes])
except Exception as e:
logger.warning(f"[thumbnail_fitness] 헤더 다운로드 실패: {url} - {e}")
return None
async def score_pool_thumbnail_fitness(
pool: list[dict],
client: httpx.AsyncClient,
concurrency: int = _DEFAULT_CONCURRENCY,
total_timeout: float = 20.0,
) -> dict[str, dict]:
"""이미지 풀(URL 중복 제거)의 썸네일 픽셀 적합도를 병렬로 계산합니다.
다운로드/파싱 실패 항목은 결과 dict에서 아예 빠진다 — 호출자가 "데이터
없음 = 판정 보류(중립)"로 처리하도록 유도(하드 배제로 오판하지 않기 위함).
전체 배치에 total_timeout 상한을 걸어, 네트워크 불량 시 이 부가 기능이
영상 생성 사전 준비 전체를 지연시키지 않도록 한다(초과 시 빈 dict).
Returns:
{image_url: {"score": float, "reject": str}} — 성공한 URL만 포함.
"""
semaphore = asyncio.Semaphore(concurrency)
urls = list({item["image_url"] for item in pool if item.get("image_url")})
async def _score_one(url: str) -> tuple[str, dict | None]:
async with semaphore:
header = await _fetch_header_bytes(client, url)
if header is None:
return url, None
size = parse_image_size(header)
if size is None:
logger.warning(f"[thumbnail_fitness] 이미지 크기 파싱 실패(포맷 미지원/헤더 부족): {url}")
return url, None
width, height = size
return url, score_pixel_fitness(width, height)
try:
results = await asyncio.wait_for(
asyncio.gather(*[_score_one(u) for u in urls]),
timeout=total_timeout,
)
except asyncio.TimeoutError:
logger.warning(
f"[thumbnail_fitness] 전체 스코어링 타임아웃({total_timeout}s, urls={len(urls)}) — "
"픽셀 적합도 없이(태그 점수만) 진행"
)
return {}
return {url: fitness for url, fitness in results if fitness is not None}

View File

@ -1,110 +0,0 @@
# -*- coding: utf-8 -*-
"""
썸네일 최종 선택 — 규칙/픽셀로 압축한 top-N 후보 중 비전 LLM이 1개 선택 (Phase 2).
배경 (하이브리드 설계):
규칙(태그 매칭) + 픽셀 헤더 필터로 썸네일 후보를 소수(top-3)로 압축한 뒤,
그 소수 중 최종 1컷만 비전 LLM이 이미지를 실제로 보고 고른다. 선택지가
"1슬롯 × 3후보"로 갇혀 있어 전면 LLM 배정의 위험(제약 위반·중복 배정·큰
블라스트 반경)이 없고, 실패 시 규칙 1위로 폴백한다.
이 단계의 실질 가치: Pillow를 쓰지 않아 포기했던 지각적 축을 비전으로 되찾음.
- 이미지 속 글자/간판/워터마크가 커버 텍스트 4슬롯과 겹치는지
- 중앙 9:16 크롭 후 주제가 잘리거나 어중간해지는지
- 업종 대표성·클릭 유인
"""
from __future__ import annotations
import asyncio
from pydantic import BaseModel, Field
from app.utils.logger import get_logger
from app.utils.prompts.chatgpt_prompt import ChatgptService
logger = get_logger("thumbnail_vision")
# 비전 판정 모델·타임아웃 — 부가 기능이므로 실패해도 규칙 폴백, 파이프라인은 진행
_VISION_MODEL = "gpt-5-mini"
_VISION_TIMEOUT = 20.0
class ThumbnailPickOutput(BaseModel):
"""비전 LLM의 썸네일 선택 출력."""
choice_index: int = Field(..., description="선택한 이미지의 0-기반 인덱스 (첨부 이미지 순서와 동일)")
reason: str = Field(..., description="선택 근거 한 줄 (텍스트 충돌/크롭 구도/대표성 관점)")
def _build_prompt(candidate_count: int, industry: str, business_name: str) -> str:
return f"""당신은 숏폼 광고 영상의 **썸네일(커버) 배경 이미지**를 고르는 전문가입니다.
첨부된 {candidate_count}장의 이미지는 이미 태그·화질 필터를 통과한 후보들입니다.
이 중 커버로 가장 적합한 **1장**을 골라 0-기반 인덱스로 반환하세요.
(첫 번째 이미지 = 0, 두 번째 = 1, ...)
업체 정보: {business_name} ({industry} 업종)
썸네일 위에는 아래 4개의 텍스트가 흰 글자로 얹힙니다:
- 상단(약 8% 높이): 카테고리 뱃지
- 중앙(약 50%): 업체명 (큰 글자)
- 중앙 하단(약 62%): 지역
- 최하단(약 94%): 해시태그
선택 기준 (중요도 순):
0. **업종 대표성·클릭 유인**: 한눈에 어떤 곳인지 전달되고 매력적일 것
1. **텍스트 충돌 회피**: 이미지 속 간판·안내판 글자·워터마크가 위 텍스트 영역과 겹치지 않을 것
2. **크롭 후 구도**: 세로 9:16 중앙 크롭 시 핵심 주제가 잘리지 않고 살아있을 것
3. **가독성**: 텍스트가 얹히는 영역(상/중/하단)이 너무 밝거나 복잡하지 않아 흰 글자가 잘 보일 것
"""
async def pick_thumbnail_by_vision(
candidates: list[dict],
industry: str,
business_name: str,
) -> dict | None:
"""후보 이미지 중 비전 LLM이 최종 1컷을 선택합니다.
Args:
candidates: [{"image_url": str, "image_tag": dict}, ...] — 규칙/픽셀로
압축한 top-N 후보 (점수 내림차순, 즉 index 0이 규칙 1위).
Returns:
선택된 candidate dict. 후보가 1개 이하이거나 호출 실패/타임아웃/무효
인덱스면 None (호출자가 규칙 1위 폴백).
"""
if len(candidates) < 2:
# 선택할 게 없음 — 규칙 1위(있으면)로 폴백
return None
urls = [c["image_url"] for c in candidates]
prompt = _build_prompt(len(candidates), industry, business_name)
chatgpt = ChatgptService(model_type="gpt", timeout=_VISION_TIMEOUT)
try:
result: ThumbnailPickOutput = await asyncio.wait_for(
chatgpt.generate_structured_output_multi_image(
prompt_text=prompt,
output_format=ThumbnailPickOutput,
model=_VISION_MODEL,
img_urls=urls,
),
timeout=_VISION_TIMEOUT,
)
except Exception as e:
logger.warning(f"[thumbnail_vision] 비전 선택 실패 — 규칙 폴백: {e}")
return None
idx = result.choice_index
if not (0 <= idx < len(candidates)):
logger.warning(
f"[thumbnail_vision] 무효 인덱스({idx}, 후보 {len(candidates)}개) — 규칙 폴백"
)
return None
logger.info(
f"[thumbnail_vision] 비전 선택 — index={idx}"
f"{' (규칙 1위와 동일)' if idx == 0 else ' (규칙 1위 아님)'}, "
f"url={candidates[idx]['image_url']}, reason={result.reason}"
)
return candidates[idx]

View File

@ -32,17 +32,12 @@ URL 경로 형식:
"""
import asyncio
import base64
import os
import re
import time
from collections.abc import AsyncIterator
from pathlib import Path
from urllib.parse import urlencode
import aiofiles
import httpx
from fastapi import UploadFile
from app.utils.logger import get_logger
from config import azure_blob_settings
@ -50,15 +45,6 @@ from config import azure_blob_settings
# 로거 설정
logger = get_logger("blob")
class BlobUploadTooLargeError(ValueError):
"""스트리밍 중 파일 크기 상한을 초과했을 때 발생합니다."""
def __init__(self, max_size_bytes: int):
self.max_size_bytes = max_size_bytes
super().__init__(f"업로드 파일은 {max_size_bytes} bytes를 초과할 수 없습니다.")
# =============================================================================
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
# =============================================================================
@ -90,23 +76,6 @@ async def close_shared_blob_client() -> None:
logger.info("[AzureBlobUploader] Shared HTTP client closed")
def to_playback_url(blob_url: str | None) -> str | None:
"""DB에 저장된 SAS 미포함 공개 URL에 읽기용 SAS 토큰을 붙여 반환합니다.
Azure Blob 익명(공개) 접근은 x-ms-version 헤더가 없어 Range 요청을 지원하지
않는 구버전 API로 처리되어 브라우저에서 영상/오디오 seek이 동작하지 않는다.
SAS 토큰(sv= 포함)을 붙이면 최신 API 버전으로 처리되어 Range/Accept-Ranges가
정상 동작한다. DB에는 SAS 없는 URL을 그대로 저장하고, 응답 시점에만 붙인다.
"""
if not blob_url:
return blob_url
sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN.strip("?'\"")
if not sas_token:
return blob_url
separator = "&" if "?" in blob_url else "?"
return f"{blob_url}{separator}{sas_token}"
class AzureBlobUploader:
"""Azure Blob Storage 업로드 클래스
@ -131,8 +100,6 @@ class AzureBlobUploader:
".gif": "image/gif",
".webp": "image/webp",
".bmp": "image/bmp",
".heic": "image/heic",
".heif": "image/heif",
}
def __init__(self, user_uuid: str, task_id: str):
@ -144,7 +111,7 @@ class AzureBlobUploader:
"""
self._user_uuid = user_uuid
self._task_id = task_id
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL.rstrip("/")
self._base_url = azure_blob_settings.AZURE_BLOB_BASE_URL
self._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
self._last_public_url: str = ""
@ -237,12 +204,8 @@ class AzureBlobUploader:
logger.debug(f"[{log_prefix}] Starting upload... "
f"(size: {size} bytes, timeout: {timeout}s)")
request_headers = {
**headers,
"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION,
}
response = await asyncio.wait_for(
client.put(upload_url, content=file_content, headers=request_headers),
client.put(upload_url, content=file_content, headers=headers),
timeout=timeout,
)
upload_time = time.perf_counter()
@ -283,170 +246,6 @@ class AzureBlobUploader:
f"{type(e).__name__}: {e}")
return False
@staticmethod
def _append_query(upload_url: str, **params: str) -> str:
"""SAS 쿼리를 유지하며 Azure REST API 쿼리를 추가합니다."""
separator = "&" if "?" in upload_url else "?"
return f"{upload_url}{separator}{urlencode(params)}"
async def _delete_upload_url(self, upload_url: str, log_prefix: str) -> bool:
"""실패한 업로드의 커밋/미커밋 Blob을 정리합니다."""
try:
client = await get_shared_blob_client()
response = await client.delete(
upload_url,
headers={"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION},
)
if response.status_code in {202, 404}:
return True
logger.warning(
f"[{log_prefix}] Blob cleanup failed - Status: "
f"{response.status_code}, Response: {response.text[:500]}"
)
except Exception as exc:
logger.warning(
f"[{log_prefix}] Blob cleanup error - {type(exc).__name__}: {exc}"
)
return False
async def _upload_stream(
self,
chunks: AsyncIterator[bytes],
upload_url: str,
content_type: str,
timeout: float,
log_prefix: str,
*,
max_size_bytes: int | None = None,
expected_size_bytes: int | None = None,
cleanup_blob_on_failure: bool = False,
) -> bool:
"""Azure Block Blob API로 비동기 청크 스트림을 업로드합니다.
각 블록만 메모리에 유지하므로 파일 전체 크기와 무관하게 메모리 사용량이
일정합니다. 커밋 전 오류가 발생하면 업로드 대상 Blob 삭제를 시도합니다.
"""
block_ids: list[str] = []
block_id_nonce = os.urandom(16)
uploaded_size = 0
start_time = time.perf_counter()
async def cleanup_failed_stream() -> None:
# 기존 deterministic key는 미커밋 블록만 TTL 정리되게 두어 정상 Blob을 보존합니다.
if cleanup_blob_on_failure:
await self._delete_upload_url(upload_url, log_prefix)
try:
client = await get_shared_blob_client()
async with asyncio.timeout(timeout):
async for chunk in chunks:
if not chunk:
continue
uploaded_size += len(chunk)
if max_size_bytes is not None and uploaded_size > max_size_bytes:
raise BlobUploadTooLargeError(max_size_bytes)
raw_block_id = block_id_nonce + len(block_ids).to_bytes(4, "big")
block_id = base64.b64encode(raw_block_id).decode("ascii")
block_url = self._append_query(
upload_url,
comp="block",
blockid=block_id,
)
response = await client.put(
block_url,
content=chunk,
headers={
"Content-Type": "application/octet-stream",
"x-ms-version": (
azure_blob_settings.AZURE_BLOB_API_VERSION
),
},
)
if response.status_code != 201:
logger.error(
f"[{log_prefix}] Block upload failed - Status: "
f"{response.status_code}, Response: {response.text[:500]}"
)
await cleanup_failed_stream()
return False
block_ids.append(block_id)
if uploaded_size == 0:
logger.warning(f"[{log_prefix}] Empty upload stream")
await cleanup_failed_stream()
return False
if (
expected_size_bytes is not None
and uploaded_size != expected_size_bytes
):
logger.error(
f"[{log_prefix}] Stream size changed - expected: "
f"{expected_size_bytes}, actual: {uploaded_size}"
)
await cleanup_failed_stream()
return False
block_list = "".join(
f"<Latest>{block_id}</Latest>" for block_id in block_ids
)
commit_body = (
f'<?xml version="1.0" encoding="utf-8"?>'
f"<BlockList>{block_list}</BlockList>"
).encode("utf-8")
commit_url = self._append_query(upload_url, comp="blocklist")
response = await client.put(
commit_url,
content=commit_body,
headers={
"Content-Type": "application/xml; charset=utf-8",
"x-ms-blob-content-type": content_type,
"x-ms-version": azure_blob_settings.AZURE_BLOB_API_VERSION,
},
)
if response.status_code not in {200, 201}:
logger.error(
f"[{log_prefix}] Block list commit failed - Status: "
f"{response.status_code}, Response: {response.text[:500]}"
)
await cleanup_failed_stream()
return False
duration_ms = (time.perf_counter() - start_time) * 1000
logger.info(
f"[{log_prefix}] SUCCESS - blocks: {len(block_ids)}, "
f"size: {uploaded_size} bytes, Duration: {duration_ms:.1f}ms"
)
return True
except BlobUploadTooLargeError:
await cleanup_failed_stream()
raise
except asyncio.CancelledError:
# 클라이언트 연결 종료 중에도 가능한 범위에서 staged block을 정리합니다.
await asyncio.shield(cleanup_failed_stream())
raise
except TimeoutError:
elapsed = time.perf_counter() - start_time
logger.error(f"[{log_prefix}] TIMEOUT after {elapsed:.1f}s")
except httpx.HTTPError as exc:
elapsed = time.perf_counter() - start_time
logger.error(
f"[{log_prefix}] HTTP_ERROR after {elapsed:.1f}s - "
f"{type(exc).__name__}: {exc}"
)
except Exception as exc:
elapsed = time.perf_counter() - start_time
logger.error(
f"[{log_prefix}] ERROR after {elapsed:.1f}s - "
f"{type(exc).__name__}: {exc}"
)
await cleanup_failed_stream()
return False
async def _upload_file(
self,
file_path: str,
@ -474,20 +273,17 @@ class AzureBlobUploader:
self._last_public_url = self._build_public_url(category, file_name)
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
async def iter_file() -> AsyncIterator[bytes]:
async with aiofiles.open(file_path, "rb") as file:
while chunk := await file.read(
azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES
):
yield chunk
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
return await self._upload_stream(
chunks=iter_file(),
async with aiofiles.open(file_path, "rb") as file:
file_content = await file.read()
return await self._upload_bytes(
file_content=file_content,
upload_url=upload_url,
content_type=content_type,
headers=headers,
timeout=timeout,
log_prefix=log_prefix,
expected_size_bytes=Path(file_path).stat().st_size,
)
async def upload_music(self, file_path: str) -> bool:
@ -686,51 +482,6 @@ class AzureBlobUploader:
log_prefix=log_prefix,
)
async def upload_image_stream(
self,
file: UploadFile,
file_name: str,
*,
expected_size_bytes: int | None = None,
max_size_bytes: int | None = None,
) -> bool:
"""FastAPI UploadFile을 Azure Block Blob으로 청크 업로드합니다."""
extension = Path(file_name).suffix.lower()
content_type = self.IMAGE_CONTENT_TYPES.get(extension, "image/jpeg")
file_name = self._sanitize_filename(file_name)
upload_url = self._build_upload_url("image", file_name)
self._last_public_url = self._build_public_url("image", file_name)
log_prefix = "upload_image_stream"
chunk_size = azure_blob_settings.AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES
max_size = (
max_size_bytes
if max_size_bytes is not None
else azure_blob_settings.IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES
)
async def iter_upload() -> AsyncIterator[bytes]:
await file.seek(0)
while chunk := await file.read(chunk_size):
yield chunk
return await self._upload_stream(
chunks=iter_upload(),
upload_url=upload_url,
content_type=content_type,
timeout=60.0,
log_prefix=log_prefix,
max_size_bytes=max_size,
expected_size_bytes=expected_size_bytes,
cleanup_blob_on_failure=True,
)
async def delete_image(self, file_name: str) -> bool:
"""이미지 Blob을 삭제합니다. DB 저장 실패 보상 처리용입니다."""
sanitized_name = self._sanitize_filename(file_name)
upload_url = self._build_upload_url("image", sanitized_name)
return await self._delete_upload_url(upload_url, "delete_image")
# 사용 예시:
# import asyncio

View File

@ -1,173 +0,0 @@
"""영상 파일에서 SNS 공유용 포스터 이미지를 생성하고 저장합니다."""
import asyncio
from pathlib import Path
from app.utils.logger import get_logger
from app.utils.upload_blob_as_request import AzureBlobUploader
logger = get_logger("video_poster")
FFMPEG_TIMEOUT_SECONDS = 30.0
FFMPEG_CLEANUP_TIMEOUT_SECONDS = 5.0
_STDERR_LOG_LIMIT = 500
async def _kill_and_wait(process: asyncio.subprocess.Process) -> None:
"""실행 중인 ffmpeg 프로세스를 종료하고 자원을 회수합니다."""
try:
process.kill()
except ProcessLookupError:
pass
except Exception as exc:
logger.warning(
"[video_poster] ffmpeg 프로세스 종료에 실패했습니다: %s",
exc,
)
try:
await asyncio.wait_for(
process.wait(),
timeout=FFMPEG_CLEANUP_TIMEOUT_SECONDS,
)
except TimeoutError:
logger.warning(
"[video_poster] 종료한 ffmpeg 프로세스 회수 시간이 초과되었습니다 "
"(timeout=%ss)",
FFMPEG_CLEANUP_TIMEOUT_SECONDS,
)
except Exception as exc:
logger.warning(
"[video_poster] ffmpeg 프로세스 회수에 실패했습니다: %s",
exc,
)
def _format_stderr(stderr: bytes) -> str:
"""ffmpeg 표준 오류를 로그에 안전한 길이의 문자열로 변환합니다."""
return stderr.decode("utf-8", errors="replace").strip()[-_STDERR_LOG_LIMIT:]
async def extract_first_frame(video_path: str | Path) -> bytes | None:
"""로컬 영상의 첫 프레임을 JPEG 바이트로 추출하고 실패 시 ``None``을 반환합니다."""
process: asyncio.subprocess.Process | None = None
try:
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-nostdin",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(video_path),
"-frames:v",
"1",
"-f",
"image2pipe",
"-c:v",
"mjpeg",
"pipe:1",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(),
timeout=FFMPEG_TIMEOUT_SECONDS,
)
except TimeoutError:
await _kill_and_wait(process)
logger.warning(
"[video_poster] ffmpeg 첫 프레임 추출 시간이 초과되었습니다 "
"(path=%s, timeout=%ss)",
video_path,
FFMPEG_TIMEOUT_SECONDS,
)
return None
if process.returncode != 0:
logger.warning(
"[video_poster] ffmpeg 첫 프레임 추출에 실패했습니다 "
"(path=%s, returncode=%s, stderr=%s)",
video_path,
process.returncode,
_format_stderr(stderr),
)
return None
if not stdout:
logger.warning(
"[video_poster] ffmpeg가 빈 이미지를 반환했습니다 (path=%s)",
video_path,
)
return None
return stdout
except asyncio.CancelledError:
if process is not None:
await _kill_and_wait(process)
raise
except Exception as exc:
if process is not None:
await _kill_and_wait(process)
logger.warning(
"[video_poster] 첫 프레임 추출 중 오류가 발생했습니다 "
"(path=%s, error=%s: %s)",
video_path,
type(exc).__name__,
exc,
)
return None
async def generate_and_store_poster(
*,
video_path: str | Path,
user_uuid: str,
task_id: str,
file_stem: str,
) -> str | None:
"""첫 프레임을 Blob에 저장하고 공개 URL을 반환하며, 실패 시 ``None``을 반환합니다."""
try:
image_bytes = await extract_first_frame(video_path)
if image_bytes is None:
return None
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
uploaded = await uploader.upload_image_bytes(
image_bytes,
f"{file_stem}.jpg",
)
if not uploaded:
logger.warning(
"[video_poster] 포스터 Blob 업로드에 실패했습니다 "
"(path=%s, task_id=%s)",
video_path,
task_id,
)
return None
if not uploader.public_url:
logger.warning(
"[video_poster] 포스터 업로드 후 공개 URL이 비어 있습니다 "
"(path=%s, task_id=%s)",
video_path,
task_id,
)
return None
return uploader.public_url
except Exception as exc:
logger.warning(
"[video_poster] 포스터 생성 또는 저장 중 오류가 발생했습니다 "
"(path=%s, task_id=%s, error=%s: %s)",
video_path,
task_id,
type(exc).__name__,
exc,
)
return None

View File

@ -1,322 +0,0 @@
"""
Meta 전환 추적(Conversion Tracking) API Router
Meta 픽셀/Conversions API 전환 이벤트 발화를 위한 엔드포인트를 정의합니다.
엔드포인트 목록:
- POST /tracking/meta/first-video-created: 첫 영상 생성 완료(FirstVideoCreated) 이벤트 발화
동작 원리 (event_id 중복제거):
1. 프론트가 영상 생성 완료(서버 성공 응답)를 확인한 뒤 이 엔드포인트를 호출
2. 서버는 completed 상태의 영상 존재를 검증하고,
first_video_created_at IS NULL 조건의 원자적 UPDATE로 "계정당 최초 1회"를 판정
3. 최초 1회로 판정되면 event_id(UUID)를 생성하여 Conversions API로 서버 이벤트 전송
4. 프론트는 응답의 fired=true일 때만 동일한 event_id로 fbq('trackCustom', ...)를 발화
→ Meta가 (event_name, event_id) 기준으로 브라우저/서버 이벤트를 중복제거
사용 예시:
from app.video.api.routers.v1.tracking import router
app.include_router(router)
"""
import ipaddress
import uuid
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, Field
from sqlalchemy import exists, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql import func
from app.database.session import get_session
from app.home.models import Project
from app.user.dependencies.auth import get_current_user
from app.user.models import User
from app.utils.logger import get_logger
from app.utils.meta_capi import send_capi_event
from app.video.models import Video
logger = get_logger("tracking")
router = APIRouter(prefix="/tracking", tags=["Tracking"])
# 브라우저 fbq('trackCustom', ...)와 철자/대소문자가 완전히 일치해야 중복제거가 동작함
FIRST_VIDEO_CREATED_EVENT = "FirstVideoCreated"
# Meta 표준 이벤트명 (브라우저 fbq('track', ...)와 동일해야 중복제거가 동작함)
COMPLETE_REGISTRATION_EVENT = "CompleteRegistration"
# 배포 이전에 가입한 기존 계정의 오발화 방지용 윈도우
# (가입 직후 프론트가 호출하므로 실제로는 수 분 내 도달함)
REGISTRATION_TRACK_WINDOW = timedelta(hours=24)
class FirstVideoCreatedRequest(BaseModel):
"""FirstVideoCreated 이벤트 발화 요청 스키마
fbc/fbp는 Meta 픽셀이 서비스 도메인에 심는 1st-party 쿠키로,
API 서버 도메인이 달라 요청에 자동 포함되지 않으므로 프론트가
document.cookie에서 읽어 body로 전달합니다. (해싱 금지, 원문 그대로)
"""
fbc: str | None = Field(default=None, description="Meta 클릭 ID 쿠키(_fbc) 원문")
fbp: str | None = Field(default=None, description="Meta 브라우저 ID 쿠키(_fbp) 원문")
event_source_url: str | None = Field(
default=None, description="이벤트가 발생한 페이지 URL"
)
class FirstVideoCreatedResponse(BaseModel):
"""FirstVideoCreated 이벤트 발화 응답 스키마"""
fired: bool = Field(description="이번 요청으로 이벤트가 최초 발화되었는지 여부")
event_id: str | None = Field(
default=None,
description="브라우저 fbq 발화 시 사용할 event_id (fired=true일 때만 제공)",
)
def _is_public_ip(value: str) -> bool:
"""Meta 매칭에 사용 가능한 공인 IP인지 검사합니다.
사설/루프백 대역(Docker 내부 172.x, 로컬 127.0.0.1 등)을 보내면
Meta가 유효하지 않은 값으로 폐기하면서 매개변수 전송률만 깎이므로,
공인 IP가 아니면 아예 전송하지 않기 위해 사용합니다.
"""
try:
ip = ipaddress.ip_address(value)
except ValueError:
return False
return not (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_reserved
or ip.is_multicast
or ip.is_unspecified
)
def _extract_client_ip(request: Request) -> str | None:
"""리버스 프록시 환경을 고려하여 클라이언트 공인 IP를 추출합니다.
후보를 순서대로 검사하여 처음 발견된 공인 IP를 반환합니다.
1. X-Real-IP: nginx가 $remote_addr로 덮어쓰므로 위조 불가 (최우선)
2. X-Forwarded-For: 클라이언트가 보낸 값 뒤에 nginx가 덧붙이는 구조라
맨 앞 항목이 위조될 수 있으므로, 항목을 순회하며 공인 IP를 찾음
3. 소켓 peer 주소: 프록시가 없는 환경 대비
공인 IP를 하나도 찾지 못하면 None을 반환합니다 (전송 생략).
"""
candidates: list[str] = []
real_ip = request.headers.get("x-real-ip")
if real_ip:
candidates.append(real_ip.strip())
forwarded_for = request.headers.get("x-forwarded-for")
if forwarded_for:
candidates.extend(part.strip() for part in forwarded_for.split(","))
if request.client:
candidates.append(request.client.host)
for candidate in candidates:
if _is_public_ip(candidate):
return candidate
return None
@router.post("/meta/first-video-created", response_model=FirstVideoCreatedResponse)
async def track_first_video_created(
body: FirstVideoCreatedRequest,
request: Request,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> FirstVideoCreatedResponse:
"""첫 영상 생성 완료(FirstVideoCreated) 전환 이벤트를 발화합니다.
계정당 최초 1회만 발화됩니다. 판정은 프론트 상태가 아니라
User.first_video_created_at 컬럼(null 여부)으로 서버에서 수행하므로
새로고침/재호출에도 중복 발화되지 않습니다.
Args:
body: fbc/fbp 쿠키 및 이벤트 소스 URL
request: IP/User-Agent 추출용 요청 객체
current_user: 인증된 사용자
session: DB 세션
Returns:
FirstVideoCreatedResponse: 발화 여부 및 event_id
"""
# 1. 이미 발화된 계정이면 즉시 종료 (원자적 UPDATE 전 빠른 경로)
if current_user.first_video_created_at is not None:
return FirstVideoCreatedResponse(fired=False)
# 2. 서버 기준 "영상 생성 성공" 검증: completed 상태 영상이 실제로 존재해야 함
# (Video는 user_uuid를 직접 갖지 않으므로 Project(소유자)를 경유하여 조회)
completed_exists = await session.scalar(
select(
exists().where(
Video.project_id == Project.id,
Project.user_uuid == current_user.user_uuid,
Video.status == "completed",
)
)
)
if not completed_exists:
logger.warning(
f"[FirstVideoCreated] completed 영상 없음 - user_uuid: {current_user.user_uuid}"
)
return FirstVideoCreatedResponse(fired=False)
# 3. 원자적 최초 1회 판정: first_video_created_at IS NULL인 경우에만 기록
# (동시 요청이 와도 rowcount=1은 단 한 요청만 가져감)
result = await session.execute(
update(User)
.where(
User.id == current_user.id,
User.first_video_created_at.is_(None),
)
.values(first_video_created_at=func.now())
)
await session.commit()
if result.rowcount != 1:
# 동시 요청 등으로 다른 요청이 먼저 발화한 경우
return FirstVideoCreatedResponse(fired=False)
# 4. CAPI 서버 이벤트 전송 (실패해도 first_video_created_at은 유지 —
# 브라우저 픽셀 발화가 백업 경로가 됨)
event_id = str(uuid.uuid4())
await send_capi_event(
event_name=FIRST_VIDEO_CREATED_EVENT,
event_id=event_id,
external_id=current_user.user_uuid,
client_ip=_extract_client_ip(request),
client_user_agent=request.headers.get("user-agent"),
fbc=body.fbc,
fbp=body.fbp,
event_source_url=body.event_source_url,
)
logger.info(
f"[FirstVideoCreated] FIRED - user_uuid: {current_user.user_uuid}, "
f"event_id: {event_id}"
)
return FirstVideoCreatedResponse(fired=True, event_id=event_id)
class CompleteRegistrationRequest(BaseModel):
"""CompleteRegistration 이벤트 발화 요청 스키마
fbc/fbp는 FirstVideoCreatedRequest와 동일하게 프론트가 쿠키 원문을 전달합니다.
UTM 5종은 프론트가 랜딩 최초 진입 시 URL에서 캡처해 localStorage에
보관하다가 가입 완료 시점에 함께 전달합니다 (first-touch 보존).
"""
fbc: str | None = Field(default=None, description="Meta 클릭 ID 쿠키(_fbc) 원문")
fbp: str | None = Field(default=None, description="Meta 브라우저 ID 쿠키(_fbp) 원문")
event_source_url: str | None = Field(
default=None, description="이벤트가 발생한 페이지 URL"
)
utm_source: str | None = Field(default=None, description="유입 매체", max_length=255)
utm_medium: str | None = Field(default=None, description="유입 방식", max_length=255)
utm_campaign: str | None = Field(default=None, description="캠페인 이름", max_length=255)
utm_content: str | None = Field(default=None, description="광고 소재 구분", max_length=255)
utm_term: str | None = Field(default=None, description="검색 키워드", max_length=255)
class CompleteRegistrationResponse(BaseModel):
"""CompleteRegistration 이벤트 발화 응답 스키마"""
fired: bool = Field(description="이번 요청으로 이벤트가 최초 발화되었는지 여부")
event_id: str | None = Field(
default=None,
description="브라우저 fbq 발화 시 사용할 event_id (fired=true일 때만 제공)",
)
@router.post("/meta/complete-registration", response_model=CompleteRegistrationResponse)
async def track_complete_registration(
body: CompleteRegistrationRequest,
request: Request,
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> CompleteRegistrationResponse:
"""회원가입 완료(CompleteRegistration) 전환 이벤트를 발화하고 UTM을 기록합니다.
계정당 최초 1회만 발화됩니다. 판정은 registration_tracked_at 컬럼(null 여부)의
원자적 UPDATE로 수행하며, 같은 UPDATE에서 UTM 5종(first-touch)을 함께 저장합니다.
가입 완료 판정: JWT 인증된 유저의 존재 자체가 서버 기준 가입 성공이며,
배포 이전 기존 계정의 오발화를 막기 위해 생성 24시간 이내 계정만 허용합니다.
Args:
body: fbc/fbp 쿠키, UTM 5종, 이벤트 소스 URL
request: IP/User-Agent 추출용 요청 객체
current_user: 인증된 사용자
session: DB 세션
Returns:
CompleteRegistrationResponse: 발화 여부 및 event_id
"""
# 1. 이미 발화된 계정이면 즉시 종료 (원자적 UPDATE 전 빠른 경로)
if current_user.registration_tracked_at is not None:
return CompleteRegistrationResponse(fired=False)
# 2. 배포 이전 가입한 기존 계정 오발화 방지 (신규 가입 직후 호출만 허용)
cutoff = datetime.now() - REGISTRATION_TRACK_WINDOW
if current_user.created_at < cutoff:
logger.warning(
f"[CompleteRegistration] 가입 24시간 경과 계정 - "
f"user_uuid: {current_user.user_uuid}, created_at: {current_user.created_at}"
)
return CompleteRegistrationResponse(fired=False)
# 3. 원자적 최초 1회 판정 + UTM first-touch 동시 기록
# (동시 요청이 와도 rowcount=1은 단 한 요청만 가져감)
result = await session.execute(
update(User)
.where(
User.id == current_user.id,
User.registration_tracked_at.is_(None),
)
.values(
registration_tracked_at=func.now(),
utm_source=body.utm_source,
utm_medium=body.utm_medium,
utm_campaign=body.utm_campaign,
utm_content=body.utm_content,
utm_term=body.utm_term,
)
)
await session.commit()
if result.rowcount != 1:
# 동시 요청 등으로 다른 요청이 먼저 발화한 경우
return CompleteRegistrationResponse(fired=False)
# 4. CAPI 서버 이벤트 전송 (실패해도 registration_tracked_at은 유지 —
# 브라우저 픽셀 발화가 백업 경로가 됨)
event_id = str(uuid.uuid4())
await send_capi_event(
event_name=COMPLETE_REGISTRATION_EVENT,
event_id=event_id,
external_id=current_user.user_uuid,
client_ip=_extract_client_ip(request),
client_user_agent=request.headers.get("user-agent"),
fbc=body.fbc,
fbp=body.fbp,
event_source_url=body.event_source_url,
)
logger.info(
f"[CompleteRegistration] FIRED - user_uuid: {current_user.user_uuid}, "
f"event_id: {event_id}, utm_source: {body.utm_source}, "
f"utm_campaign: {body.utm_campaign}"
)
return CompleteRegistrationResponse(fired=True, event_id=event_id)

View File

@ -17,8 +17,7 @@ import json
from collections import defaultdict
from typing import Literal
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
@ -30,10 +29,9 @@ from app.utils.pagination import PaginatedResponse
from app.home.models import Image, Project, MarketingIntel
from app.home.api.routers.v1.home import _extract_region_from_address
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
from app.utils.upload_blob_as_request import to_playback_url
from app.lyric.models import Lyric
from app.song.models import Song, SongTimestamp
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
from app.utils.creatomate import CreatomateService
from app.comment.models import Comment
from app.database.like_cache import (
@ -59,69 +57,16 @@ from app.video.schemas.video_schema import (
VideoRenderData,
VideoThumbnailItem,
)
from app.video.services.share_page import (
build_video_share_html,
get_video_share_data,
resolve_frontend_base_url,
resolve_share_url,
)
from app.video.worker.video_task import download_and_upload_video_to_blob
from config import creatomate_settings, prj_settings
from config import creatomate_settings
logger = get_logger("video")
router = APIRouter(prefix="/video", tags=["Video"])
def _place_id_to_site_url(place_id: str | None) -> str | None:
"""MarketingIntel.place_id("nv{네이버 place ID}")를 네이버 플레이스 URL로 변환한다.
크롤링 없이 직접 입력된 업체는 place_id가 없으므로 None을 반환한다.
"""
if place_id and place_id.startswith("nv") and place_id[2:].isdigit():
return f"https://map.naver.com/p/entry/place/{place_id[2:]}"
return None
async def _get_official_site_urls(
session: AsyncSession, projects: list[Project]
) -> dict[int, str | None]:
"""프로젝트 목록에 대해 {project_id: 공식 페이지 URL(or None)}을 일괄 조회한다.
Project.marketing_intelligence(문자열로 저장된 MarketingIntel.id)를 경유해
저장된 official_site_url을 우선 사용하고, 컬럼 도입 전 기존 행은
place_id 기반 네이버 플레이스 URL로 폴백한다.
"""
m_id_by_project: dict[int, int] = {}
for p in projects:
try:
if p.marketing_intelligence is not None:
m_id_by_project[p.id] = int(p.marketing_intelligence)
except (TypeError, ValueError):
continue
url_by_project: dict[int, str | None] = {p.id: None for p in projects}
if not m_id_by_project:
return url_by_project
rows = (
await session.execute(
select(
MarketingIntel.id,
MarketingIntel.place_id,
MarketingIntel.official_site_url,
).where(MarketingIntel.id.in_(set(m_id_by_project.values())))
)
).all()
intel_by_m_id = {m_id: (place_id, site_url) for m_id, place_id, site_url in rows}
for project_id, m_id in m_id_by_project.items():
place_id, site_url = intel_by_m_id.get(m_id, (None, None))
url_by_project[project_id] = site_url or _place_id_to_site_url(place_id)
return url_by_project
@router.get(
"/generate/{task_id}",
@ -247,7 +192,6 @@ async def generate_video(
brand_name = project.store_name
region = project.region
industry = project.industry
output_language = project.language or "Korean"
# MarketingIntel 조회
marketing_result = await session.execute(
@ -277,6 +221,11 @@ async def generate_video(
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
target_keywords = marketing_intelligence.intel_result["target_keywords"]
brand_concept = ""
for sp in marketing_intelligence.intel_result["selling_points"]:
if "concept" in sp["english_category"].lower():
brand_concept = sp["description"]
# Lyric 조회
lyric_result = await session.execute(
select(Lyric)
@ -411,9 +360,6 @@ async def generate_video(
creatomate_service = CreatomateService(
orientation=orientation,
industry=industry,
# 미매핑 업종(general 등)은 project_id % len(VST_LIST)로 템플릿을 분배하므로,
# 사전 이미지 배정 단계(creative_assets_task)와 동일 템플릿을 받으려면 필수
project_id=project_id,
)
logger.debug(
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
@ -441,22 +387,15 @@ async def generate_video(
modifications.update(subtitle_modifications)
# 썸네일 텍스트: 사전 자막 생성(LLM, 다국어) 결과에 thumb-* 슬롯이 포함되면
# 그대로 사용한다. 과거 파이프라인 산출물(subtitle에 thumb-* 없음)은
# 기존 팩트값 조립(한국어)으로 폴백.
thumbnail_fallback = creatomate_service.make_thumbnail_modification(
# revert thumbnail scene
thumbnail_modifications = creatomate_service.make_thumbnail_modification(
brand_name =brand_name,
region = region,
brand_concept = brand_concept,
category_definition= category_definition,
target_keywords=target_keywords,
detail_region_info=store_address)
for slot_name, fallback_value in thumbnail_fallback.items():
if not modifications.get(slot_name):
modifications[slot_name] = fallback_value
logger.info(
f"[generate_video] thumbnail slot fallback(factual) 적용: {slot_name} - task_id: {task_id}"
)
target_keywords=target_keywords)
modifications.update(thumbnail_modifications)
# 6-3. elements 수정
new_elements = creatomate_service.modify_element(
@ -481,8 +420,12 @@ async def generate_video(
for i, ts in enumerate(song_timestamp_list):
logger.debug(f"[generate_video] timestamp[{i}]: lyric_line={ts.lyric_line}, start_time={ts.start_time}, end_time={ts.end_time}")
# 가사 자막 폰트: CJK/태국어는 글리프 지원 폰트로, 그 외는 Noto Sans
lyric_font = LANGUAGE_FONT_MAP.get(lyric_language, "Noto Sans")
match lyric_language:
case "English" :
lyric_font = "Noto Sans"
# lyric_font = "Pretendard" # 없어요
case _ :
lyric_font = "Noto Sans"
# LYRIC AUTO 결정부
if (creatomate_settings.LYRIC_SUBTITLE):
@ -502,14 +445,6 @@ async def generate_video(
)
final_template["source"]["elements"].append(caption)
# END - LYRIC AUTO 결정부
# 언어별 폰트 교체: 템플릿 기본 폰트는 한글·라틴 전용이라 CJK/태국어는
# 지원 폰트로 전체 텍스트(자막·키워드·썸네일·가사 캡션 포함)를 교체한다.
# 가사 캡션 append 이후에 실행해야 캡션까지 커버된다.
final_template = creatomate_service.apply_language_font(
final_template, output_language
)
# logger.debug(
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
# )
@ -861,7 +796,7 @@ async def download_video(
store_name=project.store_name if project else None,
region=project.region or _extract_region_from_address(project.detail_region_info) if project else None,
task_id=task_id,
result_movie_url=to_playback_url(video.result_movie_url),
result_movie_url=video.result_movie_url,
created_at=video.created_at,
)
@ -1037,21 +972,15 @@ async def get_all_videos(
liked_map = {vid: bool(liked) for vid, liked in raw_liked.items()}
official_site_url_map = await _get_official_site_urls(
session, [p for _, p, _ in rows]
)
items = [
VideoThumbnailItem(
video_id=v.id,
store_name=p.store_name,
result_movie_url=to_playback_url(v.result_movie_url),
poster_url=v.poster_url,
result_movie_url=v.result_movie_url,
created_at=v.created_at,
like_count=like_count_map.get(v.id) or 0,
is_liked_by_me=liked_map.get(v.id, False),
comment_count=comment_count or 0,
official_site_url=official_site_url_map.get(p.id),
)
for v, p, comment_count in rows
]
@ -1151,50 +1080,6 @@ async def toggle_like(
raise HTTPException(status_code=500, detail=f"좋아요 처리에 실패했습니다: {str(e)}")
@router.get(
"/share/{video_id}",
response_class=HTMLResponse,
summary="영상 공유용 Open Graph 페이지",
description="영상별 제목, 설명, 포스터 메타데이터가 포함된 공개 HTML을 반환합니다.",
responses={
200: {"description": "공유 메타데이터 HTML 반환"},
404: {"description": "공유 가능한 완료 영상을 찾을 수 없음"},
},
)
async def get_video_share_page(
video_id: int,
request: Request,
session: AsyncSession = Depends(get_session),
) -> HTMLResponse:
"""공개 공유 페이지를 반환하고 일반 브라우저는 영상 상세로 이동시킵니다."""
share_data = await get_video_share_data(session, video_id)
if share_data is None:
raise HTTPException(status_code=404, detail="공유 가능한 영상을 찾을 수 없습니다.")
share_url = resolve_share_url(
request.headers,
str(request.url).split("?", maxsplit=1)[0],
prj_settings.SHARE_API_BASE_URL,
)
html = build_video_share_html(
share_data,
share_url=share_url,
frontend_base_url=resolve_frontend_base_url(
request.headers,
prj_settings.SHARE_FRONTEND_URL,
),
configured_default_image_url=prj_settings.SHARE_DEFAULT_IMAGE_URL,
)
return HTMLResponse(
content=html,
headers={
"Cache-Control": "public, max-age=300",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
},
)
@router.get(
"/{video_id}",
summary="단일 영상 상세 조회",
@ -1267,21 +1152,15 @@ async def get_video_detail(
liked = False
is_liked_by_me = liked
official_site_url_map = await _get_official_site_urls(session, [project])
logger.info(f"[get_video_detail] SUCCESS - video_id: {video_id}")
return VideoDetailResponse(
video_id=video.id,
result_movie_url=to_playback_url(video.result_movie_url),
poster_url=video.poster_url,
result_movie_url=video.result_movie_url,
store_name=project.store_name,
region=project.region or _extract_region_from_address(project.detail_region_info),
title=video.title,
description=video.description,
created_at=video.created_at,
like_count=like_count,
is_liked_by_me=is_liked_by_me,
official_site_url=official_site_url_map.get(project.id),
)
except HTTPException:

View File

@ -1,8 +1,7 @@
from datetime import datetime
from typing import TYPE_CHECKING, List, Optional
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
from sqlalchemy.dialects.mysql import JSON
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database.session import Base
@ -30,10 +29,6 @@ class Video(Base):
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
status: 처리 상태 (pending, processing, completed, failed 등)
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)
title: SNS 업로드 제목
description: SNS 업로드 설명
hashtags: SNS 해시태그 목록
created_at: 생성 일시 (자동 설정)
Relationships:
@ -111,30 +106,6 @@ class Video(Base):
comment="생성된 영상 URL",
)
poster_url: Mapped[Optional[str]] = mapped_column(
String(2048),
nullable=True,
comment="영상 첫 프레임 포스터 이미지 URL (SNS 공유용)",
)
title: Mapped[Optional[str]] = mapped_column(
String(100),
nullable=True,
comment="SNS 업로드 제목",
)
description: Mapped[Optional[str]] = mapped_column(
Text,
nullable=True,
comment="SNS 업로드 설명",
)
hashtags: Mapped[Optional[list]] = mapped_column(
JSON,
nullable=True,
comment="SNS 해시태그 목록",
)
is_deleted: Mapped[bool] = mapped_column(
Boolean,
nullable=False,

View File

@ -5,7 +5,7 @@ Video API Schemas
"""
from datetime import datetime
from typing import Any, Dict, List, Optional
from typing import Any, Dict, Optional
from pydantic import BaseModel, ConfigDict, Field
@ -148,7 +148,6 @@ class VideoListItem(BaseModel):
"region": "군산",
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
"poster_url": "http://localhost:8000/media/2025-01-15/video.jpg",
"created_at": "2025-01-15T12:00:00"
}
"""
@ -158,17 +157,9 @@ class VideoListItem(BaseModel):
region: Optional[str] = Field(None, description="지역명")
task_id: str = Field(..., description="작업 고유 식별자")
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
title: Optional[str] = Field(None, description="SNS 업로드 제목")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
hashtags: Optional[List[str]] = Field(None, description="SNS 해시태그 목록")
created_at: Optional[datetime] = Field(None, description="생성 일시")
like_count: int = Field(0, description="좋아요 수")
comment_count: int = Field(0, description="댓글 수 (대댓글 포함)")
is_liked_by_me: bool = Field(
False,
description="현재 로그인 사용자가 좋아요를 눌렀는지",
)
class VideoThumbnailItem(BaseModel):
@ -180,16 +171,11 @@ class VideoThumbnailItem(BaseModel):
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
store_name: str = Field(..., description="업체명")
result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL (썸네일 표시용)")
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
comment_count: int = Field(..., description="댓글 수 (대댓글 포함)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
)
class VideoDetailResponse(BaseModel):
@ -201,18 +187,11 @@ class VideoDetailResponse(BaseModel):
video_id: int = Field(..., description="영상 고유 ID")
result_movie_url: str = Field(..., description="영상 URL")
poster_url: Optional[str] = Field(None, description="영상 첫 프레임 포스터 이미지 URL")
store_name: Optional[str] = Field(None, description="업체명")
region: Optional[str] = Field(None, description="지역명")
title: Optional[str] = Field(None, description="SNS 업로드 제목 (공유 시 og:title 및 공유 제목으로 사용)")
description: Optional[str] = Field(None, description="SNS 업로드 설명")
created_at: datetime = Field(..., description="생성 일시")
like_count: int = Field(..., description="좋아요 수")
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지 (비로그인은 항상 false)")
official_site_url: Optional[str] = Field(
None,
description="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 생성 영상만 null)",
)
class LikeToggleResponse(BaseModel):

View File

@ -1,321 +0,0 @@
"""영상 공유 링크용 Open Graph HTML 생성 서비스."""
from collections.abc import Mapping
from dataclasses import dataclass
from html import escape
from urllib.parse import urlsplit, urlunsplit
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.home.models import Project
from app.video.models import Video
FALLBACK_FRONTEND_URL = "https://ado2.o2osolution.ai"
DEFAULT_SHARE_IMAGE_PATH = "/assets/images/ado2_image.png"
DEFAULT_SHARE_IMAGE_STATIC_PATH = "/static/images/ado2_image.png"
@dataclass(frozen=True, slots=True)
class VideoShareData:
"""공유 페이지에 필요한 영상 및 프로젝트 정보."""
video_id: int
poster_url: str | None
store_name: str
region: str
title: str | None = None
description: str | None = None
async def get_video_share_data(
session: AsyncSession,
video_id: int,
) -> VideoShareData | None:
"""공유 가능한 완료 영상을 프로젝트 정보와 함께 조회합니다."""
result = await session.execute(
select(
Video.id,
Video.poster_url,
Video.title,
Video.description,
Project.store_name,
Project.region,
)
.join(Project, Video.project_id == Project.id)
.where(
Video.id == video_id,
Video.status == "completed",
Video.is_deleted.is_(False),
Project.is_deleted.is_(False),
)
)
row = result.one_or_none()
if row is None:
return None
return VideoShareData(
video_id=row.id,
poster_url=row.poster_url,
store_name=row.store_name,
region=row.region,
title=row.title,
description=row.description,
)
def build_video_share_html(
data: VideoShareData,
*,
share_url: str,
frontend_base_url: str,
configured_default_image_url: str = "",
) -> str:
"""영상별 OG 메타데이터와 상세 화면 이동 기능을 포함한 HTML을 생성합니다."""
frontend_base = _normalise_frontend_base_url(frontend_base_url)
detail_url = f"{frontend_base}/video/{data.video_id}"
fallback_image_url = _resolve_default_image_url(
configured_default_image_url,
frontend_base,
share_url=share_url,
)
image_url = _absolute_http_url(data.poster_url) or fallback_image_url
title = _share_title(data.title, data.store_name)
description = _share_description(data.description)
canonical_tags = _canonical_tags(_absolute_http_url(share_url))
image_size_tags = _og_image_size_tags(image_url, fallback_image_url)
escaped_title = escape(title, quote=True)
escaped_description = escape(description, quote=True)
escaped_image_url = escape(image_url, quote=True)
escaped_detail_url = escape(detail_url, quote=True)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escaped_title}</title>
<meta name="description" content="{escaped_description}">
{canonical_tags}
<meta property="og:title" content="{escaped_title}">
<meta property="og:description" content="{escaped_description}">
<meta property="og:image" content="{escaped_image_url}">
<meta property="og:image:alt" content="{escaped_title}">
{image_size_tags} <meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{escaped_title}">
<meta name="twitter:description" content="{escaped_description}">
<meta name="twitter:image" content="{escaped_image_url}">
</head>
<body>
<main>
<h1>{escaped_title}</h1>
<p>{escaped_description}</p>
<a id="continue-link" href="{escaped_detail_url}">영상 보기</a>
</main>
<script>
(function () {{
var ua = navigator.userAgent || "";
// 앱 이름만으로 판별하면 인앱 브라우저(예: KAKAOTALK)까지 크롤러로 잡혀
// 사용자가 중간 페이지에 멈춘다. 봇 전용 토큰만 쓴다.
if (/bot|crawl|spider|slurp|facebookexternalhit|Facebot|Twitterbot|LinkedInBot|Pinterestbot|Slackbot|TelegramBot|WhatsApp|Discordbot|kakaotalk-scrap|Embedly|redditbot|Applebot/i.test(ua)) {{
return;
}}
window.location.replace(document.getElementById("continue-link").href);
}})();
</script>
</body>
</html>
"""
def _normalise_text(value: str | None, fallback: str) -> str:
"""메타데이터용 텍스트에서 불필요한 공백을 제거합니다."""
normalised = " ".join((value or "").split())
return normalised or fallback
_OG_DESCRIPTION_MAX_LEN = 300
def _share_title(title: str | None, store_name: str) -> str:
"""저장된 SNS 제목을 쓰고, 없으면 가게명 폴백을 사용합니다."""
stored = _normalise_text(title, "")
if stored:
return stored
name = _normalise_text(store_name, "ADO2 영상")
return f"{name} | ADO2"
def _share_description(description: str | None) -> str:
"""저장된 SNS 설명을 쓰고, 없으면 기본 문구를 사용합니다."""
stored = _normalise_text(description, "")
if stored:
if len(stored) > _OG_DESCRIPTION_MAX_LEN:
return stored[: _OG_DESCRIPTION_MAX_LEN - 1].rstrip() + "…"
return stored
return "ADO2 AI 마케팅 영상"
def resolve_frontend_base_url(headers: Mapping[str, str], fallback: str) -> str:
"""프록시가 넘긴 프론트 호스트를 우선해 canonical 기준 URL을 정합니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return _normalise_frontend_base_url(fallback)
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return _normalise_frontend_base_url(f"{forwarded_proto}://{forwarded_host}")
def resolve_share_url(
headers: Mapping[str, str],
request_url: str,
configured_api_base_url: str = "",
) -> str:
"""크롤러가 다시 읽어도 같은 OG 페이지가 나오는 공유 URL을 만듭니다.
nginx가 ``/api`` prefix를 떼고 넘기면 ``request.url``에는 그 prefix가 없어,
그대로 쓰면 프론트 SPA 주소가 된다. 복원할 근거가 없으면 빈 문자열을 돌려
호출부가 canonical/og:url을 생략하도록 한다.
"""
path = urlsplit(request_url).path
configured_base = _absolute_http_url(configured_api_base_url)
if configured_base:
return f"{configured_base.rstrip('/')}{path}"
origin = _forwarded_origin(headers) or _origin_from_url(request_url)
if not origin:
return ""
forwarded_prefix = (headers.get("x-forwarded-prefix") or "").strip().rstrip("/")
if forwarded_prefix:
return f"{origin}{forwarded_prefix}{path}"
if headers.get("x-forwarded-host"):
# 프록시 뒤인데 prefix 를 못 받았다. 잘못된 URL 을 내보내지 않는다.
return ""
return f"{origin}{path}"
def _forwarded_origin(headers: Mapping[str, str]) -> str | None:
"""프록시가 넘긴 외부 호스트 기준 origin을 만듭니다."""
forwarded_host = (headers.get("x-forwarded-host") or "").split(",")[0].strip()
if not forwarded_host:
return None
forwarded_proto = (headers.get("x-forwarded-proto") or "https").split(",")[0].strip().lower()
if forwarded_proto not in {"http", "https"}:
forwarded_proto = "https"
return f"{forwarded_proto}://{forwarded_host}"
def _canonical_tags(canonical_url: str | None) -> str:
"""공유 URL을 확신할 때만 canonical/og:url을 붙입니다.
잘못된 og:url을 내보내면 크롤러가 그 주소를 다시 읽어, OG 메타가 없는
프론트 SPA 문서를 미리보기로 쓴다. 확신이 없으면 크롤러가 실제로 받은
URL을 쓰도록 태그 자체를 생략한다.
"""
if not canonical_url:
return ""
escaped = escape(canonical_url, quote=True)
return (
f' <link rel="canonical" href="{escaped}">\n'
f' <meta property="og:url" content="{escaped}">\n'
)
_DEFAULT_OG_IMAGE_SIZE = (385, 385)
def _og_image_size_tags(image_url: str, fallback_image_url: str) -> str:
"""폴백 로고처럼 크기를 아는 이미지에만 width/height 메타를 붙입니다."""
if image_url != fallback_image_url:
return ""
width, height = _DEFAULT_OG_IMAGE_SIZE
return (
f' <meta property="og:image:width" content="{width}">\n'
f' <meta property="og:image:height" content="{height}">\n'
)
def _normalise_frontend_base_url(value: str) -> str:
"""프론트엔드 기준 URL을 안전한 절대 HTTP(S) URL로 정규화합니다."""
absolute_url = _absolute_http_url(value) or FALLBACK_FRONTEND_URL
parts = urlsplit(absolute_url)
path = parts.path.rstrip("/")
return urlunsplit((parts.scheme, parts.netloc, path, "", ""))
def _resolve_default_image_url(
configured_url: str,
frontend_base: str,
*,
share_url: str = "",
) -> str:
"""포스터가 없을 때 사용할 기본 OG 이미지 URL을 반환합니다.
우선순위:
1. ``SHARE_DEFAULT_IMAGE_URL`` (.env)
2. 공유 URL과 같은 API 베이스 ``.../static/images/ado2_image.png``
(``/api/video/share/1`` 이면 ``/api/static/...``)
3. ``SHARE_FRONTEND_URL`` + ``/assets/images/ado2_image.png``
"""
configured_absolute_url = _absolute_http_url(configured_url)
if configured_absolute_url:
return configured_absolute_url
share_api_base = _api_base_from_share_url(share_url)
if share_api_base:
return f"{share_api_base}{DEFAULT_SHARE_IMAGE_STATIC_PATH}"
return f"{frontend_base}{DEFAULT_SHARE_IMAGE_PATH}"
def _api_base_from_share_url(share_url: str) -> str | None:
"""공유 URL에서 API 베이스를 만듭니다. ``/api/video/share/1`` → ``https://host/api``."""
absolute_url = _absolute_http_url(share_url)
if not absolute_url:
return None
parts = urlsplit(absolute_url)
origin = urlunsplit((parts.scheme, parts.netloc, "", "", ""))
idx = (parts.path or "").find("/video/share/")
if idx < 0:
return origin
prefix = parts.path[:idx].rstrip("/")
return f"{origin}{prefix}" if prefix else origin
def _origin_from_url(value: str) -> str | None:
"""URL에서 scheme + host(+port) origin만 추출합니다."""
absolute_url = _absolute_http_url(value)
if not absolute_url:
return None
parts = urlsplit(absolute_url)
return urlunsplit((parts.scheme, parts.netloc, "", "", ""))
def _absolute_http_url(value: str | None) -> str | None:
"""값이 절대 HTTP(S) URL인 경우에만 정리된 문자열을 반환합니다."""
candidate = (value or "").strip()
if not candidate:
return None
try:
parts = urlsplit(candidate)
except ValueError:
return None
if parts.scheme.lower() not in {"http", "https"} or not parts.hostname:
return None
return candidate

View File

@ -857,16 +857,6 @@ async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
# print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
rows = (await session.execute(stmt)).all()
# print("rows", rows)
# img_tag가 JSON 리터럴 null로 저장된 행(태깅 실패 잔재)은 SQL IS NOT NULL 필터를
# 통과하므로 파이썬 레벨에서 한 번 더 걸러낸다.
null_tag_urls = [row.img_url for row in rows if row.img_tag is None]
if null_tag_urls:
logger.warning(
f"[get_image_tags_by_task_id] img_tag가 null인 이미지 {len(null_tag_urls)}개 제외 "
f"- task_id: {task_id}, urls: {null_tag_urls}"
)
return [
{"image_url": row.img_url, "image_tag": row.img_tag}
for row in rows
if row.img_tag is not None
]
# print(rows)
# print("image" , [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows])
return [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows]

View File

@ -9,15 +9,11 @@ from sqlalchemy import select
from app.database.session import BackgroundSessionLocal
from app.home.models import Project, MarketingIntel
from app.utils.subtitles import SubtitleContentsGenerator
from app.utils import thumbnail_fitness
from app.utils.thumbnail_vision import pick_thumbnail_by_vision
from app.utils.creatomate import (
CreatomateService,
SCENE_TRACK,
SUBTITLE_TRACK,
KEYWORD_TRACK,
THUMBNAIL_SLOT_MARKER,
get_shared_client,
)
from app.utils.logger import get_logger
from app.video.services.video import get_image_tags_by_task_id
@ -76,7 +72,7 @@ async def generate_creative_assets_background(
)
marketing_intelligence = marketing_result.scalar_one_or_none()
creatomate_service = CreatomateService(orientation=orientation, industry=project.industry, project_id=project.id)
creatomate_service = CreatomateService(orientation=orientation, industry=project.industry)
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
store_address = project.detail_region_info
@ -101,65 +97,12 @@ async def generate_creative_assets_background(
f"duplicate={duplicate} - task_id: {task_id}"
)
# 썸네일 슬롯(-9999)이 있는 템플릿이면 픽셀(헤더) 적합도 사전 계산.
# 헤더 바이트만 받아 크기를 파싱하므로 CPU 부담 없음 — 실패/타임아웃 시
# 빈 dict로 진행(태그 점수만 사용, 배정 자체는 막지 않음).
thumbnail_fitness_map: dict = {}
has_thumbnail_slot = any(
elem_type == "image" and name.endswith(THUMBNAIL_SLOT_MARKER)
for name, elem_type in creatomate_service.parse_template_component_name(
template["source"]["elements"]
).items()
)
if has_thumbnail_slot and taged_image_list:
client = await get_shared_client()
thumbnail_fitness_map = await thumbnail_fitness.score_pool_thumbnail_fitness(
taged_image_list, client
)
rejected = {
url: fit["reject"]
for url, fit in thumbnail_fitness_map.items()
if fit["reject"]
}
logger.info(
f"[generate_creative_assets_background] thumbnail fitness — "
f"scored={len(thumbnail_fitness_map)}/{len(taged_image_list)}, "
f"rejected={len(rejected)} {rejected if rejected else ''} - task_id: {task_id}"
)
# ── Step 1-b: 썸네일 최종 선택 (비전 LLM, Phase 2) ──────────────
# 규칙+픽셀로 압축한 top-3 후보를 비전 LLM이 실제로 보고 1컷 선택.
# 실패/후보 부족 시 thumbnail_choice가 비어 규칙 1위로 자동 폴백.
thumbnail_choice: dict = {}
if has_thumbnail_slot and taged_image_list:
candidates_by_slot = creatomate_service.rank_thumbnail_candidates(
template=template,
taged_image_list=taged_image_list,
thumbnail_fitness_map=thumbnail_fitness_map,
top_n=3,
)
for slot, candidates in candidates_by_slot.items():
chosen = await pick_thumbnail_by_vision(
candidates=candidates,
industry=project.industry,
business_name=customer_name,
)
if chosen is not None:
thumbnail_choice[slot] = chosen["image_url"]
logger.info(
f"[generate_creative_assets_background] thumbnail vision pick — "
f"{ {s: u.rsplit('/', 1)[-1] for s, u in thumbnail_choice.items()} } "
f"- task_id: {task_id}"
)
image_modifications, assigned_image_tags = creatomate_service.template_matching_taged_image(
template=template,
taged_image_list=taged_image_list,
music_url="", # 음악 URL은 영상 생성 시점에 결정 — 사전 단계에서는 빈 값
address=store_address,
duplicate=duplicate,
thumbnail_fitness_map=thumbnail_fitness_map,
thumbnail_choice=thumbnail_choice,
)
# audio-music은 영상 생성 시점에 덮어씌워지므로 image_match에서 제거
image_match = {k: v for k, v in image_modifications.items() if k != "audio-music"}

View File

@ -4,6 +4,7 @@ Video Background Tasks
영상 생성 관련 백그라운드 태스크를 정의합니다.
"""
import traceback
from pathlib import Path
import aiofiles
@ -16,7 +17,6 @@ from app.user.services.credit import consume_credit
from app.video.models import Video
from app.utils.upload_blob_as_request import AzureBlobUploader
from app.utils.logger import get_logger
from app.utils.video_poster import generate_and_store_poster
# 로거 설정
logger = get_logger("video")
@ -30,8 +30,7 @@ async def _update_video_status(
status: str,
video_url: str | None = None,
creatomate_render_id: str | None = None,
poster_url: str | None = None,
) -> int | None:
) -> bool:
"""Video 테이블의 상태를 업데이트합니다.
Args:
@ -39,10 +38,9 @@ async def _update_video_status(
status: 변경할 상태 ("processing", "completed", "failed")
video_url: 영상 URL
creatomate_render_id: Creatomate render ID (선택)
poster_url: 영상 첫 프레임 포스터 URL (선택)
Returns:
int | None: 업데이트된 Video id. 대상이 없거나 실패하면 None.
bool: 업데이트 성공 여부
"""
try:
async with BackgroundSessionLocal() as session:
@ -67,58 +65,19 @@ async def _update_video_status(
video.status = status
if video_url is not None:
video.result_movie_url = video_url
if poster_url is not None:
video.poster_url = poster_url
await session.commit()
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
return video.id
return True
else:
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
return None
return False
except SQLAlchemyError as e:
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
return None
return False
except Exception as e:
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
return None
async def _try_generate_sns_metadata(video_id: int) -> None:
"""SEO 생성 실패가 영상 완료 처리에 영향을 주지 않도록 격리합니다."""
from app.social.services.seo_service import seo_service
try:
async with BackgroundSessionLocal() as session:
await seo_service.generate_and_save_for_video(video_id, session)
await session.commit()
except Exception as e:
logger.warning(
f"[VideoSEO] Failed to generate SNS metadata - video_id: {video_id}, error: {e}",
exc_info=True,
)
async def _try_generate_poster(
temp_file_path: Path,
user_uuid: str,
task_id: str,
render_id: str,
) -> str | None:
"""포스터 생성 실패가 영상 생성 완료 처리에 영향을 주지 않도록 격리합니다."""
try:
return await generate_and_store_poster(
video_path=temp_file_path,
user_uuid=user_uuid,
task_id=task_id,
file_stem=render_id,
)
except Exception as e:
logger.warning(
f"[VideoPoster] Failed to generate poster - task_id: {task_id}, render_id: {render_id}, error: {e}",
exc_info=True,
)
return None
return False
async def _download_video(url: str, task_id: str) -> bytes:
@ -194,20 +153,8 @@ async def download_and_upload_video_to_blob(
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_url}")
poster_url = await _try_generate_poster(
temp_file_path, user_uuid, task_id, creatomate_render_id
)
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
video_id = await _update_video_status(
task_id,
"completed",
blob_url,
creatomate_render_id,
poster_url=poster_url,
)
if video_id is not None:
await _try_generate_sns_metadata(video_id)
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
async with BackgroundSessionLocal() as session:
@ -312,20 +259,13 @@ async def download_and_upload_video_by_creatomate_render_id(
blob_url = uploader.public_url
logger.info(f"[download_and_upload_video_by_creatomate_render_id] Uploaded to Blob - creatomate_render_id: {creatomate_render_id}, url: {blob_url}")
poster_url = await _try_generate_poster(
temp_file_path, user_uuid, task_id, creatomate_render_id
)
# Video 테이블 업데이트
video_id = await _update_video_status(
await _update_video_status(
task_id=task_id,
status="completed",
video_url=blob_url,
creatomate_render_id=creatomate_render_id,
poster_url=poster_url,
)
if video_id is not None:
await _try_generate_sns_metadata(video_id)
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
except httpx.HTTPError as e:

View File

@ -33,25 +33,6 @@ class ProjectSettings(BaseSettings):
ADMIN_BASE_URL: str = Field(default="/admin")
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
SHARE_FRONTEND_URL: str = Field(
default="https://ado2.o2osolution.ai",
description="공유 페이지에서 영상 상세로 이동할 프론트엔드 공개 기준 URL (.env: SHARE_FRONTEND_URL)",
)
SHARE_API_BASE_URL: str = Field(
default="",
description=(
"공유 OG 페이지가 외부에 노출되는 API 기준 URL (.env: SHARE_API_BASE_URL). "
"예: https://dev-ssul.castad.net/api. 프록시가 /api prefix 를 떼고 넘기면 "
"request.url 로는 복원할 수 없으므로 이 값이 필요하다"
),
)
SHARE_DEFAULT_IMAGE_URL: str = Field(
default="",
description=(
"포스터가 없는 영상 공유 시 사용할 절대 이미지 URL (.env: SHARE_DEFAULT_IMAGE_URL). "
"비우면 공유 API /static/images/ado2_image.png, 없으면 SHARE_FRONTEND_URL/assets 경로 사용"
),
)
DEBUG: bool = Field(default=True)
TIMEZONE: str = Field(
default="Asia/Seoul",
@ -164,40 +145,6 @@ class AzureBlobSettings(BaseSettings):
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
description="Azure Blob Storage 기본 URL",
)
AZURE_BLOB_UPLOAD_BLOCK_SIZE_BYTES: int = Field(
default=4 * 1024 * 1024,
gt=0,
description="Azure Block Blob 업로드 블록 크기 (bytes)",
)
AZURE_BLOB_API_VERSION: str = Field(
default="2023-11-03",
description="Azure Blob Storage REST API x-ms-version",
)
IMAGE_UPLOAD_MAX_FILE_SIZE_BYTES: int = Field(
default=15 * 1024 * 1024,
gt=0,
description="이미지 업로드 파일 1개당 최대 크기 (bytes)",
)
IMAGE_UPLOAD_MAX_REQUEST_SIZE_BYTES: int = Field(
default=20 * 1024 * 1024,
gt=0,
description="한 이미지 업로드 요청에 포함할 수 있는 파일 합계 최대 크기 (bytes)",
)
IMAGE_UPLOAD_LOCK_TIMEOUT_SECONDS: int = Field(
default=15,
ge=1,
description="동일 task 이미지 append 직렬화 락 대기 시간 (초)",
)
IMAGE_UPLOAD_MAX_CONCURRENT_LOCKS: int = Field(
default=10,
ge=1,
description="worker별 이미지 upload named lock 동시 점유 상한",
)
IMAGE_UPLOAD_MAX_TASK_IMAGES: int = Field(
default=100,
ge=1,
description="한 task에 누적할 수 있는 활성 이미지 최대 개수",
)
model_config = _base_config
@ -228,7 +175,10 @@ class CreatomateSettings(BaseSettings):
default=False,
description="Creatomate 자체 자동 가사 생성 기능 사용 여부",
)
LYRIC_SUBTITLE: bool = Field(default=False, description="영상 가사 표기 여부")
LYRIC_SUBTITLE: bool = Field(
default=False,
description="영상 가사 표기 여부"
)
model_config = _base_config
@ -623,30 +573,6 @@ class SocialOAuthSettings(BaseSettings):
model_config = _base_config
class MetaConversionSettings(BaseSettings):
"""Meta 픽셀 / Conversions API 설정
광고 전환 추적(픽셀 + 서버사이드 CAPI)을 위한 설정입니다.
Meta 이벤트 관리자 > 데이터 세트에서 Pixel ID와 액세스 토큰을 발급받습니다.
"""
FACEBOOK_PIXEL_ID: str = Field(
default="",
description="Meta 픽셀(데이터 세트) ID",
)
FACEBOOK_ACCESS_TOKEN: str = Field(
default="",
description="Conversions API 시스템 생성 액세스 토큰",
)
FACEBOOK_TEST_EVENT_CODE: str = Field(
default="",
description="이벤트 관리자 '테스트 이벤트' 검증용 코드 (예: TEST12345). "
"설정 시 CAPI 이벤트가 테스트 이벤트 탭으로 격리됨. 운영 시 빈 값 유지",
)
model_config = _base_config
class InternalSettings(BaseSettings):
"""내부 서버 간 통신 설정"""
@ -705,6 +631,5 @@ kakao_settings = KakaoSettings()
jwt_settings = JWTSettings()
recovery_settings = RecoverySettings()
social_oauth_settings = SocialOAuthSettings()
meta_conversion_settings = MetaConversionSettings()
internal_settings = InternalSettings()
social_upload_settings = SocialUploadSettings()

View File

@ -1,15 +0,0 @@
-- ============================================================
-- Migration: user 테이블에 first_video_created_at 컬럼 추가
-- Date: 2026-07-23
-- Description: Meta 픽셀/Conversions API 전환 이벤트 FirstVideoCreated의
-- "계정당 최초 1회 발화" 판정용 컬럼
-- - NULL: 아직 첫 영상 생성 완료 이벤트가 발화되지 않은 계정
-- - NOT NULL: 발화 완료 (값 = 첫 영상 생성 완료 일시)
-- 서버가 first_video_created_at IS NULL 조건의 원자적 UPDATE로
-- 최초 1회를 판정하므로 새로고침/동시 요청에도 중복 발화되지 않음
-- 관련 코드: app/video/api/routers/v1/tracking.py, app/utils/meta_capi.py
-- ============================================================
ALTER TABLE `user`
ADD COLUMN `first_video_created_at` DATETIME NULL
COMMENT '첫 영상 생성 완료 일시 (Meta FirstVideoCreated 전환 이벤트 1회 발화 판정용)' AFTER `last_login_at`;

View File

@ -1,27 +0,0 @@
-- ============================================================
-- Migration: user 테이블에 UTM 5컬럼 + registration_tracked_at 추가
-- Date: 2026-07-23
-- Description: Meta 픽셀/Conversions API 전환 추적 확장
-- 1) registration_tracked_at: CompleteRegistration 전환 이벤트의
-- "계정당 최초 1회 발화" 판정용 (first_video_created_at과 동일 패턴)
-- - NULL: 아직 가입 전환 이벤트가 발화되지 않은 계정
-- - NOT NULL: 발화 완료 (값 = 추적 시점)
-- 2) utm_source/medium/campaign/content/term: 광고 유입 경로 보존
-- 가입 시점 first-touch UTM을 기록 (Meta 리포트와 내부 DB 대조용)
-- registration_tracked_at 기록과 같은 원자적 UPDATE에서 함께 저장됨
-- 관련 코드: app/video/api/routers/v1/tracking.py, app/utils/meta_capi.py
-- ============================================================
ALTER TABLE `user`
ADD COLUMN `registration_tracked_at` DATETIME NULL
COMMENT '가입 전환 추적 일시 (Meta CompleteRegistration 전환 이벤트 1회 발화 판정용)' AFTER `first_video_created_at`,
ADD COLUMN `utm_source` VARCHAR(255) NULL
COMMENT '유입 매체 (예: meta, google, naver)' AFTER `registration_tracked_at`,
ADD COLUMN `utm_medium` VARCHAR(255) NULL
COMMENT '유입 방식 (예: paid_social, cpc)' AFTER `utm_source`,
ADD COLUMN `utm_campaign` VARCHAR(255) NULL
COMMENT '캠페인 이름' AFTER `utm_medium`,
ADD COLUMN `utm_content` VARCHAR(255) NULL
COMMENT '광고 소재 구분 (A/B 테스트용)' AFTER `utm_campaign`,
ADD COLUMN `utm_term` VARCHAR(255) NULL
COMMENT '검색 키워드' AFTER `utm_content`;

View File

@ -1,48 +0,0 @@
-- ============================================================
-- Migration: 기존 회원의 first_video_created_at 백필
-- Date: 2026-07-27
-- Description: Meta FirstVideoCreated 전환 이벤트 오발화 방지
--
-- [문제]
-- first_video_created_at은 신규 컬럼이라 기존 회원 전원이 NULL이다.
-- FirstVideoCreated 발화 조건은 "completed 영상 존재 + 컬럼이 NULL"이므로,
-- 배포 후 기존 회원이 영상을 하나 더 만들면 그것이 51번째 영상이어도
-- "첫 영상 생성"으로 발화되어 핵심 전환 이벤트가 오염된다.
-- (CompleteRegistration은 is_new_user 게이트 + 24시간 가드가 있어 해당 없음)
--
-- [조치]
-- 이미 completed 영상을 보유한 회원은 그 최초 영상의 생성 시각으로
-- 컬럼을 채워 "이미 발화 완료" 상태로 만든다.
--
-- [실행 시점]
-- 반드시 백엔드 배포 전에 실행할 것.
-- (ALTER TABLE 마이그레이션 2건을 먼저 적용한 뒤 실행)
--
-- 관련 코드: app/video/api/routers/v1/tracking.py
-- ============================================================
-- 백필 대상 건수 사전 확인 (실행 전 참고용)
-- SELECT COUNT(*) FROM `user` u
-- WHERE u.first_video_created_at IS NULL
-- AND EXISTS (
-- SELECT 1 FROM video v
-- JOIN project p ON v.project_id = p.id
-- WHERE p.user_uuid = u.user_uuid AND v.status = 'completed'
-- );
UPDATE `user` u
SET u.first_video_created_at = (
SELECT MIN(v.created_at)
FROM video v
JOIN project p ON v.project_id = p.id
WHERE p.user_uuid = u.user_uuid
AND v.status = 'completed'
)
WHERE u.first_video_created_at IS NULL
AND EXISTS (
SELECT 1
FROM video v
JOIN project p ON v.project_id = p.id
WHERE p.user_uuid = u.user_uuid
AND v.status = 'completed'
);

View File

@ -1,10 +0,0 @@
-- ============================================================
-- Migration: video 테이블에 poster_url 컬럼 추가
-- Date: 2026-08-13
-- Description: 영상 첫 프레임 포스터 이미지 URL. SNS 공유 og:image 용도.
-- 관련 코드: app/utils/video_poster.py, app/video/worker/video_task.py
-- ============================================================
ALTER TABLE `video`
ADD COLUMN `poster_url` VARCHAR(2048) NULL
COMMENT '영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)' AFTER `result_movie_url`;

View File

@ -1,15 +0,0 @@
-- ============================================================
-- Migration: video 테이블에 SNS 메타데이터 컬럼 추가
-- Date: 2026-08-19
-- Description: 영상 생성 완료 시 저장하는 제목/설명/해시태그.
-- 관련 코드: app/social/services/seo_service.py,
-- app/video/worker/video_task.py
-- ============================================================
ALTER TABLE `video`
ADD COLUMN `title` VARCHAR(100) NULL
COMMENT 'SNS 업로드 제목' AFTER `poster_url`,
ADD COLUMN `description` TEXT NULL
COMMENT 'SNS 업로드 설명' AFTER `title`,
ADD COLUMN `hashtags` JSON NULL
COMMENT 'SNS 해시태그 목록' AFTER `description`;

View File

@ -20,7 +20,6 @@ from app.lyric.api.routers.v1.lyric import router as lyric_router
from app.song.api.routers.v1.song import router as song_router
from app.sns.api.routers.v1.sns import router as sns_router
from app.video.api.routers.v1.video import router as video_router
from app.video.api.routers.v1.tracking import router as tracking_router
from app.social.api.routers.v1.oauth import router as social_oauth_router
from app.social.api.routers.v1.upload import router as social_upload_router
from app.social.api.routers.v1.seo import router as social_seo_router
@ -411,7 +410,6 @@ app.include_router(social_account_router, prefix="/user") # Social Account API
app.include_router(lyric_router)
app.include_router(song_router)
app.include_router(video_router)
app.include_router(tracking_router) # Meta 전환 추적 라우터
app.include_router(archive_router) # Archive API 라우터 추가
app.include_router(comment_router) # Comment API 라우터 추가
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB