Compare commits
No commits in common. "main" and "feature-BGM" have entirely different histories.
main
...
feature-BG
8
.gitignore
vendored
8
.gitignore
vendored
@ -32,11 +32,8 @@ media/
|
|||||||
|
|
||||||
|
|
||||||
*.ipynb_checkpoint*
|
*.ipynb_checkpoint*
|
||||||
# Static files (공유 기본 이미지는 예외로 추적)
|
# Static files
|
||||||
static/*
|
static/
|
||||||
!static/images/
|
|
||||||
static/images/*
|
|
||||||
!static/images/ado2_image.png
|
|
||||||
|
|
||||||
# Log files
|
# Log files
|
||||||
*.log
|
*.log
|
||||||
@ -56,4 +53,3 @@ Dockerfile
|
|||||||
|
|
||||||
zzz/
|
zzz/
|
||||||
credentials/service_account.json
|
credentials/service_account.json
|
||||||
o2o-castad-scheduler/
|
|
||||||
63
README.md
63
README.md
@ -69,9 +69,6 @@ PROJECT_DOMAIN=localhost:8000 # 프로젝트 도메인 (호스트:포
|
|||||||
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
PROJECT_VERSION=0.1.0 # 프로젝트 버전
|
||||||
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
DESCRIPTION=FastAPI 기반 CastAD 프로젝트 # 프로젝트 설명
|
||||||
ADMIN_BASE_URL=/admin # 관리자 페이지 기본 URL
|
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: 운영)
|
DEBUG=True # 디버그 모드 (True: 개발, False: 운영)
|
||||||
|
|
||||||
# ================================
|
# ================================
|
||||||
@ -179,66 +176,6 @@ fastapi dev main.py
|
|||||||
fastapi run 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 문서
|
## API 문서
|
||||||
|
|
||||||
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
서버 실행 후 `/docs` 에서 Scalar API 문서를 확인할 수 있습니다.
|
||||||
|
|||||||
@ -16,14 +16,7 @@ from app.user.dependencies.auth import get_current_user
|
|||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.utils.upload_blob_as_request import to_playback_url
|
from app.video.models import Video
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.database.like_cache import (
|
|
||||||
bulk_is_user_liked,
|
|
||||||
get_like_counts,
|
|
||||||
mset_like_counts,
|
|
||||||
)
|
|
||||||
from app.video.models import Video, VideoReaction
|
|
||||||
from app.video.schemas.video_schema import VideoListItem
|
from app.video.schemas.video_schema import VideoListItem
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@ -106,22 +99,9 @@ async def get_videos(
|
|||||||
total_result = await session.execute(count_query)
|
total_result = await session.execute(count_query)
|
||||||
total = total_result.scalar() or 0
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
|
# 쿼리 2: Video + Project 데이터 조회 (task_id별 최신 영상만)
|
||||||
comment_count_subq = (
|
|
||||||
select(func.count(Comment.id))
|
|
||||||
.where(
|
|
||||||
Comment.video_id == Video.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
data_query = (
|
data_query = (
|
||||||
select(
|
select(Video, Project)
|
||||||
Video,
|
|
||||||
Project,
|
|
||||||
comment_count_subq.label("comment_count"),
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
.join(Project, Video.project_id == Project.id)
|
||||||
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
||||||
.order_by(Video.created_at.desc())
|
.order_by(Video.created_at.desc())
|
||||||
@ -131,47 +111,6 @@ async def get_videos(
|
|||||||
result = await session.execute(data_query)
|
result = await session.execute(data_query)
|
||||||
rows = result.all()
|
rows = result.all()
|
||||||
|
|
||||||
# Redis mget으로 like_count 일괄 조회
|
|
||||||
video_ids = [video.id for video, project, _ in rows]
|
|
||||||
like_count_map = await get_like_counts(video_ids)
|
|
||||||
|
|
||||||
# 캐시 미스(None)인 video_id만 DB에서 보정
|
|
||||||
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
|
|
||||||
if missing_ids:
|
|
||||||
db_counts = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id.in_(missing_ids))
|
|
||||||
.group_by(VideoReaction.video_id)
|
|
||||||
)).all()
|
|
||||||
db_found_ids = set()
|
|
||||||
batch = {}
|
|
||||||
for vid, cnt in db_counts:
|
|
||||||
batch[vid] = cnt
|
|
||||||
like_count_map[vid] = cnt
|
|
||||||
db_found_ids.add(vid)
|
|
||||||
await mset_like_counts(batch)
|
|
||||||
for vid in missing_ids:
|
|
||||||
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으로 변환
|
# VideoListItem으로 변환
|
||||||
items = [
|
items = [
|
||||||
VideoListItem(
|
VideoListItem(
|
||||||
@ -179,17 +118,10 @@ async def get_videos(
|
|||||||
store_name=project.store_name,
|
store_name=project.store_name,
|
||||||
region=project.region,
|
region=project.region,
|
||||||
task_id=video.task_id,
|
task_id=video.task_id,
|
||||||
result_movie_url=to_playback_url(video.result_movie_url),
|
result_movie_url=video.result_movie_url,
|
||||||
poster_url=video.poster_url,
|
|
||||||
title=video.title,
|
|
||||||
description=video.description,
|
|
||||||
hashtags=video.hashtags,
|
|
||||||
created_at=video.created_at,
|
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
|
for video, project in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
|
|||||||
@ -1,177 +0,0 @@
|
|||||||
"""
|
|
||||||
Comment API Router
|
|
||||||
|
|
||||||
영상 댓글 관련 엔드포인트를 제공합니다.
|
|
||||||
|
|
||||||
엔드포인트 목록:
|
|
||||||
- POST /comment/video/{video_id}: 댓글/대댓글 작성 (로그인 필수)
|
|
||||||
- GET /comment/video/{video_id}: 댓글 목록 조회 (비로그인 허용)
|
|
||||||
- DELETE /comment/{comment_id}: 본인 댓글 소프트 삭제 (로그인 필수)
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.comment.schemas.comment_schema import (
|
|
||||||
CommentCreateRequest,
|
|
||||||
CommentCreateResponse,
|
|
||||||
CommentItem,
|
|
||||||
DeleteCommentResponse,
|
|
||||||
)
|
|
||||||
from app.comment.services.comment import create_comment, delete_comment, list_comments
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
|
||||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
||||||
from app.user.models import User
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.pagination import PaginatedResponse
|
|
||||||
|
|
||||||
logger = get_logger("comment")
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/comment", tags=["Comment"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/video/{video_id}",
|
|
||||||
summary="댓글/대댓글 작성",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상에 댓글 또는 대댓글을 작성합니다. 로그인 필수.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 댓글을 달 영상의 ID
|
|
||||||
|
|
||||||
## 요청 본문
|
|
||||||
- **content**: 댓글 본문 (1~100자)
|
|
||||||
- **parent_id**: 대댓글일 때만 부모 댓글 id (생략 시 최상위 댓글)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보를 그대로 사용합니다 (클라이언트에서 지정 불가).
|
|
||||||
- 대댓글에 또 대댓글을 다는 것은 불가합니다 (최대 2-depth).
|
|
||||||
""",
|
|
||||||
response_model=CommentCreateResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "댓글 작성 성공"},
|
|
||||||
400: {"description": "잘못된 parent_id (2-depth 초과, 다른 영상의 댓글 등)"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def post_comment(
|
|
||||||
video_id: int,
|
|
||||||
body: CommentCreateRequest,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> CommentCreateResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[post_comment] START - video_id: {video_id}, user: {current_user.user_uuid}, "
|
|
||||||
f"parent_id: {body.parent_id}"
|
|
||||||
)
|
|
||||||
comment = await create_comment(
|
|
||||||
session=session,
|
|
||||||
video_id=video_id,
|
|
||||||
user_uuid=current_user.user_uuid,
|
|
||||||
nickname=current_user.nickname,
|
|
||||||
content=body.content,
|
|
||||||
parent_id=body.parent_id,
|
|
||||||
)
|
|
||||||
logger.info(f"[post_comment] SUCCESS - comment_id: {comment.id}")
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/video/{video_id}",
|
|
||||||
summary="댓글 목록 조회",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상의 댓글 목록을 페이지네이션하여 반환합니다. 비로그인도 접근 가능.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 댓글을 조회할 영상의 ID
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (기본값: 1)
|
|
||||||
- **page_size**: 페이지당 댓글 수 (기본값: 10, 최대: 100)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 최상위 댓글만 페이지네이션됩니다. 각 댓글의 대댓글은 전부 포함됩니다.
|
|
||||||
- 작성자 닉네임/프로필 이미지는 카카오 로그인 정보 기준이며, is_mine으로 본인 댓글 여부도 확인 가능합니다.
|
|
||||||
- 삭제된 댓글은 content=null로 노출됩니다 (대댓글이 있는 경우).
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[CommentItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "댓글 목록 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_comments(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
) -> PaginatedResponse[CommentItem]:
|
|
||||||
logger.info(
|
|
||||||
f"[get_comments] START - video_id: {video_id}, "
|
|
||||||
f"page: {pagination.page}, page_size: {pagination.page_size}"
|
|
||||||
)
|
|
||||||
current_user_uuid = current_user.user_uuid if current_user else None
|
|
||||||
result = await list_comments(
|
|
||||||
session=session,
|
|
||||||
video_id=video_id,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
current_user_uuid=current_user_uuid,
|
|
||||||
)
|
|
||||||
logger.info(f"[get_comments] SUCCESS - total: {result.total}, items: {len(result.items)}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete(
|
|
||||||
"/{comment_id}",
|
|
||||||
summary="댓글 소프트 삭제",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
본인이 작성한 댓글을 소프트 삭제합니다. 로그인 필수.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **comment_id**: 삭제할 댓글의 ID
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 본인 댓글만 삭제 가능합니다.
|
|
||||||
- 소프트 삭제 방식으로 DB에 데이터는 유지됩니다.
|
|
||||||
- 부모 댓글 삭제 시 대댓글은 유지되며, 목록 조회 시 content=null로 표시됩니다.
|
|
||||||
""",
|
|
||||||
response_model=DeleteCommentResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "삭제 성공"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
403: {"description": "삭제 권한 없음"},
|
|
||||||
404: {"description": "댓글을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def remove_comment(
|
|
||||||
comment_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> DeleteCommentResponse:
|
|
||||||
logger.info(
|
|
||||||
f"[remove_comment] START - comment_id: {comment_id}, user: {current_user.user_uuid}"
|
|
||||||
)
|
|
||||||
await delete_comment(
|
|
||||||
session=session,
|
|
||||||
comment_id=comment_id,
|
|
||||||
current_user_uuid=current_user.user_uuid,
|
|
||||||
)
|
|
||||||
logger.info(f"[remove_comment] SUCCESS - comment_id: {comment_id}")
|
|
||||||
return DeleteCommentResponse(
|
|
||||||
success=True,
|
|
||||||
comment_id=comment_id,
|
|
||||||
message="댓글이 삭제되었습니다.",
|
|
||||||
)
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
||||||
|
|
||||||
from app.database.session import Base
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from app.user.models import User
|
|
||||||
from app.video.models import Video
|
|
||||||
|
|
||||||
|
|
||||||
class Comment(Base):
|
|
||||||
"""
|
|
||||||
영상 댓글 테이블
|
|
||||||
|
|
||||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
|
||||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
|
||||||
작성자 닉네임은 카카오 로그인 정보를 작성 시점에 그대로 저장한 스냅샷이며,
|
|
||||||
프로필 이미지는 별도 컬럼 없이 응답 시 User 테이블을 조인해 최신값을 조회한다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "comment"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("idx_comment_video_id", "video_id"),
|
|
||||||
Index("idx_comment_user_uuid", "user_uuid"),
|
|
||||||
Index("idx_comment_parent_id", "parent_id"),
|
|
||||||
Index("idx_comment_is_deleted", "is_deleted"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
|
||||||
)
|
|
||||||
video_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="연결된 Video의 id",
|
|
||||||
)
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="작성자 UUID (응답 미노출, 권한 검증용)",
|
|
||||||
)
|
|
||||||
parent_id: Mapped[Optional[int]] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("comment.id", ondelete="CASCADE"),
|
|
||||||
nullable=True,
|
|
||||||
comment="NULL=최상위 댓글, 값=대댓글의 부모 id",
|
|
||||||
)
|
|
||||||
nickname: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(50), nullable=True, comment="댓글 작성자 카카오 닉네임 스냅샷 (null이면 익명)"
|
|
||||||
)
|
|
||||||
content: Mapped[str] = mapped_column(
|
|
||||||
String(100), nullable=False, comment="댓글 본문 (한글 기준 100자 이내)"
|
|
||||||
)
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
|
||||||
Boolean, nullable=False, default=False, comment="소프트 삭제 여부"
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="작성 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
|
||||||
parent: Mapped[Optional["Comment"]] = relationship(
|
|
||||||
"Comment", remote_side=[id], back_populates="replies"
|
|
||||||
)
|
|
||||||
replies: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="parent",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
)
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
from datetime import datetime
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateRequest(BaseModel):
|
|
||||||
content: str = Field(..., min_length=1, max_length=100, description="댓글 본문 (한글 기준 100자 이내)")
|
|
||||||
parent_id: Optional[int] = Field(None, description="대댓글일 때만 부모 댓글 id")
|
|
||||||
|
|
||||||
|
|
||||||
class ReplyItem(BaseModel):
|
|
||||||
"""대댓글 응답"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class CommentItem(BaseModel):
|
|
||||||
"""최상위 댓글 응답 — replies 포함"""
|
|
||||||
|
|
||||||
id: int = Field(..., description="댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필, 로그인 시점 기준 최신값)")
|
|
||||||
content: Optional[str] = Field(None, description="본문 (소프트 삭제된 경우 null)")
|
|
||||||
is_deleted: bool = Field(..., description="삭제 여부")
|
|
||||||
is_mine: bool = Field(..., description="현재 로그인 사용자의 댓글 여부")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
replies: List[ReplyItem] = Field(default_factory=list, description="대댓글 목록")
|
|
||||||
|
|
||||||
|
|
||||||
class CommentCreateResponse(BaseModel):
|
|
||||||
id: int = Field(..., description="생성된 댓글 고유 ID")
|
|
||||||
nickname: str = Field(..., description="작성자 닉네임 (카카오 닉네임, 미보유 시 '익명')")
|
|
||||||
profile_image_url: Optional[str] = Field(None, description="작성자 프로필 이미지 URL (카카오 프로필)")
|
|
||||||
parent_id: Optional[int] = Field(None, description="부모 댓글 id (대댓글인 경우)")
|
|
||||||
content: str = Field(..., description="댓글 본문")
|
|
||||||
created_at: datetime = Field(..., description="작성 일시")
|
|
||||||
|
|
||||||
|
|
||||||
class DeleteCommentResponse(BaseModel):
|
|
||||||
success: bool = Field(..., description="삭제 성공 여부")
|
|
||||||
comment_id: int = Field(..., description="삭제된 댓글 ID")
|
|
||||||
message: str = Field(..., description="결과 메시지")
|
|
||||||
@ -1,203 +0,0 @@
|
|||||||
from collections import defaultdict
|
|
||||||
from typing import List, Optional
|
|
||||||
|
|
||||||
from fastapi import HTTPException
|
|
||||||
from sqlalchemy import exists, select
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _validate_parent(
|
|
||||||
session: AsyncSession,
|
|
||||||
parent_id: int,
|
|
||||||
video_id: int,
|
|
||||||
) -> None:
|
|
||||||
"""2-depth 제한 + 동일 video 검증."""
|
|
||||||
result = await session.execute(
|
|
||||||
select(Comment).where(
|
|
||||||
Comment.id == parent_id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
parent = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if parent is None:
|
|
||||||
raise HTTPException(status_code=400, detail="부모 댓글을 찾을 수 없습니다.")
|
|
||||||
if parent.video_id != video_id:
|
|
||||||
raise HTTPException(status_code=400, detail="다른 영상의 댓글에는 대댓글을 달 수 없습니다.")
|
|
||||||
if parent.parent_id is not None:
|
|
||||||
raise HTTPException(status_code=400, detail="대댓글에는 대댓글을 달 수 없습니다. (최대 2-depth)")
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
raw_replies = replies_map.get(c.id, [])
|
|
||||||
replies = [
|
|
||||||
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,
|
|
||||||
created_at=r.created_at,
|
|
||||||
)
|
|
||||||
for r in raw_replies
|
|
||||||
]
|
|
||||||
items.append(
|
|
||||||
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,
|
|
||||||
created_at=c.created_at,
|
|
||||||
replies=replies,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
async def create_comment(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
user_uuid: str,
|
|
||||||
nickname: Optional[str],
|
|
||||||
content: str,
|
|
||||||
parent_id: Optional[int],
|
|
||||||
) -> Comment:
|
|
||||||
# Video 존재 확인
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if video_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
# parent_id 검증
|
|
||||||
if parent_id is not None:
|
|
||||||
await _validate_parent(session, parent_id, video_id)
|
|
||||||
|
|
||||||
comment = Comment(
|
|
||||||
video_id=video_id,
|
|
||||||
user_uuid=user_uuid,
|
|
||||||
nickname=nickname,
|
|
||||||
parent_id=parent_id,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
session.add(comment)
|
|
||||||
await session.commit()
|
|
||||||
await session.refresh(comment)
|
|
||||||
return comment
|
|
||||||
|
|
||||||
|
|
||||||
async def list_comments(
|
|
||||||
session: AsyncSession,
|
|
||||||
video_id: int,
|
|
||||||
page: int,
|
|
||||||
page_size: int,
|
|
||||||
current_user_uuid: Optional[str],
|
|
||||||
) -> PaginatedResponse[CommentItem]:
|
|
||||||
offset = (page - 1) * page_size
|
|
||||||
|
|
||||||
# 살아있는 자식이 있는지 확인하는 서브쿼리
|
|
||||||
has_live_reply = (
|
|
||||||
exists()
|
|
||||||
.where(
|
|
||||||
Comment.parent_id == Comment.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Comment)
|
|
||||||
)
|
|
||||||
|
|
||||||
# 최상위 댓글 필터: 삭제 안 됐거나 살아있는 대댓글이 있는 것
|
|
||||||
parent_where = [
|
|
||||||
Comment.video_id == video_id,
|
|
||||||
Comment.parent_id.is_(None),
|
|
||||||
(Comment.is_deleted == False) | has_live_reply, # noqa: E712
|
|
||||||
]
|
|
||||||
|
|
||||||
from sqlalchemy import func
|
|
||||||
|
|
||||||
count_q = select(func.count(Comment.id)).where(*parent_where)
|
|
||||||
total = (await session.execute(count_q)).scalar() or 0
|
|
||||||
|
|
||||||
parents_q = (
|
|
||||||
select(Comment)
|
|
||||||
.where(*parent_where)
|
|
||||||
.order_by(Comment.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(page_size)
|
|
||||||
)
|
|
||||||
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 = (
|
|
||||||
select(Comment)
|
|
||||||
.where(
|
|
||||||
Comment.parent_id.in_(parent_ids),
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.order_by(Comment.created_at.asc())
|
|
||||||
)
|
|
||||||
replies = (await session.execute(replies_q)).scalars().all()
|
|
||||||
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)
|
|
||||||
|
|
||||||
return PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=page,
|
|
||||||
page_size=page_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def delete_comment(
|
|
||||||
session: AsyncSession,
|
|
||||||
comment_id: int,
|
|
||||||
current_user_uuid: str,
|
|
||||||
) -> None:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Comment).where(
|
|
||||||
Comment.id == comment_id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
comment = result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if comment is None:
|
|
||||||
raise HTTPException(status_code=404, detail="댓글을 찾을 수 없습니다.")
|
|
||||||
if comment.user_uuid != current_user_uuid:
|
|
||||||
raise HTTPException(status_code=403, detail="삭제 권한이 없습니다.")
|
|
||||||
|
|
||||||
comment.is_deleted = True
|
|
||||||
await session.commit()
|
|
||||||
@ -51,9 +51,6 @@ async def lifespan(app: FastAPI):
|
|||||||
await close_shared_client()
|
await close_shared_client()
|
||||||
await close_shared_blob_client()
|
await close_shared_blob_client()
|
||||||
|
|
||||||
from app.database.like_cache import close_like_cache
|
|
||||||
await close_like_cache()
|
|
||||||
|
|
||||||
# 데이터베이스 엔진 종료
|
# 데이터베이스 엔진 종료
|
||||||
from app.database.session import dispose_engine
|
from app.database.session import dispose_engine
|
||||||
|
|
||||||
|
|||||||
@ -433,10 +433,6 @@ class YouTubeAnalyticsService:
|
|||||||
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
logger.debug("[YouTubeAnalyticsService._fetch_region] SUCCESS")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 5xx/네트워크 오류 재시도 설정 (구글 backendError 등 일시적 장애 대응)
|
|
||||||
_MAX_RETRIES = 3
|
|
||||||
_RETRY_BACKOFF_SECONDS = (0.5, 1.0, 2.0)
|
|
||||||
|
|
||||||
async def _call_api(
|
async def _call_api(
|
||||||
self,
|
self,
|
||||||
params: dict[str, str],
|
params: dict[str, str],
|
||||||
@ -457,18 +453,15 @@ class YouTubeAnalyticsService:
|
|||||||
Raises:
|
Raises:
|
||||||
YouTubeQuotaExceededError: 할당량 초과 (429)
|
YouTubeQuotaExceededError: 할당량 초과 (429)
|
||||||
YouTubeAuthError: 인증 실패 (401, 403)
|
YouTubeAuthError: 인증 실패 (401, 403)
|
||||||
YouTubeAPIError: 기타 API 오류 (5xx/네트워크 오류는 최대 3회 재시도 후 발생)
|
YouTubeAPIError: 기타 API 오류
|
||||||
|
|
||||||
Note:
|
Note:
|
||||||
- 타임아웃: 30초
|
- 타임아웃: 30초
|
||||||
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
- 할당량 초과 시 자동으로 YouTubeQuotaExceededError 발생
|
||||||
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
- 인증 실패 시 자동으로 YouTubeAuthError 발생
|
||||||
- 5xx 응답 및 네트워크 오류는 지수 백오프(0.5s→1s→2s)로 최대 3회 재시도
|
|
||||||
"""
|
"""
|
||||||
headers = {"Authorization": f"Bearer {access_token}"}
|
headers = {"Authorization": f"Bearer {access_token}"}
|
||||||
|
|
||||||
last_error: Exception | None = None
|
|
||||||
for attempt in range(self._MAX_RETRIES + 1):
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
response = await client.get(
|
response = await client.get(
|
||||||
@ -495,29 +488,16 @@ class YouTubeAnalyticsService:
|
|||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
except (YouTubeAuthError, YouTubeQuotaExceededError):
|
||||||
raise # 이미 처리된 예외는 재시도 없이 그대로 전파
|
raise # 이미 처리된 예외는 그대로 전파
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
f"[YouTubeAnalyticsService._call_api] HTTP_ERROR - "
|
||||||
f"status={e.response.status_code}, body={e.response.text[:500]}"
|
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}")
|
raise YouTubeAPIError(f"HTTP {e.response.status_code}")
|
||||||
last_error = YouTubeAPIError(f"HTTP {e.response.status_code}")
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
logger.error(f"[YouTubeAnalyticsService._call_api] REQUEST_ERROR - {e}")
|
||||||
last_error = YouTubeAPIError(f"네트워크 오류: {e}")
|
raise YouTubeAPIError(f"네트워크 오류: {e}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
logger.error(f"[YouTubeAnalyticsService._call_api] UNEXPECTED_ERROR - {e}")
|
||||||
raise YouTubeAPIError(f"알 수 없는 오류: {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
|
|
||||||
|
|||||||
@ -1,235 +0,0 @@
|
|||||||
"""
|
|
||||||
좋아요 Redis 캐시 클라이언트
|
|
||||||
|
|
||||||
Write-Behind 패턴 적용:
|
|
||||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
|
||||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
|
||||||
|
|
||||||
Key 패턴:
|
|
||||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
|
||||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
|
||||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
|
||||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
|
||||||
|
|
||||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import redis.asyncio as aioredis
|
|
||||||
|
|
||||||
from config import db_settings
|
|
||||||
|
|
||||||
_client: aioredis.Redis | None = None
|
|
||||||
|
|
||||||
# 원자적 토글 Lua 스크립트 — 동시 더블클릭 race condition 방지
|
|
||||||
_TOGGLE_LIKE_SCRIPT = """
|
|
||||||
local user_key = KEYS[1]
|
|
||||||
local count_key = KEYS[2]
|
|
||||||
local user_uuid = ARGV[1]
|
|
||||||
|
|
||||||
if redis.call('SISMEMBER', user_key, user_uuid) == 1 then
|
|
||||||
redis.call('SREM', user_key, user_uuid)
|
|
||||||
local c = tonumber(redis.call('DECR', count_key))
|
|
||||||
if c < 0 then
|
|
||||||
redis.call('SET', count_key, 0)
|
|
||||||
c = 0
|
|
||||||
end
|
|
||||||
return {0, c}
|
|
||||||
else
|
|
||||||
redis.call('SADD', user_key, user_uuid)
|
|
||||||
local c = tonumber(redis.call('INCR', count_key))
|
|
||||||
return {1, c}
|
|
||||||
end
|
|
||||||
"""
|
|
||||||
|
|
||||||
_DIRTY_KEY = "video:reaction:dirty"
|
|
||||||
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
|
|
||||||
|
|
||||||
|
|
||||||
def get_like_cache() -> aioredis.Redis:
|
|
||||||
global _client
|
|
||||||
if _client is None:
|
|
||||||
_client = aioredis.Redis(
|
|
||||||
host=db_settings.REDIS_HOST,
|
|
||||||
port=db_settings.REDIS_PORT,
|
|
||||||
db=2,
|
|
||||||
decode_responses=True,
|
|
||||||
)
|
|
||||||
return _client
|
|
||||||
|
|
||||||
|
|
||||||
async def close_like_cache() -> None:
|
|
||||||
global _client
|
|
||||||
if _client:
|
|
||||||
await _client.aclose()
|
|
||||||
_client = None
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Key 헬퍼
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _key(video_id: int) -> str:
|
|
||||||
return f"video:like:count:{video_id}"
|
|
||||||
|
|
||||||
|
|
||||||
def _user_key(video_id: int) -> str:
|
|
||||||
return f"video:like:users:{video_id}"
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 카운트 (기존 API 유지)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def get_like_count(video_id: int) -> int | None:
|
|
||||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
|
||||||
val = await get_like_cache().get(_key(video_id))
|
|
||||||
if val is None:
|
|
||||||
return None
|
|
||||||
return max(int(val), 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
|
||||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
|
||||||
캐시 미스인 video_id는 None으로 반환."""
|
|
||||||
if not video_ids:
|
|
||||||
return {}
|
|
||||||
keys = [_key(vid) for vid in video_ids]
|
|
||||||
values = await get_like_cache().mget(*keys)
|
|
||||||
return {
|
|
||||||
vid: max(int(v), 0) if v is not None else None
|
|
||||||
for vid, v in zip(video_ids, values)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def set_like_count(video_id: int, count: int) -> None:
|
|
||||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
|
||||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
|
||||||
|
|
||||||
|
|
||||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
|
||||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
|
||||||
if not counts:
|
|
||||||
return
|
|
||||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
|
||||||
|
|
||||||
|
|
||||||
async def incr_like_count(video_id: int) -> int:
|
|
||||||
"""like_count를 1 증가 후 반환."""
|
|
||||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
|
||||||
|
|
||||||
|
|
||||||
async def decr_like_count(video_id: int) -> int:
|
|
||||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
|
||||||
count = int(await get_like_cache().decr(_key(video_id)))
|
|
||||||
if count < 0:
|
|
||||||
await get_like_cache().set(_key(video_id), 0)
|
|
||||||
return 0
|
|
||||||
return count
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# 유저 SET (is_liked_by_me source of truth)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
|
||||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(is_liked, new_count) 튜플
|
|
||||||
"""
|
|
||||||
result = await get_like_cache().eval(
|
|
||||||
_TOGGLE_LIKE_SCRIPT,
|
|
||||||
2,
|
|
||||||
_user_key(video_id),
|
|
||||||
_key(video_id),
|
|
||||||
user_uuid,
|
|
||||||
)
|
|
||||||
return bool(result[0]), int(result[1])
|
|
||||||
|
|
||||||
|
|
||||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
|
||||||
"""Redis user-set에서 좋아요 여부 조회.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True/False: 조회 성공
|
|
||||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
|
||||||
"""
|
|
||||||
client = get_like_cache()
|
|
||||||
key = _user_key(video_id)
|
|
||||||
if not await client.exists(key):
|
|
||||||
return None
|
|
||||||
return bool(await client.sismember(key, user_uuid))
|
|
||||||
|
|
||||||
|
|
||||||
async def is_user_set_exists(video_id: int) -> bool:
|
|
||||||
"""Redis user-set 키 존재 여부 확인."""
|
|
||||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
|
||||||
|
|
||||||
|
|
||||||
async def bulk_is_user_liked(
|
|
||||||
video_ids: list[int], user_uuid: str
|
|
||||||
) -> dict[int, bool | None]:
|
|
||||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
|
||||||
"""
|
|
||||||
if not video_ids:
|
|
||||||
return {}
|
|
||||||
client = get_like_cache()
|
|
||||||
async with client.pipeline(transaction=False) as pipe:
|
|
||||||
for vid in video_ids:
|
|
||||||
pipe.exists(_user_key(vid))
|
|
||||||
pipe.sismember(_user_key(vid), user_uuid)
|
|
||||||
responses = await pipe.execute()
|
|
||||||
|
|
||||||
return {
|
|
||||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
|
||||||
for i, vid in enumerate(video_ids)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
|
||||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
|
||||||
if user_uuids:
|
|
||||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
# Dirty SET (Write-Behind 큐)
|
|
||||||
# ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
|
||||||
"""DB 동기화 대기 목록에 추가."""
|
|
||||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
|
||||||
|
|
||||||
|
|
||||||
async def drain_dirty() -> list[tuple[int, str]]:
|
|
||||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
|
||||||
|
|
||||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
|
||||||
"""
|
|
||||||
client = get_like_cache()
|
|
||||||
|
|
||||||
# 이전 크래시 잔여 항목 병합
|
|
||||||
if await client.exists(_DIRTY_PROCESSING_KEY):
|
|
||||||
await client.sunionstore(_DIRTY_KEY, _DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
|
||||||
await client.delete(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
||||||
if not await client.exists(_DIRTY_KEY):
|
|
||||||
return []
|
|
||||||
|
|
||||||
# RENAME으로 플러시 중 새로 들어오는 토글과 분리
|
|
||||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
|
||||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for member in members:
|
|
||||||
vid_str, user_uuid = member.split(":", 1)
|
|
||||||
result.append((int(vid_str), user_uuid))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def commit_dirty_processing() -> None:
|
|
||||||
"""DB 반영 완료 후 processing SET 삭제."""
|
|
||||||
await get_like_cache().delete(_DIRTY_PROCESSING_KEY)
|
|
||||||
@ -6,8 +6,6 @@ from fastapi import HTTPException
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
from sqlalchemy.orm import DeclarativeBase
|
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 app.utils.logger import get_logger
|
||||||
from config import db_settings
|
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.home.models import Image, Project, MarketingIntel, ImageTag # noqa: F401
|
||||||
from app.lyric.models import Lyric # noqa: F401
|
from app.lyric.models import Lyric # noqa: F401
|
||||||
from app.song.models import Song, SongTimestamp # noqa: F401
|
from app.song.models import Song, SongTimestamp # noqa: F401
|
||||||
from app.video.models import Video, VideoReaction # noqa: F401
|
from app.video.models import Video # noqa: F401
|
||||||
from app.comment.models import Comment # noqa: F401
|
|
||||||
from app.sns.models import SNSUploadTask # noqa: F401
|
from app.sns.models import SNSUploadTask # noqa: F401
|
||||||
from app.social.models import SocialUpload # noqa: F401
|
from app.social.models import SocialUpload # noqa: F401
|
||||||
from app.dashboard.models import Dashboard # noqa: F401
|
from app.dashboard.models import Dashboard # noqa: F401
|
||||||
@ -99,8 +96,6 @@ async def create_db_tables():
|
|||||||
Song.__table__,
|
Song.__table__,
|
||||||
SongTimestamp.__table__,
|
SongTimestamp.__table__,
|
||||||
Video.__table__,
|
Video.__table__,
|
||||||
VideoReaction.__table__,
|
|
||||||
Comment.__table__,
|
|
||||||
SNSUploadTask.__table__,
|
SNSUploadTask.__table__,
|
||||||
SocialUpload.__table__,
|
SocialUpload.__table__,
|
||||||
MarketingIntel.__table__,
|
MarketingIntel.__table__,
|
||||||
@ -142,25 +137,8 @@ async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
|||||||
yield session
|
yield session
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
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:
|
except Exception as e:
|
||||||
await session.rollback()
|
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(traceback.format_exc())
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
f"[get_session] ROLLBACK - error: {type(e).__name__}: {e}, "
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -114,13 +114,6 @@ class Project(Base):
|
|||||||
comment="마케팅 인텔리전스 결과 정보 저장",
|
comment="마케팅 인텔리전스 결과 정보 저장",
|
||||||
)
|
)
|
||||||
|
|
||||||
industry: Mapped[str] = mapped_column(
|
|
||||||
String(50),
|
|
||||||
nullable=False,
|
|
||||||
default="",
|
|
||||||
comment="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)",
|
|
||||||
)
|
|
||||||
|
|
||||||
language: Mapped[str] = mapped_column(
|
language: Mapped[str] = mapped_column(
|
||||||
String(50),
|
String(50),
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -274,7 +267,6 @@ class MarketingIntel(Base):
|
|||||||
Attributes:
|
Attributes:
|
||||||
id: 고유 식별자 (자동 증가)
|
id: 고유 식별자 (자동 증가)
|
||||||
place_id : 데이터 소스별 식별자
|
place_id : 데이터 소스별 식별자
|
||||||
official_site_url : 업체 공식 링크 (플레이스 홈페이지 항목, 없으면 크롤링 소스 URL)
|
|
||||||
intel_result : 마케팅 분석 결과물 json
|
intel_result : 마케팅 분석 결과물 json
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
"""
|
"""
|
||||||
@ -297,16 +289,10 @@ class MarketingIntel(Base):
|
|||||||
comment="고유 식별자",
|
comment="고유 식별자",
|
||||||
)
|
)
|
||||||
|
|
||||||
place_id: Mapped[Optional[str]] = mapped_column(
|
place_id: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
nullable=True,
|
nullable=False,
|
||||||
comment="매장 소스별 고유 식별자 (네이버 크롤링 시 'nv{id}' 형식; 직접 입력 시 NULL)",
|
comment="매장 소스별 고유 식별자",
|
||||||
)
|
|
||||||
|
|
||||||
official_site_url: Mapped[Optional[str]] = mapped_column(
|
|
||||||
String(2048),
|
|
||||||
nullable=True,
|
|
||||||
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 크롤링 소스 URL; 직접 입력 시 NULL)",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
intel_result : Mapped[dict[str, Any]] = mapped_column(
|
||||||
@ -321,12 +307,6 @@ class MarketingIntel(Base):
|
|||||||
comment="자막 정보 생성 결과물",
|
comment="자막 정보 생성 결과물",
|
||||||
)
|
)
|
||||||
|
|
||||||
image_match : Mapped[dict[str, Any]] = mapped_column(
|
|
||||||
JSON,
|
|
||||||
nullable=True,
|
|
||||||
comment="이미지 슬롯 배정 결과물 — {슬롯명: image_url, 'audio-music': music_url, ...}",
|
|
||||||
)
|
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -377,10 +357,8 @@ class ImageTag(Base):
|
|||||||
)
|
)
|
||||||
|
|
||||||
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
img_tag: Mapped[dict[str, Any]] = mapped_column(
|
||||||
# none_as_null=True: ORM에서 None 대입 시 JSON 리터럴 null이 아닌 SQL NULL로 저장.
|
JSON,
|
||||||
# 미지정 시 JSON null이 저장되어 `img_tag.is_not(None)` 필터를 통과하는 버그가 있었음.
|
|
||||||
JSON(none_as_null=True),
|
|
||||||
nullable=True,
|
nullable=True,
|
||||||
default=None,
|
default=False,
|
||||||
comment="태그 JSON",
|
comment="태그 JSON",
|
||||||
)
|
)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
from typing import Literal, Optional
|
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
|
from app.utils.prompts.schemas import MarketingPromptOutput
|
||||||
|
|
||||||
class CrawlingRequest(BaseModel):
|
class CrawlingRequest(BaseModel):
|
||||||
@ -78,7 +78,6 @@ class ProcessedInfo(BaseModel):
|
|||||||
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
customer_name: str = Field(..., description="고객명/가게명 (base_info.name)")
|
||||||
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
region: str = Field(..., description="지역명 (roadAddress에서 추출한 시 이름)")
|
||||||
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
detail_region_info: str = Field(..., description="상세 지역 정보 (roadAddress)")
|
||||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
|
||||||
|
|
||||||
|
|
||||||
# class MarketingAnalysisDetail(BaseModel):
|
# class MarketingAnalysisDetail(BaseModel):
|
||||||
@ -98,12 +97,6 @@ class ProcessedInfo(BaseModel):
|
|||||||
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
# selling_points: list[str] = Field(default_factory=list, description="추천 부대시설 목록")
|
||||||
|
|
||||||
|
|
||||||
class ImageListItem(BaseModel):
|
|
||||||
"""크롤링 이미지 아이템 (미리보기 URL + 원본 URL)"""
|
|
||||||
preview: str = Field(..., description="미리보기용 CDN URL")
|
|
||||||
original: str = Field(..., description="업로드용 원본 URL")
|
|
||||||
|
|
||||||
|
|
||||||
class CrawlingResponse(BaseModel):
|
class CrawlingResponse(BaseModel):
|
||||||
"""크롤링 응답 스키마"""
|
"""크롤링 응답 스키마"""
|
||||||
|
|
||||||
@ -111,7 +104,7 @@ class CrawlingResponse(BaseModel):
|
|||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"image_list": [{"preview": "https://example.com/image1.jpg", "original": "https://example.com/image1.jpg"}],
|
"image_list": ["https://example.com/image1.jpg", "https://example.com/image2.jpg"],
|
||||||
"image_count": 2,
|
"image_count": 2,
|
||||||
"processed_info": {
|
"processed_info": {
|
||||||
"customer_name": "스테이 머뭄",
|
"customer_name": "스테이 머뭄",
|
||||||
@ -240,8 +233,8 @@ class CrawlingResponse(BaseModel):
|
|||||||
default="completed",
|
default="completed",
|
||||||
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
description="처리 상태 (completed: 성공, failed: ChatGPT 분석 실패)"
|
||||||
)
|
)
|
||||||
image_list: Optional[list[ImageListItem]] = Field(None, description="이미지 목록 (preview/original URL)")
|
image_list: Optional[list[str]] = Field(None, description="이미지 URL 목록")
|
||||||
image_count: Optional[int] = Field(None, description="이미지 개수")
|
image_count: int = Field(..., description="이미지 개수")
|
||||||
processed_info: Optional[ProcessedInfo] = Field(
|
processed_info: Optional[ProcessedInfo] = Field(
|
||||||
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
None, description="가공된 장소 정보 (customer_name, region, detail_region_info)"
|
||||||
)
|
)
|
||||||
@ -249,43 +242,6 @@ class CrawlingResponse(BaseModel):
|
|||||||
None, description="마케팅 분석 결과 . 실패 시 null"
|
None, description="마케팅 분석 결과 . 실패 시 null"
|
||||||
)
|
)
|
||||||
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
m_id : int = Field(..., description="마케팅 분석 결과 ID")
|
||||||
industry: str = Field(default="", description="업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general)")
|
|
||||||
|
|
||||||
|
|
||||||
class ManualMarketingRequest(BaseModel):
|
|
||||||
"""업체명+주소 직접 입력 마케팅 분석 요청"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"example": {
|
|
||||||
"store_name": "스테이 머뭄",
|
|
||||||
"address": "전북특별자치도 군산시 절골길 18",
|
|
||||||
"category": "펜션",
|
|
||||||
"official_site_url": "https://www.staymeomoom.com",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
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):
|
class ErrorResponse(BaseModel):
|
||||||
|
|||||||
@ -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,
|
|
||||||
)
|
|
||||||
@ -32,7 +32,7 @@ class NaverSearchClient:
|
|||||||
display: int = 5,
|
display: int = 5,
|
||||||
) -> List[dict]:
|
) -> List[dict]:
|
||||||
"""
|
"""
|
||||||
장소 검색 (숙박, 음식점 등)
|
숙박/펜션 검색
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: 검색어
|
query: 검색어
|
||||||
@ -41,7 +41,8 @@ class NaverSearchClient:
|
|||||||
Returns:
|
Returns:
|
||||||
검색 결과 리스트 (address, roadAddress, title)
|
검색 결과 리스트 (address, roadAddress, title)
|
||||||
"""
|
"""
|
||||||
search_query = query
|
# 숙박/펜션 카테고리 검색을 위해 쿼리에 키워드 추가
|
||||||
|
search_query = f"{query} 숙박"
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
"X-Naver-Client-Id": self.client_id,
|
"X-Naver-Client-Id": self.client_id,
|
||||||
|
|||||||
@ -40,10 +40,8 @@ from app.lyric.schemas.lyric import (
|
|||||||
LyricDetailResponse,
|
LyricDetailResponse,
|
||||||
LyricListItem,
|
LyricListItem,
|
||||||
LyricStatusResponse,
|
LyricStatusResponse,
|
||||||
SubtitleStatusResponse,
|
|
||||||
)
|
)
|
||||||
from app.lyric.worker.lyric_task import generate_lyric_background
|
from app.lyric.worker.lyric_task import generate_lyric_background, generate_subtitle_background
|
||||||
from app.video.worker.creative_assets_task import generate_subtitle_background
|
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.pagination import PaginatedResponse, get_paginated
|
from app.utils.pagination import PaginatedResponse, get_paginated
|
||||||
@ -161,7 +159,6 @@ async def get_lyric_by_task_id(
|
|||||||
project_id=lyric.project_id,
|
project_id=lyric.project_id,
|
||||||
status=lyric.status,
|
status=lyric.status,
|
||||||
lyric_result=lyric.lyric_result,
|
lyric_result=lyric.lyric_result,
|
||||||
genre=lyric.genre,
|
|
||||||
created_at=lyric.created_at,
|
created_at=lyric.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -271,21 +268,24 @@ async def generate_lyric(
|
|||||||
step1_start = time.perf_counter()
|
step1_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
logger.debug(f"[generate_lyric] Step 1: 서비스 초기화 및 프롬프트 생성...")
|
||||||
|
|
||||||
|
promotional_expressions = {
|
||||||
|
"Korean" : "인스타 감성, 사진같은 하루, 힐링, 여행, 감성 숙소",
|
||||||
|
"English" : "Instagram vibes, picture-perfect day, healing, travel, getaway",
|
||||||
|
"Chinese" : "网红打卡, 治愈系, 旅行, 度假, 拍照圣地",
|
||||||
|
"Japanese" : "インスタ映え, 写真のような一日, 癒し, 旅行, 絶景",
|
||||||
|
"Thai" : "ที่พักสวย, ฮีลใจ, เที่ยว, ถ่ายรูป, วิวสวย",
|
||||||
|
"Vietnamese" : "check-in đẹp, healing, du lịch, nghỉ dưỡng, view đẹp"
|
||||||
|
}# HARD CODED, 어디에 정리하지? 아직 정리되지 않음
|
||||||
|
|
||||||
|
timing_rules = {
|
||||||
|
"60s" : """
|
||||||
|
8–12 lines
|
||||||
|
Full verse flow, immersive mood
|
||||||
|
"""
|
||||||
|
}
|
||||||
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
marketing_intel_result = await session.execute(select(MarketingIntel).where(MarketingIntel.id == request_body.m_id))
|
||||||
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
marketing_intel = marketing_intel_result.scalar_one_or_none()
|
||||||
|
|
||||||
# 자동 선택(genre 미지정) 재생성 시, 직전에 사용된 장르를 조회해 이번엔 다른 장르가 나오도록 제외 처리
|
|
||||||
exclude_genre = ""
|
|
||||||
if not request_body.genre:
|
|
||||||
previous_lyric_result = await session.execute(
|
|
||||||
select(Lyric)
|
|
||||||
.where(Lyric.task_id == task_id, Lyric.genre.is_not(None))
|
|
||||||
.order_by(Lyric.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
previous_lyric = previous_lyric_result.scalar_one_or_none()
|
|
||||||
if previous_lyric:
|
|
||||||
exclude_genre = previous_lyric.genre
|
|
||||||
|
|
||||||
lyric_input_data = {
|
lyric_input_data = {
|
||||||
"customer_name" : request_body.customer_name,
|
"customer_name" : request_body.customer_name,
|
||||||
@ -293,14 +293,10 @@ async def generate_lyric(
|
|||||||
"detail_region_info" : request_body.detail_region_info or "",
|
"detail_region_info" : request_body.detail_region_info or "",
|
||||||
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
"marketing_intelligence_summary" : json.dumps(marketing_intel.intel_result, ensure_ascii = False),
|
||||||
"language" : request_body.language,
|
"language" : request_body.language,
|
||||||
"genre": request_body.genre or "", # 비어있으면 GPT가 가사 무드에 맞춰 장르를 직접 추천
|
"promotional_expression_example" : promotional_expressions[request_body.language],
|
||||||
"exclude_genre": exclude_genre, # 자동 선택 재생성 시 직전 장르 제외
|
"timing_rules" : timing_rules["60s"], # 아직은 선택지 하나
|
||||||
"industry": request_body.industry, # 크롤 응답에서 받아 전달된 업종 enum
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# 업종 분기는 프롬프트 내부 {industry}로 처리하므로 단일 프롬프트 사용
|
|
||||||
selected_lyric_prompt = lyric_prompt
|
|
||||||
|
|
||||||
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
step1_elapsed = (time.perf_counter() - step1_start) * 1000
|
||||||
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
#logger.debug(f"[generate_lyric] Step 1 완료 - 프롬프트 {len(prompt)}자 ({step1_elapsed:.1f}ms)")
|
||||||
|
|
||||||
@ -326,8 +322,7 @@ async def generate_lyric(
|
|||||||
detail_region_info=request_body.detail_region_info,
|
detail_region_info=request_body.detail_region_info,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
marketing_intelligence=request_body.m_id,
|
marketing_intelligence = request_body.m_id
|
||||||
industry=request_body.industry,
|
|
||||||
)
|
)
|
||||||
session.add(project)
|
session.add(project)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@ -341,7 +336,7 @@ async def generate_lyric(
|
|||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
logger.debug(f"[generate_lyric] Step 3: Lyric 저장 (processing)...")
|
||||||
|
|
||||||
estimated_prompt = selected_lyric_prompt.build_prompt(lyric_input_data)
|
estimated_prompt = lyric_prompt.build_prompt(lyric_input_data)
|
||||||
lyric = Lyric(
|
lyric = Lyric(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
@ -349,7 +344,6 @@ async def generate_lyric(
|
|||||||
lyric_prompt=estimated_prompt,
|
lyric_prompt=estimated_prompt,
|
||||||
lyric_result=None,
|
lyric_result=None,
|
||||||
language=request_body.language,
|
language=request_body.language,
|
||||||
genre=request_body.genre or None, # 자동 선택인 경우 GPT 응답 완료 후 채워짐
|
|
||||||
)
|
)
|
||||||
session.add(lyric)
|
session.add(lyric)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@ -373,7 +367,7 @@ async def generate_lyric(
|
|||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
generate_lyric_background,
|
generate_lyric_background,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
prompt=selected_lyric_prompt,
|
prompt=lyric_prompt,
|
||||||
lyric_input_data=lyric_input_data,
|
lyric_input_data=lyric_input_data,
|
||||||
lyric_id=lyric.id,
|
lyric_id=lyric.id,
|
||||||
)
|
)
|
||||||
@ -521,96 +515,6 @@ async def list_lyrics(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/subtitle/status/{task_id}",
|
|
||||||
summary="자막 생성 상태 조회",
|
|
||||||
description="""
|
|
||||||
자막(subtitle) 생성 완료 여부를 조회합니다.
|
|
||||||
|
|
||||||
## 인증
|
|
||||||
**Bearer 토큰 필수** - `Authorization: Bearer {access_token}` 헤더를 포함해야 합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **task_id**: 프로젝트 task_id (필수)
|
|
||||||
|
|
||||||
## 상태 값
|
|
||||||
- **pending**: 자막 생성 진행 중 — 잠시 후 재요청
|
|
||||||
- **completed**: 자막 생성 완료 — `/video/generate/{task_id}` 호출 가능
|
|
||||||
|
|
||||||
## 사용 예시 (cURL)
|
|
||||||
```bash
|
|
||||||
curl -X GET "http://localhost:8000/lyric/subtitle/status/019123ab-cdef-7890-abcd-ef1234567890" \\
|
|
||||||
-H "Authorization: Bearer {access_token}"
|
|
||||||
```
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
- 자막은 `/lyric/generate` 호출 시 백그라운드에서 자동 생성됩니다.
|
|
||||||
- 클라이언트는 `completed` 상태 확인 후 `/video/generate`를 호출해야 합니다.
|
|
||||||
""",
|
|
||||||
response_model=SubtitleStatusResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "상태 조회 성공"},
|
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
|
||||||
404: {"description": "해당 task_id에 해당하는 프로젝트를 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_subtitle_status(
|
|
||||||
task_id: str,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> SubtitleStatusResponse:
|
|
||||||
"""task_id로 자막 생성 상태를 조회합니다."""
|
|
||||||
logger.info(f"[get_subtitle_status] START - task_id: {task_id}")
|
|
||||||
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(Project.task_id == task_id)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 프로젝트를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
intel = marketing_result.scalar_one_or_none()
|
|
||||||
if not intel:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 마케팅 인텔리전스를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
# 자막과 이미지 배정 모두 완료돼야 영상 생성 가능
|
|
||||||
subtitle_done = bool(intel.subtitle)
|
|
||||||
image_match_done = bool(intel.image_match)
|
|
||||||
|
|
||||||
if subtitle_done and image_match_done:
|
|
||||||
logger.info(f"[get_subtitle_status] completed (subtitle+image_match) - task_id: {task_id}")
|
|
||||||
return SubtitleStatusResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status="completed",
|
|
||||||
message="자막 생성 및 이미지 배정이 완료되었습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
pending_parts = []
|
|
||||||
if not subtitle_done:
|
|
||||||
pending_parts.append("자막")
|
|
||||||
if not image_match_done:
|
|
||||||
pending_parts.append("이미지 배정")
|
|
||||||
pending_msg = ", ".join(pending_parts)
|
|
||||||
logger.info(f"[get_subtitle_status] pending ({pending_msg}) - task_id: {task_id}")
|
|
||||||
return SubtitleStatusResponse(
|
|
||||||
task_id=task_id,
|
|
||||||
status="pending",
|
|
||||||
message=f"{pending_msg} 처리가 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/{task_id}",
|
"/{task_id}",
|
||||||
summary="가사 상세 조회",
|
summary="가사 상세 조회",
|
||||||
|
|||||||
@ -93,13 +93,6 @@ class Lyric(Base):
|
|||||||
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
comment="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
)
|
||||||
|
|
||||||
genre: Mapped[str | None] = mapped_column(
|
|
||||||
String(20),
|
|
||||||
nullable=True,
|
|
||||||
comment="음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). "
|
|
||||||
"수동 선택 시 요청값, 자동 선택 시 GPT 추천값",
|
|
||||||
)
|
|
||||||
|
|
||||||
is_deleted: Mapped[bool] = mapped_column(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@ -70,19 +70,13 @@ class GenerateLyricRequest(BaseModel):
|
|||||||
language: str = Field(
|
language: str = Field(
|
||||||
default="Korean",
|
default="Korean",
|
||||||
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
description="가사 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)",
|
||||||
)
|
),
|
||||||
orientation: Literal["horizontal", "vertical"] = Field(
|
orientation: Literal["horizontal", "vertical"] = Field(
|
||||||
default="vertical",
|
default="vertical",
|
||||||
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
description="영상 방향 (horizontal: 가로형, vertical: 세로형)",
|
||||||
)
|
),
|
||||||
m_id : Optional[int] = Field(None, description="마케팅 인텔리전스 ID 값")
|
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 전용 모드 (가사 생성 안 함)")
|
instrumental: bool = Field(default=False, description="BGM 전용 모드 (가사 생성 안 함)")
|
||||||
genre: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="사용자가 수동으로 고른 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). "
|
|
||||||
"'자동 선택'인 경우 None으로 전달하면 GPT가 가사 무드에 맞춰 추천",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GenerateLyricResponse(BaseModel):
|
class GenerateLyricResponse(BaseModel):
|
||||||
@ -205,62 +199,9 @@ class LyricDetailResponse(BaseModel):
|
|||||||
project_id: int = Field(..., description="프로젝트 ID")
|
project_id: int = Field(..., description="프로젝트 ID")
|
||||||
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
status: str = Field(..., description="처리 상태 (processing, completed, failed)")
|
||||||
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
lyric_result: Optional[str] = Field(None, description="생성된 가사 또는 에러 메시지 (실패 시)")
|
||||||
genre: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="확정된 음악 장르. 수동 선택 시 요청값, 자동 선택 시 GPT 추천값 (완료 전에는 None)",
|
|
||||||
)
|
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
|
|
||||||
|
|
||||||
class SubtitleStatusResponse(BaseModel):
|
|
||||||
"""자막 생성 상태 조회 응답 스키마
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /subtitle/status/{task_id}
|
|
||||||
클라이언트가 subtitle 완료 여부를 polling할 때 사용합니다.
|
|
||||||
|
|
||||||
Status Values:
|
|
||||||
- pending: 자막 생성 진행 중 (재시도 필요)
|
|
||||||
- completed: 자막 생성 완료 (/video/generate 호출 가능)
|
|
||||||
- failed: 자막 생성 실패 (/lyric/generate 재호출 필요)
|
|
||||||
"""
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
|
||||||
json_schema_extra={
|
|
||||||
"examples": [
|
|
||||||
{
|
|
||||||
"summary": "생성 중",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "pending",
|
|
||||||
"message": "자막 생성이 진행 중입니다. 잠시 후 다시 확인해주세요.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "완료",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "completed",
|
|
||||||
"message": "자막 생성이 완료되었습니다.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"summary": "실패",
|
|
||||||
"value": {
|
|
||||||
"task_id": "0694b716-dbff-7219-8000-d08cb5fce431",
|
|
||||||
"status": "failed",
|
|
||||||
"message": "자막 생성에 실패했습니다. 다시 시도해주세요.",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
|
||||||
status: Literal["pending", "completed", "failed"] = Field(..., description="자막 생성 상태")
|
|
||||||
message: str = Field(..., description="상태 메시지")
|
|
||||||
|
|
||||||
|
|
||||||
class LyricListItem(BaseModel):
|
class LyricListItem(BaseModel):
|
||||||
"""가사 목록 아이템 스키마
|
"""가사 목록 아이템 스키마
|
||||||
|
|
||||||
|
|||||||
@ -4,24 +4,30 @@ Lyric Background Tasks
|
|||||||
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
가사 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import traceback
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
|
from app.home.models import Image, Project, MarketingIntel
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
from app.utils.prompts.chatgpt_prompt import ChatgptService, ChatGPTResponseError
|
||||||
|
from app.utils.subtitles import SubtitleContentsGenerator
|
||||||
|
from app.utils.creatomate import CreatomateService
|
||||||
from app.utils.prompts.prompts import Prompt
|
from app.utils.prompts.prompts import Prompt
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("lyric")
|
logger = get_logger("lyric")
|
||||||
|
|
||||||
|
|
||||||
async def _update_lyric_status(
|
async def _update_lyric_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
status: str,
|
status: str,
|
||||||
result: str | None = None,
|
result: str | None = None,
|
||||||
lyric_id: int | None = None,
|
lyric_id: int | None = None,
|
||||||
genre: str | None = None,
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Lyric 테이블의 상태를 업데이트합니다.
|
"""Lyric 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
@ -30,7 +36,6 @@ async def _update_lyric_status(
|
|||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
result: 가사 결과 또는 에러 메시지
|
result: 가사 결과 또는 에러 메시지
|
||||||
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
lyric_id: 특정 Lyric 레코드 ID (재생성 시 정확한 레코드 식별용)
|
||||||
genre: 확정된 음악 장르 (자동 선택이었던 경우 GPT 추천값으로 갱신)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 업데이트 성공 여부
|
bool: 업데이트 성공 여부
|
||||||
@ -56,8 +61,6 @@ async def _update_lyric_status(
|
|||||||
lyric.status = status
|
lyric.status = status
|
||||||
if result is not None:
|
if result is not None:
|
||||||
lyric.lyric_result = result
|
lyric.lyric_result = result
|
||||||
if genre is not None:
|
|
||||||
lyric.genre = genre
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
logger.info(f"[Lyric] Status updated - task_id: {task_id}, lyric_id: {lyric_id}, status: {status}")
|
||||||
return True
|
return True
|
||||||
@ -114,18 +117,14 @@ async def generate_lyric_background(
|
|||||||
#result = await service.generate(prompt=prompt)
|
#result = await service.generate(prompt=prompt)
|
||||||
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
result_response = await chatgpt.generate_structured_output(prompt, lyric_input_data)
|
||||||
result = result_response.lyric
|
result = result_response.lyric
|
||||||
confirmed_genre = result_response.recommended_genre
|
|
||||||
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
step2_elapsed = (time.perf_counter() - step2_start) * 1000
|
||||||
logger.info(
|
logger.info(f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자 ({step2_elapsed:.1f}ms)")
|
||||||
f"[generate_lyric_background] Step 2 완료 - 응답 {len(result)}자, "
|
|
||||||
f"genre: {confirmed_genre} ({step2_elapsed:.1f}ms)"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ========== Step 3: DB 상태 업데이트 ==========
|
# ========== Step 3: DB 상태 업데이트 ==========
|
||||||
step3_start = time.perf_counter()
|
step3_start = time.perf_counter()
|
||||||
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
logger.debug(f"[generate_lyric_background] Step 3: DB 상태 업데이트...")
|
||||||
|
|
||||||
await _update_lyric_status(task_id, "completed", result, lyric_id, genre=confirmed_genre)
|
await _update_lyric_status(task_id, "completed", result, lyric_id)
|
||||||
|
|
||||||
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
step3_elapsed = (time.perf_counter() - step3_start) * 1000
|
||||||
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
logger.debug(f"[generate_lyric_background] Step 3 완료 ({step3_elapsed:.1f}ms)")
|
||||||
@ -156,3 +155,55 @@ async def generate_lyric_background(
|
|||||||
elapsed = (time.perf_counter() - task_start) * 1000
|
elapsed = (time.perf_counter() - task_start) * 1000
|
||||||
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
logger.error(f"[generate_lyric_background] EXCEPTION - task_id: {task_id}, error: {e} ({elapsed:.1f}ms)", exc_info=True)
|
||||||
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
await _update_lyric_status(task_id, "failed", f"Error: {str(e)}", lyric_id)
|
||||||
|
|
||||||
|
async def generate_subtitle_background(
|
||||||
|
orientation: str,
|
||||||
|
task_id: str
|
||||||
|
) -> None:
|
||||||
|
logger.info(f"[generate_subtitle_background] task_id: {task_id}, {orientation}")
|
||||||
|
creatomate_service = CreatomateService(orientation=orientation)
|
||||||
|
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
||||||
|
pitchings = creatomate_service.extract_text_format_from_template(template)
|
||||||
|
|
||||||
|
subtitle_generator = SubtitleContentsGenerator()
|
||||||
|
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.task_id == task_id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
store_address = project.detail_region_info
|
||||||
|
customer_name = project.store_name
|
||||||
|
logger.info(f"[generate_subtitle_background] customer_name: {customer_name}, {store_address}")
|
||||||
|
|
||||||
|
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
||||||
|
marketing_intelligence = marketing_intelligence.intel_result,
|
||||||
|
pitching_label_list = pitchings,
|
||||||
|
customer_name = customer_name,
|
||||||
|
detail_region_info = store_address,
|
||||||
|
)
|
||||||
|
pitching_output_list = generated_subtitles.pitching_results
|
||||||
|
|
||||||
|
subtitle_modifications = {pitching_output.pitching_tag : pitching_output.pitching_data for pitching_output in pitching_output_list}
|
||||||
|
logger.info(f"[generate_subtitle_background] subtitle_modifications: {subtitle_modifications}")
|
||||||
|
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
marketing_intelligence.subtitle = subtitle_modifications
|
||||||
|
await session.commit()
|
||||||
|
logger.info(f"[generate_subtitle_background] task_id: {task_id} DONE")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return
|
||||||
|
|||||||
@ -33,5 +33,5 @@ async def youtube_seo_description(
|
|||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
return await seo_service.get_youtube_seo_description(
|
return await seo_service.get_youtube_seo_description(
|
||||||
request_body.video_id, current_user, session
|
request_body.task_id, current_user, session
|
||||||
)
|
)
|
||||||
|
|||||||
@ -95,6 +95,8 @@ YOUTUBE_SCOPES = [
|
|||||||
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
"https://www.googleapis.com/auth/userinfo.profile", # 사용자 프로필
|
||||||
]
|
]
|
||||||
|
|
||||||
|
YOUTUBE_SEO_HASH = "SEO_Describtion_YT"
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Instagram/Facebook OAuth Scopes (추후 구현)
|
# Instagram/Facebook OAuth Scopes (추후 구현)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@ -8,12 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
class YoutubeDescriptionRequest(BaseModel):
|
class YoutubeDescriptionRequest(BaseModel):
|
||||||
"""유튜브 SEO Description 제안 요청"""
|
"""유튜브 SEO Description 제안 요청"""
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"example": {
|
"example": {
|
||||||
"video_id": 123
|
"task_id": "019c739f-65fc-7d15-8c88-b31be00e588e"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -56,8 +56,6 @@ class SocialUploadResponse(BaseModel):
|
|||||||
platform: str = Field(..., description="플랫폼명")
|
platform: str = Field(..., description="플랫폼명")
|
||||||
status: str = Field(..., description="업로드 상태")
|
status: str = Field(..., description="업로드 상태")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
scheduled_at: Optional[datetime] = Field(None, description="예약 시간 (예약 업로드 충돌 시 반환)")
|
|
||||||
|
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = ConfigDict(
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
@ -67,7 +65,6 @@ class SocialUploadResponse(BaseModel):
|
|||||||
"platform": "youtube",
|
"platform": "youtube",
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"message": "업로드 요청이 접수되었습니다.",
|
"message": "업로드 요청이 접수되었습니다.",
|
||||||
"scheduled_at": "2026-02-02T15:00:00",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,132 +1,88 @@
|
|||||||
"""
|
"""
|
||||||
유튜브 SEO 서비스
|
유튜브 SEO 서비스
|
||||||
|
|
||||||
영상 제목/설명/해시태그를 생성하고 video 테이블에 저장합니다.
|
SEO description 생성 및 Redis 캐싱 로직을 처리합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException
|
||||||
|
from redis.asyncio import Redis
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from config import db_settings
|
||||||
from app.home.models import MarketingIntel, Project
|
from app.home.models import MarketingIntel, Project
|
||||||
|
from app.social.constants import YOUTUBE_SEO_HASH
|
||||||
from app.social.schemas import YoutubeDescriptionResponse
|
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.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__)
|
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:
|
class SeoService:
|
||||||
"""유튜브 SEO 비즈니스 로직 서비스"""
|
"""유튜브 SEO 비즈니스 로직 서비스"""
|
||||||
|
|
||||||
async def get_youtube_seo_description(
|
async def get_youtube_seo_description(
|
||||||
self,
|
self,
|
||||||
video_id: int,
|
task_id: str,
|
||||||
current_user: User,
|
current_user: User,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
"""
|
"""
|
||||||
저장된 SNS 메타데이터를 반환하거나, 없으면 생성 후 video에 저장합니다.
|
유튜브 SEO description 생성
|
||||||
|
|
||||||
|
Redis 캐시 확인 후 miss이면 GPT로 생성하고 캐싱.
|
||||||
"""
|
"""
|
||||||
logger.info(
|
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)
|
cached = await self._get_from_redis(task_id)
|
||||||
if video is None:
|
if cached:
|
||||||
raise HTTPException(
|
return cached
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail=f"video_id '{video_id}'에 해당하는 영상을 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
if has_stored_sns_metadata(video):
|
logger.info(f"[SEO_SERVICE] Cache miss - user: {current_user.user_uuid}")
|
||||||
return self._response_from_video(video)
|
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
|
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(
|
async def _generate_seo_description(
|
||||||
self,
|
self,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
|
current_user: User,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
) -> YoutubeDescriptionResponse:
|
) -> YoutubeDescriptionResponse:
|
||||||
"""GPT를 사용하여 SEO description 생성"""
|
"""GPT를 사용하여 SEO description 생성"""
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
logger.info(f"[SEO_SERVICE] Generating SEO - user: {current_user.user_uuid}")
|
||||||
from app.utils.prompts.prompts import yt_upload_prompt
|
|
||||||
|
|
||||||
logger.info(f"[SEO_SERVICE] Generating SEO - task_id: {task_id}")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
project_result = await session.execute(
|
project_result = await session.execute(
|
||||||
select(Project)
|
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())
|
.order_by(Project.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
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(
|
marketing_result = await session.execute(
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
)
|
)
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
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"]
|
hashtags = marketing_intelligence.intel_result["target_keywords"]
|
||||||
|
|
||||||
@ -138,13 +94,10 @@ class SeoService:
|
|||||||
),
|
),
|
||||||
"language": project.language,
|
"language": project.language,
|
||||||
"target_keywords": hashtags,
|
"target_keywords": hashtags,
|
||||||
"industry": project.industry or "",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
chatgpt = ChatgptService(timeout=180)
|
chatgpt = ChatgptService(timeout=180)
|
||||||
yt_seo_output = await chatgpt.generate_structured_output(
|
yt_seo_output = await chatgpt.generate_structured_output(yt_upload_prompt, yt_seo_input_data)
|
||||||
yt_upload_prompt, yt_seo_input_data
|
|
||||||
)
|
|
||||||
|
|
||||||
return YoutubeDescriptionResponse(
|
return YoutubeDescriptionResponse(
|
||||||
title=yt_seo_output.title,
|
title=yt_seo_output.title,
|
||||||
@ -152,8 +105,6 @@ class SeoService:
|
|||||||
keywords=hashtags,
|
keywords=hashtags,
|
||||||
)
|
)
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
logger.error(f"[SEO_SERVICE] EXCEPTION - error: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
@ -161,12 +112,18 @@ class SeoService:
|
|||||||
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
detail=f"유튜브 SEO 생성에 실패했습니다. : {str(e)}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _response_from_video(self, video: Video) -> YoutubeDescriptionResponse:
|
async def _get_from_redis(self, task_id: str) -> YoutubeDescriptionResponse | None:
|
||||||
return YoutubeDescriptionResponse(
|
field = f"task_id:{task_id}"
|
||||||
title=video.title or "",
|
yt_seo_info = await redis_seo_client.hget(YOUTUBE_SEO_HASH, field)
|
||||||
description=video.description or "",
|
if yt_seo_info:
|
||||||
keywords=list(video.hashtags or []),
|
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()
|
seo_service = SeoService()
|
||||||
|
|||||||
@ -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 [])
|
|
||||||
@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import BackgroundTasks, HTTPException, status
|
from fastapi import BackgroundTasks, HTTPException, status
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import TIMEZONE
|
from config import TIMEZONE
|
||||||
@ -26,7 +26,6 @@ from app.social.schemas import (
|
|||||||
SocialUploadRequest,
|
SocialUploadRequest,
|
||||||
)
|
)
|
||||||
from app.social.services.account_service import SocialAccountService
|
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.social.worker.upload_task import process_social_upload
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
@ -77,12 +76,6 @@ class SocialUploadService:
|
|||||||
detail="영상이 아직 준비되지 않았습니다. 영상 생성이 완료된 후 시도해주세요.",
|
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. 소셜 계정 조회 및 소유권 검증
|
# 2. 소셜 계정 조회 및 소유권 검증
|
||||||
account = await self._account_service.get_account_by_id(
|
account = await self._account_service.get_account_by_id(
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
@ -97,19 +90,12 @@ class SocialUploadService:
|
|||||||
)
|
)
|
||||||
raise SocialAccountNotFoundError()
|
raise SocialAccountNotFoundError()
|
||||||
|
|
||||||
# 3. 중복 업로드 확인
|
# 3. 진행 중인 업로드 확인 (pending 또는 uploading 상태만)
|
||||||
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
|
||||||
|
|
||||||
# 3-1. 진행 중인 업로드 확인 (즉시 pending 또는 uploading)
|
|
||||||
in_progress_result = await session.execute(
|
in_progress_result = await session.execute(
|
||||||
select(SocialUpload).where(
|
select(SocialUpload).where(
|
||||||
SocialUpload.video_id == body.video_id,
|
SocialUpload.video_id == body.video_id,
|
||||||
SocialUpload.social_account_id == account.id,
|
SocialUpload.social_account_id == account.id,
|
||||||
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
SocialUpload.status.in_([UploadStatus.PENDING.value, UploadStatus.UPLOADING.value]),
|
||||||
or_(
|
|
||||||
SocialUpload.scheduled_at.is_(None),
|
|
||||||
SocialUpload.scheduled_at <= now_kst_naive,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
in_progress_upload = in_progress_result.scalar_one_or_none()
|
in_progress_upload = in_progress_result.scalar_one_or_none()
|
||||||
@ -126,32 +112,6 @@ class SocialUploadService:
|
|||||||
message="이미 업로드가 진행 중입니다.",
|
message="이미 업로드가 진행 중입니다.",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3-2. 미래 예약 업로드 확인
|
|
||||||
scheduled_result = await session.execute(
|
|
||||||
select(SocialUpload).where(
|
|
||||||
SocialUpload.video_id == body.video_id,
|
|
||||||
SocialUpload.social_account_id == account.id,
|
|
||||||
SocialUpload.status == UploadStatus.PENDING.value,
|
|
||||||
SocialUpload.scheduled_at.isnot(None),
|
|
||||||
SocialUpload.scheduled_at > now_kst_naive,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
scheduled_upload = scheduled_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
if scheduled_upload:
|
|
||||||
logger.info(
|
|
||||||
f"[UPLOAD_SERVICE] 예약된 업로드 존재 - "
|
|
||||||
f"upload_id: {scheduled_upload.id}, scheduled_at: {scheduled_upload.scheduled_at}"
|
|
||||||
)
|
|
||||||
return SocialUploadResponse(
|
|
||||||
success=False,
|
|
||||||
upload_id=scheduled_upload.id,
|
|
||||||
platform=account.platform,
|
|
||||||
status=scheduled_upload.status,
|
|
||||||
message="이미 예약된 업로드가 있습니다.",
|
|
||||||
scheduled_at=scheduled_upload.scheduled_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 4. 업로드 순번 계산
|
# 4. 업로드 순번 계산
|
||||||
max_seq_result = await session.execute(
|
max_seq_result = await session.execute(
|
||||||
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
select(func.coalesce(func.max(SocialUpload.upload_seq), 0)).where(
|
||||||
@ -193,6 +153,7 @@ class SocialUploadService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
# 6. 즉시 업로드이면 백그라운드 태스크 등록
|
||||||
|
now_kst_naive = datetime.now(TIMEZONE).replace(tzinfo=None)
|
||||||
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
is_scheduled = body.scheduled_at and body.scheduled_at > now_kst_naive
|
||||||
if not is_scheduled:
|
if not is_scheduled:
|
||||||
background_tasks.add_task(process_social_upload, social_upload.id)
|
background_tasks.add_task(process_social_upload, social_upload.id)
|
||||||
|
|||||||
@ -144,7 +144,7 @@ class YouTubeUploader(BaseSocialUploader):
|
|||||||
body = {
|
body = {
|
||||||
"snippet": {
|
"snippet": {
|
||||||
"title": metadata.title,
|
"title": metadata.title,
|
||||||
"description": self._sanitize_description(metadata.description),
|
"description": metadata.description or "",
|
||||||
"tags": metadata.tags or [],
|
"tags": metadata.tags or [],
|
||||||
"categoryId": self._get_category_id(metadata),
|
"categoryId": self._get_category_id(metadata),
|
||||||
},
|
},
|
||||||
@ -380,12 +380,6 @@ class YouTubeUploader(BaseSocialUploader):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def _sanitize_description(self, description: str | None) -> str:
|
|
||||||
"""YouTube API가 거부하는 문자를 제거합니다. (<, > 포함 시 invalidVideoDescription 오류 발생)"""
|
|
||||||
if not description:
|
|
||||||
return ""
|
|
||||||
return description.replace("<", "").replace(">", "")
|
|
||||||
|
|
||||||
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
def _convert_privacy_status(self, privacy_status: PrivacyStatus) -> str:
|
||||||
"""
|
"""
|
||||||
PrivacyStatus를 YouTube API 형식으로 변환
|
PrivacyStatus를 YouTube API 형식으로 변환
|
||||||
|
|||||||
@ -35,33 +35,6 @@ logger = get_logger("song")
|
|||||||
|
|
||||||
router = APIRouter(prefix="/song", tags=["Song"])
|
router = APIRouter(prefix="/song", tags=["Song"])
|
||||||
|
|
||||||
TARGET_SONG_DURATION_SECONDS = 40.0
|
|
||||||
|
|
||||||
|
|
||||||
def _select_clip_by_duration(
|
|
||||||
clips_data: list[dict], target_seconds: float = TARGET_SONG_DURATION_SECONDS
|
|
||||||
) -> dict:
|
|
||||||
"""생성된 클립 중 목표 길이(target_seconds)에 가장 가까운 클립을 선택합니다.
|
|
||||||
|
|
||||||
길이 차이가 동일하면 먼저 등장한 클립(더 낮은 인덱스)을 선택합니다.
|
|
||||||
duration 정보가 없는 클립은 비교 대상에서 제외되며, 모든 클립에 duration이 없으면
|
|
||||||
첫 번째 클립을 반환합니다.
|
|
||||||
"""
|
|
||||||
best_clip = None
|
|
||||||
best_diff = None
|
|
||||||
|
|
||||||
for clip in clips_data:
|
|
||||||
duration = clip.get("duration")
|
|
||||||
if duration is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
diff = abs(duration - target_seconds)
|
|
||||||
if best_diff is None or diff < best_diff:
|
|
||||||
best_diff = diff
|
|
||||||
best_clip = clip
|
|
||||||
|
|
||||||
return best_clip if best_clip is not None else clips_data[0]
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/generate/{task_id}",
|
"/generate/{task_id}",
|
||||||
@ -99,7 +72,7 @@ curl -X POST "http://localhost:8000/song/generate/019123ab-cdef-7890-abcd-ef1234
|
|||||||
```
|
```
|
||||||
|
|
||||||
## 참고
|
## 참고
|
||||||
- 생성되는 노래는 약 40초 내외 길이입니다.
|
- 생성되는 노래는 약 1분 이내 길이입니다.
|
||||||
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- song_id를 사용하여 /status/{song_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
- Song 테이블에 데이터가 저장되며, project_id와 lyric_id가 자동으로 연결됩니다.
|
||||||
""",
|
""",
|
||||||
@ -440,14 +413,12 @@ async def get_song_status(
|
|||||||
clips_data = response_data.get("sunoData") or []
|
clips_data = response_data.get("sunoData") or []
|
||||||
|
|
||||||
if clips_data:
|
if clips_data:
|
||||||
# 생성된 클립(보통 2개) 중 목표 길이(1분)에 가장 가까운 클립 선택 (동일하면 첫 번째)
|
# 첫 번째 클립(clips[0])의 audioUrl과 duration 사용
|
||||||
first_clip = _select_clip_by_duration(clips_data)
|
first_clip = clips_data[0]
|
||||||
audio_url = first_clip.get("audioUrl")
|
audio_url = first_clip.get("audioUrl")
|
||||||
clip_duration = first_clip.get("duration")
|
clip_duration = first_clip.get("duration")
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[get_song_status] Selected clip by duration - id: {first_clip.get('id')}, "
|
f"[get_song_status] Using first clip - id: {first_clip.get('id')}, audio_url: {audio_url}, duration: {clip_duration}"
|
||||||
f"audio_url: {audio_url}, duration: {clip_duration}, "
|
|
||||||
f"candidates: {[(c.get('id'), c.get('duration')) for c in clips_data]}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if audio_url:
|
if audio_url:
|
||||||
@ -498,20 +469,6 @@ async def get_song_status(
|
|||||||
word_data = await suno_service.get_lyric_timestamp(
|
word_data = await suno_service.get_lyric_timestamp(
|
||||||
suno_task_id, suno_audio_id
|
suno_task_id, suno_audio_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# None이면 Suno 타임스탬프가 아직 미준비 상태.
|
|
||||||
# processing을 반환해 클라이언트가 폴링을 계속하도록 한다.
|
|
||||||
if word_data is None:
|
|
||||||
logger.info(
|
|
||||||
f"[get_song_status] 타임스탬프 미준비 - 폴링 유지, "
|
|
||||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}"
|
|
||||||
)
|
|
||||||
return PollingSongResponse(
|
|
||||||
success=True,
|
|
||||||
status="processing",
|
|
||||||
message="타임스탬프 생성 중입니다.",
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[get_song_status] word_data from get_lyric_timestamp - "
|
f"[get_song_status] word_data from get_lyric_timestamp - "
|
||||||
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
f"suno_task_id: {suno_task_id}, suno_audio_id: {suno_audio_id}, "
|
||||||
|
|||||||
@ -158,12 +158,10 @@ async def kakao_callback(
|
|||||||
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
logger.warning(f"[ROUTER] 소셜 계정 토큰 갱신 실패 (무시) - error: {e}")
|
||||||
|
|
||||||
# 프론트엔드로 토큰과 함께 리다이렉트
|
# 프론트엔드로 토큰과 함께 리다이렉트
|
||||||
# is_new_user: 프론트가 신규 가입 시에만 Meta CompleteRegistration 전환 추적을 호출하도록 전달
|
|
||||||
redirect_url = (
|
redirect_url = (
|
||||||
f"{prj_settings.PROJECT_DOMAIN}"
|
f"{prj_settings.PROJECT_DOMAIN}"
|
||||||
f"?access_token={result.access_token}"
|
f"?access_token={result.access_token}"
|
||||||
f"&refresh_token={result.refresh_token}"
|
f"&refresh_token={result.refresh_token}"
|
||||||
f"&is_new_user={str(result.is_new_user).lower()}"
|
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
f"[ROUTER] 카카오 콜백 완료, 프론트엔드로 리다이렉트 - redirect_url: {redirect_url[:50]}..."
|
||||||
|
|||||||
@ -16,10 +16,8 @@ from app.database.session import Base
|
|||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.credit.models import CreditChargeRequest, CreditTransaction
|
from app.credit.models import CreditChargeRequest, CreditTransaction
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.video.models import VideoReaction
|
|
||||||
|
|
||||||
|
|
||||||
class User(Base):
|
class User(Base):
|
||||||
@ -219,51 +217,6 @@ class User(Base):
|
|||||||
comment="마지막 로그인 일시",
|
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(
|
credits: Mapped[int] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -342,20 +295,6 @@ class User(Base):
|
|||||||
lazy="noload",
|
lazy="noload",
|
||||||
)
|
)
|
||||||
|
|
||||||
comments: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
video_reactions: Mapped[List["VideoReaction"]] = relationship(
|
|
||||||
"VideoReaction",
|
|
||||||
back_populates="user",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return (
|
return (
|
||||||
f"<User("
|
f"<User("
|
||||||
|
|||||||
@ -1,112 +0,0 @@
|
|||||||
# 특별시/광역시/세종시: 하위 행정구역이 구(gu)이므로 별도 처리
|
|
||||||
METRO_SIDOS: dict[str, str] = {
|
|
||||||
"서울특별시": "서울시",
|
|
||||||
"부산광역시": "부산시",
|
|
||||||
"대구광역시": "대구시",
|
|
||||||
"인천광역시": "인천시",
|
|
||||||
"광주광역시": "광주시",
|
|
||||||
"대전광역시": "대전시",
|
|
||||||
"울산광역시": "울산시",
|
|
||||||
"세종특별시": "세종시",
|
|
||||||
}
|
|
||||||
|
|
||||||
SIDO_CITIES: dict[str, list[str]] = {
|
|
||||||
"서울특별시": ["서울시"],
|
|
||||||
"부산광역시": ["부산시"],
|
|
||||||
"대구광역시": ["대구시"],
|
|
||||||
"인천광역시": ["인천시"],
|
|
||||||
"광주광역시": ["광주시"],
|
|
||||||
"대전광역시": ["대전시"],
|
|
||||||
"울산광역시": ["울산시"],
|
|
||||||
"세종특별시": ["세종시"],
|
|
||||||
"경기도": [
|
|
||||||
"수원시", "성남시", "고양시", "용인시", "부천시", "안산시", "안양시", "남양주시",
|
|
||||||
"화성시", "평택시", "의정부시", "시흥시", "파주시", "김포시", "광주시", "광명시",
|
|
||||||
"군포시", "하남시", "오산시", "이천시", "안성시", "구리시", "양주시", "포천시",
|
|
||||||
"여주시", "동두천시", "과천시", "가평군", "양평군", "연천군",
|
|
||||||
],
|
|
||||||
"강원도": [
|
|
||||||
"춘천시", "원주시", "강릉시", "동해시", "태백시", "속초시", "삼척시",
|
|
||||||
"홍천군", "횡성군", "영월군", "평창군", "정선군", "철원군", "화천군",
|
|
||||||
"양구군", "인제군", "고성군", "양양군",
|
|
||||||
],
|
|
||||||
"충청북도": ["청주시", "충주시", "제천시", "보은군", "옥천군", "영동군", "증평군", "진천군", "괴산군", "음성군", "단양군"],
|
|
||||||
"충청남도": ["천안시", "공주시", "보령시", "아산시", "서산시", "논산시", "계룡시", "당진시", "금산군", "부여군", "서천군", "청양군", "홍성군", "예산군", "태안군"],
|
|
||||||
"전라북도": ["전주시", "군산시", "익산시", "정읍시", "남원시", "김제시", "완주군", "진안군", "무주군", "장수군", "임실군", "순창군", "고창군", "부안군"],
|
|
||||||
"전라남도": ["목포시", "여수시", "순천시", "나주시", "광양시", "담양군", "곡성군", "구례군", "고흥군", "보성군", "화순군", "장흥군", "강진군", "해남군", "영암군", "무안군", "함평군", "영광군", "장성군", "완도군", "진도군", "신안군"],
|
|
||||||
"경상북도": ["포항시", "경주시", "김천시", "안동시", "구미시", "영주시", "영천시", "상주시", "문경시", "경산시", "의성군", "청송군", "영양군", "영덕군", "청도군", "고령군", "성주군", "칠곡군", "예천군", "봉화군", "울진군", "울릉군"],
|
|
||||||
"경상남도": ["창원시", "진주시", "통영시", "사천시", "김해시", "밀양시", "거제시", "양산시", "의령군", "함안군", "창녕군", "고성군", "남해군", "하동군", "산청군", "함양군", "거창군", "합천군"],
|
|
||||||
"제주도": ["제주시", "서귀포시"],
|
|
||||||
}
|
|
||||||
|
|
||||||
# 도 약칭 → 정식 명칭
|
|
||||||
SIDO_NAME_ALIASES: dict[str, str] = {
|
|
||||||
"서울": "서울특별시", "부산": "부산광역시", "대구": "대구광역시",
|
|
||||||
"인천": "인천광역시", "광주": "광주광역시", "대전": "대전광역시",
|
|
||||||
"울산": "울산광역시", "세종": "세종특별시",
|
|
||||||
"경기": "경기도", "강원": "강원도",
|
|
||||||
"충북": "충청북도", "충남": "충청남도",
|
|
||||||
"전북": "전라북도", "전남": "전라남도",
|
|
||||||
"경북": "경상북도", "경남": "경상남도",
|
|
||||||
"제주": "제주도",
|
|
||||||
}
|
|
||||||
|
|
||||||
# 도 정식 명칭 → 약칭 + 이형 목록 (필터 검색용)
|
|
||||||
SIDO_SEARCH_ALIASES: dict[str, list[str]] = {
|
|
||||||
"경기도": ["경기도", "경기"],
|
|
||||||
"강원도": ["강원도", "강원", "강원특별자치도"],
|
|
||||||
"충청북도": ["충청북도", "충북", "충북특별자치도"],
|
|
||||||
"충청남도": ["충청남도", "충남"],
|
|
||||||
"전라북도": ["전라북도", "전북", "전북특별자치도"],
|
|
||||||
"전라남도": ["전라남도", "전남"],
|
|
||||||
"경상북도": ["경상북도", "경북"],
|
|
||||||
"경상남도": ["경상남도", "경남"],
|
|
||||||
"제주도": ["제주도", "제주", "제주특별자치도"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def extract_sigungu(address: str) -> str:
|
|
||||||
"""주소 문자열에서 시/군 이름을 추출합니다."""
|
|
||||||
tokens = address.split()
|
|
||||||
if not tokens:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# 첫 토큰으로 도 판별 (정식명 or 약칭)
|
|
||||||
sido = SIDO_NAME_ALIASES.get(tokens[0], tokens[0])
|
|
||||||
|
|
||||||
# 특별시/광역시/세종시: 구(district) 레벨이 하위이므로 시 이름을 바로 반환
|
|
||||||
metro_name = METRO_SIDOS.get(sido)
|
|
||||||
if metro_name:
|
|
||||||
return metro_name
|
|
||||||
|
|
||||||
cities = SIDO_CITIES.get(sido)
|
|
||||||
if cities and len(tokens) >= 2:
|
|
||||||
second = tokens[1]
|
|
||||||
if second in cities:
|
|
||||||
return second
|
|
||||||
# DB에 없는 신설 행정구역 대비 — 시/군 접미사 폴백
|
|
||||||
if second.endswith(("시", "군")):
|
|
||||||
return second
|
|
||||||
|
|
||||||
# 도 판별 실패 시 전체 도에서 토큰 완전 일치 검색
|
|
||||||
token_set = set(tokens)
|
|
||||||
for city_list in SIDO_CITIES.values():
|
|
||||||
for city in city_list:
|
|
||||||
if city in token_set:
|
|
||||||
return city
|
|
||||||
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_region_from_address(
|
|
||||||
road_address: str | None,
|
|
||||||
jibun_address: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""도로명 주소 우선으로 시/군을 추출합니다. 실패 시 지번 주소로 재시도합니다."""
|
|
||||||
if road_address:
|
|
||||||
result = extract_sigungu(road_address)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
if jibun_address:
|
|
||||||
return extract_sigungu(jibun_address)
|
|
||||||
return ""
|
|
||||||
@ -6,49 +6,43 @@ from app.utils.prompts.schemas import SpaceType, Subject, Camera, MotionRecommen
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
# medium 추론은 출력 비용의 80%를 차지하고, minimal은 A/B 비교(4회)에서 narrative 점수가
|
async def autotag_image(image_url : str) -> list[str]: #tag_list
|
||||||
# welcome 단계로 편향되고 태그를 과다 선택하는 패턴이 반복돼 low로 고정한다.
|
chatgpt = ChatgptService(model_type="gemini")
|
||||||
IMAGE_TAG_REASONING_EFFORT = "low"
|
|
||||||
|
|
||||||
async def autotag_image(image_url : str, industry: str = "") -> list[str]: #tag_list
|
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
|
||||||
image_input_data = {
|
image_input_data = {
|
||||||
"img_url" : image_url,
|
"img_url" : image_url,
|
||||||
"industry" : industry,
|
|
||||||
"space_type" : list(SpaceType),
|
"space_type" : list(SpaceType),
|
||||||
"subject" : list(Subject),
|
"subject" : list(Subject),
|
||||||
"camera" : list(Camera),
|
"camera" : list(Camera),
|
||||||
"motion_recommended" : list(MotionRecommended)
|
"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
|
return image_result
|
||||||
|
|
||||||
async def autotag_images(image_url_list : list[str], industry: str = "") -> list[dict]: #tag_list
|
async def autotag_images(image_url_list : list[str]) -> list[dict]: #tag_list
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
chatgpt = ChatgptService(model_type="gemini")
|
||||||
image_input_data_list = [{
|
image_input_data_list = [{
|
||||||
"img_url" : image_url,
|
"img_url" : image_url,
|
||||||
"industry" : industry,
|
|
||||||
"space_type" : list(SpaceType),
|
"space_type" : list(SpaceType),
|
||||||
"subject" : list(Subject),
|
"subject" : list(Subject),
|
||||||
"camera" : list(Camera),
|
"camera" : list(Camera),
|
||||||
"motion_recommended" : list(MotionRecommended)
|
"motion_recommended" : list(MotionRecommended)
|
||||||
}for image_url in image_url_list]
|
}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)
|
image_result_list: list[BaseModel | BaseException] = await asyncio.gather(*image_result_tasks, return_exceptions=True)
|
||||||
MAX_RETRY = 2
|
MAX_RETRY = 2 # 하드코딩, 어떻게 처리할지는 나중에
|
||||||
for _ in range(MAX_RETRY):
|
for _ in range(MAX_RETRY):
|
||||||
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
failed_idx = [i for i, r in enumerate(image_result_list) if isinstance(r, Exception)]
|
||||||
# print("Failed", failed_idx)
|
print("Failed", failed_idx)
|
||||||
if not failed_idx:
|
if not failed_idx:
|
||||||
break
|
break
|
||||||
retried = await asyncio.gather(
|
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
|
return_exceptions=True
|
||||||
)
|
)
|
||||||
for i, result in zip(failed_idx, retried):
|
for i, result in zip(failed_idx, retried):
|
||||||
image_result_list[i] = result
|
image_result_list[i] = result
|
||||||
|
|
||||||
# print("Failed", failed_idx)
|
print("Failed", failed_idx)
|
||||||
return image_result_list
|
return image_result_list
|
||||||
@ -4,24 +4,15 @@ BGM 모드용 더미 가사 템플릿
|
|||||||
instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
|
instrumental=True 호출 시 Suno가 가사 길이/구조를 참고해 60초짜리 BGM을 생성하도록
|
||||||
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
|
placeholder 가사를 제공합니다. 실제 보컬은 생성되지 않습니다.
|
||||||
|
|
||||||
장르 순서: K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz
|
3가지 버전 모두 섹션 태그 없이 한국어 9줄로 통일.
|
||||||
섹션 태그 없이 한국어 10줄로 구성.
|
분위기(밝음/감성/에너지)만 가사 텍스트로 차별화합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_BGM_DUMMY_LYRICS: dict[str, str] = {
|
import random
|
||||||
"K-Pop": (
|
|
||||||
"반짝이는 눈빛으로 날 바라봐\n"
|
_BGM_DUMMY_LYRICS_LIST = [
|
||||||
"심장이 터질 것 같은 이 느낌\n"
|
# 버전 1 — 밝고 경쾌한 분위기
|
||||||
"너만 보면 세상이 달라 보여\n"
|
(
|
||||||
"오늘도 설레임이 멈추질 않아\n"
|
|
||||||
"같이 걸어가는 이 길 위에서\n"
|
|
||||||
"우리 둘만의 노래가 흘러\n"
|
|
||||||
"빛나는 순간들을 모아모아\n"
|
|
||||||
"영원히 기억할 우리의 이야기\n"
|
|
||||||
"지금 이 순간 네 곁에 있을게\n"
|
|
||||||
"함께라면 뭐든 할 수 있어\n"
|
|
||||||
),
|
|
||||||
"Pop": (
|
|
||||||
"햇살 가득한 아침이 시작되고\n"
|
"햇살 가득한 아침이 시작되고\n"
|
||||||
"따스한 바람이 살며시 불어와\n"
|
"따스한 바람이 살며시 불어와\n"
|
||||||
"거리마다 웃음꽃이 피어나고\n"
|
"거리마다 웃음꽃이 피어나고\n"
|
||||||
@ -33,31 +24,8 @@ _BGM_DUMMY_LYRICS: dict[str, str] = {
|
|||||||
"오늘 하루도 빛나는 하루야\n"
|
"오늘 하루도 빛나는 하루야\n"
|
||||||
"환한 미소로 하루를 마무리해\n"
|
"환한 미소로 하루를 마무리해\n"
|
||||||
),
|
),
|
||||||
"R&B": (
|
# 버전 2 — 잔잔하고 감성적인 분위기
|
||||||
"부드럽게 흐르는 이 그루브에\n"
|
(
|
||||||
"몸이 저절로 리듬을 타기 시작해\n"
|
|
||||||
"네 목소리가 귓가에 맴돌고\n"
|
|
||||||
"이 감각이 온몸을 감싸줘\n"
|
|
||||||
"달콤한 밤이 깊어질수록\n"
|
|
||||||
"너와 나 사이 거리가 좁혀져\n"
|
|
||||||
"촛불처럼 은은하게 타오르는\n"
|
|
||||||
"이 감정을 숨길 수가 없어\n"
|
|
||||||
"부드럽게 내 손을 잡아줘\n"
|
|
||||||
"오늘 밤 우리만의 노래를 불러\n"
|
|
||||||
),
|
|
||||||
"Hip-Hop": (
|
|
||||||
"내 방식대로 살아가는 이 길\n"
|
|
||||||
"누가 뭐래도 내 페이스로 가\n"
|
|
||||||
"매일 아침 눈을 뜨면 새로운 무대\n"
|
|
||||||
"두려움 없이 앞으로 나아가\n"
|
|
||||||
"땀과 노력으로 쌓아온 오늘\n"
|
|
||||||
"포기란 없어 끝까지 밀어붙여\n"
|
|
||||||
"내 이름을 기억해 두라고\n"
|
|
||||||
"이 무대 위에 내 발자국 남겨\n"
|
|
||||||
"하나둘 쌓여가는 내 이야기\n"
|
|
||||||
"진짜배기는 지금부터 시작이야\n"
|
|
||||||
),
|
|
||||||
"Ballad": (
|
|
||||||
"저녁 노을이 물드는 창가에서\n"
|
"저녁 노을이 물드는 창가에서\n"
|
||||||
"조용히 흘러가는 시간 속에\n"
|
"조용히 흘러가는 시간 속에\n"
|
||||||
"잔잔한 바람이 마음을 적시고\n"
|
"잔잔한 바람이 마음을 적시고\n"
|
||||||
@ -69,7 +37,8 @@ _BGM_DUMMY_LYRICS: dict[str, str] = {
|
|||||||
"이 순간 여기 머무는 것만으로도 충분해\n"
|
"이 순간 여기 머무는 것만으로도 충분해\n"
|
||||||
"고요한 밤이 나를 감싸 안아줘\n"
|
"고요한 밤이 나를 감싸 안아줘\n"
|
||||||
),
|
),
|
||||||
"EDM": (
|
# 버전 3 — 강렬하고 에너지 넘치는 분위기
|
||||||
|
(
|
||||||
"밤거리에 불빛이 타오르고\n"
|
"밤거리에 불빛이 타오르고\n"
|
||||||
"심장이 두근두근 뛰기 시작해\n"
|
"심장이 두근두근 뛰기 시작해\n"
|
||||||
"온몸에 퍼지는 뜨거운 열기\n"
|
"온몸에 퍼지는 뜨거운 열기\n"
|
||||||
@ -81,63 +50,14 @@ _BGM_DUMMY_LYRICS: dict[str, str] = {
|
|||||||
"끝까지 불태워 이 에너지를\n"
|
"끝까지 불태워 이 에너지를\n"
|
||||||
"새벽빛이 밝아올 때까지 달려\n"
|
"새벽빛이 밝아올 때까지 달려\n"
|
||||||
),
|
),
|
||||||
"Rock": (
|
]
|
||||||
"굉음을 내며 울려 퍼지는 기타\n"
|
|
||||||
"온몸을 뒤흔드는 강렬한 비트\n"
|
|
||||||
"규칙 따위는 집어치우고\n"
|
|
||||||
"있는 그대로 외쳐봐\n"
|
|
||||||
"불꽃처럼 타오르는 이 열정\n"
|
|
||||||
"아무도 막을 수 없어 지금\n"
|
|
||||||
"거침없이 달려가는 이 무대\n"
|
|
||||||
"목청껏 소리 질러 자유를\n"
|
|
||||||
"부서질 듯 뜨겁게 흔들어\n"
|
|
||||||
"이 밤이 끝날 때까지 록이야\n"
|
|
||||||
),
|
|
||||||
"Jazz": (
|
|
||||||
"커피 향이 피어오르는 오후\n"
|
|
||||||
"느긋하게 흐르는 재즈 선율에\n"
|
|
||||||
"발끝이 리듬을 타기 시작해\n"
|
|
||||||
"달콤한 여유가 가득 차오르고\n"
|
|
||||||
"창밖엔 황금빛 도시가 반짝여\n"
|
|
||||||
"잔을 들어 이 순간을 건배해\n"
|
|
||||||
"스윙하는 박자에 몸을 맡기고\n"
|
|
||||||
"부드럽게 흘러가는 이 밤을\n"
|
|
||||||
"기억 속에 새겨두고 싶어\n"
|
|
||||||
"재즈처럼 자유롭게 살고 싶어\n"
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_GENRE_ALIAS: dict[str, str] = {
|
def get_random_bgm_lyrics() -> tuple[str, int]:
|
||||||
"kpop": "K-Pop",
|
"""BGM 더미 가사 3종 중 하나를 랜덤으로 반환합니다.
|
||||||
"k-pop": "K-Pop",
|
|
||||||
"k_pop": "K-Pop",
|
|
||||||
"pop": "Pop",
|
|
||||||
"rnb": "R&B",
|
|
||||||
"r&b": "R&B",
|
|
||||||
"r_b": "R&B",
|
|
||||||
"hiphop": "Hip-Hop",
|
|
||||||
"hip-hop": "Hip-Hop",
|
|
||||||
"hip_hop": "Hip-Hop",
|
|
||||||
"ballad": "Ballad",
|
|
||||||
"edm": "EDM",
|
|
||||||
"rock": "Rock",
|
|
||||||
"jazz": "Jazz",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_genre(genre: str) -> str:
|
|
||||||
return _GENRE_ALIAS.get(genre.lower(), genre)
|
|
||||||
|
|
||||||
|
|
||||||
def get_bgm_lyrics(genre: str) -> str:
|
|
||||||
"""장르에 맞는 BGM 더미 가사를 반환합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
genre: 장르명 (K-Pop, Pop, R&B, Hip-Hop, Ballad, EDM, Rock, Jazz)
|
|
||||||
대소문자 및 구분자(-, _) 무관하게 처리됩니다.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
선택된 장르의 가사 텍스트
|
(lyrics, version): 선택된 가사 텍스트와 버전 번호 (1~3)
|
||||||
"""
|
"""
|
||||||
return _BGM_DUMMY_LYRICS[normalize_genre(genre)]
|
index = random.randrange(len(_BGM_DUMMY_LYRICS_LIST))
|
||||||
|
return _BGM_DUMMY_LYRICS_LIST[index], index + 1
|
||||||
|
|||||||
@ -30,7 +30,6 @@ response = await creatomate.make_creatomate_call(template_id, modifications)
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import copy
|
import copy
|
||||||
import random
|
|
||||||
import time
|
import time
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
@ -225,120 +224,28 @@ autotext_template_h_1 = {
|
|||||||
"stroke_color": "#333333",
|
"stroke_color": "#333333",
|
||||||
"stroke_width": "0.2 vmin"
|
"stroke_width": "0.2 vmin"
|
||||||
}
|
}
|
||||||
DVST0001 = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
DVST0001 = "75161273-0422-4771-adeb-816bd7263fb0"
|
||||||
DVRT0001 = "3c4b74c4-82d7-4742-9b05-4b999fef4cbd"
|
DVST0002 = "c68cf750-bc40-485a-a2c5-3f9fe301e386"
|
||||||
DVCF0001 = "ebb532ec-e49a-4a7e-b0e2-a8132f1bf1f7"
|
DVST0003 = "e1fb5b00-1f02-4f63-99fa-7524b433ba47"
|
||||||
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"
|
DHST0001 = "660be601-080a-43ea-bf0f-adcf4596fa98"
|
||||||
|
DHST0002 = "3f194cc7-464e-4581-9db2-179d42d3e40f"
|
||||||
HST_LIST = [DHST0001]
|
DHST0003 = "f45df555-2956-4a13-9004-ead047070b3d"
|
||||||
VST_LIST = [DVST0001,DVRT0001,DVCF0001,DVAT0001,DVSL0001,DVCL0001,DVFT0001,DVAC0001]
|
DVST0001T = "fe11aeab-ff29-4bc8-9f75-c695c7e243e6"
|
||||||
|
HST_LIST = [DHST0001,DHST0002,DHST0003]
|
||||||
# industry → 세로형 템플릿 매핑 (신규 세로형 템플릿 추가 시 여기에만 등록)
|
VST_LIST = [DVST0001,DVST0002,DVST0003, DVST0001T]
|
||||||
VERTICAL_INDUSTRY_TEMPLATE_MAP: dict[str, str] = {
|
|
||||||
"stay": DVST0001,
|
|
||||||
"restaurant": DVRT0001,
|
|
||||||
"cafe": DVCF0001,
|
|
||||||
"attraction": DVAT0001,
|
|
||||||
"salon": DVSL0001,
|
|
||||||
"clinic": DVCL0001,
|
|
||||||
"fitness": DVFT0001,
|
|
||||||
"academy": DVAC0001,
|
|
||||||
}
|
|
||||||
|
|
||||||
# 매핑에 없는 업종(general)의 폴백 템플릿
|
|
||||||
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
|
SCENE_TRACK = 1
|
||||||
AUDIO_TRACK = 2
|
AUDIO_TRACK = 2
|
||||||
SUBTITLE_TRACK = 3
|
SUBTITLE_TRACK = 3
|
||||||
KEYWORD_TRACK = 4
|
KEYWORD_TRACK = 4
|
||||||
|
|
||||||
# 썸네일 컴포지션의 이미지 슬롯명 접미사 (8개 업종 템플릿 전수 확인, 예:
|
def select_template(orientation:OrientationType):
|
||||||
# "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:
|
|
||||||
"""orientation과 industry에 따라 Creatomate 템플릿 ID를 선택합니다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
orientation: 영상 방향 ("horizontal" 또는 "vertical")
|
|
||||||
industry: 업종 분류 (stay|restaurant|cafe|attraction 등).
|
|
||||||
세로형에서 매핑에 있으면 전용 템플릿을 반환합니다.
|
|
||||||
project_id: 미매핑 업종(general 등)의 세로형 분배용 키.
|
|
||||||
project_id % len(VST_LIST)로 결정적으로 선택하므로, 같은
|
|
||||||
프로젝트는 몇 번 호출해도 동일 템플릿을 받아 사전/렌더 단계가
|
|
||||||
일치한다. 없으면 DEFAULT_VERTICAL_TEMPLATE로 폴백한다.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
선택된 템플릿 ID
|
|
||||||
"""
|
|
||||||
if orientation == "horizontal":
|
if orientation == "horizontal":
|
||||||
template_id = DHST0001
|
return DHST0001
|
||||||
elif orientation == "vertical":
|
elif orientation == "vertical":
|
||||||
mapped = VERTICAL_INDUSTRY_TEMPLATE_MAP.get(industry)
|
return DVST0001T
|
||||||
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
|
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
logger.info(f"[select_template] orientation={orientation}, industry={industry}, project_id={project_id}, template_id={template_id}")
|
|
||||||
return template_id
|
|
||||||
|
|
||||||
async def get_shared_client() -> httpx.AsyncClient:
|
async def get_shared_client() -> httpx.AsyncClient:
|
||||||
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
"""공유 HTTP 클라이언트를 반환합니다. 없으면 생성합니다."""
|
||||||
@ -386,25 +293,21 @@ class CreatomateService:
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
orientation: OrientationType = "vertical",
|
orientation: OrientationType = "vertical"
|
||||||
industry: str | None = None,
|
|
||||||
project_id: int | None = None,
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Args:
|
Args:
|
||||||
api_key: Creatomate API 키 (Bearer token으로 사용)
|
api_key: Creatomate API 키 (Bearer token으로 사용)
|
||||||
None일 경우 config에서 자동으로 가져옴
|
None일 경우 config에서 자동으로 가져옴
|
||||||
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
orientation: 영상 방향 ("horizontal" 또는 "vertical", 기본값: "vertical")
|
||||||
industry: 업종 분류 (stay|restaurant|cafe|attraction 등). 세로형 템플릿 선택에 사용되며,
|
target_duration: 목표 영상 길이 (초)
|
||||||
매핑에 있으면 전용 템플릿을 사용
|
None일 경우 orientation에 해당하는 기본값 사용
|
||||||
project_id: 미매핑 업종(general 등)의 세로형 분배용 키. 매핑에 없을 때
|
|
||||||
project_id % len(VST_LIST)로 결정적 선택
|
|
||||||
"""
|
"""
|
||||||
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
self.api_key = api_key or apikey_settings.CREATOMATE_API_KEY
|
||||||
self.orientation = orientation
|
self.orientation = orientation
|
||||||
|
|
||||||
# orientation·industry에 따른 템플릿 설정 가져오기
|
# orientation에 따른 템플릿 설정 가져오기
|
||||||
self.template_id = select_template(orientation, industry=industry, project_id=project_id)
|
self.template_id = select_template(orientation)
|
||||||
self.headers = {
|
self.headers = {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Authorization": f"Bearer {self.api_key}",
|
"Authorization": f"Bearer {self.api_key}",
|
||||||
@ -540,340 +443,52 @@ class CreatomateService:
|
|||||||
template : dict,
|
template : dict,
|
||||||
target_template_type : str
|
target_template_type : str
|
||||||
) -> list:
|
) -> list:
|
||||||
"""target_template_type 요소 개수를 셉니다.
|
|
||||||
|
|
||||||
target_template_type이 "image"인 경우, 고정 자산('-fixed' 명명) 및
|
|
||||||
5토큰 명명 규칙을 따르지 않는 요소(고정 워터마크 등)는 콘텐츠 슬롯이
|
|
||||||
아니므로 카운트에서 제외합니다 (template_matching_taged_image의
|
|
||||||
image_slots 필터링과 동일 기준).
|
|
||||||
"""
|
|
||||||
source_elements = template["source"]["elements"]
|
source_elements = template["source"]["elements"]
|
||||||
template_component_data = self.parse_template_component_name(source_elements)
|
template_component_data = self.parse_template_component_name(source_elements)
|
||||||
count = 0
|
count = 0
|
||||||
|
|
||||||
for name, template_type in template_component_data.items():
|
for _, (_, template_type) in enumerate(template_component_data.items()):
|
||||||
if template_type != target_template_type:
|
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
|
count += 1
|
||||||
return count
|
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(
|
def template_matching_taged_image(
|
||||||
self,
|
self,
|
||||||
template : dict,
|
template : dict,
|
||||||
taged_image_list: list, # [{"image_url": str, "image_tag": dict}] — 이미 마케팅 적합성 필터를 통과한 이미지만 전달할 것
|
taged_image_list : list, # [{"image_name" : str , "image_tag" : dict}]
|
||||||
music_url: str,
|
music_url: str,
|
||||||
address : str,
|
address : str,
|
||||||
duplicate: bool = False,
|
duplicate : bool = False
|
||||||
thumbnail_fitness_map: dict | None = None, # {image_url: {"score", "reject"}} — 썸네일 슬롯 픽셀 적합도
|
) -> list:
|
||||||
thumbnail_choice: dict | None = None, # {슬롯명: image_url} — 비전 LLM 최종 선택(있으면 결정론 최고점 대신 사용)
|
|
||||||
) -> tuple[dict, dict]:
|
|
||||||
"""템플릿 슬롯에 이미지를 배정합니다.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(modifications, assigned) 튜플
|
|
||||||
- modifications: {슬롯명: image_url, "audio-music": music_url, ...} — modify_element에 전달
|
|
||||||
- assigned: {슬롯명: image_tag} — 자막 생성 컨텍스트에 전달 (modify_element에 넣지 않음)
|
|
||||||
|
|
||||||
배정 전략 (duplicate=False):
|
|
||||||
- 순수 greedy pop 대신 "난이도 우선 greedy" 적용
|
|
||||||
- 사전에 전체 슬롯 점수를 계산해 최고 달성 점수가 낮은(=까다로운) 슬롯을 먼저 배정
|
|
||||||
- 이미지 배정 직전 점수를 재계산 (pop 이후 인덱스 변동 대응)
|
|
||||||
|
|
||||||
배정 전략 (duplicate=True, 이미지 < 슬롯):
|
|
||||||
- 1단계(최소 커버리지 보장): 이미지별로 가장 잘 맞는 슬롯을 찾아 pool의 모든 이미지를
|
|
||||||
서로 다른 슬롯에 먼저 배정한다 (이미지 수 <= 슬롯 수이므로 전량 배정 가능).
|
|
||||||
슬롯을 먼저 소진시키는 것이 아니라 "이미지"를 기준으로 난이도 우선 배정하여,
|
|
||||||
특정 이미지 몇 장이 여러 슬롯을 독점하는 것을 방지한다.
|
|
||||||
- 2단계: 1단계에서 못 채운 나머지 슬롯은 기존처럼 pool 전체에서 최고점 이미지를
|
|
||||||
배정한다 (여기서부터는 중복 재사용 불가피).
|
|
||||||
|
|
||||||
Note:
|
|
||||||
marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되므로 여기서는 재필터링하지 않는다.
|
|
||||||
taged_image_list를 그대로 pool로 사용한다.
|
|
||||||
"""
|
|
||||||
source_elements = template["source"]["elements"]
|
source_elements = template["source"]["elements"]
|
||||||
template_component_data = self.parse_template_component_name(source_elements)
|
template_component_data = self.parse_template_component_name(source_elements)
|
||||||
|
|
||||||
pool = taged_image_list
|
taged_image_list = [img for img in taged_image_list if img.get("image_tag") is not None]
|
||||||
modifications: dict = {}
|
modifications = {}
|
||||||
assigned: dict = {} # {슬롯명: image_tag} — 자막 컨텍스트용
|
|
||||||
|
|
||||||
# 이미지 슬롯과 텍스트 슬롯 분리
|
for slot_idx, (template_component_name, template_type) in enumerate(template_component_data.items()):
|
||||||
# 고정 자산(이름이 '-fixed'로 끝나는 로고 등) 및 5토큰 명명 규칙을 따르지
|
match template_type:
|
||||||
# 않는 image 요소는 콘텐츠 슬롯이 아니므로 배정 대상에서 제외 — 그렇지
|
case "image":
|
||||||
# 않으면 파싱 실패로 0점 처리되어 "가장 까다로운 슬롯"으로 취급되고
|
image_score_list = self.calculate_image_slot_score_multi(taged_image_list, template_component_name)
|
||||||
# 무작위 이미지로 덮어써진다.
|
maximum_idx = image_score_list.index(max(image_score_list))
|
||||||
# 썸네일 슬롯(-9999)은 파싱 실패해도 배정해야 하므로 여기서 제외하고
|
if duplicate:
|
||||||
# _collect_thumbnail_slots가 별도로 수집한다.
|
selected = taged_image_list[maximum_idx]
|
||||||
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
|
|
||||||
]
|
|
||||||
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:
|
else:
|
||||||
sel_idx, sel_source = max(range(len(scores)), key=lambda i: scores[i]), "결정론"
|
selected = taged_image_list.pop(maximum_idx)
|
||||||
selected = pool[sel_idx] if duplicate else pool.pop(sel_idx)
|
image_name = selected["image_url"]
|
||||||
modifications[slot] = selected["image_url"]
|
modifications[template_component_name] =image_name
|
||||||
assigned[slot] = selected["image_tag"]
|
pass
|
||||||
logger.info(
|
case "text":
|
||||||
f"[template_matching_taged_image] 썸네일 배정({sel_source}) — slot: {slot}, "
|
if "address_input" in template_component_name:
|
||||||
f"score: {scores[sel_idx]:.3f}, url: {selected['image_url']}"
|
modifications[template_component_name] = address
|
||||||
)
|
|
||||||
|
|
||||||
if not pool:
|
|
||||||
logger.warning("[template_matching_taged_image] 태그된 이미지가 없음 — 이미지 슬롯 배정 불가")
|
|
||||||
elif duplicate:
|
|
||||||
# 이미지 부족(슬롯 수 > 이미지 수)
|
|
||||||
# Step 1: 최소 커버리지 보장 — 이미지마다 가장 잘 맞는 슬롯을 찾아 서로 다른 슬롯에 배정
|
|
||||||
remaining_slots = list(image_slots)
|
|
||||||
|
|
||||||
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]
|
|
||||||
if score > best_score:
|
|
||||||
best_slot, best_score = slot, score
|
|
||||||
return best_slot, best_score
|
|
||||||
|
|
||||||
# 사전 점수 계산 (최고 달성 점수가 낮을수록 배정처가 마땅찮은=까다로운 이미지)
|
|
||||||
prelim_best_score = [
|
|
||||||
_best_slot_for_image(image, remaining_slots)[1] for image in pool
|
|
||||||
]
|
|
||||||
ordered_indices = sorted(
|
|
||||||
range(len(pool)),
|
|
||||||
key=lambda i: prelim_best_score[i]
|
|
||||||
)
|
|
||||||
|
|
||||||
for idx in ordered_indices:
|
|
||||||
if not remaining_slots:
|
|
||||||
break
|
|
||||||
image = pool[idx]
|
|
||||||
# 슬롯이 이전 배정으로 줄어들었으므로 현재 remaining_slots 기준 재계산
|
|
||||||
best_slot, _ = _best_slot_for_image(image, remaining_slots)
|
|
||||||
if best_slot is None:
|
|
||||||
continue
|
|
||||||
modifications[best_slot] = image["image_url"]
|
|
||||||
assigned[best_slot] = image["image_tag"]
|
|
||||||
remaining_slots.remove(best_slot)
|
|
||||||
|
|
||||||
# Step 2: 커버리지 배정 후 남은 슬롯은 pool 전체에서 최고점 이미지 배정 (재사용 불가피)
|
|
||||||
for slot in remaining_slots:
|
|
||||||
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
|
|
||||||
if not scores:
|
|
||||||
continue
|
|
||||||
best_idx = scores.index(max(scores))
|
|
||||||
selected = pool[best_idx]
|
|
||||||
modifications[slot] = selected["image_url"]
|
|
||||||
assigned[slot] = selected["image_tag"]
|
|
||||||
else:
|
|
||||||
# 이미지 충분(슬롯 수 <= 이미지 수): 난이도 우선 greedy 배정
|
|
||||||
# Step 1: 정렬용 사전 점수 계산 (최고 달성 점수가 낮을수록 배정이 어려운 슬롯)
|
|
||||||
prelim = {slot: self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map) for slot in image_slots}
|
|
||||||
ordered_slots = sorted(
|
|
||||||
image_slots,
|
|
||||||
key=lambda s: max(prelim[s]) if prelim[s] else 0.0
|
|
||||||
# 오름차순 → 달성 최대값이 낮은(어려운) 슬롯이 앞으로
|
|
||||||
)
|
|
||||||
|
|
||||||
# Step 2: 난이도 순서로 배정, 배정마다 pool에서 pop + 다음 슬롯은 점수 재계산
|
|
||||||
for slot in ordered_slots:
|
|
||||||
if not pool:
|
|
||||||
logger.warning(f"[template_matching_taged_image] 이미지 풀 소진 — 남은 슬롯 배정 불가: {slot}")
|
|
||||||
break
|
|
||||||
# pop 후 인덱스 변동이 있으므로 현재 pool로 재계산 (사전 점수 재사용 금지)
|
|
||||||
scores = self._slot_scores_with_fitness(pool, slot, thumbnail_fitness_map)
|
|
||||||
if not scores:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# 상위 3장 중 점수 비례 확률로 선택 (영상 다양화)
|
|
||||||
sorted_indices = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True)
|
|
||||||
top_indices = sorted_indices[:min(3, len(sorted_indices))]
|
|
||||||
weights = [scores[i] for i in top_indices]
|
|
||||||
if sum(weights) > 0:
|
|
||||||
chosen_pool_idx = random.choices(top_indices, weights=weights, k=1)[0]
|
|
||||||
else:
|
|
||||||
chosen_pool_idx = random.choice(top_indices)
|
|
||||||
|
|
||||||
selected = pool.pop(chosen_pool_idx)
|
|
||||||
modifications[slot] = selected["image_url"]
|
|
||||||
assigned[slot] = selected["image_tag"]
|
|
||||||
|
|
||||||
# 텍스트 슬롯 처리
|
|
||||||
for name, _ in text_slots:
|
|
||||||
if "address_input" in name:
|
|
||||||
modifications[name] = address
|
|
||||||
|
|
||||||
modifications["audio-music"] = music_url
|
modifications["audio-music"] = music_url
|
||||||
return modifications, assigned
|
return modifications
|
||||||
|
|
||||||
# 점수 계산 상수 (모듈 레벨로 빼면 더 좋지만 기존 코드 스타일 따라 클래스 내부에 위치)
|
|
||||||
_NARR_FLOOR = 0.25 # narrative 최솟값 modulator: base 점수가 완전히 0이 되는 것을 방지
|
|
||||||
_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):
|
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)
|
|
||||||
- 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]
|
image_tag_list = [taged_image["image_tag"] for taged_image in taged_image_list]
|
||||||
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
|
slot_tag_dict = self.parse_slot_name_to_tag(slot_name)
|
||||||
if slot_tag_dict is None:
|
image_score_list = [0] * len(image_tag_list)
|
||||||
# parse 실패 시 전부 0점 반환 (슬롯 skip)
|
|
||||||
return [0.0] * len(image_tag_list)
|
|
||||||
|
|
||||||
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():
|
for slot_tag_cate, slot_tag_item in slot_tag_dict.items():
|
||||||
if slot_tag_cate == "narrative_preference":
|
if slot_tag_cate == "narrative_preference":
|
||||||
@ -882,58 +497,32 @@ class CreatomateService:
|
|||||||
|
|
||||||
match slot_tag_cate:
|
match slot_tag_cate:
|
||||||
case "space_type":
|
case "space_type":
|
||||||
weight = 2.0
|
weight = 2
|
||||||
case "subject" :
|
case "subject" :
|
||||||
weight = 3.0
|
weight = 2
|
||||||
case "camera":
|
case "camera":
|
||||||
weight = 1.0
|
weight = 1
|
||||||
case "motion_recommended" :
|
case "motion_recommended" :
|
||||||
weight = 0.5
|
weight = 0.5
|
||||||
case _:
|
case _:
|
||||||
raise ValueError(f"[calculate_image_slot_score_multi] unknown slot tag category: {slot_tag_cate}")
|
raise
|
||||||
|
|
||||||
for idx, image_tag in enumerate(image_tag_list):
|
for idx, image_tag in enumerate(image_tag_list):
|
||||||
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
|
if slot_tag_item.value in image_tag[slot_tag_cate]: #collect!
|
||||||
base_score_list[idx] += weight
|
image_score_list[idx] += weight
|
||||||
|
|
||||||
# narrative 점수 적용: 순수 곱셈 대신 "완화 modulator + 독립 보너스"
|
|
||||||
# - narr_mod: base를 흐릴 수 있지만 NARR_FLOOR 이하로는 안 떨어짐
|
|
||||||
# - NARR_BONUS * narr: base=0이어도 narrative가 높으면 양수 점수 확보
|
|
||||||
image_score_list = []
|
|
||||||
for idx, image_tag in enumerate(image_tag_list):
|
for idx, image_tag in enumerate(image_tag_list):
|
||||||
raw_narr = image_tag.get("narrative_preference", {}).get(slot_tag_narrative, 0.0)
|
image_narrative_score = image_tag["narrative_preference"][slot_tag_narrative]
|
||||||
clamped_narr = min(self._NARR_MAX, max(self._NARR_MIN, float(raw_narr)))
|
image_score_list[idx] = image_score_list[idx] * image_narrative_score
|
||||||
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
|
return image_score_list
|
||||||
|
|
||||||
def parse_slot_name_to_tag(self, slot_name: str) -> dict[str, StrEnum] | None:
|
def parse_slot_name_to_tag(self, slot_name : str) -> dict[str, StrEnum]:
|
||||||
"""슬롯 이름을 파싱하여 태그 딕셔너리를 반환합니다.
|
|
||||||
|
|
||||||
슬롯 이름 형식: {space_type}-{subject}-{camera}-{motion}-{narrative}
|
|
||||||
|
|
||||||
위치 기반 파싱에 실패하면 토큰 위치를 무시한 대조로 한 번 더 시도한다
|
|
||||||
(_parse_slot_name_loosely). 슬롯명 오타로 슬롯이 배정에서 빠지면
|
|
||||||
modify_element가 템플릿 원본(샘플 이미지)을 그대로 남겨 완성 영상에
|
|
||||||
그대로 나가기 때문이다.
|
|
||||||
|
|
||||||
둘 다 실패하면 None을 반환합니다 (호출자가 해당 슬롯을 skip+log 처리).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tag_list = slot_name.split("-")
|
tag_list = slot_name.split("-")
|
||||||
if len(tag_list) < 5:
|
|
||||||
logger.warning(f"[parse_slot_name_to_tag] 슬롯명 토큰 부족 ({len(tag_list)}개): '{slot_name}' — 슬롯 skip")
|
|
||||||
return None
|
|
||||||
space_type = SpaceType(tag_list[0])
|
space_type = SpaceType(tag_list[0])
|
||||||
subject = Subject(tag_list[1])
|
subject = Subject(tag_list[1])
|
||||||
camera = Camera(tag_list[2])
|
camera = Camera(tag_list[2])
|
||||||
# 모션 동의어(zoom_in, pan_left 등)는 표준 enum 값으로 정규화 후 변환
|
motion = MotionRecommended(tag_list[3])
|
||||||
motion = MOTION_TOKEN_NORMALIZATION.get(tag_list[3]) or MotionRecommended(tag_list[3])
|
|
||||||
narrative = NarrativePhase(tag_list[4])
|
narrative = NarrativePhase(tag_list[4])
|
||||||
tag_dict = {
|
tag_dict = {
|
||||||
"space_type" : space_type,
|
"space_type" : space_type,
|
||||||
@ -943,64 +532,6 @@ class CreatomateService:
|
|||||||
"narrative_preference" : narrative,
|
"narrative_preference" : narrative,
|
||||||
}
|
}
|
||||||
return tag_dict
|
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(
|
def elements_connect_resource_blackbox(
|
||||||
self,
|
self,
|
||||||
@ -1031,26 +562,20 @@ class CreatomateService:
|
|||||||
return modifications
|
return modifications
|
||||||
|
|
||||||
def modify_element(self, elements: list, modification: dict) -> list:
|
def modify_element(self, elements: list, modification: dict) -> list:
|
||||||
"""elements의 source를 modification에 따라 수정합니다.
|
"""elements의 source를 modification에 따라 수정합니다."""
|
||||||
|
|
||||||
modification에 없는 image/text 요소(예: '-fixed' 명명의 고정 로고·
|
|
||||||
연락처 문구처럼 배정/자막 생성 대상에서 제외된 고정 콘텐츠)는 템플릿에
|
|
||||||
이미 설정된 source/text를 그대로 보존한다 (KeyError·빈 문자열로
|
|
||||||
렌더가 깨지지 않도록).
|
|
||||||
"""
|
|
||||||
|
|
||||||
def recursive_modify(element: dict) -> None:
|
def recursive_modify(element: dict) -> None:
|
||||||
if "name" in element:
|
if "name" in element:
|
||||||
match element["type"]:
|
match element["type"]:
|
||||||
case "image":
|
case "image":
|
||||||
element["source"] = modification.get(element["name"], element.get("source"))
|
element["source"] = modification[element["name"]]
|
||||||
case "audio":
|
case "audio":
|
||||||
element["source"] = modification.get(element["name"], "")
|
element["source"] = modification.get(element["name"], "")
|
||||||
case "video":
|
case "video":
|
||||||
element["source"] = modification[element["name"]]
|
element["source"] = modification[element["name"]]
|
||||||
case "text":
|
case "text":
|
||||||
#element["source"] = modification[element["name"]]
|
#element["source"] = modification[element["name"]]
|
||||||
element["text"] = modification.get(element["name"], element.get("text", ""))
|
element["text"] = modification.get(element["name"], "")
|
||||||
case "composition":
|
case "composition":
|
||||||
for minor in element["elements"]:
|
for minor in element["elements"]:
|
||||||
recursive_modify(minor)
|
recursive_modify(minor)
|
||||||
@ -1154,8 +679,6 @@ class CreatomateService:
|
|||||||
"""
|
"""
|
||||||
url = f"{self.BASE_URL}/v2/renders"
|
url = f"{self.BASE_URL}/v2/renders"
|
||||||
|
|
||||||
source["frame_rate"] = 30
|
|
||||||
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
for attempt in range(recovery_settings.CREATOMATE_MAX_RETRIES + 1):
|
||||||
@ -1232,20 +755,6 @@ class CreatomateService:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return response.json()
|
return response.json()
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _entrance_transition_offset(elem: dict) -> float:
|
|
||||||
"""요소의 entrance transition(transition=true, time==0) duration 합을 반환합니다.
|
|
||||||
|
|
||||||
순차 배치 시 이 값만큼 이전 요소와 겹치도록 시작점이 앞당겨집니다.
|
|
||||||
"""
|
|
||||||
offset = 0.0
|
|
||||||
for animation in elem.get("animations", []):
|
|
||||||
if "reversed" in animation:
|
|
||||||
continue
|
|
||||||
if "transition" in animation and animation["transition"] and animation.get("time", 0) == 0:
|
|
||||||
offset += animation["duration"]
|
|
||||||
return offset
|
|
||||||
|
|
||||||
def calc_scene_duration(self, template: dict) -> float:
|
def calc_scene_duration(self, template: dict) -> float:
|
||||||
"""템플릿의 전체 장면 duration을 계산합니다."""
|
"""템플릿의 전체 장면 duration을 계산합니다."""
|
||||||
total_template_duration = 0.0
|
total_template_duration = 0.0
|
||||||
@ -1259,7 +768,16 @@ class CreatomateService:
|
|||||||
if elem["track"] not in track_maximum_duration:
|
if elem["track"] not in track_maximum_duration:
|
||||||
continue
|
continue
|
||||||
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
|
if "time" not in elem or elem["time"] == 0: # elem is auto / 만약 마지막 elem이 auto인데 그 앞에 time이 있는 elem 일 시 버그 발생 확률 있음
|
||||||
track_maximum_duration[elem["track"]] += elem["duration"] - self._entrance_transition_offset(elem)
|
track_maximum_duration[elem["track"]] += elem["duration"]
|
||||||
|
|
||||||
|
if "animations" not in elem:
|
||||||
|
continue
|
||||||
|
for animation in elem["animations"]:
|
||||||
|
if "reversed" in animation:
|
||||||
|
continue
|
||||||
|
assert animation.get("time",0) == 0 # 0이 아닌 경우 확인 필요
|
||||||
|
if "transition" in animation and animation["transition"]:
|
||||||
|
track_maximum_duration[elem["track"]] -= animation["duration"]
|
||||||
else:
|
else:
|
||||||
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
|
track_maximum_duration[elem["track"]] = max(track_maximum_duration[elem["track"]], elem["time"] + elem["duration"])
|
||||||
|
|
||||||
@ -1271,73 +789,6 @@ class CreatomateService:
|
|||||||
|
|
||||||
return total_template_duration
|
return total_template_duration
|
||||||
|
|
||||||
def calc_track_time_ranges(self, template: dict, track: int) -> list[tuple[str, float, float]]:
|
|
||||||
"""최상위 요소 중 지정 track의 (name, start, end) 시간 범위를 계산합니다.
|
|
||||||
|
|
||||||
Creatomate 규칙에 따라 `time`이 없거나 0인 요소는 같은 트랙의 이전 요소 뒤에
|
|
||||||
순차 배치되며, entrance transition duration만큼 이전 요소와 겹치도록
|
|
||||||
시작점을 앞당깁니다(calc_scene_duration의 차감 규칙과 동치).
|
|
||||||
`time`이 명시된 요소는 해당 시각에서 시작하고, 커서는 그 요소의 끝으로 이동합니다.
|
|
||||||
"""
|
|
||||||
ranges: list[tuple[str, float, float]] = []
|
|
||||||
cursor = 0.0
|
|
||||||
for elem in template["source"]["elements"]:
|
|
||||||
try:
|
|
||||||
if elem.get("track") != track:
|
|
||||||
continue
|
|
||||||
duration = elem["duration"]
|
|
||||||
|
|
||||||
if "time" not in elem or elem["time"] == 0: # 순차 배치(auto)
|
|
||||||
start = max(0.0, cursor - self._entrance_transition_offset(elem))
|
|
||||||
else:
|
|
||||||
start = elem["time"]
|
|
||||||
|
|
||||||
end = start + duration
|
|
||||||
cursor = end
|
|
||||||
ranges.append((elem.get("name", ""), start, end))
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(traceback.format_exc())
|
|
||||||
logger.error(f"[calc_track_time_ranges] Error processing element: {elem}, {e}")
|
|
||||||
|
|
||||||
return ranges
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _scale_element(elem: dict, extend_rate: float) -> None:
|
|
||||||
"""element 하나의 time/duration/animations를 extend_rate만큼 스케일링합니다.
|
|
||||||
|
|
||||||
composition의 자식 element까지 재귀적으로 처리합니다. 재귀하지 않으면
|
|
||||||
부모 컴포지션(씬 슬롯)만 늘어나고 내부 leaf 이미지/애니메이션은 원래
|
|
||||||
길이에서 끝나버려, 슬롯이 끝나기 전 컴포지션의 fill_color(회색)가
|
|
||||||
노출되는 버그가 생긴다 — modify_element/parse_template_component_name과
|
|
||||||
동일하게 composition을 재귀 순회해야 함.
|
|
||||||
|
|
||||||
오디오 판별은 track 번호가 아닌 type == "audio"로 한다. composition
|
|
||||||
내부의 track 번호는 그 컴포지션 안에서만 유효한 로컬 레이어 인덱스라
|
|
||||||
전역 AUDIO_TRACK/KEYWORD_TRACK 상수와 우연히 값이 겹칠 수 있다
|
|
||||||
(실제 템플릿에도 다중 레이어 씬의 2번째/3번째 이미지가 track=2, track=4인
|
|
||||||
경우가 존재함).
|
|
||||||
"""
|
|
||||||
if elem.get("type") == "audio":
|
|
||||||
return
|
|
||||||
|
|
||||||
# "time"은 숫자 오프셋 대신 "end"(컴포지션 끝에 고정) 같은 특수 키워드일 수 있음 —
|
|
||||||
# 이런 값은 렌더 시점에 (이미 스케일링된) duration 기준으로 재계산되므로 그대로 둔다.
|
|
||||||
if "time" in elem and isinstance(elem["time"], (int, float)):
|
|
||||||
elem["time"] = elem["time"] * extend_rate
|
|
||||||
if "duration" in elem and isinstance(elem["duration"], (int, float)):
|
|
||||||
elem["duration"] = elem["duration"] * extend_rate
|
|
||||||
|
|
||||||
for animation in elem.get("animations", []):
|
|
||||||
anim_time = animation.get("time", 0)
|
|
||||||
if isinstance(anim_time, (int, float)) and anim_time != 0:
|
|
||||||
animation["time"] = anim_time * extend_rate
|
|
||||||
if isinstance(animation.get("duration"), (int, float)):
|
|
||||||
animation["duration"] = animation["duration"] * extend_rate
|
|
||||||
|
|
||||||
if elem.get("type") == "composition":
|
|
||||||
for child in elem.get("elements", []):
|
|
||||||
CreatomateService._scale_element(child, extend_rate)
|
|
||||||
|
|
||||||
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
def extend_template_duration(self, template: dict, target_duration: float) -> dict:
|
||||||
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
"""템플릿의 duration을 target_duration으로 확장합니다."""
|
||||||
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
# template["duration"] = target_duration + 0.5 # 늘린것보단 짧게
|
||||||
@ -1348,10 +799,21 @@ class CreatomateService:
|
|||||||
|
|
||||||
for elem in new_template["source"]["elements"]:
|
for elem in new_template["source"]["elements"]:
|
||||||
try:
|
try:
|
||||||
if elem["track"] == AUDIO_TRACK : # audio track(최상위 음악 트랙)은 패스
|
# if elem["type"] == "audio":
|
||||||
|
# continue
|
||||||
|
if elem["track"] == AUDIO_TRACK : # audio track은 패스
|
||||||
continue
|
continue
|
||||||
|
|
||||||
self._scale_element(elem, extend_rate)
|
if "time" in elem:
|
||||||
|
elem["time"] = elem["time"] * extend_rate
|
||||||
|
if "duration" in elem:
|
||||||
|
elem["duration"] = elem["duration"] * extend_rate
|
||||||
|
|
||||||
|
if "animations" not in elem:
|
||||||
|
continue
|
||||||
|
for animation in elem["animations"]:
|
||||||
|
assert animation["time"] == 0 # 0이 아닌 경우 확인 필요
|
||||||
|
animation["duration"] = animation["duration"] * extend_rate
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
f"[extend_template_duration] Error processing element: {elem}, {e}"
|
||||||
@ -1388,38 +850,12 @@ class CreatomateService:
|
|||||||
case "horizontal":
|
case "horizontal":
|
||||||
return autotext_template_h_1
|
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):
|
def extract_text_format_from_template(self, template:dict):
|
||||||
keyword_list = []
|
keyword_list = []
|
||||||
subtitle_list = []
|
subtitle_list = []
|
||||||
for elem in template["source"]["elements"]:
|
for elem in template["source"]["elements"]:
|
||||||
try: #최상위 내 텍스트만 검사
|
try: #최상위 내 텍스트만 검사
|
||||||
if elem["type"] == "text" and not is_fixed_slot_name(elem["name"]):
|
if elem["type"] == "text":
|
||||||
if elem["track"] == SUBTITLE_TRACK:
|
if elem["track"] == SUBTITLE_TRACK:
|
||||||
subtitle_list.append(elem["name"])
|
subtitle_list.append(elem["name"])
|
||||||
elif elem["track"] == KEYWORD_TRACK:
|
elif elem["track"] == KEYWORD_TRACK:
|
||||||
@ -1433,41 +869,21 @@ class CreatomateService:
|
|||||||
assert(len(keyword_list)==len(subtitle_list))
|
assert(len(keyword_list)==len(subtitle_list))
|
||||||
except Exception as E:
|
except Exception as E:
|
||||||
logger.error("this template does not have same amount of keyword and subtitle.")
|
logger.error("this template does not have same amount of keyword and subtitle.")
|
||||||
|
pitching_list = keyword_list + subtitle_list
|
||||||
# 썸네일 텍스트(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
|
|
||||||
return pitching_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
|
len_keywords = len(target_keywords) if len(target_keywords) < 3 else 3
|
||||||
|
|
||||||
hashtaged_target_keywords = [f"#{tk}" for tk in target_keywords[:len_keywords]]
|
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 = {
|
mod_dict = {
|
||||||
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
"thumb-hashtag-primary" : ' '.join(hashtaged_target_keywords),
|
||||||
"thumb-headline-brand_name-factual" : brand_name,
|
"thumb-brand-wordmark" : brand_name,
|
||||||
"thumb-subheadline-local_info-factual" : region_display,
|
"thumb-subheadline-selling_point" : f"{brand_name} · {normalize_location(region)}",
|
||||||
|
"thumb-headline-hook_claim-aspirational" : brand_concept,
|
||||||
"thumb-badge-category" : category_definition,
|
"thumb-badge-category" : category_definition,
|
||||||
}
|
}
|
||||||
return mod_dict
|
return mod_dict
|
||||||
|
|||||||
@ -1,125 +0,0 @@
|
|||||||
"""크롤링 단계 이미지 마케팅 적합성 필터.
|
|
||||||
|
|
||||||
마케팅 적합성(marketing_acceptable) 판정은 이 모듈이 전담한다. 크롤링 시점에
|
|
||||||
방문자/AI View 사진(extra_photo_urls)만을 대상으로 판정하며, 업로드 이후 단계의
|
|
||||||
태깅(app/utils/autotag.py의 autotag_images(), sheet 기반 image_autotag_prompt)은
|
|
||||||
공간/피사체/카메라/모션/내러티브 태그만 다루고 marketing_acceptable은 포함하지 않는다
|
|
||||||
(이미 이 단계에서 걸러진 이미지만 태깅 대상이 되기 때문). 프롬프트가 짧아
|
|
||||||
시트(Prompt) 대신 코드에 하드코딩한다.
|
|
||||||
|
|
||||||
업체 등록 사진(owner_images)은 이 필터의 대상이 아니며, 호출측(라우터)에서
|
|
||||||
owner_images 개수가 NvMapScraper.SUPPLEMENT_THRESHOLD(30) 이상이면 이 모듈을
|
|
||||||
아예 호출하지 않는 것이 원칙이다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
|
||||||
from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|
||||||
from app.utils.prompts.schemas import MarketingFilterOutput
|
|
||||||
|
|
||||||
logger = get_logger("image_filter")
|
|
||||||
|
|
||||||
# 크롤링 단계 필터링에 사용할 Gemini 모델. 시트(prompts.py)와 달리 코드에 고정한다.
|
|
||||||
MARKETING_FILTER_MODEL = "gpt-5-mini"
|
|
||||||
|
|
||||||
MAX_RETRY = 2
|
|
||||||
|
|
||||||
MARKETING_FILTER_PROMPT_TEMPLATE = """\
|
|
||||||
당신은 광고 영상 제작을 위한 이미지 심사자입니다. 아래 업종의 광고 영상에 이 이미지를 \
|
|
||||||
사용해도 되는지 판정하세요.
|
|
||||||
|
|
||||||
업종(industry): {industry}
|
|
||||||
|
|
||||||
## MARKETING SUITABILITY CHECK (industry-aware)
|
|
||||||
- marketing_acceptable: true | false
|
|
||||||
- reject_reason: short reason if false (e.g., 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출). Empty if acceptable.
|
|
||||||
- Person identity protection: 식별 가능한 얼굴이 주피사체이고 동의 여부가 불확실하면 보수적으로 처리.
|
|
||||||
|
|
||||||
주어진 스키마에 맞춰 marketing_acceptable과 reject_reason만 반환하세요.\
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
def _build_prompt(industry: str) -> str:
|
|
||||||
return MARKETING_FILTER_PROMPT_TEMPLATE.format(industry=industry or "미상")
|
|
||||||
|
|
||||||
|
|
||||||
async def filter_marketing_images(image_url_list: list[str], industry: str = "") -> list[bool]:
|
|
||||||
"""이미지 URL마다 마케팅 적합성(marketing_acceptable)만 판정해 통과 여부 리스트를 반환한다.
|
|
||||||
|
|
||||||
이미지 1장당 API 호출 1회를 asyncio.gather로 병렬 실행한다(배치 호출 아님).
|
|
||||||
실패한 이미지는 최대 MAX_RETRY회 재시도하며, 그래도 실패하면 보수적으로
|
|
||||||
False(제외)로 처리한다.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
image_url_list: 판정할 이미지 URL 목록 (extra_photo_urls의 original만 전달할 것)
|
|
||||||
industry: 업종 분류값 (_resolve_industry 결과). 필터 프롬프트 앞에서 먼저 계산되어야 함.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
image_url_list와 같은 순서의 통과 여부(bool) 리스트.
|
|
||||||
"""
|
|
||||||
if not image_url_list:
|
|
||||||
return []
|
|
||||||
|
|
||||||
chatgpt = ChatgptService(model_type="gpt")
|
|
||||||
prompt_text = _build_prompt(industry)
|
|
||||||
|
|
||||||
async def _call(url: str):
|
|
||||||
return await chatgpt._call_pydantic_output_chat_completion(
|
|
||||||
prompt=prompt_text,
|
|
||||||
output_format=MarketingFilterOutput,
|
|
||||||
model=MARKETING_FILTER_MODEL,
|
|
||||||
img_url=url,
|
|
||||||
image_detail_high=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
results: list[MarketingFilterOutput | BaseException] = await asyncio.gather(
|
|
||||||
*[_call(url) for url in image_url_list], return_exceptions=True
|
|
||||||
)
|
|
||||||
|
|
||||||
for _ in range(MAX_RETRY):
|
|
||||||
failed_idx = [i for i, r in enumerate(results) if isinstance(r, Exception)]
|
|
||||||
if not failed_idx:
|
|
||||||
break
|
|
||||||
# logger.warning(f"[image_filter] 재시도 대상 인덱스: {failed_idx}")
|
|
||||||
retried = await asyncio.gather(
|
|
||||||
*[_call(image_url_list[i]) for i in failed_idx], return_exceptions=True
|
|
||||||
)
|
|
||||||
for i, result in zip(failed_idx, retried):
|
|
||||||
results[i] = result
|
|
||||||
|
|
||||||
final_failed = [i for i, r in enumerate(results) if isinstance(r, Exception)]
|
|
||||||
# if final_failed:
|
|
||||||
# logger.warning(
|
|
||||||
# f"[image_filter] {len(final_failed)}건 최종 실패 → 보수적으로 제외 처리: {final_failed}"
|
|
||||||
# )
|
|
||||||
|
|
||||||
return [
|
|
||||||
(not isinstance(r, Exception)) and r.marketing_acceptable
|
|
||||||
for r in results
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def assemble_images(
|
|
||||||
owner_images: list[dict],
|
|
||||||
extra_images: list[dict],
|
|
||||||
extra_pass_flags: list[bool],
|
|
||||||
max_images: int,
|
|
||||||
) -> list[dict]:
|
|
||||||
"""owner_images 전량 + 필터 통과한 extra_images를 합쳐 최대 max_images장을 만든다.
|
|
||||||
|
|
||||||
owner_images는 무조건 포함(필터링 대상 아님). extra_images는 extra_pass_flags와
|
|
||||||
같은 순서로 매칭되며, 통과분만 순서대로 이어붙인다. original URL 기준으로 중복을
|
|
||||||
제거한다(nvMapScraper._extract_origins의 dedup 방식과 동일).
|
|
||||||
"""
|
|
||||||
passed_extra = [img for img, ok in zip(extra_images, extra_pass_flags) if ok]
|
|
||||||
|
|
||||||
seen: set[str] = set()
|
|
||||||
combined: list[dict] = []
|
|
||||||
for img in owner_images + passed_extra:
|
|
||||||
original = img.get("original")
|
|
||||||
if original and original not in seen:
|
|
||||||
seen.add(original)
|
|
||||||
combined.append(img)
|
|
||||||
|
|
||||||
return combined[:max_images]
|
|
||||||
@ -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
|
|
||||||
@ -1,7 +1,5 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import random
|
|
||||||
import re
|
import re
|
||||||
from html import unescape
|
|
||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
from playwright.async_api import async_playwright
|
from playwright.async_api import async_playwright
|
||||||
from urllib import parse
|
from urllib import parse
|
||||||
@ -20,76 +18,23 @@ class NvMapPwScraper():
|
|||||||
_win_width = 1280
|
_win_width = 1280
|
||||||
_win_height = 720
|
_win_height = 720
|
||||||
_max_retry = 3
|
_max_retry = 3
|
||||||
_timeout = 30 # place id timeout threshold seconds
|
_timeout = 60 # place id timeout threshold seconds
|
||||||
_retry_delay_ms = 1500 # 검색 실패 후 재시도 전 대기시간 (네이버 요청 밀도 완화)
|
|
||||||
|
|
||||||
# UA·뷰포트·sec-ch-ua를 한 세트로 묶은 브라우저 프로필 후보군 (안티봇 핑거프린트 회피용)
|
|
||||||
_UA_PROFILES = [
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
|
|
||||||
"viewport": {"width": 1280, "height": 720},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="129", "Google Chrome";v="129"',
|
|
||||||
"viewport": {"width": 1366, "height": 768},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="130", "Google Chrome";v="130"',
|
|
||||||
"viewport": {"width": 1440, "height": 900},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
||||||
"sec_ch_ua": '"Not?A_Brand";v="99", "Chromium";v="131", "Google Chrome";v="131"',
|
|
||||||
"viewport": {"width": 1536, "height": 864},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
_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
|
# instance var
|
||||||
page = None
|
page = None
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _pick_profile(cls):
|
|
||||||
"""현재 프로필과 다른 프로필을 무작위로 선택한다 (후보가 1개뿐이면 그대로 반환)."""
|
|
||||||
candidates = [p for p in cls._UA_PROFILES if p is not cls._current_profile]
|
|
||||||
return random.choice(candidates or cls._UA_PROFILES)
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def default_context_builder(cls):
|
def default_context_builder(cls):
|
||||||
cls._current_profile = cls._pick_profile()
|
|
||||||
profile = cls._current_profile
|
|
||||||
|
|
||||||
context_builder_dict = {}
|
context_builder_dict = {}
|
||||||
context_builder_dict['viewport'] = dict(profile['viewport'])
|
context_builder_dict['viewport'] = {
|
||||||
context_builder_dict['screen'] = dict(profile['viewport'])
|
'width' : cls._win_width,
|
||||||
context_builder_dict['user_agent'] = profile['user_agent']
|
'height' : cls._win_height
|
||||||
|
}
|
||||||
|
context_builder_dict['screen'] = {
|
||||||
|
'width' : cls._win_width,
|
||||||
|
'height' : cls._win_height
|
||||||
|
}
|
||||||
|
context_builder_dict['user_agent'] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||||||
context_builder_dict['locale'] = 'ko-KR'
|
context_builder_dict['locale'] = 'ko-KR'
|
||||||
context_builder_dict['timezone_id']='Asia/Seoul'
|
context_builder_dict['timezone_id']='Asia/Seoul'
|
||||||
|
|
||||||
@ -105,147 +50,6 @@ if (originalQuery) {
|
|||||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
||||||
cls.is_ready = True
|
cls.is_ready = True
|
||||||
|
|
||||||
@classmethod
|
|
||||||
async def _recreate_context(cls):
|
|
||||||
"""네이버 안티봇 차단이 의심될 때 브라우저 컨텍스트를 새로 생성해 세션/핑거프린트를 초기화한다."""
|
|
||||||
old_context = cls._context
|
|
||||||
cls._context = await cls._browser.new_context(**cls.default_context_builder())
|
|
||||||
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
|
|
||||||
async def fetch_graphql(
|
|
||||||
cls,
|
|
||||||
place_id: str,
|
|
||||||
payloads: list[dict],
|
|
||||||
capture_apollo: bool = False,
|
|
||||||
) -> list[dict | None] | None | tuple[list[dict | None] | None, str | None]:
|
|
||||||
"""실제 브라우저로 네이버 WTM 안티봇 캡차를 통과해 GraphQL 쿼리를 실행한다.
|
|
||||||
|
|
||||||
네이버 pcmap GraphQL은 두 헤더를 검사한다:
|
|
||||||
- x-wtm-graphql : base64({"arg": place_id, "type": ..., "source": "place"})
|
|
||||||
- x-wtm-ncaptcha-token : 캡차 JS가 생성 (요청마다 발급, 직접 생성 불가)
|
|
||||||
둘 다 없으면 405(캡차)로 막힌다. 따라서 place 페이지를 실제로 로드해
|
|
||||||
페이지가 자연 발생시키는 GraphQL 요청에서 두 헤더를 캡처한 뒤,
|
|
||||||
같은 토큰으로 우리 쿼리들을 in-page fetch로 실행한다.
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
page = await cls._new_stealth_page()
|
|
||||||
captured: dict = {}
|
|
||||||
|
|
||||||
def on_request(req):
|
|
||||||
if "/graphql" in req.url and req.method == "POST" and "tok" not in captured:
|
|
||||||
tok = req.headers.get("x-wtm-ncaptcha-token")
|
|
||||||
if tok:
|
|
||||||
captured["tok"] = tok
|
|
||||||
captured["wtm"] = req.headers.get("x-wtm-graphql")
|
|
||||||
|
|
||||||
page.on("request", on_request)
|
|
||||||
|
|
||||||
try:
|
|
||||||
for t in ("place", "restaurant", "accommodation"):
|
|
||||||
try:
|
|
||||||
await page.goto(
|
|
||||||
f"https://pcmap.place.naver.com/{t}/{place_id}/home",
|
|
||||||
wait_until="domcontentloaded",
|
|
||||||
timeout=30000,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"[NvMapPwScraper] goto {t} 실패: {e}")
|
|
||||||
continue
|
|
||||||
# 페이지가 GraphQL 요청을 보내며 토큰이 헤더에 실릴 때까지 대기 (최대 ~9초)
|
|
||||||
for _ in range(30):
|
|
||||||
if captured.get("tok"):
|
|
||||||
break
|
|
||||||
await page.wait_for_timeout(300)
|
|
||||||
if captured.get("tok"):
|
|
||||||
logger.info(f"[NvMapPwScraper] WTM 토큰 캡처 성공 (type={t})")
|
|
||||||
break
|
|
||||||
|
|
||||||
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}")
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"x-wtm-graphql": captured["wtm"],
|
|
||||||
"x-wtm-ncaptcha-token": captured["tok"],
|
|
||||||
}
|
|
||||||
|
|
||||||
results: list[dict | None] = []
|
|
||||||
for idx, payload in enumerate(payloads, start=1):
|
|
||||||
r = await page.evaluate(
|
|
||||||
"""async (a) => {
|
|
||||||
const [url, body, hdr] = a;
|
|
||||||
try {
|
|
||||||
const r = await fetch(url, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: hdr,
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (r.status !== 200) return {__err: r.status};
|
|
||||||
return await r.json();
|
|
||||||
} catch (e) { return {__err: String(e)}; }
|
|
||||||
}""",
|
|
||||||
[cls.GRAPHQL_URL, payload, headers],
|
|
||||||
)
|
|
||||||
if isinstance(r, dict) and "__err" in r:
|
|
||||||
op = payload.get("operationName", "?")
|
|
||||||
logger.warning(
|
|
||||||
f"[NvMapPwScraper] graphql fetch 실패 "
|
|
||||||
f"({idx}/{len(payloads)} {op}): {r['__err']}"
|
|
||||||
)
|
|
||||||
results.append(None)
|
|
||||||
else:
|
|
||||||
results.append(r)
|
|
||||||
return _ret(results, apollo_json)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[NvMapPwScraper] fetch_graphql 오류: {e}")
|
|
||||||
return _ret(None)
|
|
||||||
finally:
|
|
||||||
await page.close()
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
if not self.is_ready:
|
if not self.is_ready:
|
||||||
raise Exception("nvMapScraper is not initiated")
|
raise Exception("nvMapScraper is not initiated")
|
||||||
@ -258,7 +62,35 @@ if (originalQuery) {
|
|||||||
await self.page.close()
|
await self.page.close()
|
||||||
|
|
||||||
async def create_page(self):
|
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': '\"Not?A_Brand\";v=\"99\", \"Chromium\";v=\"130\"'
|
||||||
|
})
|
||||||
await self.page.goto("http://google.com")
|
await self.page.goto("http://google.com")
|
||||||
|
|
||||||
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
async def goto_url(self, url, wait_until="domcontentloaded", timeout=20000):
|
||||||
@ -267,9 +99,7 @@ if (originalQuery) {
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _clean_title(text: str) -> str:
|
def _clean_title(text: str) -> str:
|
||||||
text = unescape(text) # HTML 엔티티 디코딩 (& → &)
|
return re.sub(r"<.*?>", "", text).strip()
|
||||||
text = re.sub(r"<.*?>", "", text) # HTML 태그 제거
|
|
||||||
return text.strip()
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _similarity(a: str, b: str) -> float:
|
def _similarity(a: str, b: str) -> float:
|
||||||
@ -351,114 +181,33 @@ if (originalQuery) {
|
|||||||
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
|
logger.debug(f"[DEBUG] 목록 후보 {len(candidates)}개 추출")
|
||||||
return candidates
|
return candidates
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_allsearch_candidates(body: dict) -> list[dict]:
|
|
||||||
"""allSearch 응답 JSON에서 후보 목록을 추출한다."""
|
|
||||||
place_list = (((body.get("result") or {}).get("place") or {}).get("list")) or []
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"title": p.get("name") or "",
|
|
||||||
"place_url": f"https://map.naver.com/p/entry/place/{p['id']}",
|
|
||||||
"roadAddress": p.get("roadAddress") or "",
|
|
||||||
"address": p.get("address") or "",
|
|
||||||
}
|
|
||||||
for p in place_list
|
|
||||||
if p.get("id")
|
|
||||||
]
|
|
||||||
|
|
||||||
def _select_best_candidate(self, candidates: list[dict], title: str, address: str) -> dict | None:
|
|
||||||
"""이름 유사도(70%)와 주소 유사도(30%)를 함께 고려해 최적 후보를 선택한다.
|
|
||||||
|
|
||||||
주소 없이 업체명만으로 검색한 경우(3차 폴백)는 이름 유사도만 사용한다.
|
|
||||||
"""
|
|
||||||
if not candidates:
|
|
||||||
return None
|
|
||||||
|
|
||||||
for c in candidates:
|
|
||||||
name_score = self._similarity(title, self._clean_title(c["title"]))
|
|
||||||
cand_addr = c.get("roadAddress") or c.get("address") or ""
|
|
||||||
addr_score = self._similarity(address, cand_addr) if address and cand_addr else 0.0
|
|
||||||
c["_name_score"] = name_score
|
|
||||||
c["_addr_score"] = addr_score
|
|
||||||
c["_total_score"] = name_score * 0.7 + addr_score * 0.3 if address else name_score
|
|
||||||
|
|
||||||
return max(candidates, key=lambda c: c["_total_score"])
|
|
||||||
|
|
||||||
async def _capture_allsearch(self, url: str, timeout_s: float = 5.0) -> list[dict]:
|
|
||||||
"""검색 페이지가 자연 발생시키는 allSearch API 응답을 캡처해 후보 목록으로 변환한다.
|
|
||||||
|
|
||||||
pcmap iframe 렌더링을 기다릴 필요가 없어 경쟁 조건이 없고, 업체명 단독
|
|
||||||
검색처럼 iframe 자체가 생성되지 않는 케이스에서도 동작한다.
|
|
||||||
"""
|
|
||||||
captured: list[dict] = []
|
|
||||||
|
|
||||||
def on_response(resp):
|
|
||||||
# GET 요청의 실제 응답만 캡처 (OPTIONS preflight 등 오탐 방지)
|
|
||||||
if "allSearch" in resp.url and resp.request.method == "GET":
|
|
||||||
async def _consume():
|
|
||||||
try:
|
|
||||||
captured.append(await resp.json())
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
asyncio.create_task(_consume())
|
|
||||||
|
|
||||||
self.page.on("response", on_response)
|
|
||||||
try:
|
|
||||||
try:
|
|
||||||
await self.goto_url(url, wait_until="domcontentloaded", timeout=self._timeout * 1000)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[ERROR] Can't Finish domcontentloaded")
|
|
||||||
|
|
||||||
deadline = time.monotonic() + timeout_s
|
|
||||||
while time.monotonic() < deadline:
|
|
||||||
if "/place/" in self.page.url or captured:
|
|
||||||
break
|
|
||||||
await self.page.wait_for_timeout(200)
|
|
||||||
finally:
|
|
||||||
self.page.remove_listener("response", on_response)
|
|
||||||
|
|
||||||
if not captured:
|
|
||||||
return []
|
|
||||||
return self._parse_allsearch_candidates(captured[0])
|
|
||||||
|
|
||||||
async def _try_search(self, address: str, title: str) -> str | None:
|
async def _try_search(self, address: str, title: str) -> str | None:
|
||||||
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
|
"""주어진 주소+업체명으로 검색해서 place URL을 반환한다. 실패 시 None."""
|
||||||
encoded_query = parse.quote(f"{address} {title}".strip())
|
encoded_query = parse.quote(f"{address} {title}".strip())
|
||||||
url = f"https://map.naver.com/p/search/{encoded_query}"
|
url = f"https://map.naver.com/p/search/{encoded_query}"
|
||||||
|
|
||||||
# 1순위: allSearch API 응답 캡처 (iframe 렌더링 대기 불필요, 업체명 단독 검색도 지원)
|
try:
|
||||||
candidates = await self._capture_allsearch(url)
|
await self.goto_url(url, wait_until="networkidle", timeout=self._timeout * 1000)
|
||||||
|
except:
|
||||||
|
if "/place/" in self.page.url:
|
||||||
|
return self.page.url
|
||||||
|
logger.error("[ERROR] Can't Finish networkidle")
|
||||||
|
|
||||||
if "/place/" in self.page.url:
|
if "/place/" in self.page.url:
|
||||||
return self.page.url
|
return self.page.url
|
||||||
|
|
||||||
|
candidates = await self._extract_candidates_from_list_page()
|
||||||
if candidates:
|
if candidates:
|
||||||
best = self._select_best_candidate(candidates, title, address)
|
best = max(
|
||||||
|
candidates,
|
||||||
|
key=lambda c: self._similarity(title, self._clean_title(c['title']))
|
||||||
|
)
|
||||||
|
best_score = self._similarity(title, self._clean_title(best['title']))
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[AUTO-SELECT] '{title}' → '{best['title']}' "
|
f"[AUTO-SELECT] '{title}' → '{best['title']}' (score={best_score:.2f}) {best['place_url']}"
|
||||||
f"(name={best['_name_score']:.2f}, addr={best['_addr_score']:.2f}) {best['place_url']}"
|
|
||||||
)
|
)
|
||||||
return best['place_url']
|
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']
|
|
||||||
|
|
||||||
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
# isCorrectAnswer=true 로 강제 단일결과 재시도 (원본 로직 유지)
|
||||||
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
correct_url = self.page.url.replace("?", "?isCorrectAnswer=true&")
|
||||||
try:
|
try:
|
||||||
@ -486,27 +235,16 @@ if (originalQuery) {
|
|||||||
# 2차 시도: 정제 주소 + 업체명
|
# 2차 시도: 정제 주소 + 업체명
|
||||||
refined = self._refine_address(address)
|
refined = self._refine_address(address)
|
||||||
if refined != address:
|
if refined != address:
|
||||||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
|
||||||
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
logger.info(f"[REFINE] 주소 정제: '{address}' → '{refined}'")
|
||||||
result = await self._try_search(refined, title)
|
result = await self._try_search(refined, title)
|
||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 3차 시도: 업체명만으로 검색
|
# 3차 시도: 업체명만으로 검색
|
||||||
await self.page.wait_for_timeout(self._retry_delay_ms)
|
|
||||||
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
logger.info(f"[RETRY] 업체명만으로 재시도: '{title}'")
|
||||||
result = await self._try_search("", title)
|
result = await self._try_search("", title)
|
||||||
if result:
|
if result:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# 1~3차 모두 실패 → 네이버 안티봇 차단 의심, 컨텍스트 재생성 후 원본 조건으로 1회 재시도
|
|
||||||
logger.warning(f"[BLOCK-SUSPECTED] 3회 시도 모두 실패 → 컨텍스트 재생성 후 재시도: '{title}'")
|
|
||||||
await self.page.close()
|
|
||||||
await self._recreate_context()
|
|
||||||
await self.create_page()
|
|
||||||
result = await self._try_search(address, title)
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
|
|
||||||
logger.error(f"[ERROR] Not found url for {selected}")
|
logger.error(f"[ERROR] Not found url for {selected}")
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -35,13 +35,6 @@ class NvMapScraper:
|
|||||||
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
GRAPHQL_URL: str = "https://pcmap-api.place.naver.com/graphql"
|
||||||
REQUEST_TIMEOUT = 120 # 초
|
REQUEST_TIMEOUT = 120 # 초
|
||||||
data_source_identifier = "nv"
|
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)
|
|
||||||
OVERVIEW_QUERY: str = """
|
OVERVIEW_QUERY: str = """
|
||||||
query getAccommodation($id: String!, $deviceType: String) {
|
query getAccommodation($id: String!, $deviceType: String) {
|
||||||
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
business: placeDetail(input: {id: $id, isNx: true, deviceType: $deviceType}) {
|
||||||
@ -57,37 +50,8 @@ query getAccommodation($id: String!, $deviceType: String) {
|
|||||||
conveniences
|
conveniences
|
||||||
visitorReviewsTotal
|
visitorReviewsTotal
|
||||||
}
|
}
|
||||||
menus {
|
|
||||||
name
|
|
||||||
price
|
|
||||||
description
|
|
||||||
recommend
|
|
||||||
}
|
|
||||||
images { images { origin url } }
|
images { images { origin url } }
|
||||||
}
|
cpImages(source: [ugcImage]) { images { origin url } }
|
||||||
}"""
|
|
||||||
|
|
||||||
PHOTO_VIEWER_QUERY: str = """
|
|
||||||
query getPhotoViewerItems($input: PhotoViewerInput) {
|
|
||||||
photoViewer(input: $input) {
|
|
||||||
photos {
|
|
||||||
originalUrl
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}"""
|
|
||||||
|
|
||||||
REVIEW_STATS_QUERY: str = """
|
|
||||||
query getVisitorReviewStats($id: String!) {
|
|
||||||
visitorReviewStats(input: {businessId: $id}) {
|
|
||||||
analysis {
|
|
||||||
votedKeyword {
|
|
||||||
details {
|
|
||||||
code
|
|
||||||
displayName
|
|
||||||
count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}"""
|
}"""
|
||||||
|
|
||||||
@ -105,14 +69,9 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
)
|
)
|
||||||
self.scrap_type: str | None = None
|
self.scrap_type: str | None = None
|
||||||
self.rawdata: dict | None = None
|
self.rawdata: dict | None = None
|
||||||
self.image_link_list: list[dict] | None = None # [{"preview": str, "original": str}]
|
self.image_link_list: list[str] | None = None
|
||||||
self.owner_images: list[dict] | None = None # 업체 등록 사진 (마케팅 필터 제외 대상)
|
|
||||||
self.extra_photo_urls: list[dict] | None = None # 방문자/AI View 보충 사진 (마케팅 필터 대상)
|
|
||||||
self.base_info: dict | None = None
|
self.base_info: dict | None = None
|
||||||
self.facility_info: str | None = None
|
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:
|
def _get_request_headers(self) -> dict:
|
||||||
headers = self.DEFAULT_HEADERS.copy()
|
headers = self.DEFAULT_HEADERS.copy()
|
||||||
@ -120,98 +79,6 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
headers["Cookie"] = self.cookies
|
headers["Cookie"] = self.cookies
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
_GIF_PATTERN = re.compile(r"\.gif(?:/|$)", re.IGNORECASE)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _is_gif_url(cls, url: str) -> bool:
|
|
||||||
"""URL의 확장자가 gif인지 확인한다.
|
|
||||||
|
|
||||||
네이버 CDN은 리사이즈 파라미터를 확장자 뒤에 경로 세그먼트로 붙인다
|
|
||||||
(예: ".../3982000924.gif/500x500") — endswith(".gif")로는 잡히지 않으므로
|
|
||||||
경로 세그먼트 단위로 ".gif" 뒤에 "/" 또는 문자열 끝이 오는지 검사한다.
|
|
||||||
"""
|
|
||||||
return bool(cls._GIF_PATTERN.search(url))
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_origins(image_node: dict) -> list[dict]:
|
|
||||||
"""GraphQL 이미지 노드({ images: [{origin, url}, ...] })에서 {preview, original} 목록을 추출한다.
|
|
||||||
origin = 원본(업로드용), url = CDN 리사이즈(미리보기용)
|
|
||||||
"""
|
|
||||||
items = image_node.get("images") or []
|
|
||||||
return [
|
|
||||||
{"preview": item.get("url") or item["origin"], "original": item["origin"]}
|
|
||||||
for item in items
|
|
||||||
if item.get("origin") and not NvMapScraper._is_gif_url(item["origin"])
|
|
||||||
]
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _extract_photo_viewer_urls(photo_viewer_raw: dict | None) -> list[dict]:
|
|
||||||
"""getPhotoViewerItems 응답에서 {preview, original} 목록을 추출한다.
|
|
||||||
photoViewer는 썸네일 필드가 없으므로 preview = original 동일하게 사용.
|
|
||||||
"""
|
|
||||||
photos = ((photo_viewer_raw or {}).get("data") or {}).get("photoViewer") or {}
|
|
||||||
items = photos.get("photos") or []
|
|
||||||
return [
|
|
||||||
{"preview": p["originalUrl"], "original": p["originalUrl"]}
|
|
||||||
for p in items
|
|
||||||
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 기준으로 순서를 유지한 채 중복 제거한다."""
|
|
||||||
seen: set[str] = set()
|
|
||||||
result = []
|
|
||||||
for img in images:
|
|
||||||
original = img.get("original")
|
|
||||||
if original and original not in seen:
|
|
||||||
seen.add(original)
|
|
||||||
result.append(img)
|
|
||||||
return result
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _interleave(a: list, b: list) -> list:
|
|
||||||
"""두 리스트를 1:1 교차 병합한다. 남은 항목은 뒤에 붙인다."""
|
|
||||||
result = [x for pair in zip(a, b) for x in pair]
|
|
||||||
longer = a[len(b):] if len(a) > len(b) else b[len(a):]
|
|
||||||
return result + longer
|
|
||||||
|
|
||||||
async def parse_url(self) -> str:
|
async def parse_url(self) -> str:
|
||||||
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
"""URL에서 place ID를 추출합니다. 단축 URL인 경우 실제 URL로 변환합니다."""
|
||||||
place_pattern = r"/place/(\d+)"
|
place_pattern = r"/place/(\d+)"
|
||||||
@ -226,267 +93,28 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
raise URLNotFoundException("This URL does not contain a place ID")
|
raise URLNotFoundException("This URL does not contain a place ID")
|
||||||
|
|
||||||
match = re.search(place_pattern, self.url)
|
match = re.search(place_pattern, self.url)
|
||||||
if not match:
|
|
||||||
# place.naver.com/{type}/{id} 형식 (예: m.place.naver.com/restaurant/11667161)
|
|
||||||
type_match = re.search(r"place\.naver\.com/([a-zA-Z]+)/(\d+)", self.url)
|
|
||||||
if type_match:
|
|
||||||
return type_match.group(2)
|
|
||||||
if not match:
|
if not match:
|
||||||
raise URLNotFoundException("Failed to parse place ID from URL")
|
raise URLNotFoundException("Failed to parse place ID from URL")
|
||||||
return match[1]
|
return match[1]
|
||||||
|
|
||||||
async def scrap(self):
|
async def scrap(self):
|
||||||
place_id = await self.parse_url()
|
place_id = await self.parse_url()
|
||||||
|
data = await self._call_get_accommodation(place_id)
|
||||||
# ── 빠른 경로: 직접 aiohttp 호출 (현재 비활성화) ──
|
|
||||||
# 데이터센터 IP에서는 네이버 WTM 안티봇이 대부분 405(캡차)로 차단해
|
|
||||||
# 시간만 버리므로 비활성화하고 곧바로 브라우저 경로를 탄다.
|
|
||||||
# 네이버가 차단을 완화하거나 다른 IP에서 운영해 직접 호출 성공률이
|
|
||||||
# 높아지면 아래 try 블록을 되살려 빠른 경로를 우선 시도하면 된다.
|
|
||||||
# try:
|
|
||||||
# data, fac_data, stats_data = await asyncio.gather(
|
|
||||||
# self._call_get_accommodation(place_id),
|
|
||||||
# self._get_facility_string(place_id),
|
|
||||||
# self._call_get_review_stats(place_id),
|
|
||||||
# )
|
|
||||||
# self.scrap_type = "GraphQL"
|
|
||||||
# except (GraphQLException, CrawlingTimeoutException) as e:
|
|
||||||
# logger.info(f"[NvMapScraper] 직접 호출 실패({e}) → 브라우저 폴백 시도")
|
|
||||||
# data, stats_data = await self._scrap_via_browser(place_id)
|
|
||||||
# fac_data = None # 편의시설(HTML)은 폴백 경로에서 생략 (best-effort)
|
|
||||||
# self.scrap_type = "GraphQL-Browser"
|
|
||||||
|
|
||||||
# ── 실제 브라우저로 WTM 캡차 우회 ──
|
|
||||||
data, stats_data, extra_photo_urls, biz_photo_urls, homepage_url = 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
|
|
||||||
self.scrap_type = "GraphQL-Browser"
|
|
||||||
|
|
||||||
self.rawdata = data
|
self.rawdata = data
|
||||||
|
fac_data = await self._get_facility_string(place_id)
|
||||||
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
# Naver 기준임, 구글 등 다른 데이터 소스의 경우 고유 Identifier 사용할 것.
|
||||||
self.place_id = self.data_source_identifier + place_id
|
self.place_id = self.data_source_identifier + place_id
|
||||||
self.rawdata["facilities"] = fac_data
|
self.rawdata["facilities"] = fac_data
|
||||||
business = data["data"]["business"]
|
self.image_link_list = [
|
||||||
|
nv_image["origin"]
|
||||||
# 업체 등록 이미지 (마케팅 적합성 필터 제외 대상 — 자체 dedup).
|
for nv_image in data["data"]["business"]["images"]["images"]
|
||||||
# placeDetail.images가 대표 사진 일부만 반환하는 경우가 있어 biz 커서 결과를 병합한다.
|
]
|
||||||
self.owner_images = self._dedup_by_original(
|
|
||||||
self._extract_origins(business.get("images") or {}) + biz_photo_urls
|
|
||||||
)
|
|
||||||
|
|
||||||
# 방문자/AI View 보충 사진 (마케팅 적합성 필터 대상). owner에 이미 있는 원본은 제외.
|
|
||||||
owner_originals = {img["original"] for img in self.owner_images}
|
|
||||||
self.extra_photo_urls = self._dedup_by_original(
|
|
||||||
[img for img in extra_photo_urls if img["original"] not in owner_originals]
|
|
||||||
)
|
|
||||||
|
|
||||||
# 업체 사진이 임계값 미만이면 내부/외부/리뷰 사진으로 보충
|
|
||||||
# (필터링 없는 기본 조립. 마케팅 적합성 필터를 적용하려면 호출측에서
|
|
||||||
# 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)
|
|
||||||
self.base_info = data["data"]["business"]["base"]
|
self.base_info = data["data"]["business"]["base"]
|
||||||
self.facility_info = fac_data
|
self.facility_info = fac_data
|
||||||
self.voted_keyword_stats = stats_data
|
self.scrap_type = "GraphQL"
|
||||||
self.menu_info = business.get("menus") or None
|
|
||||||
self.official_site_url = homepage_url
|
|
||||||
|
|
||||||
return
|
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]:
|
|
||||||
"""직접 호출이 WTM 캡차에 막힌 경우, 실제 브라우저로 GraphQL을 호출한다.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(overview_data, review_stats_details, extra_photo_urls, biz_photo_urls, homepage_url)
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
GraphQLException: 브라우저 폴백마저 실패한 경우
|
|
||||||
"""
|
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
|
||||||
|
|
||||||
overview_payload = {
|
|
||||||
"operationName": "getAccommodation",
|
|
||||||
"variables": {"id": place_id, "deviceType": "pc"},
|
|
||||||
"query": self.OVERVIEW_QUERY,
|
|
||||||
}
|
|
||||||
stats_payload = {
|
|
||||||
"operationName": "getVisitorReviewStats",
|
|
||||||
"variables": {"id": place_id},
|
|
||||||
"query": self.REVIEW_STATS_QUERY,
|
|
||||||
}
|
|
||||||
interior_payload = {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [{"id": "aiView"}],
|
|
||||||
"filter": "AI View",
|
|
||||||
"subFilter": "INTERIOR",
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": self.PHOTO_VIEWER_QUERY,
|
|
||||||
}
|
|
||||||
exterior_payload = {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [{"id": "aiView"}],
|
|
||||||
"filter": "AI View",
|
|
||||||
"subFilter": "EXTERIOR",
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"query": self.PHOTO_VIEWER_QUERY,
|
|
||||||
}
|
|
||||||
review_payload = {
|
|
||||||
"operationName": "getPhotoViewerItems",
|
|
||||||
"variables": {
|
|
||||||
"input": {
|
|
||||||
"businessId": place_id,
|
|
||||||
"cursors": [{"id": "placeReview"}],
|
|
||||||
"dateRange": "",
|
|
||||||
"excludeAuthorIds": [],
|
|
||||||
"excludeClipIds": [],
|
|
||||||
"excludeSection": [],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"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]
|
|
||||||
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
|
|
||||||
except Exception as e:
|
|
||||||
last_error = e
|
|
||||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 오류: {e}")
|
|
||||||
if attempt < MAX_RETRY:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
continue
|
|
||||||
if results and results[0] is not None and "data" in results[0]:
|
|
||||||
break
|
|
||||||
logger.warning(f"[NvMapScraper] 브라우저 폴백 시도 {attempt}/{MAX_RETRY} 실패(405 등), 재시도")
|
|
||||||
results = None
|
|
||||||
if attempt < MAX_RETRY:
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if not results or results[0] is None or "data" not in results[0]:
|
|
||||||
if last_error:
|
|
||||||
raise GraphQLException(f"브라우저 폴백 실패: {last_error}")
|
|
||||||
raise GraphQLException("브라우저 폴백 크롤링 실패 (WTM 토큰 또는 응답 없음)")
|
|
||||||
|
|
||||||
data = results[0]
|
|
||||||
stats_data = None
|
|
||||||
stats_raw = results[1] if len(results) > 1 else None
|
|
||||||
if stats_raw:
|
|
||||||
_vrs = (stats_raw.get("data") or {}).get("visitorReviewStats") or {}
|
|
||||||
_analysis = _vrs.get("analysis") or {}
|
|
||||||
_voted = _analysis.get("votedKeyword") or {}
|
|
||||||
stats_data = _voted.get("details") or None
|
|
||||||
|
|
||||||
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)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 홈페이지 링크: 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
|
|
||||||
|
|
||||||
async def _call_get_accommodation(self, place_id: str) -> dict:
|
async def _call_get_accommodation(self, place_id: str) -> dict:
|
||||||
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
"""GraphQL API를 호출하여 숙소 정보를 가져옵니다.
|
||||||
|
|
||||||
@ -515,14 +143,13 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
async with session.post(
|
async with session.post(
|
||||||
self.GRAPHQL_URL,
|
self.GRAPHQL_URL,
|
||||||
data=json_payload,
|
data=json_payload,
|
||||||
headers=self._get_request_headers(),
|
headers=self._get_request_headers()
|
||||||
) as response:
|
) as response:
|
||||||
if response.status == 200:
|
if response.status == 200:
|
||||||
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
logger.info(f"[NvMapScraper] SUCCESS - place_id: {place_id}")
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
||||||
# 405/429 등 실패: 네이버 WTM 안티봇 캡차 차단 (데이터센터 IP/지문 기반).
|
# 실패 상태 코드
|
||||||
# 직접 호출로는 통과 불가 → scrap()에서 브라우저 폴백으로 전환.
|
|
||||||
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
logger.error(f"[NvMapScraper] Failed with status {response.status} - place_id: {place_id}")
|
||||||
raise GraphQLException(
|
raise GraphQLException(
|
||||||
f"Request failed with status {response.status}"
|
f"Request failed with status {response.status}"
|
||||||
@ -531,74 +158,33 @@ query getVisitorReviewStats($id: String!) {
|
|||||||
except (TimeoutError, asyncio.TimeoutError):
|
except (TimeoutError, asyncio.TimeoutError):
|
||||||
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
logger.error(f"[NvMapScraper] Timeout - place_id: {place_id}")
|
||||||
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
raise CrawlingTimeoutException(f"Request timed out after {self.REQUEST_TIMEOUT}s")
|
||||||
|
|
||||||
except aiohttp.ClientError as e:
|
except aiohttp.ClientError as e:
|
||||||
logger.error(f"[NvMapScraper] Client error: {e}")
|
logger.error(f"[NvMapScraper] Client error: {e}")
|
||||||
raise GraphQLException(f"Client error: {e}")
|
raise GraphQLException(f"Client error: {e}")
|
||||||
|
|
||||||
async def _call_get_review_stats(self, place_id: str) -> list[dict] | None:
|
async def _get_facility_string(self, place_id: str) -> str | None:
|
||||||
"""방문자 키워드 투표 집계를 가져옵니다.
|
"""숙소 페이지에서 편의시설 정보를 크롤링합니다.
|
||||||
|
|
||||||
Returns:
|
|
||||||
[{"code": ..., "displayName": ..., "count": ...}, ...] 또는 None
|
|
||||||
"""
|
|
||||||
payload = {
|
|
||||||
"operationName": "getVisitorReviewStats",
|
|
||||||
"variables": {"id": place_id},
|
|
||||||
"query": self.REVIEW_STATS_QUERY,
|
|
||||||
}
|
|
||||||
timeout = aiohttp.ClientTimeout(total=self.REQUEST_TIMEOUT)
|
|
||||||
try:
|
|
||||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
|
||||||
async with session.post(
|
|
||||||
self.GRAPHQL_URL,
|
|
||||||
json=payload,
|
|
||||||
headers=self._get_request_headers(),
|
|
||||||
) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
logger.warning(f"[NvMapScraper] review stats failed: {response.status}")
|
|
||||||
return None
|
|
||||||
result = await response.json()
|
|
||||||
_vrs = (result.get("data") or {}).get("visitorReviewStats") or {}
|
|
||||||
_analysis = _vrs.get("analysis") or {}
|
|
||||||
_voted = _analysis.get("votedKeyword") or {}
|
|
||||||
return _voted.get("details") or None
|
|
||||||
except Exception as e:
|
|
||||||
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]:
|
|
||||||
"""장소 페이지에서 편의시설 정보와 홈페이지 링크를 크롤링합니다. 숙소, 음식점 순으로 시도합니다.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
place_id: 네이버 지도 장소 ID
|
place_id: 네이버 지도 장소 ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
(편의시설 정보 문자열 또는 None, 홈페이지 링크 또는 None)
|
편의시설 정보 문자열 또는 None
|
||||||
"""
|
"""
|
||||||
facility: str | None = None
|
url = f"https://pcmap.place.naver.com/accommodation/{place_id}/home"
|
||||||
homepage: str | None = None
|
|
||||||
place_types = ["place", "accommodation", "restaurant"]
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
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:
|
async with session.get(url, headers=self._get_request_headers()) as response:
|
||||||
raw = await response.read()
|
soup = bs4.BeautifulSoup(await response.read(), "html.parser")
|
||||||
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")
|
|
||||||
c_elem = soup.find("span", "place_blind", string="편의")
|
c_elem = soup.find("span", "place_blind", string="편의")
|
||||||
if c_elem:
|
if c_elem:
|
||||||
facility = c_elem.parent.parent.find("div").string
|
facilities = c_elem.parent.parent.find("div").string
|
||||||
if facility is not None and homepage is not None:
|
return facilities
|
||||||
break
|
return None
|
||||||
return facility, homepage
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"[NvMapScraper] Failed to get facility/homepage info: {e}")
|
logger.warning(f"[NvMapScraper] Failed to get facility info: {e}")
|
||||||
return facility, homepage
|
return None
|
||||||
|
|
||||||
|
|
||||||
# if __name__ == "__main__":
|
# if __name__ == "__main__":
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from pydantic import BaseModel, ValidationError
|
from pydantic import BaseModel
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
@ -47,19 +47,6 @@ class ChatgptService:
|
|||||||
case _:
|
case _:
|
||||||
raise NotImplementedError(f"Unknown Provider : {model_type}")
|
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(
|
async def _call_pydantic_output(
|
||||||
self,
|
self,
|
||||||
prompt : str,
|
prompt : str,
|
||||||
@ -128,8 +115,7 @@ class ChatgptService:
|
|||||||
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
output_format : BaseModel, #입력 output_format의 경우 Pydantic BaseModel Class를 상속한 Class 자체임에 유의할 것
|
||||||
model : str,
|
model : str,
|
||||||
img_url : str,
|
img_url : str,
|
||||||
image_detail_high : bool,
|
image_detail_high : bool) -> BaseModel:
|
||||||
reasoning_effort : Optional[str] = None) -> BaseModel:
|
|
||||||
content = []
|
content = []
|
||||||
if img_url:
|
if img_url:
|
||||||
content.append({
|
content.append({
|
||||||
@ -143,41 +129,25 @@ class ChatgptService:
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"text": prompt
|
"text": prompt
|
||||||
})
|
})
|
||||||
# gpt-5.4 계열/Gemini 호환 엔드포인트는 허용 값이 다르거나 파라미터를 거부하므로 지정된 경우에만 전달
|
|
||||||
extra_kwargs = {"reasoning_effort": reasoning_effort} if reasoning_effort else {}
|
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(self.max_retries + 1):
|
for attempt in range(self.max_retries + 1):
|
||||||
try:
|
|
||||||
response = await self.client.beta.chat.completions.parse(
|
response = await self.client.beta.chat.completions.parse(
|
||||||
model=model,
|
model=model,
|
||||||
messages=[{"role": "user", "content": content}],
|
messages=[{"role": "user", "content": content}],
|
||||||
response_format=output_format,
|
response_format=output_format
|
||||||
**extra_kwargs,
|
|
||||||
)
|
)
|
||||||
except (ValidationError, json.JSONDecodeError) as e:
|
|
||||||
# 모델이 스키마에 맞지 않는 JSON을 반환한 경우 (예: trailing characters).
|
|
||||||
# 확률적 출력 문제일 수 있으므로 재시도 대상에 포함한다.
|
|
||||||
logger.warning(
|
|
||||||
f"[ChatgptService({self.model_type})] Structured output 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:
|
|
||||||
logger.info(f"[ChatgptService({self.model_type})] Retrying request...")
|
|
||||||
continue
|
|
||||||
self._log_usage(response, model, output_format)
|
|
||||||
# Response 디버그 로깅
|
# Response 디버그 로깅
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
logger.debug(f"[ChatgptService({self.model_type})] attempt: {attempt}")
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
logger.debug(f"[ChatgptService({self.model_type})] Response ID: {response.id}")
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
|
logger.debug(f"[ChatgptService({self.model_type})] Response finish_reason: {response.id}")
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
logger.debug(f"[ChatgptService({self.model_type})] Response model: {response.model}")
|
||||||
|
|
||||||
choice = response.choices[0]
|
choice = response.choices[0]
|
||||||
finish_reason = choice.finish_reason
|
finish_reason = choice.finish_reason
|
||||||
|
|
||||||
if finish_reason == "stop":
|
if finish_reason == "stop":
|
||||||
# output_text = choice.message.content or ""
|
output_text = choice.message.content or ""
|
||||||
# logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
|
logger.debug(f"[ChatgptService({self.model_type})] Response output_text: {output_text[:200]}..." if len(output_text) > 200 else f"[ChatgptService] Response output_text: {output_text}")
|
||||||
return choice.message.parsed
|
return choice.message.parsed
|
||||||
|
|
||||||
elif finish_reason == "length":
|
elif finish_reason == "length":
|
||||||
@ -200,69 +170,13 @@ class ChatgptService:
|
|||||||
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
logger.error(f"[ChatgptService({self.model_type})] All retries exhausted. Last error: {last_error}")
|
||||||
raise 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(
|
async def generate_structured_output(
|
||||||
self,
|
self,
|
||||||
prompt : Prompt,
|
prompt : Prompt,
|
||||||
input_data : dict,
|
input_data : dict,
|
||||||
img_url : Optional[str] = None,
|
img_url : Optional[str] = None,
|
||||||
img_detail_high : bool = False,
|
img_detail_high : bool = False,
|
||||||
silent : bool = True,
|
silent : bool = False
|
||||||
reasoning_effort : Optional[str] = None,
|
|
||||||
) -> BaseModel:
|
) -> BaseModel:
|
||||||
prompt_text = prompt.build_prompt(input_data, silent)
|
prompt_text = prompt.build_prompt(input_data, silent)
|
||||||
|
|
||||||
@ -273,5 +187,5 @@ class ChatgptService:
|
|||||||
# GPT API 호출
|
# GPT API 호출
|
||||||
#parsed = await self._call_structured_output_with_response_gpt_api(prompt_text, prompt.prompt_output, prompt.prompt_model)
|
#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(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
|
return parsed
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import re
|
|
||||||
|
|
||||||
import gspread
|
import gspread
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from google.oauth2.service_account import Credentials
|
from google.oauth2.service_account import Credentials
|
||||||
@ -8,48 +6,13 @@ from app.utils.logger import get_logger
|
|||||||
from app.utils.prompts.schemas import *
|
from app.utils.prompts.schemas import *
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
# {known_field} 형태만 치환하기 위한 패턴. JSON 예시 등 리터럴 중괄호는 보존된다.
|
|
||||||
_PLACEHOLDER_RE = re.compile(r"\{(\w+)\}")
|
|
||||||
|
|
||||||
logger = get_logger("prompt")
|
logger = get_logger("prompt")
|
||||||
|
|
||||||
_SCOPES = [
|
_SCOPES = [
|
||||||
"https://www.googleapis.com/auth/spreadsheets.readonly"
|
"https://www.googleapis.com/auth/spreadsheets.readonly",
|
||||||
|
"https://www.googleapis.com/auth/drive.readonly"
|
||||||
]
|
]
|
||||||
|
|
||||||
_sheet_cache: dict[str, tuple[str, str]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _get_spreadsheet():
|
|
||||||
creds = Credentials.from_service_account_file(
|
|
||||||
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
|
||||||
)
|
|
||||||
return gspread.authorize(creds).open_by_key(prompt_settings.PROMPT_SPREADSHEET)
|
|
||||||
|
|
||||||
|
|
||||||
def _preload_all_sheets(sheet_names: list[str]):
|
|
||||||
ranges = [f"{name}!B2:B3" for name in sheet_names]
|
|
||||||
response = _get_spreadsheet().values_batch_get(ranges)
|
|
||||||
for value_range in response["valueRanges"]:
|
|
||||||
range_name = value_range.get("range", "")
|
|
||||||
name = range_name.split("!")[0].strip("'")
|
|
||||||
if name not in sheet_names:
|
|
||||||
continue
|
|
||||||
values = value_range.get("values", [])
|
|
||||||
if len(values) < 2 or not values[0] or not values[1]:
|
|
||||||
logger.warning(f"Sheet '{name}' has missing data, skipping preload")
|
|
||||||
continue
|
|
||||||
_sheet_cache[name] = (values[1][0], values[0][0]) # (B3=template, B2=model)
|
|
||||||
|
|
||||||
|
|
||||||
def _read_sheet_data(sheet_name: str) -> tuple[str, str]:
|
|
||||||
if sheet_name not in _sheet_cache:
|
|
||||||
ws = _get_spreadsheet().worksheet(sheet_name)
|
|
||||||
values = ws.batch_get(["B2:B3"])[0]
|
|
||||||
_sheet_cache[sheet_name] = (values[1][0], values[0][0])
|
|
||||||
return _sheet_cache[sheet_name]
|
|
||||||
|
|
||||||
|
|
||||||
class Prompt():
|
class Prompt():
|
||||||
sheet_name: str
|
sheet_name: str
|
||||||
prompt_template: str
|
prompt_template: str
|
||||||
@ -62,33 +25,30 @@ class Prompt():
|
|||||||
self.sheet_name = sheet_name
|
self.sheet_name = sheet_name
|
||||||
self.prompt_input_class = prompt_input_class
|
self.prompt_input_class = prompt_input_class
|
||||||
self.prompt_output_class = prompt_output_class
|
self.prompt_output_class = prompt_output_class
|
||||||
self.prompt_template, self.prompt_model = _read_sheet_data(sheet_name)
|
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
||||||
|
|
||||||
|
def _read_from_sheets(self) -> tuple[str, str]:
|
||||||
|
creds = Credentials.from_service_account_file(
|
||||||
|
prompt_settings.GOOGLE_SERVICE_ACCOUNT_JSON, scopes=_SCOPES
|
||||||
|
)
|
||||||
|
gc = gspread.authorize(creds)
|
||||||
|
ws = gc.open_by_key(prompt_settings.PROMPT_SPREADSHEET).worksheet(self.sheet_name)
|
||||||
|
model = ws.cell(2, 2).value
|
||||||
|
input_text = ws.cell(3, 2).value
|
||||||
|
return input_text, model
|
||||||
|
|
||||||
|
def _reload_prompt(self):
|
||||||
|
self.prompt_template, self.prompt_model = self._read_from_sheets()
|
||||||
|
|
||||||
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
def build_prompt(self, input_data:dict, silent:bool = False) -> str:
|
||||||
verified_input = self.prompt_input_class(**input_data)
|
verified_input = self.prompt_input_class(**input_data)
|
||||||
values = verified_input.model_dump()
|
build_template = self.prompt_template
|
||||||
# 알려진 {필드}만 치환하고, 프롬프트 내 JSON 예시 등 리터럴 중괄호는 그대로 둔다.
|
build_template = build_template.format(**verified_input.model_dump())
|
||||||
# (str.format은 모든 {..}를 필드로 해석해 리터럴 중괄호에서 깨지므로 사용하지 않음)
|
|
||||||
build_template = _PLACEHOLDER_RE.sub(
|
|
||||||
lambda m: str(values[m.group(1)]) if m.group(1) in values else m.group(0),
|
|
||||||
self.prompt_template,
|
|
||||||
)
|
|
||||||
if not silent:
|
if not silent:
|
||||||
logger.debug(f"build_template: {build_template}")
|
logger.debug(f"build_template: {build_template}")
|
||||||
logger.debug(f"input_data: {input_data}")
|
logger.debug(f"input_data: {input_data}")
|
||||||
return build_template
|
return build_template
|
||||||
|
|
||||||
# 업종 분기는 각 프롬프트 내부에서 {industry} 변수로 처리하므로 시트는 타입별 1개로 통합.
|
|
||||||
_preload_all_sheets([
|
|
||||||
"marketing",
|
|
||||||
"lyric",
|
|
||||||
"subtitle",
|
|
||||||
"yt_upload",
|
|
||||||
"image_tag",
|
|
||||||
])
|
|
||||||
|
|
||||||
|
|
||||||
# 업종 분기는 프롬프트 내부 {industry} 변수로 처리하므로 타입별 단일 프롬프트만 둔다.
|
|
||||||
marketing_prompt = Prompt(
|
marketing_prompt = Prompt(
|
||||||
sheet_name="marketing",
|
sheet_name="marketing",
|
||||||
prompt_input_class=MarketingPromptInput,
|
prompt_input_class=MarketingPromptInput,
|
||||||
@ -114,10 +74,16 @@ image_autotag_prompt = Prompt(
|
|||||||
)
|
)
|
||||||
|
|
||||||
@lru_cache()
|
@lru_cache()
|
||||||
def create_dynamic_subtitle_prompt(length: int, industry: str = "") -> Prompt:
|
def create_dynamic_subtitle_prompt(length: int) -> Prompt:
|
||||||
# industry 인자는 캐시 구분/하위 호환용. 시트는 단일 'subtitle'로 통합됨.
|
|
||||||
return Prompt(
|
return Prompt(
|
||||||
sheet_name="subtitle",
|
sheet_name="subtitle",
|
||||||
prompt_input_class=SubtitlePromptInput,
|
prompt_input_class=SubtitlePromptInput,
|
||||||
prompt_output_class=SubtitlePromptOutput[length],
|
prompt_output_class=SubtitlePromptOutput[length],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reload_all_prompt():
|
||||||
|
marketing_prompt._reload_prompt()
|
||||||
|
lyric_prompt._reload_prompt()
|
||||||
|
yt_upload_prompt._reload_prompt()
|
||||||
|
image_autotag_prompt._reload_prompt()
|
||||||
@ -3,7 +3,6 @@ from typing import List, Optional
|
|||||||
from enum import StrEnum, auto
|
from enum import StrEnum, auto
|
||||||
|
|
||||||
class SpaceType(StrEnum):
|
class SpaceType(StrEnum):
|
||||||
"""이미지가 촬영된 공간·장소 유형. LLM이 이미지를 보고 해당하는 공간 태그를 선택하는 데 사용."""
|
|
||||||
exterior_front = auto()
|
exterior_front = auto()
|
||||||
exterior_night = auto()
|
exterior_night = auto()
|
||||||
exterior_aerial = auto()
|
exterior_aerial = auto()
|
||||||
@ -42,64 +41,8 @@ class SpaceType(StrEnum):
|
|||||||
detail_lighting = auto()
|
detail_lighting = auto()
|
||||||
detail_decor = auto()
|
detail_decor = auto()
|
||||||
detail_tableware = 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):
|
class Subject(StrEnum):
|
||||||
"""이미지 내 주요 피사체 유형. 화면에 무엇이 담겨 있는지를 분류하는 태그."""
|
|
||||||
empty_space = auto()
|
empty_space = auto()
|
||||||
exterior_building = auto()
|
exterior_building = auto()
|
||||||
architecture_detail = auto()
|
architecture_detail = auto()
|
||||||
@ -110,31 +53,8 @@ class Subject(StrEnum):
|
|||||||
signage = auto()
|
signage = auto()
|
||||||
amenity_item = auto()
|
amenity_item = auto()
|
||||||
person = 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):
|
class Camera(StrEnum):
|
||||||
"""이미지의 촬영 기법·구도·조명 특성. 영상 편집 시 컷의 시각적 스타일을 구분하는 태그."""
|
|
||||||
wide_angle = auto()
|
wide_angle = auto()
|
||||||
tight_crop = auto()
|
tight_crop = auto()
|
||||||
panoramic = auto()
|
panoramic = auto()
|
||||||
@ -148,7 +68,6 @@ class Camera(StrEnum):
|
|||||||
has_face = auto()
|
has_face = auto()
|
||||||
|
|
||||||
class MotionRecommended(StrEnum):
|
class MotionRecommended(StrEnum):
|
||||||
"""해당 이미지에 적용 가능한 카메라 모션 유형. 영상 제작 시 각 컷에 어울리는 움직임을 추천하는 데 사용."""
|
|
||||||
static = auto()
|
static = auto()
|
||||||
slow_pan = auto()
|
slow_pan = auto()
|
||||||
slow_zoom_in = auto()
|
slow_zoom_in = auto()
|
||||||
@ -157,60 +76,32 @@ class MotionRecommended(StrEnum):
|
|||||||
dolly = auto()
|
dolly = auto()
|
||||||
|
|
||||||
class NarrativePhase(StrEnum):
|
class NarrativePhase(StrEnum):
|
||||||
"""광고 영상 내러티브 단계. 이미지가 스토리 흐름에서 어느 위치에 배치되어야 하는지를 나타냄."""
|
|
||||||
intro = auto()
|
intro = auto()
|
||||||
welcome = auto()
|
welcome = auto()
|
||||||
core = auto()
|
core = auto()
|
||||||
highlight = auto()
|
highlight = auto()
|
||||||
support = auto()
|
support = auto()
|
||||||
accent = auto()
|
accent = auto()
|
||||||
cta = auto()
|
|
||||||
|
|
||||||
NARRATIVE_PHASE_LABEL: dict[NarrativePhase, str] = {
|
|
||||||
NarrativePhase.intro: "첫인상(intro)",
|
|
||||||
NarrativePhase.welcome: "진입(welcome)",
|
|
||||||
NarrativePhase.core: "핵심(core)",
|
|
||||||
NarrativePhase.highlight: "차별화(highlight)",
|
|
||||||
NarrativePhase.support: "보조(support)",
|
|
||||||
NarrativePhase.accent: "감성(accent)",
|
|
||||||
NarrativePhase.cta: "행동유도(cta)",
|
|
||||||
}
|
|
||||||
|
|
||||||
class NarrativePreference(BaseModel):
|
class NarrativePreference(BaseModel):
|
||||||
"""이미지가 각 내러티브 단계에 얼마나 적합한지를 나타내는 점수 모델 (0.0 ~ 100.0).
|
|
||||||
LLM이 이미지를 분석한 뒤 각 단계별 적합도를 수치로 반환하며,
|
|
||||||
영상 편집기가 이 점수를 기반으로 컷 배치 순서를 자동 결정하는 데 사용."""
|
|
||||||
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
|
intro: float = Field(..., description="첫인상 — 여기가 어디인가 | 장소의 정체성과 위치를 전달하는 이미지. 영상 첫 1~2초에 어떤 곳인지 즉시 인지시키는 역할. 건물 외관, 간판, 정원 등 **장소 자체를 보여주는** 컷")
|
||||||
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
|
welcome: float = Field(..., description="진입/환영 — 어떻게 들어가나 | 도착 후 내부로 들어가는 경험을 전달하는 이미지. 공간의 첫 분위기와 동선을 보여줘 들어가고 싶다는 기대감을 만드는 역할. **문을 열고 들어갔을 때 보이는** 컷.")
|
||||||
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
|
core: float = Field(..., description="핵심 가치 — 무엇을 경험하나 | **고객이 이 장소를 찾는 본질적 이유.** 이 이미지가 없으면 영상 자체가 성립하지 않음. 질문: 이 비즈니스에서 돈을 지불하는 대상이 뭔가? → 그 답이 core.")
|
||||||
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
|
highlight: float = Field(..., description="차별화 — 뭐가 특별한가 | **같은 카테고리의 경쟁사 대비 이곳을 선택하게 만드는 이유.** core가 왜 왔는가라면, highlight는 왜 **여기**인가에 대한 답.")
|
||||||
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
|
support: float = Field(..., description="보조/부대 — 그 외에 뭐가 있나 | 핵심은 아니지만 전체 경험을 풍성하게 하는 부가 요소. 없어도 영상은 성립하지만, 있으면 설득력이 올라감. **이것도 있어요** 라고 말하는 컷.")
|
||||||
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
|
accent: float = Field(..., description="감성/마무리 — 어떤 느낌인가 | 공간의 분위기와 톤을 전달하는 감성 디테일 컷. 직접적 정보 전달보다 **느낌과 무드**를 제공. 영상 사이사이에 삽입되어 완성도를 높이는 역할.")
|
||||||
cta: float = Field(..., description="행동 유도 — 지금 예약/방문하고 싶게 만드는 컷 | 가격·혜택·한정 오퍼 등 직접적 행동을 촉구하거나, 마지막 장면에서 강한 인상을 남기는 이미지.")
|
|
||||||
|
|
||||||
# Input 정의
|
# Input 정의
|
||||||
class ImageTagPromptInput(BaseModel):
|
class ImageTagPromptInput(BaseModel):
|
||||||
"""이미지 태깅 프롬프트에 전달되는 입력 스키마.
|
|
||||||
분석할 이미지 URL과 업종 정보, LLM이 선택 가능한 태그 후보 리스트를 포함."""
|
|
||||||
img_url : str = Field(..., description="이미지 URL")
|
img_url : str = Field(..., description="이미지 URL")
|
||||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
|
||||||
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
|
space_type: list[str] = Field(list(SpaceType), description="공간적 정보를 가지는 태그 리스트")
|
||||||
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
|
subject: list[str] = Field(list(Subject), description="피사체 정보를 가지는 태그 리스트")
|
||||||
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
|
camera: list[str] = Field(list(Camera), description="카메라 정보를 가지는 태그 리스트")
|
||||||
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
|
motion_recommended: list[str] = Field(list(MotionRecommended), description="가능한 카메라 모션 리스트")
|
||||||
|
|
||||||
class MarketingFilterOutput(BaseModel):
|
|
||||||
"""크롤링 단계에서 이미지의 마케팅(광고 영상) 사용 가능 여부만 판정하는 프롬프트의 출력 스키마.
|
|
||||||
ImageTagPromptOutput과 달리 태깅(공간/피사체/카메라/모션/내러티브)은 포함하지 않는다."""
|
|
||||||
marketing_acceptable: bool = Field(..., description="광고 영상 사용 가능 여부")
|
|
||||||
reject_reason: str = Field(default="", description="거부 사유 (marketing_acceptable=false 시). 예: 저화질, 인물 신원 노출, 업종 부적합, 민감/의료 장면, 미성년자 식별 노출")
|
|
||||||
|
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class ImageTagPromptOutput(BaseModel):
|
class ImageTagPromptOutput(BaseModel):
|
||||||
"""이미지 태깅 프롬프트의 LLM 출력 스키마.
|
#ad_avaliable : bool = Field(..., description="광고 영상 사용 가능 이미지 여부")
|
||||||
공간·피사체·카메라·모션 태그와 내러티브 적합도 점수를 담아 반환.
|
|
||||||
마케팅 사용 가능 여부는 크롤링 단계(MarketingFilterOutput)에서 이미 판정되므로 포함하지 않는다."""
|
|
||||||
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
|
space_type: list[SpaceType] = Field(..., description="공간적 정보를 가지는 태그 리스트")
|
||||||
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
|
subject: list[Subject] = Field(..., description="피사체 정보를 가지는 태그 리스트")
|
||||||
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")
|
camera: list[Camera] = Field(..., description="카메라 정보를 가지는 태그 리스트")
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Literal, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
GENRE_VALUES = Literal["kpop", "pop", "ballad", "hip-hop", "rnb", "edm", "jazz", "rock"]
|
|
||||||
|
|
||||||
# Input 정의
|
# Input 정의
|
||||||
class LyricPromptInput(BaseModel):
|
class LyricPromptInput(BaseModel):
|
||||||
@ -10,16 +8,10 @@ class LyricPromptInput(BaseModel):
|
|||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||||
language : str= Field(..., description = "가사 언어")
|
language : str= Field(..., description = "가사 언어")
|
||||||
genre : str = Field(default="", description = "지정된 음악 장르 (kpop|pop|ballad|hip-hop|rnb|edm|jazz|rock). 비어있으면 GPT가 무드에 맞는 장르를 자유롭게 추천")
|
promotional_expression_example : str = Field(..., description = "판촉 가사 표현 예시")
|
||||||
exclude_genre : str = Field(default="", description = "재생성 시 직전에 사용된 장르. genre가 비어있는 경우(자동 선택) 이 장르는 추천에서 제외")
|
timing_rules : str = Field(..., description = "시간 제어문")
|
||||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class LyricPromptOutput(BaseModel):
|
class LyricPromptOutput(BaseModel):
|
||||||
lyric: str = Field(..., description="생성된 가사")
|
lyric: str = Field(..., description="생성된 가사")
|
||||||
suno_prompt: str = Field(..., description="Suno AI용 프롬프트")
|
suno_prompt: str = Field(..., description="Suno AI용 프롬프트")
|
||||||
recommended_genre: GENRE_VALUES = Field(
|
|
||||||
...,
|
|
||||||
description="가사에 사용된(또는 가장 잘 맞는) 음악 장르. genre 입력이 주어졌다면 그 값을 그대로 반환하고, "
|
|
||||||
"비어있었다면 exclude_genre를 제외한 나머지 중 가사 무드에 가장 잘 맞는 장르를 직접 선택해 반환",
|
|
||||||
)
|
|
||||||
@ -6,12 +6,6 @@ class MarketingPromptInput(BaseModel):
|
|||||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||||
region : str = Field(..., description = "마케팅 대상 지역")
|
region : str = Field(..., description = "마케팅 대상 지역")
|
||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
|
||||||
category : str = Field(default="", description = "네이버 지도 원본 카테고리 텍스트 (예: 펜션, 한식). industry 모듈 선택에는 관여하지 않고 세부 묘사의 참고 정보로만 사용")
|
|
||||||
facility_info : str = Field(default="", description = "편의시설 정보 (네이버 지도 크롤링, 없으면 빈 문자열)")
|
|
||||||
voted_keywords : str = Field(default="", description = "방문자 키워드 투표 상위 5개 (쉼표 구분, 없으면 빈 문자열)")
|
|
||||||
# 메뉴 전달은 잠정 보류 (사이드 메뉴가 분석에 과도한 영향을 주는 문제). 재도입 시 주석 해제.
|
|
||||||
# menu_info : str = Field(default="", description = "메뉴/상품 목록 (네이버 지도 크롤링, 쉼표 구분, 없으면 빈 문자열). 실제 판매 품목 기반 셀링포인트 작성에 사용")
|
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class BrandIdentity(BaseModel):
|
class BrandIdentity(BaseModel):
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel, create_model, Field
|
from pydantic import BaseModel, create_model, Field
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
|
||||||
# Input 정의
|
# Input 정의
|
||||||
@ -9,9 +9,8 @@ class SubtitlePromptInput(BaseModel):
|
|||||||
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
|
pitching_tag_list_string : str = Field(..., description="필요한 피칭 레이블 리스트 stringify")
|
||||||
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
customer_name : str = Field(..., description = "마케팅 대상 사업체 이름")
|
||||||
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
detail_region_info : str = Field(..., description = "마케팅 대상 지역 상세")
|
||||||
language : str = Field(default="Korean", description="자막 출력 언어 (Korean, English, Chinese, Japanese, Thai, Vietnamese)")
|
|
||||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
|
||||||
|
|
||||||
|
#subtillecars :
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class PitchingOutput(BaseModel):
|
class PitchingOutput(BaseModel):
|
||||||
pitching_tag: str = Field(..., description="피칭 레이블")
|
pitching_tag: str = Field(..., description="피칭 레이블")
|
||||||
|
|||||||
@ -8,7 +8,6 @@ class YTUploadPromptInput(BaseModel):
|
|||||||
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
marketing_intelligence_summary : Optional[str] = Field(None, description = "마케팅 분석 정보 보고서")
|
||||||
language : str= Field(..., description = "영상 언어")
|
language : str= Field(..., description = "영상 언어")
|
||||||
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
target_keywords: List[str] = Field(..., description="태그 키워드 리스트")
|
||||||
industry : str = Field(default="", description = "업종 분류 (stay|restaurant|cafe|salon|clinic|fitness|academy|attraction|general). 통합 프롬프트가 이 값으로 내부 분기")
|
|
||||||
|
|
||||||
# Output 정의
|
# Output 정의
|
||||||
class YTUploadPromptOutput(BaseModel):
|
class YTUploadPromptOutput(BaseModel):
|
||||||
|
|||||||
233
app/utils/prompts/templates/subtitle_prompt.txt
Normal file
233
app/utils/prompts/templates/subtitle_prompt.txt
Normal file
@ -0,0 +1,233 @@
|
|||||||
|
# System Prompt: 숙박 숏폼 자막 생성 (OpenAI Optimized)
|
||||||
|
|
||||||
|
You are a subtitle copywriter for hospitality short-form videos. You generate subtitle text AND layer names from marketing JSON data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### RULES
|
||||||
|
|
||||||
|
1. NEVER copy JSON verbatim. ALWAYS rewrite into video-optimized copy.
|
||||||
|
2. NEVER invent facts not in the data. You MAY freely transform expressions.
|
||||||
|
3. Each scene = 1 subtitle + 1 keyword (a "Pair"). Same pair_id for both.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### LAYER NAME FORMAT (5-criteria)
|
||||||
|
|
||||||
|
```
|
||||||
|
(track_role)-(narrative_phase)-(content_type)-(tone)-(pair_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Criteria separator: hyphen `-`
|
||||||
|
- Multi-word value: underscore `_`
|
||||||
|
- pair_id: 3-digit zero-padded (`001`~`999`)
|
||||||
|
|
||||||
|
Example: `subtitle-intro-hook_claim-aspirational-001`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TAG VALUES
|
||||||
|
|
||||||
|
**track_role**: `subtitle` | `keyword`
|
||||||
|
|
||||||
|
**narrative_phase** (= emotion goal):
|
||||||
|
- `intro` → Curiosity (stop the scroll)
|
||||||
|
- `welcome` → Warmth
|
||||||
|
- `core` → Trust
|
||||||
|
- `highlight` → Desire (peak moment)
|
||||||
|
- `support` → Discovery
|
||||||
|
- `accent` → Belonging
|
||||||
|
- `cta` → Action
|
||||||
|
|
||||||
|
**content_type** → source mapping:
|
||||||
|
- `hook_claim` ← selling_points[0] or core_value
|
||||||
|
- `space_feature` ← selling_points[].description
|
||||||
|
- `emotion_cue` ← same source, sensory rewrite
|
||||||
|
- `brand_name` ← store_name (verbatim OK)
|
||||||
|
- `brand_address` ← detail_region_info (verbatim OK)
|
||||||
|
- `lifestyle_fit` ← target_persona[].favor_target
|
||||||
|
- `local_info` ← location_feature_analysis
|
||||||
|
- `target_tag` ← target_keywords[] as hashtags
|
||||||
|
- `availability` ← fixed: "지금 예약 가능"
|
||||||
|
- `cta_action` ← fixed: "예약하러 가기"
|
||||||
|
|
||||||
|
**tone**: `sensory` | `factual` | `empathic` | `aspirational` | `social_proof` | `urgent`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SCENE STRUCTURE
|
||||||
|
|
||||||
|
**Anchors (FIXED — never remove):**
|
||||||
|
|
||||||
|
| Position | Phase | subtitle | keyword |
|
||||||
|
|---|---|---|---|
|
||||||
|
| First | intro | hook_claim | brand_name |
|
||||||
|
| Last-3 | support | brand_address | brand_name |
|
||||||
|
| Last-2 | accent | target_tag | lifestyle_fit |
|
||||||
|
| Last | cta | availability | cta_action |
|
||||||
|
|
||||||
|
**Middle (FLEXIBLE — fill by selling_points score desc):**
|
||||||
|
|
||||||
|
| Phase | subtitle | keyword |
|
||||||
|
|---|---|---|
|
||||||
|
| welcome | emotion_cue | space_feature |
|
||||||
|
| core | space_feature | emotion_cue |
|
||||||
|
| highlight | space_feature | emotion_cue |
|
||||||
|
| support(mid) | local_info | lifestyle_fit |
|
||||||
|
|
||||||
|
Default: 7 scenes. Fewer scenes → remove flexible slots only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TEXT SPECS
|
||||||
|
|
||||||
|
**subtitle**: 8~18 chars. Sentence fragment, conversational.
|
||||||
|
**keyword**: 2~6 chars. MUST follow Korean word-formation rules below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### KEYWORD RULES (한국어 조어법 기반)
|
||||||
|
|
||||||
|
Keywords MUST follow one of these **permitted Korean patterns**. Any keyword that does not match a pattern below is INVALID.
|
||||||
|
|
||||||
|
#### Pattern 1: 관형형 + 명사 (Attributive + Noun) — 가장 자연스러운 패턴
|
||||||
|
한국어는 수식어가 앞, 피수식어가 뒤. 형용사의 관형형(~ㄴ/~한/~는/~운)을 명사 앞에 붙인다.
|
||||||
|
|
||||||
|
| Structure | GOOD | BAD (역순/비문) |
|
||||||
|
|---|---|---|
|
||||||
|
| 형용사 관형형 + 명사 | 고요한 숲, 깊은 쉼, 온전한 쉼 | ~~숲고요~~, ~~쉼깊은~~ |
|
||||||
|
| 형용사 관형형 + 명사 | 따뜻한 독채, 느린 하루 | ~~독채따뜻~~, ~~하루느린~~ |
|
||||||
|
| 동사 관형형 + 명사 | 쉬어가는 숲, 머무는 시간 | ~~숲쉬어가는~~ |
|
||||||
|
|
||||||
|
#### Pattern 2: 기존 대중화 합성어 ONLY (Established Trending Compound)
|
||||||
|
이미 SNS·미디어에서 대중화된 합성어만 허용. 임의 신조어 생성 금지.
|
||||||
|
|
||||||
|
| GOOD (대중화 확인됨) | Origin | BAD (임의 생성) |
|
||||||
|
|---|---|---|
|
||||||
|
| 숲멍 | 숲+멍때리기 (불멍, 물멍 시리즈) | ~~숲고요~~, ~~숲힐~~ |
|
||||||
|
| 댕캉스 | 댕댕이+바캉스 (여행업계 통용) | ~~댕쉼~~, ~~댕여행~~ |
|
||||||
|
| 꿀잠 / 꿀쉼 | 꿀+잠/쉼 (일상어 정착) | ~~꿀독채~~, ~~꿀숲~~ |
|
||||||
|
| 집콕 / 숲콕 | 집+콕 → 숲+콕 (변형 허용) | ~~계곡콕~~ |
|
||||||
|
| 주말러 | 주말+~러 (~러 접미사 정착) | ~~평일러~~ |
|
||||||
|
|
||||||
|
> **판별 기준**: "이 단어를 네이버/인스타에서 검색하면 결과가 나오는가?" YES → 허용, NO → 금지
|
||||||
|
|
||||||
|
#### Pattern 3: 명사 + 명사 (Natural Compound Noun)
|
||||||
|
한국어 복합명사 규칙을 따르는 결합만 허용. 앞 명사가 뒷 명사를 수식하는 관계여야 한다.
|
||||||
|
|
||||||
|
| Structure | GOOD | BAD (부자연스러운 결합) |
|
||||||
|
|---|---|---|
|
||||||
|
| 장소 + 유형 | 숲속독채, 계곡펜션 | ~~햇살독채~~ (햇살은 장소가 아님) |
|
||||||
|
| 대상 + 활동 | 반려견산책, 가족피크닉 | ~~견주피크닉~~ (견주가 피크닉하는 건 어색) |
|
||||||
|
| 시간 + 활동 | 주말탈출, 새벽산책 | ~~자연독채~~ (자연은 시간/방식이 아님) |
|
||||||
|
|
||||||
|
#### Pattern 4: 해시태그형 (#키워드)
|
||||||
|
accent(target_tag) 씬에서만 사용. 기존 검색 키워드를 # 붙여서 사용.
|
||||||
|
|
||||||
|
| GOOD | BAD |
|
||||||
|
|---|---|
|
||||||
|
| #프라이빗독채, #홍천여행 | #숲고요, #감성쩌는 (검색량 없음) |
|
||||||
|
|
||||||
|
#### Pattern 5: 감각/상태 명사 (단독 사용 가능한 것만)
|
||||||
|
그 자체로 의미가 완결되는 감각·상태 명사만 단독 사용 허용.
|
||||||
|
|
||||||
|
| GOOD (단독 의미 완결) | BAD (단독으로 의미 불완전) |
|
||||||
|
|---|---|
|
||||||
|
| 고요, 여유, 쉼, 온기 | ~~감성~~, ~~자연~~, ~~힐링~~ (너무 모호) |
|
||||||
|
| 숲멍, 꿀쉼 | ~~좋은쉼~~, ~~편안함~~ (형용사 포함 시 Pattern 1 사용) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### KEYWORD VALIDATION CHECKLIST (생성 후 자가 검증)
|
||||||
|
|
||||||
|
Every keyword MUST pass ALL of these:
|
||||||
|
|
||||||
|
- [ ] 한국어 어순이 자연스러운가? (수식어→피수식어 순서)
|
||||||
|
- [ ] 소리 내어 읽었을 때 어색하지 않은가?
|
||||||
|
- [ ] 네이버/인스타에서 검색하면 실제 결과가 나올 법한 표현인가?
|
||||||
|
- [ ] 허용된 5개 Pattern 중 하나에 해당하는가?
|
||||||
|
- [ ] 이전 씬 keyword와 동일한 Pattern을 연속 사용하지 않았는가?
|
||||||
|
- [ ] 금지 표현 사전에 해당하지 않는가?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### EXPRESSION DICTIONARY
|
||||||
|
|
||||||
|
**SCAN BEFORE WRITING.** If JSON contains these → MUST replace:
|
||||||
|
|
||||||
|
| Forbidden | → Use Instead |
|
||||||
|
|---|---|
|
||||||
|
| 눈치 없는/없이 | 눈치 안 보는 · 프라이빗한 · 온전한 · 마음 편히 |
|
||||||
|
| 감성 쩌는/쩌이 | 감성 가득한 · 감성이 머무는 |
|
||||||
|
| 가성비 | 합리적인 · 가치 있는 |
|
||||||
|
| 힐링되는 | 회복되는 · 쉬어가는 · 숨 쉬는 |
|
||||||
|
| 인스타감성 | 감성 스팟 · 기록하고 싶은 |
|
||||||
|
| 혜자 | 풍성한 · 넉넉한 |
|
||||||
|
|
||||||
|
**ALWAYS FORBIDDEN**: 저렴한, 싼, 그냥, 보통, 무난한, 평범한, 쩌는, 쩔어, 개(접두사), 존맛, 핵, 인스타, 유튜브, 틱톡
|
||||||
|
|
||||||
|
**SYNONYM ROTATION**: Same Korean word max 2 scenes. Rotate:
|
||||||
|
- 프라이빗 계열: 온전한 · 오롯한 · 나만의 · 독채 · 단독
|
||||||
|
- 자연 계열: 숲속 · 초록 · 산림 · 계곡
|
||||||
|
- 쉼 계열: 쉼 · 여유 · 느린 하루 · 머무름 · 숨고르기
|
||||||
|
- 반려견: 댕댕이(max 1회, intro/accent만) · 반려견 · 우리 강아지
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### TRANSFORM RULES BY CONTENT_TYPE
|
||||||
|
|
||||||
|
**hook_claim** (intro only):
|
||||||
|
- Format: question OR exclamation OR provocation. Pick ONE.
|
||||||
|
- FORBIDDEN: brand name, generic greetings
|
||||||
|
- `"반려견과 눈치 없는 힐링"` → BAD: 그대로 복사 → GOOD: "댕댕이가 먼저 뛰어간 숲"
|
||||||
|
|
||||||
|
**space_feature** (core/highlight):
|
||||||
|
- ONE selling point per scene
|
||||||
|
- NEVER use korean_category directly
|
||||||
|
- Viewer must imagine themselves there
|
||||||
|
- `"홍천 자연 속 조용한 쉼"` → BAD: "입지 환경이 좋은 곳" → GOOD: "계곡 소리만 들리는 독채"
|
||||||
|
|
||||||
|
**emotion_cue** (welcome/core/highlight):
|
||||||
|
- Senses: smell, sound, touch, temperature, light
|
||||||
|
- Poetic fragments, not full sentences
|
||||||
|
- `"감성 쩌이 완성되는 공간"` → GOOD: "햇살이 내려앉는 테라스"
|
||||||
|
|
||||||
|
**lifestyle_fit** (accent/support):
|
||||||
|
- Address target directly in their language
|
||||||
|
- `persona: "서울·경기 주말러"` → GOOD: "이번 주말, 댕댕이랑 어디 가지?"
|
||||||
|
|
||||||
|
**local_info** (support):
|
||||||
|
- Accessibility or charm, NOT administrative address
|
||||||
|
- GOOD: "서울에서 1시간 반, 홍천 숲속" / BAD: "강원 홍천군 화촌면"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### PACING
|
||||||
|
|
||||||
|
```
|
||||||
|
intro(8~12) → welcome(12~18) → core(alternate 8~12 ↔ 12~18) → highlight(8~14) → support(12~18) → accent(variable) → cta(12~16)
|
||||||
|
```
|
||||||
|
|
||||||
|
**RULE: No 3+ consecutive scenes in same char-count range.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Keyword pattern analysis:
|
||||||
|
- "스테이펫" → brand_name verbatim (허용)
|
||||||
|
- "고요한 숲" → Pattern 1: 관형형+명사 (형용사 관형형 "고요한" + 명사 "숲")
|
||||||
|
- "깊은 쉼" → Pattern 1: 관형형+명사 (형용사 관형형 "깊은" + 명사 "쉼")
|
||||||
|
- "숲멍" → Pattern 2: 기존 대중화 합성어 (불멍·물멍·숲멍 시리즈)
|
||||||
|
- "댕캉스" → Pattern 2: 기존 대중화 합성어 (댕댕이+바캉스, 여행업계 통용)
|
||||||
|
- "예약하기" → Pattern 5: 의미 완결 동사 명사형
|
||||||
|
|
||||||
|
|
||||||
|
# 입력
|
||||||
|
**입력 1: 레이어 이름 리스트**
|
||||||
|
{pitching_tag_list_string}
|
||||||
|
|
||||||
|
**입력 2: 마케팅 인텔리전스 JSON**
|
||||||
|
{marketing_intelligence}
|
||||||
|
|
||||||
|
**입력 3: 비즈니스 정보 **
|
||||||
|
Business Name: {customer_name}
|
||||||
|
Region Details: {detail_region_info}
|
||||||
@ -1,5 +1,4 @@
|
|||||||
import copy
|
import copy
|
||||||
import re
|
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from typing import Literal, Any
|
from typing import Literal, Any
|
||||||
@ -11,44 +10,12 @@ from app.utils.prompts.chatgpt_prompt import ChatgptService
|
|||||||
from app.utils.prompts.schemas import *
|
from app.utils.prompts.schemas import *
|
||||||
from app.utils.prompts.prompts import *
|
from app.utils.prompts.prompts import *
|
||||||
|
|
||||||
logger = get_logger("subtitle")
|
|
||||||
|
|
||||||
# 비한국어 출력에서 번역 누락(한글 잔존)을 탐지하기 위한 패턴
|
|
||||||
_HANGUL_RE = re.compile(r"[가-힣]")
|
|
||||||
|
|
||||||
# 한글 잔존 시 GPT 재호출 최대 횟수 (최초 호출 포함)
|
|
||||||
_MAX_LANGUAGE_ATTEMPTS = 3
|
|
||||||
|
|
||||||
|
|
||||||
class SubtitleContentsGenerator():
|
class SubtitleContentsGenerator():
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.chatgpt_service = ChatgptService(timeout=60.0)
|
self.chatgpt_service = ChatgptService()
|
||||||
|
|
||||||
@staticmethod
|
async def generate_subtitle_contents(self, marketing_intelligence : dict[str, Any], pitching_label_list : list[Any], customer_name : str, detail_region_info : str) -> SubtitlePromptOutput:
|
||||||
def _find_hangul_leftovers(output_data: SubtitlePromptOutput) -> list[str]:
|
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list))
|
||||||
"""비한국어 출력에서 한글이 남아 있는 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(
|
|
||||||
f"[SubtitleContentsGenerator] START - customer: {customer_name}, "
|
|
||||||
f"pitching_count: {len(pitching_label_list)}, "
|
|
||||||
f"labels: {pitching_label_list}, "
|
|
||||||
f"language: {language}, "
|
|
||||||
f"industry: {industry}"
|
|
||||||
)
|
|
||||||
|
|
||||||
dynamic_subtitle_prompt = create_dynamic_subtitle_prompt(len(pitching_label_list), industry)
|
|
||||||
pitching_label_string = "\n".join(pitching_label_list)
|
pitching_label_string = "\n".join(pitching_label_list)
|
||||||
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
marketing_intel_string = json.dumps(marketing_intelligence, ensure_ascii=False)
|
||||||
input_data = {
|
input_data = {
|
||||||
@ -56,52 +23,8 @@ class SubtitleContentsGenerator():
|
|||||||
"pitching_tag_list_string" : pitching_label_string,
|
"pitching_tag_list_string" : pitching_label_string,
|
||||||
"customer_name" : customer_name,
|
"customer_name" : customer_name,
|
||||||
"detail_region_info" : detail_region_info,
|
"detail_region_info" : detail_region_info,
|
||||||
"language" : language,
|
|
||||||
"industry": industry, # 가사/영상 파이프라인에서 전달된 업종 enum
|
|
||||||
}
|
}
|
||||||
|
output_data = await self.chatgpt_service.generate_structured_output(dynamic_subtitle_prompt, input_data)
|
||||||
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
|
|
||||||
|
|
||||||
elapsed = (time.perf_counter() - start) * 1000
|
|
||||||
logger.info(
|
|
||||||
f"[SubtitleContentsGenerator] DONE - 소요시간: {elapsed:.0f}ms, "
|
|
||||||
f"결과: {[r.pitching_tag for r in output_data.pitching_results]}"
|
|
||||||
)
|
|
||||||
return output_data
|
return output_data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,7 @@ from typing import Any
|
|||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
|
from app.song.schemas.song_schema import PollingSongResponse, SongClipData
|
||||||
from app.utils.bgm_lyrics import get_bgm_lyrics, normalize_genre as _normalize_genre
|
from app.utils.bgm_lyrics import get_random_bgm_lyrics
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import apikey_settings, recovery_settings
|
from config import apikey_settings, recovery_settings
|
||||||
|
|
||||||
@ -113,11 +113,11 @@ class SunoService:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
prompt: 가사 (customMode=true일 때 가사로 사용)
|
prompt: 가사 (customMode=true일 때 가사로 사용)
|
||||||
40초 이내 길이의 노래에 적합한 가사여야 함
|
1분 이내 길이의 노래에 적합한 가사여야 함
|
||||||
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM", "Rock", "Jazz")
|
genre: 음악 장르 (예: "K-Pop", "Pop", "R&B", "Hip-Hop", "Ballad", "EDM")
|
||||||
None일 경우 style 파라미터를 전송하지 않음
|
None일 경우 style 파라미터를 전송하지 않음
|
||||||
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
callback_url: 생성 완료 시 알림 받을 URL (None일 경우 config에서 기본값 사용)
|
||||||
instrumental: True이면 BGM 전용 — 더미 가사로 40초 길이를 유도하고 보컬 없이 생성
|
instrumental: True이면 BGM 전용 — 더미 가사로 60초 길이를 유도하고 보컬 없이 생성
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
task_id: 작업 추적용 ID
|
task_id: 작업 추적용 ID
|
||||||
@ -125,19 +125,17 @@ class SunoService:
|
|||||||
Note:
|
Note:
|
||||||
- 스트림 URL: 30-40초 내 생성
|
- 스트림 URL: 30-40초 내 생성
|
||||||
- 다운로드 URL: 2-3분 내 생성
|
- 다운로드 URL: 2-3분 내 생성
|
||||||
- 생성되는 노래는 약 40초 이내의 길이
|
- 생성되는 노래는 약 1분 이내의 길이
|
||||||
"""
|
"""
|
||||||
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
|
actual_callback_url = callback_url or apikey_settings.SUNO_CALLBACK_URL
|
||||||
|
|
||||||
normalized_genre = _normalize_genre(genre) if genre else None
|
|
||||||
|
|
||||||
if instrumental:
|
if instrumental:
|
||||||
bgm_lyrics = get_bgm_lyrics(genre)
|
bgm_lyrics, bgm_version = get_random_bgm_lyrics()
|
||||||
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} 선택됨")
|
logger.info(f"[Suno] BGM 더미 가사 버전 {bgm_version} 선택됨")
|
||||||
else:
|
else:
|
||||||
formatted_prompt = (
|
formatted_prompt = (
|
||||||
f"[Song Duration: Around 40 seconds]\n{prompt}"
|
f"[Song Duration: Exactly 1 minute - Must be precisely 60 seconds]\n{prompt}"
|
||||||
)
|
)
|
||||||
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@ -147,8 +145,8 @@ class SunoService:
|
|||||||
"prompt": formatted_prompt,
|
"prompt": formatted_prompt,
|
||||||
"callBackUrl": actual_callback_url,
|
"callBackUrl": actual_callback_url,
|
||||||
}
|
}
|
||||||
if normalized_genre:
|
if genre:
|
||||||
payload["style"] = f"{normalized_genre}, around 40 seconds" if instrumental else normalized_genre
|
payload["style"] = f"{genre}, around 60 seconds" if instrumental else genre
|
||||||
|
|
||||||
last_error: Exception | None = None
|
last_error: Exception | None = None
|
||||||
|
|
||||||
@ -249,7 +247,7 @@ class SunoService:
|
|||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
async def get_lyric_timestamp(self, task_id: str, audio_id: str) -> dict[str, Any] | None:
|
async def get_lyric_timestamp(self, task_id: str, audio_id: str) -> dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
음악 타임스탬프 정보 추출
|
음악 타임스탬프 정보 추출
|
||||||
|
|
||||||
@ -272,10 +270,8 @@ class SunoService:
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
# Suno는 오디오 생성 완료 후에도 타임스탬프 정렬을 별도로 처리하므로,
|
|
||||||
# 빈 응답은 아직 준비되지 않은 상태를 의미한다. None을 반환해 호출부에서 폴링을 유지하게 한다.
|
|
||||||
if not data or not data["data"]:
|
if not data or not data["data"]:
|
||||||
return None
|
raise ValueError("Suno API returned empty response for task status")
|
||||||
|
|
||||||
return data["data"]["alignedWords"]
|
return data["data"]["alignedWords"]
|
||||||
|
|
||||||
|
|||||||
@ -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}
|
|
||||||
@ -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]
|
|
||||||
@ -32,17 +32,12 @@ URL 경로 형식:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from collections.abc import AsyncIterator
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import UploadFile
|
|
||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from config import azure_blob_settings
|
from config import azure_blob_settings
|
||||||
@ -50,15 +45,6 @@ from config import azure_blob_settings
|
|||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("blob")
|
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 클라이언트 (싱글톤 패턴)
|
# 모듈 레벨 공유 HTTP 클라이언트 (싱글톤 패턴)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@ -90,23 +76,6 @@ async def close_shared_blob_client() -> None:
|
|||||||
logger.info("[AzureBlobUploader] Shared HTTP client closed")
|
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:
|
class AzureBlobUploader:
|
||||||
"""Azure Blob Storage 업로드 클래스
|
"""Azure Blob Storage 업로드 클래스
|
||||||
|
|
||||||
@ -131,8 +100,6 @@ class AzureBlobUploader:
|
|||||||
".gif": "image/gif",
|
".gif": "image/gif",
|
||||||
".webp": "image/webp",
|
".webp": "image/webp",
|
||||||
".bmp": "image/bmp",
|
".bmp": "image/bmp",
|
||||||
".heic": "image/heic",
|
|
||||||
".heif": "image/heif",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, user_uuid: str, task_id: str):
|
def __init__(self, user_uuid: str, task_id: str):
|
||||||
@ -144,7 +111,7 @@ class AzureBlobUploader:
|
|||||||
"""
|
"""
|
||||||
self._user_uuid = user_uuid
|
self._user_uuid = user_uuid
|
||||||
self._task_id = task_id
|
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._sas_token = azure_blob_settings.AZURE_BLOB_SAS_TOKEN
|
||||||
self._last_public_url: str = ""
|
self._last_public_url: str = ""
|
||||||
|
|
||||||
@ -237,12 +204,8 @@ class AzureBlobUploader:
|
|||||||
logger.debug(f"[{log_prefix}] Starting upload... "
|
logger.debug(f"[{log_prefix}] Starting upload... "
|
||||||
f"(size: {size} bytes, timeout: {timeout}s)")
|
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(
|
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,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
upload_time = time.perf_counter()
|
upload_time = time.perf_counter()
|
||||||
@ -283,170 +246,6 @@ class AzureBlobUploader:
|
|||||||
f"{type(e).__name__}: {e}")
|
f"{type(e).__name__}: {e}")
|
||||||
return False
|
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(
|
async def _upload_file(
|
||||||
self,
|
self,
|
||||||
file_path: str,
|
file_path: str,
|
||||||
@ -474,20 +273,17 @@ class AzureBlobUploader:
|
|||||||
self._last_public_url = self._build_public_url(category, file_name)
|
self._last_public_url = self._build_public_url(category, file_name)
|
||||||
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
logger.debug(f"[{log_prefix}] URL (without SAS): {self._last_public_url}")
|
||||||
|
|
||||||
async def iter_file() -> AsyncIterator[bytes]:
|
headers = {"Content-Type": content_type, "x-ms-blob-type": "BlockBlob"}
|
||||||
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
|
|
||||||
|
|
||||||
return await self._upload_stream(
|
async with aiofiles.open(file_path, "rb") as file:
|
||||||
chunks=iter_file(),
|
file_content = await file.read()
|
||||||
|
|
||||||
|
return await self._upload_bytes(
|
||||||
|
file_content=file_content,
|
||||||
upload_url=upload_url,
|
upload_url=upload_url,
|
||||||
content_type=content_type,
|
headers=headers,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
log_prefix=log_prefix,
|
log_prefix=log_prefix,
|
||||||
expected_size_bytes=Path(file_path).stat().st_size,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
async def upload_music(self, file_path: str) -> bool:
|
async def upload_music(self, file_path: str) -> bool:
|
||||||
@ -686,51 +482,6 @@ class AzureBlobUploader:
|
|||||||
log_prefix=log_prefix,
|
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
|
# import asyncio
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
"""
|
|
||||||
내부 전용 좋아요 반응 플러시 API
|
|
||||||
|
|
||||||
스케줄러가 1분마다 호출하여 Redis dirty SET의 좋아요 토글을 MySQL에 bulk write합니다.
|
|
||||||
X-Internal-Secret 헤더로 인증합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Header, HTTPException, status
|
|
||||||
from sqlalchemy import delete, insert, tuple_
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.database.like_cache import (
|
|
||||||
commit_dirty_processing,
|
|
||||||
drain_dirty,
|
|
||||||
is_user_liked,
|
|
||||||
)
|
|
||||||
from app.database.session import get_session
|
|
||||||
from app.video.models import VideoReaction
|
|
||||||
from config import internal_settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/internal/video", tags=["Internal"])
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/reactions/flush",
|
|
||||||
summary="[내부] 좋아요 반응 DB 플러시",
|
|
||||||
description="스케줄러 서버에서 1분마다 호출하는 내부 전용 엔드포인트입니다. "
|
|
||||||
"Redis dirty SET의 항목을 MySQL video_reaction 테이블에 bulk write합니다.",
|
|
||||||
)
|
|
||||||
async def flush_reactions(
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
x_internal_secret: str = Header(...),
|
|
||||||
) -> dict:
|
|
||||||
"""Redis dirty SET → MySQL bulk write.
|
|
||||||
|
|
||||||
1. drain_dirty(): dirty SET을 processing으로 RENAME 후 항목 조회
|
|
||||||
2. 각 항목의 현재 Redis 상태(is_liked) 확인
|
|
||||||
3. is_liked=True → INSERT IGNORE, is_liked=False → DELETE
|
|
||||||
4. commit_dirty_processing(): processing SET 삭제
|
|
||||||
"""
|
|
||||||
if x_internal_secret != internal_settings.INTERNAL_SECRET_KEY:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Invalid internal secret",
|
|
||||||
)
|
|
||||||
|
|
||||||
pairs = await drain_dirty()
|
|
||||||
if not pairs:
|
|
||||||
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
|
|
||||||
return {"flushed": 0, "adds": 0, "dels": 0}
|
|
||||||
|
|
||||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건")
|
|
||||||
|
|
||||||
adds: list[dict] = []
|
|
||||||
dels: list[tuple[int, str]] = []
|
|
||||||
|
|
||||||
# Redis 현재 상태 기준으로 add / delete 분류
|
|
||||||
for video_id, user_uuid in pairs:
|
|
||||||
liked = await is_user_liked(video_id, user_uuid)
|
|
||||||
if liked:
|
|
||||||
adds.append({"video_id": video_id, "user_uuid": user_uuid})
|
|
||||||
else:
|
|
||||||
dels.append((video_id, user_uuid))
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리
|
|
||||||
if adds:
|
|
||||||
await session.execute(
|
|
||||||
insert(VideoReaction).prefix_with("IGNORE").values(adds)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Bulk DELETE
|
|
||||||
if dels:
|
|
||||||
await session.execute(
|
|
||||||
delete(VideoReaction).where(
|
|
||||||
tuple_(
|
|
||||||
VideoReaction.video_id,
|
|
||||||
VideoReaction.user_uuid,
|
|
||||||
).in_(dels)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
|
||||||
await commit_dirty_processing()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}"
|
|
||||||
)
|
|
||||||
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
await session.rollback()
|
|
||||||
logger.error(f"[REACTION_FLUSH] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"플러시 실패: {str(e)}")
|
|
||||||
@ -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)
|
|
||||||
@ -14,115 +14,40 @@ Video API Router
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from collections import defaultdict
|
import asyncio
|
||||||
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from sqlalchemy import select
|
||||||
from sqlalchemy import func, or_, select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
from app.dependencies.pagination import PaginationParams, get_pagination_params
|
from app.user.dependencies.auth import get_current_user
|
||||||
from app.user.dependencies.auth import get_current_user, get_current_user_optional
|
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.utils.pagination import PaginatedResponse
|
from app.home.models import Image, Project, MarketingIntel, ImageTag
|
||||||
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.lyric.models import Lyric
|
||||||
from app.song.models import Song, SongTimestamp
|
from app.song.models import Song, SongTimestamp
|
||||||
from app.utils.creatomate import CreatomateService, LANGUAGE_FONT_MAP
|
from app.utils.creatomate import CreatomateService
|
||||||
|
from app.utils.subtitles import SubtitleContentsGenerator
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.database.like_cache import (
|
|
||||||
backfill_user_set,
|
|
||||||
bulk_is_user_liked,
|
|
||||||
get_like_count,
|
|
||||||
get_like_counts,
|
|
||||||
is_user_liked,
|
|
||||||
is_user_set_exists,
|
|
||||||
mark_dirty,
|
|
||||||
mset_like_counts,
|
|
||||||
set_like_count,
|
|
||||||
toggle_like_atomic,
|
|
||||||
)
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.video.models import Video, VideoReaction
|
from app.video.models import Video
|
||||||
from app.video.schemas.video_schema import (
|
from app.video.schemas.video_schema import (
|
||||||
DownloadVideoResponse,
|
DownloadVideoResponse,
|
||||||
GenerateVideoResponse,
|
GenerateVideoResponse,
|
||||||
LikeToggleResponse,
|
|
||||||
PollingVideoResponse,
|
PollingVideoResponse,
|
||||||
VideoDetailResponse,
|
|
||||||
VideoRenderData,
|
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 app.video.worker.video_task import download_and_upload_video_to_blob
|
||||||
|
from app.video.services.video import get_image_tags_by_task_id
|
||||||
|
|
||||||
|
from config import creatomate_settings
|
||||||
from config import creatomate_settings, prj_settings
|
|
||||||
|
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
|
|
||||||
router = APIRouter(prefix="/video", tags=["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(
|
@router.get(
|
||||||
"/generate/{task_id}",
|
"/generate/{task_id}",
|
||||||
summary="영상 생성 요청",
|
summary="영상 생성 요청",
|
||||||
@ -223,9 +148,37 @@ async def generate_video(
|
|||||||
image_urls: list[str] = []
|
image_urls: list[str] = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
subtitle_done = False
|
||||||
|
count = 0
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
project_result = await session.execute(
|
||||||
|
select(Project)
|
||||||
|
.where(Project.task_id == task_id)
|
||||||
|
.order_by(Project.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
while not subtitle_done:
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
logger.info(f"[generate_video] Checking subtitle- task_id: {task_id}, count : {count}")
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence = marketing_result.scalar_one_or_none()
|
||||||
|
subtitle_done = bool(marketing_intelligence.subtitle)
|
||||||
|
if subtitle_done:
|
||||||
|
logger.info(f"[generate_video] Check subtitle done task_id: {task_id}")
|
||||||
|
break
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
if count > 60 :
|
||||||
|
raise Exception("subtitle 결과 생성 실패")
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
|
||||||
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
# 세션을 명시적으로 열고 DB 작업 후 바로 닫음
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
# ===== 순차 쿼리 실행: Project, MarketingIntel, Lyric, Song, Image =====
|
# ===== 순차 쿼리 실행: Project, Lyric, Song, Image =====
|
||||||
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
# Note: AsyncSession은 동일 세션에서 병렬 쿼리를 지원하지 않음
|
||||||
|
|
||||||
# Project 조회
|
# Project 조회
|
||||||
@ -235,47 +188,6 @@ async def generate_video(
|
|||||||
.order_by(Project.created_at.desc())
|
.order_by(Project.created_at.desc())
|
||||||
.limit(1)
|
.limit(1)
|
||||||
)
|
)
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
if not project:
|
|
||||||
logger.warning(f"[generate_video] Project NOT FOUND - task_id: {task_id}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=404,
|
|
||||||
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
|
||||||
)
|
|
||||||
project_id = project.id
|
|
||||||
store_address = project.detail_region_info
|
|
||||||
brand_name = project.store_name
|
|
||||||
region = project.region
|
|
||||||
industry = project.industry
|
|
||||||
output_language = project.language or "Korean"
|
|
||||||
|
|
||||||
# MarketingIntel 조회
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence: MarketingIntel = marketing_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
# 자막 + 이미지 배정 미완료 시 즉시 반환 — Lyric/Song/Image 쿼리 전에 체크하여 불필요한 조회 방지
|
|
||||||
# 클라이언트가 /lyric/subtitle/status/{task_id} 폴링 후 재시도
|
|
||||||
if not marketing_intelligence.subtitle or not marketing_intelligence.image_match:
|
|
||||||
pending_what = []
|
|
||||||
if not marketing_intelligence.subtitle:
|
|
||||||
pending_what.append("자막")
|
|
||||||
if not marketing_intelligence.image_match:
|
|
||||||
pending_what.append("이미지 배정")
|
|
||||||
pending_msg = ", ".join(pending_what)
|
|
||||||
logger.info(f"[generate_video] 사전 준비 미완료 ({pending_msg}) - task_id: {task_id}")
|
|
||||||
return GenerateVideoResponse(
|
|
||||||
success=False,
|
|
||||||
status="subtitle_pending",
|
|
||||||
task_id=task_id,
|
|
||||||
creatomate_render_id=None,
|
|
||||||
message=f"{pending_msg} 생성이 아직 완료되지 않았습니다. /lyric/subtitle/status/{{task_id}}로 완료 확인 후 재요청하세요.",
|
|
||||||
error_message=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
category_definition = marketing_intelligence.intel_result["market_positioning"]["category_definition"]
|
|
||||||
target_keywords = marketing_intelligence.intel_result["target_keywords"]
|
|
||||||
|
|
||||||
# Lyric 조회
|
# Lyric 조회
|
||||||
lyric_result = await session.execute(
|
lyric_result = await session.execute(
|
||||||
@ -306,6 +218,34 @@ async def generate_video(
|
|||||||
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
f"elapsed: {(query_time - request_start) * 1000:.1f}ms"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ===== 결과 처리: Project =====
|
||||||
|
project = project_result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
logger.warning(
|
||||||
|
f"[generate_video] Project NOT FOUND - task_id: {task_id}"
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"task_id '{task_id}'에 해당하는 Project를 찾을 수 없습니다.",
|
||||||
|
)
|
||||||
|
project_id = project.id
|
||||||
|
store_address = project.detail_region_info
|
||||||
|
brand_name = project.store_name
|
||||||
|
region = project.region
|
||||||
|
|
||||||
|
marketing_result = await session.execute(
|
||||||
|
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
||||||
|
)
|
||||||
|
marketing_intelligence : MarketingIntel = marketing_result.scalar_one_or_none()
|
||||||
|
|
||||||
|
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 =====
|
||||||
lyric = lyric_result.scalar_one_or_none()
|
lyric = lyric_result.scalar_one_or_none()
|
||||||
if not lyric:
|
if not lyric:
|
||||||
@ -353,19 +293,10 @@ async def generate_video(
|
|||||||
)
|
)
|
||||||
image_urls = [img.img_url for img in images]
|
image_urls = [img.img_url for img in images]
|
||||||
|
|
||||||
# SongTimestamp 조회 (외부 API 호출 전 필요한 데이터이므로 1단계에서 수집)
|
|
||||||
song_timestamp_result = await session.execute(
|
|
||||||
select(SongTimestamp).where(
|
|
||||||
SongTimestamp.suno_audio_id == song.suno_audio_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
song_timestamp_list = song_timestamp_result.scalars().all()
|
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[generate_video] Data loaded - task_id: {task_id}, "
|
f"[generate_video] Data loaded - task_id: {task_id}, "
|
||||||
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
f"project_id: {project_id}, lyric_id: {lyric_id}, "
|
||||||
f"song_id: {song_id}, images: {len(image_urls)}, "
|
f"song_id: {song_id}, images: {len(image_urls)}"
|
||||||
f"timestamps: {len(song_timestamp_list)}"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
||||||
@ -409,11 +340,7 @@ async def generate_video(
|
|||||||
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
|
f"[generate_video] Stage 2 START - Creatomate API - task_id: {task_id}"
|
||||||
)
|
)
|
||||||
creatomate_service = CreatomateService(
|
creatomate_service = CreatomateService(
|
||||||
orientation=orientation,
|
orientation=orientation
|
||||||
industry=industry,
|
|
||||||
# 미매핑 업종(general 등)은 project_id % len(VST_LIST)로 템플릿을 분배하므로,
|
|
||||||
# 사전 이미지 배정 단계(creative_assets_task)와 동일 템플릿을 받으려면 필수
|
|
||||||
project_id=project_id,
|
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
f"[generate_video] Using template_id: {creatomate_service.template_id}, (song duration: {song_duration})"
|
||||||
@ -426,37 +353,40 @@ async def generate_video(
|
|||||||
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
|
logger.debug(f"[generate_video] Template fetched - task_id: {task_id}")
|
||||||
|
|
||||||
# 6-2. elements에서 리소스 매핑 생성
|
# 6-2. elements에서 리소스 매핑 생성
|
||||||
# 이미지 배정은 /lyric/generate 사전 단계에서 이미 수행되어 marketing_intelligence.image_match에 저장됨.
|
# modifications = creatomate_service.elements_connect_resource_blackbox(
|
||||||
# 여기서는 사전 산출물을 읽어 음악 URL과 주소만 보완한다.
|
# elements=template["source"]["elements"],
|
||||||
modifications: dict = dict(marketing_intelligence.image_match) # 슬롯→image_url 사전 배정 결과
|
# image_url_list=image_urls,
|
||||||
modifications["audio-music"] = music_url
|
# music_url=music_url,
|
||||||
# address_input 슬롯은 사전 단계에서도 채워지지만 영상 시점에 재확인
|
# address=store_address
|
||||||
for key in list(modifications.keys()):
|
taged_image_list = await get_image_tags_by_task_id(task_id)
|
||||||
if "address_input" in key:
|
min_image_num = creatomate_service.counting_component(
|
||||||
modifications[key] = store_address
|
template = template,
|
||||||
logger.info(f"[generate_video] image_match loaded from DB (slots: {len(modifications)}) - task_id: {task_id}")
|
target_template_type = "image"
|
||||||
|
)
|
||||||
|
duplicate = bool(len(taged_image_list) < min_image_num)
|
||||||
|
logger.info(f"[generate_video] Duplicate : {duplicate} | length of taged_image {len(taged_image_list)}, min_len {min_image_num},- task_id: {task_id}")
|
||||||
|
modifications = creatomate_service.template_matching_taged_image(
|
||||||
|
template = template,
|
||||||
|
taged_image_list = taged_image_list,
|
||||||
|
music_url = music_url,
|
||||||
|
address = store_address,
|
||||||
|
duplicate = duplicate,
|
||||||
|
)
|
||||||
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
|
logger.debug(f"[generate_video] Modifications created - task_id: {task_id}")
|
||||||
|
|
||||||
subtitle_modifications = marketing_intelligence.subtitle
|
subtitle_modifications = marketing_intelligence.subtitle
|
||||||
|
|
||||||
modifications.update(subtitle_modifications)
|
modifications.update(subtitle_modifications)
|
||||||
|
|
||||||
# 썸네일 텍스트: 사전 자막 생성(LLM, 다국어) 결과에 thumb-* 슬롯이 포함되면
|
# revert thumbnail scene
|
||||||
# 그대로 사용한다. 과거 파이프라인 산출물(subtitle에 thumb-* 없음)은
|
# thumbnail_modifications = creatomate_service.make_thumbnail_modification(
|
||||||
# 기존 팩트값 조립(한국어)으로 폴백.
|
# brand_name =brand_name,
|
||||||
thumbnail_fallback = creatomate_service.make_thumbnail_modification(
|
# region = region,
|
||||||
brand_name =brand_name,
|
# brand_concept = brand_concept,
|
||||||
region = region,
|
# category_definition= category_definition,
|
||||||
category_definition= category_definition,
|
# target_keywords=target_keywords)
|
||||||
target_keywords=target_keywords,
|
|
||||||
detail_region_info=store_address)
|
|
||||||
|
|
||||||
for slot_name, fallback_value in thumbnail_fallback.items():
|
# modifications.update(thumbnail_modifications)
|
||||||
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}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 6-3. elements 수정
|
# 6-3. elements 수정
|
||||||
new_elements = creatomate_service.modify_element(
|
new_elements = creatomate_service.modify_element(
|
||||||
@ -476,13 +406,24 @@ async def generate_video(
|
|||||||
|
|
||||||
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
|
logger.debug(f"[generate_video] Duration extended - task_id: {task_id}")
|
||||||
|
|
||||||
|
song_timestamp_result = await session.execute(
|
||||||
|
select(SongTimestamp).where(
|
||||||
|
SongTimestamp.suno_audio_id == song.suno_audio_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
song_timestamp_list = song_timestamp_result.scalars().all()
|
||||||
|
|
||||||
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
|
logger.debug(f"[generate_video] song_timestamp_list count: {len(song_timestamp_list)}")
|
||||||
|
|
||||||
for i, ts in enumerate(song_timestamp_list):
|
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}")
|
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
|
match lyric_language:
|
||||||
lyric_font = LANGUAGE_FONT_MAP.get(lyric_language, "Noto Sans")
|
case "English" :
|
||||||
|
lyric_font = "Noto Sans"
|
||||||
|
# lyric_font = "Pretendard" # 없어요
|
||||||
|
case _ :
|
||||||
|
lyric_font = "Noto Sans"
|
||||||
|
|
||||||
# LYRIC AUTO 결정부
|
# LYRIC AUTO 결정부
|
||||||
if (creatomate_settings.LYRIC_SUBTITLE):
|
if (creatomate_settings.LYRIC_SUBTITLE):
|
||||||
@ -502,14 +443,6 @@ async def generate_video(
|
|||||||
)
|
)
|
||||||
final_template["source"]["elements"].append(caption)
|
final_template["source"]["elements"].append(caption)
|
||||||
# END - LYRIC AUTO 결정부
|
# END - LYRIC AUTO 결정부
|
||||||
|
|
||||||
# 언어별 폰트 교체: 템플릿 기본 폰트는 한글·라틴 전용이라 CJK/태국어는
|
|
||||||
# 지원 폰트로 전체 텍스트(자막·키워드·썸네일·가사 캡션 포함)를 교체한다.
|
|
||||||
# 가사 캡션 append 이후에 실행해야 캡션까지 커버된다.
|
|
||||||
final_template = creatomate_service.apply_language_font(
|
|
||||||
final_template, output_language
|
|
||||||
)
|
|
||||||
|
|
||||||
# logger.debug(
|
# logger.debug(
|
||||||
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
# f"[generate_video] final_template: {json.dumps(final_template, indent=2, ensure_ascii=False)}"
|
||||||
# )
|
# )
|
||||||
@ -741,7 +674,7 @@ async def get_video_status(
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}\n{traceback.format_exc()}"
|
f"[get_video_status] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}"
|
||||||
)
|
)
|
||||||
return PollingVideoResponse(
|
return PollingVideoResponse(
|
||||||
success=False,
|
success=False,
|
||||||
@ -749,7 +682,7 @@ async def get_video_status(
|
|||||||
message="상태 조회에 실패했습니다.",
|
message="상태 조회에 실패했습니다.",
|
||||||
render_data=None,
|
render_data=None,
|
||||||
raw_response=None,
|
raw_response=None,
|
||||||
error_message=f"{type(e).__name__}: {e}",
|
error_message=f"{type(e).__name__}: {e}\n{traceback.format_exc()}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -859,9 +792,9 @@ async def download_video(
|
|||||||
status="completed",
|
status="completed",
|
||||||
message="영상 다운로드가 완료되었습니다.",
|
message="영상 다운로드가 완료되었습니다.",
|
||||||
store_name=project.store_name if project else None,
|
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,
|
region=project.region if project else None,
|
||||||
task_id=task_id,
|
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,
|
created_at=video.created_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -873,419 +806,3 @@ async def download_video(
|
|||||||
message="영상 다운로드 조회에 실패했습니다.",
|
message="영상 다운로드 조회에 실패했습니다.",
|
||||||
error_message=str(e),
|
error_message=str(e),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
|
||||||
"/all",
|
|
||||||
summary="ADO2 콘텐츠 - 전체 사용자 영상 갤러리",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
모든 사용자가 생성 완료한 영상을 페이지네이션하여 반환합니다.
|
|
||||||
|
|
||||||
## 쿼리 파라미터
|
|
||||||
- **page**: 페이지 번호 (1부터 시작, 기본값: 1)
|
|
||||||
- **page_size**: 페이지당 데이터 수 (기본값: 10, 최대: 100)
|
|
||||||
- **sort_by**: 정렬 기준 (created_at: 최신순, like_count: 좋아요순, comment_count: 댓글순, 기본값: created_at)
|
|
||||||
- **order**: 정렬 방향 (desc: 내림차순, asc: 오름차순, 기본값: desc)
|
|
||||||
- **store_name**: 업체명 검색 (부분 일치, 값이 있을 때만 전송)
|
|
||||||
- **region**: 지역명 검색 (부분 일치, 값이 있을 때만 전송)
|
|
||||||
""",
|
|
||||||
response_model=PaginatedResponse[VideoThumbnailItem],
|
|
||||||
responses={
|
|
||||||
200: {"description": "갤러리 조회 성공"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_all_videos(
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination_params),
|
|
||||||
sort_by: str = Query(default="created_at", description="정렬 기준 (created_at, like_count, comment_count)"),
|
|
||||||
order: str = Query(default="desc", description="정렬 방향 (desc, asc)"),
|
|
||||||
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
|
|
||||||
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
|
|
||||||
) -> PaginatedResponse[VideoThumbnailItem]:
|
|
||||||
"""전체 사용자의 완료된 영상 갤러리를 반환합니다."""
|
|
||||||
logger.info(
|
|
||||||
f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, "
|
|
||||||
f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}"
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
|
||||||
|
|
||||||
where_clauses = [
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
Project.is_deleted == False, # noqa: E712
|
|
||||||
Video.result_movie_url.is_not(None),
|
|
||||||
]
|
|
||||||
if store_name:
|
|
||||||
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
|
|
||||||
if region:
|
|
||||||
cities = SIDO_CITIES.get(region)
|
|
||||||
if cities:
|
|
||||||
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
|
|
||||||
where_clauses.append(
|
|
||||||
or_(
|
|
||||||
Project.region.in_(cities),
|
|
||||||
*[Project.detail_region_info.ilike(f"%{a}%") for a in aliases],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
where_clauses.append(
|
|
||||||
or_(
|
|
||||||
Project.region.ilike(f"%{region}%"),
|
|
||||||
Project.detail_region_info.ilike(f"%{region}%"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
count_q = (
|
|
||||||
select(func.count(Video.id))
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(*where_clauses)
|
|
||||||
)
|
|
||||||
total = (await session.execute(count_q)).scalar() or 0
|
|
||||||
|
|
||||||
comment_count_subq = (
|
|
||||||
select(func.count(Comment.id))
|
|
||||||
.where(
|
|
||||||
Comment.video_id == Video.id,
|
|
||||||
Comment.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
|
|
||||||
# like_count 정렬은 Redis 대신 서브쿼리로 처리 (ORDER BY에만 사용)
|
|
||||||
like_count_subq_for_sort = (
|
|
||||||
select(func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id == Video.id)
|
|
||||||
.correlate(Video)
|
|
||||||
.scalar_subquery()
|
|
||||||
)
|
|
||||||
sort_col_map = {
|
|
||||||
"like_count": like_count_subq_for_sort,
|
|
||||||
"comment_count": comment_count_subq,
|
|
||||||
"created_at": Video.created_at,
|
|
||||||
}
|
|
||||||
sort_col = sort_col_map.get(sort_by, Video.created_at)
|
|
||||||
order_clause = sort_col.asc() if order == "asc" else sort_col.desc()
|
|
||||||
|
|
||||||
list_q = (
|
|
||||||
select(
|
|
||||||
Video,
|
|
||||||
Project,
|
|
||||||
comment_count_subq.label("comment_count"),
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(*where_clauses)
|
|
||||||
.order_by(order_clause)
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
rows = (await session.execute(list_q)).all()
|
|
||||||
|
|
||||||
video_ids = [v.id for v, p, _ in rows]
|
|
||||||
|
|
||||||
# Redis mget으로 like_count 일괄 조회
|
|
||||||
like_count_map = await get_like_counts(video_ids)
|
|
||||||
|
|
||||||
# 카운트 캐시 미스 보정
|
|
||||||
missing_ids = [vid for vid, cnt in like_count_map.items() if cnt is None]
|
|
||||||
if missing_ids:
|
|
||||||
db_counts = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, func.count(VideoReaction.id))
|
|
||||||
.where(VideoReaction.video_id.in_(missing_ids))
|
|
||||||
.group_by(VideoReaction.video_id)
|
|
||||||
)).all()
|
|
||||||
db_found_ids = set()
|
|
||||||
batch = {}
|
|
||||||
for vid, cnt in db_counts:
|
|
||||||
batch[vid] = cnt
|
|
||||||
like_count_map[vid] = cnt
|
|
||||||
db_found_ids.add(vid)
|
|
||||||
await mset_like_counts(batch)
|
|
||||||
for vid in missing_ids:
|
|
||||||
if vid not in db_found_ids:
|
|
||||||
like_count_map[vid] = 0
|
|
||||||
|
|
||||||
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
|
|
||||||
liked_map: dict[int, bool] = {}
|
|
||||||
if current_user:
|
|
||||||
raw_liked = await bulk_is_user_liked(video_ids, current_user.user_uuid)
|
|
||||||
|
|
||||||
# user-set이 없는(None) 영상 중 count > 0인 것만 backfill 필요
|
|
||||||
needs_backfill = [
|
|
||||||
vid for vid, liked in raw_liked.items()
|
|
||||||
if liked is None and like_count_map.get(vid, 0) > 0
|
|
||||||
]
|
|
||||||
if needs_backfill:
|
|
||||||
reaction_rows = (await session.execute(
|
|
||||||
select(VideoReaction.video_id, VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id.in_(needs_backfill))
|
|
||||||
)).all()
|
|
||||||
user_map: dict[int, list[str]] = defaultdict(list)
|
|
||||||
for vid, uuid in reaction_rows:
|
|
||||||
user_map[vid].append(uuid)
|
|
||||||
for vid in needs_backfill:
|
|
||||||
await backfill_user_set(vid, user_map.get(vid, []))
|
|
||||||
|
|
||||||
# backfill 후 재조회
|
|
||||||
updated = await bulk_is_user_liked(needs_backfill, current_user.user_uuid)
|
|
||||||
raw_liked.update(updated)
|
|
||||||
|
|
||||||
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,
|
|
||||||
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
|
|
||||||
]
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
|
||||||
items=items,
|
|
||||||
total=total,
|
|
||||||
page=pagination.page,
|
|
||||||
page_size=pagination.page_size,
|
|
||||||
)
|
|
||||||
logger.info(f"[get_all_videos] SUCCESS - total: {total}, items: {len(items)}")
|
|
||||||
return response
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[get_all_videos] EXCEPTION - error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{video_id}/like",
|
|
||||||
summary="영상 좋아요 토글",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
영상에 좋아요를 토글합니다. 로그인 필수.
|
|
||||||
|
|
||||||
- 처음 호출: 좋아요 추가 (is_liked=true)
|
|
||||||
- 다시 호출: 좋아요 취소 (is_liked=false)
|
|
||||||
""",
|
|
||||||
response_model=LikeToggleResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "토글 성공"},
|
|
||||||
401: {"description": "인증 실패"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def toggle_like(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> LikeToggleResponse:
|
|
||||||
"""영상 좋아요를 토글합니다.
|
|
||||||
|
|
||||||
Write-Behind 패턴:
|
|
||||||
1. Redis user-set / count를 즉시 원자적으로 업데이트 (Lua script)
|
|
||||||
2. dirty SET에 표시 → 스케줄러가 1분마다 MySQL에 반영
|
|
||||||
DB write가 없으므로 고트래픽에서도 응답 지연 없음.
|
|
||||||
"""
|
|
||||||
logger.info(f"[toggle_like] START - video_id: {video_id}, user: {current_user.user_uuid}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# 영상 존재 확인 (DB read는 유지 — 404 처리 필수)
|
|
||||||
video_result = await session.execute(
|
|
||||||
select(Video).where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if video_result.scalar_one_or_none() is None:
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
# Cold-start 보정: Redis에 데이터가 없으면 DB에서 backfill
|
|
||||||
count = await get_like_count(video_id)
|
|
||||||
if count is None:
|
|
||||||
# 카운트와 user-set 모두 없음 → DB에서 전체 복구
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
await set_like_count(video_id, len(user_uuids))
|
|
||||||
elif count > 0:
|
|
||||||
if not await is_user_set_exists(video_id):
|
|
||||||
# 카운트는 있지만 user-set이 증발한 경우 (부분 캐시 미스)
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
|
|
||||||
# Lua 스크립트로 원자적 토글 (race condition 방지)
|
|
||||||
is_liked, like_count = await toggle_like_atomic(video_id, current_user.user_uuid)
|
|
||||||
|
|
||||||
# dirty SET에 표시 → 스케줄러가 DB에 반영
|
|
||||||
await mark_dirty(video_id, current_user.user_uuid)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[toggle_like] SUCCESS - video_id: {video_id}, "
|
|
||||||
f"is_liked: {is_liked}, count: {like_count}"
|
|
||||||
)
|
|
||||||
return LikeToggleResponse(video_id=video_id, is_liked=is_liked, like_count=like_count)
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[toggle_like] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
||||||
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="단일 영상 상세 조회",
|
|
||||||
description="""
|
|
||||||
## 개요
|
|
||||||
video_id에 해당하는 완료된 영상의 상세 정보를 반환합니다.
|
|
||||||
|
|
||||||
## 경로 파라미터
|
|
||||||
- **video_id**: 조회할 영상의 ID (Video.id)
|
|
||||||
""",
|
|
||||||
response_model=VideoDetailResponse,
|
|
||||||
responses={
|
|
||||||
200: {"description": "상세 조회 성공"},
|
|
||||||
404: {"description": "영상을 찾을 수 없음"},
|
|
||||||
500: {"description": "조회 실패"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
async def get_video_detail(
|
|
||||||
video_id: int,
|
|
||||||
current_user: User | None = Depends(get_current_user_optional),
|
|
||||||
session: AsyncSession = Depends(get_session),
|
|
||||||
) -> VideoDetailResponse:
|
|
||||||
"""video_id에 해당하는 완료된 영상 상세 정보를 반환합니다."""
|
|
||||||
logger.info(f"[get_video_detail] START - video_id: {video_id}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await session.execute(
|
|
||||||
select(Video, Project)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(
|
|
||||||
Video.id == video_id,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
Project.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.one_or_none()
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
logger.warning(f"[get_video_detail] NOT FOUND - video_id: {video_id}")
|
|
||||||
raise HTTPException(status_code=404, detail="영상을 찾을 수 없습니다.")
|
|
||||||
|
|
||||||
video, project = row
|
|
||||||
|
|
||||||
# like_count: Redis 조회, 캐시 미스 시 DB backfill
|
|
||||||
like_count = await get_like_count(video_id)
|
|
||||||
if like_count is None:
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
like_count = len(user_uuids)
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
await set_like_count(video_id, like_count)
|
|
||||||
|
|
||||||
# is_liked_by_me: Redis user-set 기준, cold-start 시 DB backfill
|
|
||||||
is_liked_by_me = False
|
|
||||||
if current_user:
|
|
||||||
liked = await is_user_liked(video_id, current_user.user_uuid)
|
|
||||||
if liked is None:
|
|
||||||
# user-set 없음 → count key로 cold-start 여부 판별
|
|
||||||
if like_count > 0:
|
|
||||||
user_uuids = (await session.execute(
|
|
||||||
select(VideoReaction.user_uuid)
|
|
||||||
.where(VideoReaction.video_id == video_id)
|
|
||||||
)).scalars().all()
|
|
||||||
await backfill_user_set(video_id, list(user_uuids))
|
|
||||||
liked = current_user.user_uuid in set(user_uuids)
|
|
||||||
else:
|
|
||||||
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,
|
|
||||||
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:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"[get_video_detail] EXCEPTION - video_id: {video_id}, error: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=f"영상 조회에 실패했습니다: {str(e)}")
|
|
||||||
|
|||||||
@ -1,18 +1,15 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, UniqueConstraint, func
|
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
||||||
from sqlalchemy.dialects.mysql import JSON
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
from app.database.session import Base
|
from app.database.session import Base
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.home.models import Project
|
from app.home.models import Project
|
||||||
from app.lyric.models import Lyric
|
from app.lyric.models import Lyric
|
||||||
from app.song.models import Song
|
from app.song.models import Song
|
||||||
from app.user.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class Video(Base):
|
class Video(Base):
|
||||||
@ -30,18 +27,12 @@ class Video(Base):
|
|||||||
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
task_id: 영상 생성 작업의 고유 식별자 (UUID7 형식)
|
||||||
status: 처리 상태 (pending, processing, completed, failed 등)
|
status: 처리 상태 (pending, processing, completed, failed 등)
|
||||||
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
result_movie_url: 생성된 영상 URL (S3, CDN 경로)
|
||||||
poster_url: 영상 첫 프레임 포스터 이미지 URL (SNS 공유 og:image용)
|
|
||||||
title: SNS 업로드 제목
|
|
||||||
description: SNS 업로드 설명
|
|
||||||
hashtags: SNS 해시태그 목록
|
|
||||||
created_at: 생성 일시 (자동 설정)
|
created_at: 생성 일시 (자동 설정)
|
||||||
|
|
||||||
Relationships:
|
Relationships:
|
||||||
project: 연결된 Project
|
project: 연결된 Project
|
||||||
lyric: 연결된 Lyric
|
lyric: 연결된 Lyric
|
||||||
song: 연결된 Song
|
song: 연결된 Song
|
||||||
comments: 영상 댓글 목록
|
|
||||||
likes: 영상 좋아요 목록
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "video"
|
__tablename__ = "video"
|
||||||
@ -111,30 +102,6 @@ class Video(Base):
|
|||||||
comment="생성된 영상 URL",
|
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(
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
Boolean,
|
Boolean,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
@ -165,20 +132,6 @@ class Video(Base):
|
|||||||
back_populates="videos",
|
back_populates="videos",
|
||||||
)
|
)
|
||||||
|
|
||||||
comments: Mapped[List["Comment"]] = relationship(
|
|
||||||
"Comment",
|
|
||||||
back_populates="video",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
reactions: Mapped[List["VideoReaction"]] = relationship(
|
|
||||||
"VideoReaction",
|
|
||||||
back_populates="video",
|
|
||||||
cascade="all, delete-orphan",
|
|
||||||
lazy="noload",
|
|
||||||
)
|
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
def truncate(value: str | None, max_len: int = 10) -> str:
|
def truncate(value: str | None, max_len: int = 10) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@ -192,50 +145,3 @@ class Video(Base):
|
|||||||
f"status='{self.status}'"
|
f"status='{self.status}'"
|
||||||
f")>"
|
f")>"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class VideoReaction(Base):
|
|
||||||
"""
|
|
||||||
영상 반응 테이블
|
|
||||||
|
|
||||||
사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글).
|
|
||||||
(user_uuid, video_id) 유니크 제약으로 1인 1회 보장.
|
|
||||||
향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "video_reaction"
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"),
|
|
||||||
Index("idx_video_reaction_video_id", "video_id"),
|
|
||||||
Index("idx_video_reaction_user_uuid", "user_uuid"),
|
|
||||||
{
|
|
||||||
"mysql_engine": "InnoDB",
|
|
||||||
"mysql_charset": "utf8mb4",
|
|
||||||
"mysql_collate": "utf8mb4_unicode_ci",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[int] = mapped_column(
|
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
|
||||||
)
|
|
||||||
video_id: Mapped[int] = mapped_column(
|
|
||||||
Integer,
|
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="연결된 Video의 id",
|
|
||||||
)
|
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
|
||||||
String(36),
|
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
|
||||||
nullable=False,
|
|
||||||
comment="반응한 사용자 UUID",
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime,
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
comment="반응 일시",
|
|
||||||
)
|
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="reactions")
|
|
||||||
user: Mapped["User"] = relationship("User", back_populates="video_reactions")
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ Video API Schemas
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@ -36,7 +36,6 @@ class GenerateVideoResponse(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
success: bool = Field(..., description="요청 성공 여부")
|
success: bool = Field(..., description="요청 성공 여부")
|
||||||
status: Optional[str] = Field(None, description="처리 상태 (subtitle_pending: 자막 미완료, completed: 정상 접수)")
|
|
||||||
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
task_id: Optional[str] = Field(None, description="내부 작업 ID (Project task_id)")
|
||||||
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
creatomate_render_id: Optional[str] = Field(None, description="Creatomate 렌더 ID")
|
||||||
message: str = Field(..., description="응답 메시지")
|
message: str = Field(..., description="응답 메시지")
|
||||||
@ -148,7 +147,6 @@ class VideoListItem(BaseModel):
|
|||||||
"region": "군산",
|
"region": "군산",
|
||||||
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
"task_id": "019123ab-cdef-7890-abcd-ef1234567890",
|
||||||
"result_movie_url": "http://localhost:8000/media/2025-01-15/video.mp4",
|
"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"
|
"created_at": "2025-01-15T12:00:00"
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@ -158,72 +156,6 @@ class VideoListItem(BaseModel):
|
|||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(..., description="작업 고유 식별자")
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
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="생성 일시")
|
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):
|
|
||||||
"""ADO2 콘텐츠 갤러리용 최소 영상 정보 (썸네일 표시 + 상세 페이지 이동용)
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/all 응답의 개별 영상 정보
|
|
||||||
"""
|
|
||||||
|
|
||||||
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 (썸네일 표시용)")
|
|
||||||
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):
|
|
||||||
"""단일 영상 상세 응답
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
GET /video/{video_id}
|
|
||||||
"""
|
|
||||||
|
|
||||||
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):
|
|
||||||
"""좋아요 토글 응답
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
POST /video/{video_id}/like
|
|
||||||
"""
|
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
|
||||||
is_liked: bool = Field(..., description="토글 후 상태 (true=좋아요 누름, false=취소됨)")
|
|
||||||
like_count: int = Field(..., description="토글 후 전체 좋아요 수")
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
|
||||||
@ -839,7 +839,7 @@ logger = get_logger("video")
|
|||||||
# )
|
# )
|
||||||
from sqlalchemy.dialects import mysql
|
from sqlalchemy.dialects import mysql
|
||||||
async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
||||||
# print("taskid", task_id)
|
print("taskid", task_id)
|
||||||
async with AsyncSessionLocal() as session:
|
async with AsyncSessionLocal() as session:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(Image.img_url, ImageTag.img_tag)
|
select(Image.img_url, ImageTag.img_tag)
|
||||||
@ -854,19 +854,9 @@ async def get_image_tags_by_task_id(task_id: str) -> list[dict]:
|
|||||||
ImageTag.img_tag.is_not(None),
|
ImageTag.img_tag.is_not(None),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
|
print(stmt.compile(dialect=mysql.dialect(), compile_kwargs={"literal_binds": True}))
|
||||||
rows = (await session.execute(stmt)).all()
|
rows = (await session.execute(stmt)).all()
|
||||||
# print("rows", rows)
|
print("rows", rows)
|
||||||
# img_tag가 JSON 리터럴 null로 저장된 행(태깅 실패 잔재)은 SQL IS NOT NULL 필터를
|
print(rows)
|
||||||
# 통과하므로 파이썬 레벨에서 한 번 더 걸러낸다.
|
print("image" , [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows])
|
||||||
null_tag_urls = [row.img_url for row in rows if row.img_tag is None]
|
return [{"image_url": row.img_url, "image_tag": row.img_tag} for row in rows]
|
||||||
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
|
|
||||||
]
|
|
||||||
@ -1,348 +0,0 @@
|
|||||||
"""
|
|
||||||
Creative Assets Background Tasks
|
|
||||||
|
|
||||||
영상 생성 전 사전 준비(이미지 배정 + 자막 생성)를 수행하는 백그라운드 태스크를 정의합니다.
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
from app.utils.prompts.schemas.image import NARRATIVE_PHASE_LABEL
|
|
||||||
|
|
||||||
# 로거 설정
|
|
||||||
logger = get_logger("video")
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_subtitle_background(
|
|
||||||
orientation: str,
|
|
||||||
task_id: str,
|
|
||||||
max_retries: int = 3,
|
|
||||||
) -> None:
|
|
||||||
"""자막 + 이미지 배정을 사전에 수행하는 백그라운드 태스크.
|
|
||||||
|
|
||||||
내부적으로 `generate_creative_assets_background`를 호출한다.
|
|
||||||
"""
|
|
||||||
await generate_creative_assets_background(
|
|
||||||
orientation=orientation,
|
|
||||||
task_id=task_id,
|
|
||||||
max_retries=max_retries,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def generate_creative_assets_background(
|
|
||||||
orientation: str,
|
|
||||||
task_id: str,
|
|
||||||
max_retries: int = 3,
|
|
||||||
) -> None:
|
|
||||||
"""이미지 배정 + 자막 생성을 사전에 수행하는 백그라운드 태스크.
|
|
||||||
|
|
||||||
실행 순서:
|
|
||||||
1. 템플릿 조회 (읽기 전용, 렌더 없음)
|
|
||||||
2. 이미지 매칭 — get_image_tags_by_task_id → template_matching_taged_image
|
|
||||||
→ MarketingIntel.image_match 저장
|
|
||||||
3. 자막 생성 — 배정된 이미지 컨텍스트(슬롯별 공간/단계 정보)를 pitching 리스트에 포함
|
|
||||||
→ MarketingIntel.subtitle 저장
|
|
||||||
"""
|
|
||||||
logger.info(f"[generate_creative_assets_background] START - task_id: {task_id}, orientation: {orientation}")
|
|
||||||
|
|
||||||
for attempt in range(1, max_retries + 1):
|
|
||||||
try:
|
|
||||||
# ── 프로젝트 / 마케팅 인텔 로드 ──────────────────────────────────
|
|
||||||
# 템플릿 선택에 project.industry가 필요하므로 서비스 생성보다 먼저 로드
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
project_result = await session.execute(
|
|
||||||
select(Project)
|
|
||||||
.where(Project.task_id == task_id)
|
|
||||||
.order_by(Project.created_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
project = project_result.scalar_one_or_none()
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
marketing_intelligence = marketing_result.scalar_one_or_none()
|
|
||||||
|
|
||||||
creatomate_service = CreatomateService(orientation=orientation, industry=project.industry, project_id=project.id)
|
|
||||||
template = await creatomate_service.get_one_template_data(creatomate_service.template_id)
|
|
||||||
|
|
||||||
store_address = project.detail_region_info
|
|
||||||
customer_name = project.store_name
|
|
||||||
language = project.language or "Korean"
|
|
||||||
logger.info(
|
|
||||||
f"[generate_creative_assets_background] customer: {customer_name}, "
|
|
||||||
f"address: {store_address}, language: {language} - task_id: {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ── Step 1: 이미지 매칭 ─────────────────────────────────────────
|
|
||||||
# marketing_acceptable 필터링은 크롤링 단계에서 이미 완료되었으므로 재필터링하지 않는다.
|
|
||||||
taged_image_list = await get_image_tags_by_task_id(task_id)
|
|
||||||
min_image_num = creatomate_service.counting_component(
|
|
||||||
template=template,
|
|
||||||
target_template_type="image",
|
|
||||||
)
|
|
||||||
duplicate = len(taged_image_list) < min_image_num
|
|
||||||
logger.info(
|
|
||||||
f"[generate_creative_assets_background] image matching — "
|
|
||||||
f"pool={len(taged_image_list)}, slots={min_image_num}, "
|
|
||||||
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"}
|
|
||||||
|
|
||||||
logger.info(f"[generate_creative_assets_background] image_match: {list(image_match.keys())} - task_id: {task_id}")
|
|
||||||
|
|
||||||
# image_match 저장
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
mi = marketing_result.scalar_one_or_none()
|
|
||||||
mi.image_match = image_match
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[generate_creative_assets_background] image_match saved - task_id: {task_id}")
|
|
||||||
|
|
||||||
# ── Step 2: 자막 생성 (이미지 컨텍스트 포함) ──────────────────────
|
|
||||||
# 슬롯별 배정된 이미지의 공간/내러티브 정보를 pitching 레이블에 포함하여
|
|
||||||
# LLM이 "이 자막이 나올 때 화면에 보이는 이미지"를 인지하고 자막을 작성할 수 있게 함.
|
|
||||||
pitchings_raw = creatomate_service.extract_text_format_from_template(template)
|
|
||||||
pitchings_with_context = _build_pitching_list_with_image_context(
|
|
||||||
pitching_label_list=pitchings_raw,
|
|
||||||
assigned_image_tags=assigned_image_tags,
|
|
||||||
template=template,
|
|
||||||
creatomate_service=creatomate_service,
|
|
||||||
)
|
|
||||||
|
|
||||||
subtitle_generator = SubtitleContentsGenerator()
|
|
||||||
generated_subtitles = await subtitle_generator.generate_subtitle_contents(
|
|
||||||
marketing_intelligence=marketing_intelligence.intel_result,
|
|
||||||
pitching_label_list=pitchings_with_context,
|
|
||||||
customer_name=customer_name,
|
|
||||||
detail_region_info=store_address,
|
|
||||||
language=language,
|
|
||||||
industry=project.industry,
|
|
||||||
)
|
|
||||||
pitching_output_list = generated_subtitles.pitching_results
|
|
||||||
|
|
||||||
# LLM이 pitching_tag에 " | 화면: ..." 컨텍스트 접미를 그대로 되돌려줄 수 있으므로 제거
|
|
||||||
subtitle_modifications = {
|
|
||||||
pitching_output.pitching_tag.split(IMAGE_CONTEXT_SEP)[0].strip(): pitching_output.pitching_data
|
|
||||||
for pitching_output in pitching_output_list
|
|
||||||
}
|
|
||||||
logger.info(f"[generate_creative_assets_background] subtitle_modifications: {subtitle_modifications}")
|
|
||||||
|
|
||||||
# subtitle 저장
|
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
marketing_result = await session.execute(
|
|
||||||
select(MarketingIntel).where(MarketingIntel.id == project.marketing_intelligence)
|
|
||||||
)
|
|
||||||
mi = marketing_result.scalar_one_or_none()
|
|
||||||
mi.subtitle = subtitle_modifications
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"[generate_creative_assets_background] DONE - task_id: {task_id} "
|
|
||||||
f"(attempt {attempt}/{max_retries})"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(
|
|
||||||
f"[generate_creative_assets_background] FAILED (attempt {attempt}/{max_retries}) "
|
|
||||||
f"- task_id: {task_id}, error: {e}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
if attempt < max_retries:
|
|
||||||
logger.info(
|
|
||||||
f"[generate_creative_assets_background] 재시도 중... "
|
|
||||||
f"({attempt + 1}/{max_retries}) - task_id: {task_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.error(f"[generate_creative_assets_background] 모든 재시도 실패 - task_id: {task_id}")
|
|
||||||
|
|
||||||
|
|
||||||
# 이미지 컨텍스트 접미 구분자 — 생성(_context_for_label)과 제거(subtitle_modifications) 양쪽에서 사용
|
|
||||||
IMAGE_CONTEXT_SEP = " | "
|
|
||||||
|
|
||||||
|
|
||||||
def _merge_unique(tags: list[dict], key: str) -> list:
|
|
||||||
"""태그들의 key 리스트를 순서 유지하며 중복 제거해 병합합니다."""
|
|
||||||
return list(dict.fromkeys(item for tag in tags for item in tag.get(key, [])))
|
|
||||||
|
|
||||||
|
|
||||||
def _aggregate_tags_by_composition(
|
|
||||||
template: dict,
|
|
||||||
assigned_image_tags: dict[str, dict],
|
|
||||||
creatomate_service: CreatomateService,
|
|
||||||
) -> dict[str, dict]:
|
|
||||||
"""SCENE_TRACK 최상위 컴포지션별로 배정된 이미지 태그를 집계합니다.
|
|
||||||
|
|
||||||
컴포지션 하위 leaf image가 여러 개면(다중 이미지 씬) space_type/subject를
|
|
||||||
순서 유지 중복 제거로 병합합니다. 배정된 태그가 없는 컴포지션(CTA 등)은
|
|
||||||
결과에 포함하지 않습니다.
|
|
||||||
"""
|
|
||||||
comp_tags: dict[str, dict] = {}
|
|
||||||
for elem in template["source"]["elements"]:
|
|
||||||
if elem.get("track") != SCENE_TRACK or not elem.get("name"):
|
|
||||||
continue
|
|
||||||
name_to_type = creatomate_service.parse_template_component_name([elem])
|
|
||||||
leaf_tags = [
|
|
||||||
assigned_image_tags[name]
|
|
||||||
for name, elem_type in name_to_type.items()
|
|
||||||
if elem_type == "image" and name in assigned_image_tags
|
|
||||||
]
|
|
||||||
if not leaf_tags:
|
|
||||||
continue
|
|
||||||
comp_tags[elem["name"]] = {
|
|
||||||
"space_type": _merge_unique(leaf_tags, "space_type"),
|
|
||||||
"subject": _merge_unique(leaf_tags, "subject"),
|
|
||||||
}
|
|
||||||
return comp_tags
|
|
||||||
|
|
||||||
|
|
||||||
def _context_for_label(
|
|
||||||
text_range: tuple[float, float],
|
|
||||||
scene_ranges: list[tuple[str, float, float]],
|
|
||||||
comp_tags: dict[str, dict],
|
|
||||||
) -> str:
|
|
||||||
"""텍스트 재생 구간과 시간 겹침이 최대인 씬의 이미지 컨텍스트 접미를 만듭니다.
|
|
||||||
|
|
||||||
태그가 배정된 컴포지션 중 겹침이 최대인 씬을 선택하며(동률 시 이른 시작 우선),
|
|
||||||
겹치는 씬이 없으면 빈 문자열을 반환합니다.
|
|
||||||
"""
|
|
||||||
text_start, text_end = text_range
|
|
||||||
candidates = [
|
|
||||||
(min(text_end, comp_end) - max(text_start, comp_start), -comp_start, comp_name)
|
|
||||||
for comp_name, comp_start, comp_end in scene_ranges
|
|
||||||
if comp_name in comp_tags
|
|
||||||
]
|
|
||||||
candidates = [c for c in candidates if c[0] > 0]
|
|
||||||
if not candidates:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
best_comp = max(candidates)[2]
|
|
||||||
tag = comp_tags[best_comp]
|
|
||||||
space_types = tag.get("space_type", [])
|
|
||||||
subjects = tag.get("subject", [])
|
|
||||||
space_str = "/".join(space_types[:2]) if space_types else "?"
|
|
||||||
subject_str = subjects[0] if subjects else "?"
|
|
||||||
# 컴포지션명 형식: {space_type}-{subject}-{camera}-{motion}-{narrative_phase}
|
|
||||||
comp_tokens = best_comp.split("-")
|
|
||||||
phase = comp_tokens[4] if len(comp_tokens) >= 5 else ""
|
|
||||||
phase_str = NARRATIVE_PHASE_LABEL.get(phase, phase)
|
|
||||||
return f"{IMAGE_CONTEXT_SEP}화면: {space_str} / {subject_str} / {phase_str} 단계"
|
|
||||||
|
|
||||||
|
|
||||||
def _build_pitching_list_with_image_context(
|
|
||||||
pitching_label_list: list[str],
|
|
||||||
assigned_image_tags: dict[str, dict],
|
|
||||||
template: dict,
|
|
||||||
creatomate_service: CreatomateService,
|
|
||||||
) -> list[str]:
|
|
||||||
"""pitching 레이블 리스트에 배정 이미지 컨텍스트를 포함하여 반환합니다.
|
|
||||||
|
|
||||||
텍스트 레이어(SUBTITLE/KEYWORD_TRACK)와 이미지 컴포지션(SCENE_TRACK)의
|
|
||||||
시간 범위를 계산해, 각 텍스트가 재생되는 동안 화면에 가장 오래 보이는
|
|
||||||
(시간 겹침이 최대인) 컴포지션의 이미지 태그를 컨텍스트로 붙입니다.
|
|
||||||
narrative_phase 토큰 매칭과 달리 phase 이름이 어긋나거나(자막 outro vs
|
|
||||||
이미지 accent) 같은 phase에 씬이 여러 개여도 올바른 짝을 찾습니다.
|
|
||||||
|
|
||||||
겹치는 씬이 없거나 해당 씬에 배정된 이미지가 없으면(CTA 등) 컨텍스트를
|
|
||||||
생략합니다. 렌더 시 extend_template_duration은 전 트랙 균등 배율이므로
|
|
||||||
원본 템플릿 기준 겹침 관계는 스케일 후에도 유지됩니다.
|
|
||||||
|
|
||||||
출력 예시:
|
|
||||||
"subtitle-welcome-emotion_cue-empathic-001 | 화면: lobby / furniture / 진입(welcome) 단계"
|
|
||||||
"""
|
|
||||||
comp_tags = _aggregate_tags_by_composition(template, assigned_image_tags, creatomate_service)
|
|
||||||
if not comp_tags:
|
|
||||||
return pitching_label_list
|
|
||||||
|
|
||||||
scene_ranges = creatomate_service.calc_track_time_ranges(template, SCENE_TRACK)
|
|
||||||
text_ranges: dict[str, tuple[float, float]] = {}
|
|
||||||
for track in (SUBTITLE_TRACK, KEYWORD_TRACK):
|
|
||||||
for name, start, end in creatomate_service.calc_track_time_ranges(template, track):
|
|
||||||
text_ranges[name] = (start, end)
|
|
||||||
|
|
||||||
result = []
|
|
||||||
for label in pitching_label_list:
|
|
||||||
text_range = text_ranges.get(label)
|
|
||||||
context_str = _context_for_label(text_range, scene_ranges, comp_tags) if text_range else ""
|
|
||||||
result.append(label + context_str)
|
|
||||||
|
|
||||||
return result
|
|
||||||
@ -4,6 +4,7 @@ Video Background Tasks
|
|||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import traceback
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@ -16,7 +17,6 @@ from app.user.services.credit import consume_credit
|
|||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
from app.utils.upload_blob_as_request import AzureBlobUploader
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.video_poster import generate_and_store_poster
|
|
||||||
|
|
||||||
# 로거 설정
|
# 로거 설정
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
@ -30,8 +30,7 @@ async def _update_video_status(
|
|||||||
status: str,
|
status: str,
|
||||||
video_url: str | None = None,
|
video_url: str | None = None,
|
||||||
creatomate_render_id: str | None = None,
|
creatomate_render_id: str | None = None,
|
||||||
poster_url: str | None = None,
|
) -> bool:
|
||||||
) -> int | None:
|
|
||||||
"""Video 테이블의 상태를 업데이트합니다.
|
"""Video 테이블의 상태를 업데이트합니다.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@ -39,10 +38,9 @@ async def _update_video_status(
|
|||||||
status: 변경할 상태 ("processing", "completed", "failed")
|
status: 변경할 상태 ("processing", "completed", "failed")
|
||||||
video_url: 영상 URL
|
video_url: 영상 URL
|
||||||
creatomate_render_id: Creatomate render ID (선택)
|
creatomate_render_id: Creatomate render ID (선택)
|
||||||
poster_url: 영상 첫 프레임 포스터 URL (선택)
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
int | None: 업데이트된 Video id. 대상이 없거나 실패하면 None.
|
bool: 업데이트 성공 여부
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
@ -67,58 +65,19 @@ async def _update_video_status(
|
|||||||
video.status = status
|
video.status = status
|
||||||
if video_url is not None:
|
if video_url is not None:
|
||||||
video.result_movie_url = video_url
|
video.result_movie_url = video_url
|
||||||
if poster_url is not None:
|
|
||||||
video.poster_url = poster_url
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
logger.info(f"[Video] Status updated - task_id: {task_id}, status: {status}")
|
||||||
return video.id
|
return True
|
||||||
else:
|
else:
|
||||||
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
logger.warning(f"[Video] NOT FOUND in DB - task_id: {task_id}")
|
||||||
return None
|
return False
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
logger.error(f"[Video] DB Error while updating status - task_id: {task_id}, error: {e}")
|
||||||
return None
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
logger.error(f"[Video] Unexpected error while updating status - task_id: {task_id}, error: {e}")
|
||||||
return None
|
return False
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
async def _download_video(url: str, task_id: str) -> bytes:
|
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
|
blob_url = uploader.public_url
|
||||||
logger.info(f"[download_and_upload_video_to_blob] Uploaded to Blob - task_id: {task_id}, url: {blob_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 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||||
video_id = await _update_video_status(
|
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
||||||
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)
|
|
||||||
|
|
||||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
@ -312,20 +259,13 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||||||
blob_url = uploader.public_url
|
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}")
|
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 테이블 업데이트
|
||||||
video_id = await _update_video_status(
|
await _update_video_status(
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
status="completed",
|
status="completed",
|
||||||
video_url=blob_url,
|
video_url=blob_url,
|
||||||
creatomate_render_id=creatomate_render_id,
|
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}")
|
logger.info(f"[download_and_upload_video_by_creatomate_render_id] SUCCESS - creatomate_render_id: {creatomate_render_id}")
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
|
|||||||
83
config.py
83
config.py
@ -33,25 +33,6 @@ class ProjectSettings(BaseSettings):
|
|||||||
ADMIN_BASE_URL: str = Field(default="/admin")
|
ADMIN_BASE_URL: str = Field(default="/admin")
|
||||||
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
ADMIN_SESSION_SECRET: str = Field(default="dev-secret-change-me-in-production")
|
||||||
ADMIN_SESSION_MAX_AGE: int = Field(default=60 * 60 * 8)
|
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)
|
DEBUG: bool = Field(default=True)
|
||||||
TIMEZONE: str = Field(
|
TIMEZONE: str = Field(
|
||||||
default="Asia/Seoul",
|
default="Asia/Seoul",
|
||||||
@ -164,40 +145,6 @@ class AzureBlobSettings(BaseSettings):
|
|||||||
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
default="https://ado2mediastoragepublic.blob.core.windows.net/ado2-media-public-access/ado2-media-original",
|
||||||
description="Azure Blob Storage 기본 URL",
|
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
|
model_config = _base_config
|
||||||
|
|
||||||
@ -228,7 +175,10 @@ class CreatomateSettings(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
description="Creatomate 자체 자동 가사 생성 기능 사용 여부",
|
description="Creatomate 자체 자동 가사 생성 기능 사용 여부",
|
||||||
)
|
)
|
||||||
LYRIC_SUBTITLE: bool = Field(default=False, description="영상 가사 표기 여부")
|
LYRIC_SUBTITLE: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="영상 가사 표기 여부"
|
||||||
|
)
|
||||||
|
|
||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
@ -623,30 +573,6 @@ class SocialOAuthSettings(BaseSettings):
|
|||||||
model_config = _base_config
|
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):
|
class InternalSettings(BaseSettings):
|
||||||
"""내부 서버 간 통신 설정"""
|
"""내부 서버 간 통신 설정"""
|
||||||
|
|
||||||
@ -705,6 +631,5 @@ kakao_settings = KakaoSettings()
|
|||||||
jwt_settings = JWTSettings()
|
jwt_settings = JWTSettings()
|
||||||
recovery_settings = RecoverySettings()
|
recovery_settings = RecoverySettings()
|
||||||
social_oauth_settings = SocialOAuthSettings()
|
social_oauth_settings = SocialOAuthSettings()
|
||||||
meta_conversion_settings = MetaConversionSettings()
|
|
||||||
internal_settings = InternalSettings()
|
internal_settings = InternalSettings()
|
||||||
social_upload_settings = SocialUploadSettings()
|
social_upload_settings = SocialUploadSettings()
|
||||||
|
|||||||
@ -1,25 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 영상 댓글 테이블 추가
|
|
||||||
-- Date: 2026-05-21
|
|
||||||
-- Description: 영상 상세 페이지 댓글/대댓글 기능 (2-depth, 소프트 삭제)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS comment (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
|
|
||||||
video_id INT NOT NULL COMMENT '연결된 Video의 id',
|
|
||||||
user_uuid VARCHAR(36) NOT NULL COMMENT '작성자 UUID (user.user_uuid 참조, 응답 미노출)',
|
|
||||||
nickname VARCHAR(20) NULL COMMENT '작성자 닉네임 (null이면 익명으로 표시)',
|
|
||||||
parent_id BIGINT NULL COMMENT 'NULL=최상위 댓글, 값=대댓글의 부모 id',
|
|
||||||
content VARCHAR(100) NOT NULL COMMENT '댓글 본문 (한글 기준 100자 이내)',
|
|
||||||
is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '소프트 삭제 여부',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '작성 일시',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT fk_comment_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_comment_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_comment_parent FOREIGN KEY (parent_id) REFERENCES comment(id) ON DELETE CASCADE,
|
|
||||||
INDEX idx_comment_video_id (video_id),
|
|
||||||
INDEX idx_comment_user_uuid (user_uuid),
|
|
||||||
INDEX idx_comment_parent_id (parent_id),
|
|
||||||
INDEX idx_comment_is_deleted (is_deleted)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
COMMENT='영상 댓글/대댓글';
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: 영상 반응(좋아요) 테이블 추가
|
|
||||||
-- Date: 2026-05-21
|
|
||||||
-- Description: 사용자 영상별 좋아요 토글 (1인 1회, 확장 가능한 구조)
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS video_reaction (
|
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '고유 식별자',
|
|
||||||
video_id INT NOT NULL COMMENT '연결된 Video의 id',
|
|
||||||
user_uuid VARCHAR(36) NOT NULL COMMENT '반응한 사용자 UUID',
|
|
||||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '반응 일시',
|
|
||||||
PRIMARY KEY (id),
|
|
||||||
CONSTRAINT uq_video_reaction_user_video UNIQUE (user_uuid, video_id),
|
|
||||||
CONSTRAINT fk_video_reaction_video FOREIGN KEY (video_id) REFERENCES video(id) ON DELETE CASCADE,
|
|
||||||
CONSTRAINT fk_video_reaction_user FOREIGN KEY (user_uuid) REFERENCES `user`(user_uuid) ON DELETE CASCADE,
|
|
||||||
INDEX idx_video_reaction_video_id (video_id),
|
|
||||||
INDEX idx_video_reaction_user_uuid (user_uuid)
|
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
COMMENT='영상 반응 (user_uuid + video_id 유니크)';
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: marketing.place_id NULL 허용
|
|
||||||
-- Date: 2026-05-28
|
|
||||||
-- Description: 업체명+주소 직접 입력으로 마케팅 분석 생성 시
|
|
||||||
-- 네이버 place_id가 없으므로 NULL 허용으로 변경
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE marketing MODIFY place_id VARCHAR(36) NULL COMMENT '매장 소스별 고유 식별자 (네이버 크롤링 시 nv{id} 형식; 직접 입력 시 NULL)';
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: project 테이블에 biz_type 컬럼 추가
|
|
||||||
-- Date: 2026-06-17
|
|
||||||
-- Description: 업종 구분 컬럼명 biz_type
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE project
|
|
||||||
ADD COLUMN biz_type VARCHAR(50) NULL DEFAULT 'place'
|
|
||||||
COMMENT '업종 분류 (place: 기본, accommodation: 숙박, restaurant: 음식점)' AFTER id;
|
|
||||||
|
|
||||||
ALTER TABLE project CHANGE biz_type industry VARCHAR(50) DEFAULT ''
|
|
||||||
COMMENT '업종 분류' AFTER id;
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: lyric 테이블에 genre 컬럼 추가
|
|
||||||
-- Date: 2026-07-02
|
|
||||||
-- Description: 가사 생성 시점에 확정된 음악 장르 저장
|
|
||||||
-- - 수동 선택: 사용자가 고른 장르
|
|
||||||
-- - 자동 선택: GPT가 가사 무드에 맞춰 추천한 장르
|
|
||||||
-- 장르-BPM 불일치로 인한 곡 길이 편차 문제 해결을 위해 도입
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE lyric
|
|
||||||
ADD COLUMN genre VARCHAR(20) NULL
|
|
||||||
COMMENT '음악 장르 (kpop, pop, ballad, hip-hop, rnb, edm, jazz, rock). 수동 선택 시 요청값, 자동 선택 시 GPT 추천값' AFTER language;
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
-- ============================================================
|
|
||||||
-- Migration: marketing 테이블에 image_match 컬럼 추가
|
|
||||||
-- Date: 2026-07-03
|
|
||||||
-- Description: 이미지 슬롯 배정 결과물 저장
|
|
||||||
-- - lyric 생성 사전 단계(get_image_tags_by_task_id → template_matching_taged_image)에서
|
|
||||||
-- 산출된 슬롯별 이미지 배정 결과를 저장
|
|
||||||
-- - 영상 생성 시점에는 저장된 결과를 읽어 음악 URL/주소만 보완하여 재사용
|
|
||||||
-- ============================================================
|
|
||||||
|
|
||||||
ALTER TABLE marketing
|
|
||||||
ADD COLUMN image_match JSON NULL
|
|
||||||
COMMENT '이미지 슬롯 배정 결과물' AFTER subtitle;
|
|
||||||
@ -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`;
|
|
||||||
@ -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`;
|
|
||||||
@ -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'
|
|
||||||
);
|
|
||||||
@ -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`;
|
|
||||||
@ -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`;
|
|
||||||
25
main.py
25
main.py
@ -20,13 +20,10 @@ 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.song.api.routers.v1.song import router as song_router
|
||||||
from app.sns.api.routers.v1.sns import router as sns_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.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.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.upload import router as social_upload_router
|
||||||
from app.social.api.routers.v1.seo import router as social_seo_router
|
from app.social.api.routers.v1.seo import router as social_seo_router
|
||||||
from app.social.api.routers.v1.internal import router as social_internal_router
|
from app.social.api.routers.v1.internal import router as social_internal_router
|
||||||
from app.comment.api.routers.v1.comment import router as comment_router
|
|
||||||
from app.video.api.routers.internal.reactions import router as video_internal_router
|
|
||||||
from app.credit.api.routers.v1.credit import router as credit_router
|
from app.credit.api.routers.v1.credit import router as credit_router
|
||||||
from app.utils.cors import CustomCORSMiddleware
|
from app.utils.cors import CustomCORSMiddleware
|
||||||
from config import prj_settings
|
from config import prj_settings
|
||||||
@ -177,25 +174,6 @@ tags_metadata = [
|
|||||||
- created_at 기준 내림차순 정렬됩니다.
|
- created_at 기준 내림차순 정렬됩니다.
|
||||||
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
- 삭제는 소프트 삭제(is_deleted=True) 방식으로 처리되며, 데이터 복구가 가능합니다.
|
||||||
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
- 삭제 대상: Video, SongTimestamp, Song, Lyric, Image, Project
|
||||||
""",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Comment",
|
|
||||||
"description": """영상 댓글 API - 댓글/대댓글 작성·조회·삭제
|
|
||||||
|
|
||||||
**인증: 조회는 불필요, 작성/삭제는 필요** - `Authorization: Bearer {access_token}` 헤더
|
|
||||||
|
|
||||||
## 주요 기능
|
|
||||||
|
|
||||||
- `POST /comment/video/{video_id}` - 댓글/대댓글 작성 (로그인 필수)
|
|
||||||
- `GET /comment/video/{video_id}` - 댓글 목록 조회 (비로그인 허용)
|
|
||||||
- `DELETE /comment/{comment_id}` - 본인 댓글 소프트 삭제 (로그인 필수)
|
|
||||||
|
|
||||||
## 참고
|
|
||||||
|
|
||||||
- 최대 2-depth (댓글 + 대댓글). 대댓글에 대댓글은 불가합니다.
|
|
||||||
- 작성자 정보는 응답에 포함되지 않습니다 (익명 정책).
|
|
||||||
- is_mine 필드로 본인 댓글 여부를 확인할 수 있습니다.
|
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -411,14 +389,11 @@ app.include_router(social_account_router, prefix="/user") # Social Account API
|
|||||||
app.include_router(lyric_router)
|
app.include_router(lyric_router)
|
||||||
app.include_router(song_router)
|
app.include_router(song_router)
|
||||||
app.include_router(video_router)
|
app.include_router(video_router)
|
||||||
app.include_router(tracking_router) # Meta 전환 추적 라우터
|
|
||||||
app.include_router(archive_router) # Archive API 라우터 추가
|
app.include_router(archive_router) # Archive API 라우터 추가
|
||||||
app.include_router(comment_router) # Comment API 라우터 추가
|
|
||||||
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
app.include_router(social_oauth_router, prefix="/social") # Social OAuth 라우터 추가
|
||||||
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
app.include_router(social_upload_router, prefix="/social") # Social Upload 라우터 추가
|
||||||
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
|
app.include_router(social_seo_router, prefix="/social") # Social Upload 라우터 추가
|
||||||
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
|
app.include_router(social_internal_router) # 내부 스케줄러 전용 라우터
|
||||||
app.include_router(video_internal_router) # 내부 좋아요 플러시 라우터
|
|
||||||
app.include_router(sns_router) # SNS API 라우터 추가
|
app.include_router(sns_router) # SNS API 라우터 추가
|
||||||
app.include_router(dashboard_router) # Dashboard API 라우터 추가
|
app.include_router(dashboard_router) # Dashboard API 라우터 추가
|
||||||
app.include_router(credit_router, prefix="/user") # Credit API 라우터 추가
|
app.include_router(credit_router, prefix="/user") # Credit API 라우터 추가
|
||||||
|
|||||||
@ -8,7 +8,7 @@ dependencies = [
|
|||||||
"aiofiles>=25.1.0",
|
"aiofiles>=25.1.0",
|
||||||
"aiohttp>=3.13.2",
|
"aiohttp>=3.13.2",
|
||||||
"aiomysql>=0.3.2",
|
"aiomysql>=0.3.2",
|
||||||
"asyncmy>=0.2.11",
|
"asyncmy>=0.2.10",
|
||||||
"beautifulsoup4>=4.14.3",
|
"beautifulsoup4>=4.14.3",
|
||||||
"fastapi-cli>=0.0.16",
|
"fastapi-cli>=0.0.16",
|
||||||
"fastapi[standard]>=0.125.0",
|
"fastapi[standard]>=0.125.0",
|
||||||
@ -23,7 +23,7 @@ dependencies = [
|
|||||||
"ruff>=0.14.9",
|
"ruff>=0.14.9",
|
||||||
"scalar-fastapi>=1.6.1",
|
"scalar-fastapi>=1.6.1",
|
||||||
"sqladmin[full]>=0.22.0",
|
"sqladmin[full]>=0.22.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.50",
|
"sqlalchemy[asyncio]>=2.0.45",
|
||||||
"uuid7>=0.1.0",
|
"uuid7>=0.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 8.7 KiB |
28
uv.lock
generated
28
uv.lock
generated
@ -744,7 +744,7 @@ requires-dist = [
|
|||||||
{ name = "aiofiles", specifier = ">=25.1.0" },
|
{ name = "aiofiles", specifier = ">=25.1.0" },
|
||||||
{ name = "aiohttp", specifier = ">=3.13.2" },
|
{ name = "aiohttp", specifier = ">=3.13.2" },
|
||||||
{ name = "aiomysql", specifier = ">=0.3.2" },
|
{ name = "aiomysql", specifier = ">=0.3.2" },
|
||||||
{ name = "asyncmy", specifier = ">=0.2.11" },
|
{ name = "asyncmy", specifier = ">=0.2.10" },
|
||||||
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
{ name = "beautifulsoup4", specifier = ">=4.14.3" },
|
||||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
{ name = "fastapi", extras = ["standard"], specifier = ">=0.125.0" },
|
||||||
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
{ name = "fastapi-cli", specifier = ">=0.0.16" },
|
||||||
@ -759,7 +759,7 @@ requires-dist = [
|
|||||||
{ name = "ruff", specifier = ">=0.14.9" },
|
{ name = "ruff", specifier = ">=0.14.9" },
|
||||||
{ name = "scalar-fastapi", specifier = ">=1.6.1" },
|
{ name = "scalar-fastapi", specifier = ">=1.6.1" },
|
||||||
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
{ name = "sqladmin", extras = ["full"], specifier = ">=0.22.0" },
|
||||||
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.50" },
|
{ name = "sqlalchemy", extras = ["asyncio"], specifier = ">=2.0.45" },
|
||||||
{ name = "uuid7", specifier = ">=0.1.0" },
|
{ name = "uuid7", specifier = ">=0.1.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1294,22 +1294,26 @@ full = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sqlalchemy"
|
name = "sqlalchemy"
|
||||||
version = "2.0.50"
|
version = "2.0.46"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/aa/9ce0f3e7a9829ead5c8ce549392f33a12c4555a6c0609bb27d882e9c7ddf/sqlalchemy-2.0.46.tar.gz", hash = "sha256:cf36851ee7219c170bb0793dbc3da3e80c582e04a5437bc601bfe8c85c9216d7", size = 9865393, upload-time = "2026-01-21T18:03:45.119Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/4b/fa7838fe20bb752810feed60e45625a9a8b0102c0c09971e2d1d95362992/sqlalchemy-2.0.46-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93a12da97cca70cea10d4b4fc602589c4511f96c1f8f6c11817620c021d21d00", size = 2150268, upload-time = "2026-01-21T19:05:56.621Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
|
{ url = "https://files.pythonhosted.org/packages/46/c1/b34dccd712e8ea846edf396e00973dda82d598cb93762e55e43e6835eba9/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af865c18752d416798dae13f83f38927c52f085c52e2f32b8ab0fef46fdd02c2", size = 3276511, upload-time = "2026-01-21T18:46:49.022Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/48/a04d9c94753e5d5d096c628c82a98c4793b9c08ca0e7155c3eb7d7db9f24/sqlalchemy-2.0.46-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d679b5f318423eacb61f933a9a0f75535bfca7056daeadbf6bd5bcee6183aee", size = 3292881, upload-time = "2026-01-21T18:40:13.089Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/f4/06eda6e91476f90a7d8058f74311cb65a2fb68d988171aced81707189131/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64901e08c33462acc9ec3bad27fc7a5c2b6491665f2aa57564e57a4f5d7c52ad", size = 3224559, upload-time = "2026-01-21T18:46:50.974Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/a2/d2af04095412ca6345ac22b33b89fe8d6f32a481e613ffcb2377d931d8d0/sqlalchemy-2.0.46-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e8ac45e8f4eaac0f9f8043ea0e224158855c6a4329fd4ee37c45c61e3beb518e", size = 3262728, upload-time = "2026-01-21T18:40:14.883Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/48/1980c7caa5978a3b8225b4d230e69a2a6538a3562b8b31cea679b6933c83/sqlalchemy-2.0.46-cp313-cp313-win32.whl", hash = "sha256:8d3b44b3d0ab2f1319d71d9863d76eeb46766f8cf9e921ac293511804d39813f", size = 2111295, upload-time = "2026-01-21T18:42:52.366Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/2d/54/f8d65bbde3d877617c4720f3c9f60e99bb7266df0d5d78b6e25e7c149f35/sqlalchemy-2.0.46-cp313-cp313-win_amd64.whl", hash = "sha256:77f8071d8fbcbb2dd11b7fd40dedd04e8ebe2eb80497916efedba844298065ef", size = 2137076, upload-time = "2026-01-21T18:42:53.924Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/ba/9be4f97c7eb2b9d5544f2624adfc2853e796ed51d2bb8aec90bc94b7137e/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1e8cc6cc01da346dc92d9509a63033b9b1bda4fed7a7a7807ed385c7dccdc10", size = 3556533, upload-time = "2026-01-21T18:33:06.636Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/a6/b1fc6634564dbb4415b7ed6419cdfeaadefd2c39cdab1e3aa07a5f2474c2/sqlalchemy-2.0.46-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:96c7cca1a4babaaf3bfff3e4e606e38578856917e52f0384635a95b226c87764", size = 3523208, upload-time = "2026-01-21T18:45:08.436Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a1/d8/41e0bdfc0f930ff236f86fccd12962d8fa03713f17ed57332d38af6a3782/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2a9f9aee38039cf4755891a1e50e1effcc42ea6ba053743f452c372c3152b1b", size = 3464292, upload-time = "2026-01-21T18:33:08.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/8b/9dcbec62d95bea85f5ecad9b8d65b78cc30fb0ffceeb3597961f3712549b/sqlalchemy-2.0.46-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db23b1bf8cfe1f7fda19018e7207b20cdb5168f83c437ff7e95d19e39289c447", size = 3473497, upload-time = "2026-01-21T18:45:10.552Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fc/a1/9c4efa03300926601c19c18582531b45aededfb961ab3c3585f1e24f120b/sqlalchemy-2.0.46-py3-none-any.whl", hash = "sha256:f9c11766e7e7c0a2767dda5acb006a118640c9fc0a4104214b96269bfb78399e", size = 1937882, upload-time = "2026-01-21T18:22:10.456Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
[package.optional-dependencies]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user