feat(ssulbox): 썰박스 생성 파이프라인 통합 및 콘텐츠 목록 UNION
This commit is contained in:
parent
8096837b13
commit
666c3bf6d1
5
.gitignore
vendored
5
.gitignore
vendored
@ -53,4 +53,7 @@ Dockerfile
|
|||||||
|
|
||||||
zzz/
|
zzz/
|
||||||
credentials/service_account.json
|
credentials/service_account.json
|
||||||
o2o-castad-scheduler/
|
o2o-castad-scheduler/
|
||||||
|
|
||||||
|
generator/output/
|
||||||
|
generator/*/load_image/
|
||||||
@ -16,10 +16,9 @@ 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.comment.models import Comment
|
from app.video.models import Video
|
||||||
from app.database.like_cache import get_like_counts, mset_like_counts
|
|
||||||
from app.video.models import Video, VideoReaction
|
|
||||||
from app.video.schemas.video_schema import VideoListItem
|
from app.video.schemas.video_schema import VideoListItem
|
||||||
|
from app.video.services import unified_list
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -79,89 +78,27 @@ async def get_videos(
|
|||||||
try:
|
try:
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
offset = (pagination.page - 1) * pagination.page_size
|
||||||
|
|
||||||
# 서브쿼리: task_id별 최신 Video ID 추출
|
items, total = await unified_list.fetch_my_contents(
|
||||||
# id는 autoincrement이므로 MAX(id)가 created_at 최신 레코드와 일치
|
session,
|
||||||
latest_video_ids = (
|
user_uuid=current_user.user_uuid,
|
||||||
select(func.max(Video.id).label("latest_id"))
|
offset=offset,
|
||||||
.join(Project, Video.project_id == Project.id)
|
limit=pagination.page_size,
|
||||||
.where(
|
|
||||||
Project.user_uuid == current_user.user_uuid,
|
|
||||||
Video.status == "completed",
|
|
||||||
Video.is_deleted == False, # noqa: E712
|
|
||||||
Project.is_deleted == False, # noqa: E712
|
|
||||||
)
|
|
||||||
.group_by(Video.task_id)
|
|
||||||
.subquery()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# 쿼리 1: 전체 개수 조회 (task_id별 최신 영상만)
|
|
||||||
count_query = select(func.count(Video.id)).where(
|
|
||||||
Video.id.in_(select(latest_video_ids.c.latest_id))
|
|
||||||
)
|
|
||||||
total_result = await session.execute(count_query)
|
|
||||||
total = total_result.scalar() or 0
|
|
||||||
|
|
||||||
# 쿼리 2: Video + Project + comment_count 조회 (like_count는 Redis에서)
|
|
||||||
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 = (
|
|
||||||
select(
|
|
||||||
Video,
|
|
||||||
Project,
|
|
||||||
comment_count_subq.label("comment_count"),
|
|
||||||
)
|
|
||||||
.join(Project, Video.project_id == Project.id)
|
|
||||||
.where(Video.id.in_(select(latest_video_ids.c.latest_id)))
|
|
||||||
.order_by(Video.created_at.desc())
|
|
||||||
.offset(offset)
|
|
||||||
.limit(pagination.page_size)
|
|
||||||
)
|
|
||||||
result = await session.execute(data_query)
|
|
||||||
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
|
|
||||||
|
|
||||||
# VideoListItem으로 변환
|
|
||||||
items = [
|
items = [
|
||||||
VideoListItem(
|
VideoListItem(
|
||||||
video_id=video.id,
|
type=it.ctype,
|
||||||
store_name=project.store_name,
|
video_id=it.id,
|
||||||
region=project.region,
|
store_name=it.store_name,
|
||||||
task_id=video.task_id,
|
region=it.region,
|
||||||
result_movie_url=video.result_movie_url,
|
# 썰박스는 task_id 개념이 없어 빈 문자열이다.
|
||||||
created_at=video.created_at,
|
# 프론트는 반드시 (type, video_id) 쌍으로 식별할 것.
|
||||||
like_count=like_count_map.get(video.id) or 0,
|
task_id=it.task_id,
|
||||||
comment_count=comment_count or 0,
|
result_movie_url=it.movie_url,
|
||||||
|
created_at=it.created_at,
|
||||||
|
like_count=it.like_count,
|
||||||
|
comment_count=it.comment_count,
|
||||||
)
|
)
|
||||||
for video, project, comment_count in rows
|
for it in items
|
||||||
]
|
]
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
|
|||||||
@ -36,7 +36,7 @@ async def soft_delete_by_task_id(task_id: str) -> dict:
|
|||||||
dict: 각 테이블별 업데이트된 레코드 수
|
dict: 각 테이블별 업데이트된 레코드 수
|
||||||
"""
|
"""
|
||||||
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
|
logger.info(f"[soft_delete_by_task_id] START - task_id: {task_id}")
|
||||||
logger.debug(f"[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
logger.debug("[soft_delete_by_task_id] DEBUG - 백그라운드 태스크 시작")
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
func,
|
||||||
|
)
|
||||||
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.ssulbox.models import SsulContent
|
||||||
from app.user.models import User
|
from app.user.models import User
|
||||||
from app.video.models import Video
|
from app.video.models import Video
|
||||||
|
|
||||||
@ -18,11 +29,22 @@ class Comment(Base):
|
|||||||
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
2-depth 구조 (최상위 댓글 + 대댓글 1단계).
|
||||||
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글.
|
||||||
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
|
작성자(user_uuid)는 DB에 저장하지만 API 응답에는 미노출 (익명 정책).
|
||||||
|
|
||||||
|
**ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id`
|
||||||
|
중 **정확히 하나**만 채워지며, 이를 DB `CHECK` 로 강제한다. MySQL 은 하나의 FK 가
|
||||||
|
두 테이블을 조건부로 가리키게 할 수 없어 컬럼을 나눠 둔 것이다.
|
||||||
|
별도의 종류 컬럼은 두지 않는다 — 어느 FK 가 채워졌는지가 곧 종류이고,
|
||||||
|
컬럼을 하나 더 두면 둘이 어긋날 수 있다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "comment"
|
__tablename__ = "comment"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(video_id IS NULL) <> (content_id IS NULL)",
|
||||||
|
name="ck_comment_one_target",
|
||||||
|
),
|
||||||
Index("idx_comment_video_id", "video_id"),
|
Index("idx_comment_video_id", "video_id"),
|
||||||
|
Index("idx_comment_content_id", "content_id"),
|
||||||
Index("idx_comment_user_uuid", "user_uuid"),
|
Index("idx_comment_user_uuid", "user_uuid"),
|
||||||
Index("idx_comment_parent_id", "parent_id"),
|
Index("idx_comment_parent_id", "parent_id"),
|
||||||
Index("idx_comment_is_deleted", "is_deleted"),
|
Index("idx_comment_is_deleted", "is_deleted"),
|
||||||
@ -36,11 +58,19 @@ class Comment(Base):
|
|||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||||
)
|
)
|
||||||
video_id: Mapped[int] = mapped_column(
|
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_comment_one_target).
|
||||||
|
video_id: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
ForeignKey("video.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=True,
|
||||||
comment="연결된 Video의 id",
|
comment="ADO2 영상 id (썰박스 댓글이면 NULL)",
|
||||||
|
)
|
||||||
|
content_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
# ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780).
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("ssul_content.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
comment="썰박스 콘텐츠 id (ADO2 댓글이면 NULL)",
|
||||||
)
|
)
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
user_uuid: Mapped[str] = mapped_column(
|
||||||
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||||
@ -69,7 +99,13 @@ class Comment(Base):
|
|||||||
comment="작성 일시",
|
comment="작성 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="comments")
|
# 썰박스 댓글이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||||
|
video: Mapped[Optional["Video"]] = relationship(
|
||||||
|
"Video", foreign_keys=[video_id], back_populates="comments"
|
||||||
|
)
|
||||||
|
content_ref: Mapped[Optional["SsulContent"]] = relationship(
|
||||||
|
"SsulContent", foreign_keys=[content_id], lazy="noload"
|
||||||
|
)
|
||||||
user: Mapped["User"] = relationship("User", back_populates="comments")
|
user: Mapped["User"] = relationship("User", back_populates="comments")
|
||||||
parent: Mapped[Optional["Comment"]] = relationship(
|
parent: Mapped[Optional["Comment"]] = relationship(
|
||||||
"Comment", remote_side=[id], back_populates="replies"
|
"Comment", remote_side=[id], back_populates="replies"
|
||||||
|
|||||||
@ -6,6 +6,8 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
from app.utils.logger import get_logger
|
from app.utils.logger import get_logger
|
||||||
from app.utils.nvMapPwScraper import NvMapPwScraper
|
from app.utils.nvMapPwScraper import NvMapPwScraper
|
||||||
|
from config import ssulbox_settings
|
||||||
|
|
||||||
logger = get_logger("core")
|
logger = get_logger("core")
|
||||||
|
|
||||||
|
|
||||||
@ -29,6 +31,37 @@ async def lifespan(app: FastAPI):
|
|||||||
from app.dashboard.migration import init_dashboard_table
|
from app.dashboard.migration import init_dashboard_table
|
||||||
await init_dashboard_table()
|
await init_dashboard_table()
|
||||||
|
|
||||||
|
# 썰박스 스키마 보장 (모든 환경 - Alembic 부재 + create_db_tables 는 DEBUG 전용)
|
||||||
|
# 실패해도 앱 기동은 막지 않는다. 썰박스 스키마 문제로 castad 전체가 죽으면 안 된다.
|
||||||
|
if ssulbox_settings.SSULBOX_ENABLED:
|
||||||
|
try:
|
||||||
|
from app.ssulbox.migration import ensure_ssulbox_schema
|
||||||
|
|
||||||
|
await ensure_ssulbox_schema()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[ssulbox] 스키마 보장 실패 (다음 기동 시 재시도): "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 고아 잡 스윕 — 이전 프로세스가 죽으며 남긴 queued/running 을 환불·정리.
|
||||||
|
# 기동 직후 인메모리 잡은 0개이므로 비터미널 잡은 전부 고아다.
|
||||||
|
# ⚠️ 이 불변식은 단일 워커 전제다(--workers 를 늘리면 정상 잡을 오판한다).
|
||||||
|
try:
|
||||||
|
from app.database.session import BackgroundSessionLocal
|
||||||
|
from app.ssulbox.services import task_service
|
||||||
|
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
swept = await task_service.sweep_orphans(session)
|
||||||
|
await session.commit()
|
||||||
|
if swept:
|
||||||
|
logger.info(f"[ssulbox] 고아 잡 {swept}건 환불·정리")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[ssulbox] 고아 스윕 실패 (다음 기동 시 재시도): "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
await NvMapPwScraper.initiate_scraper()
|
await NvMapPwScraper.initiate_scraper()
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
logger.error("Database initialization timed out")
|
logger.error("Database initialization timed out")
|
||||||
@ -44,6 +77,16 @@ async def lifespan(app: FastAPI):
|
|||||||
# Shutdown - 애플리케이션 종료 시
|
# Shutdown - 애플리케이션 종료 시
|
||||||
logger.info("Shutting down...")
|
logger.info("Shutting down...")
|
||||||
|
|
||||||
|
# 썰박스 신규 잡 큐잉 차단. dispose_engine() 전에 해야 워커 스레드가
|
||||||
|
# 죽은 커넥션에 접근하지 않는다. 진행 중이던 잡은 다음 기동의 스윕이 환불한다.
|
||||||
|
if ssulbox_settings.SSULBOX_ENABLED:
|
||||||
|
try:
|
||||||
|
from app.ssulbox.worker import job_manager
|
||||||
|
|
||||||
|
job_manager.shutdown()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ssulbox] 종료 처리 실패: {e}")
|
||||||
|
|
||||||
# 공유 HTTP 클라이언트 종료
|
# 공유 HTTP 클라이언트 종료
|
||||||
from app.utils.creatomate import close_shared_client
|
from app.utils.creatomate import close_shared_client
|
||||||
from app.utils.upload_blob_as_request import close_shared_blob_client
|
from app.utils.upload_blob_as_request import close_shared_blob_client
|
||||||
|
|||||||
@ -326,6 +326,25 @@ def add_exception_handlers(app: FastAPI):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SsulboxException 핸들러 추가
|
||||||
|
# (FastShipError 를 상속하지 않으므로 위 자동 등록에 잡히지 않는다 —
|
||||||
|
# DashboardException 과 같은 (message, status_code, code) 형태를 쓴다)
|
||||||
|
from app.ssulbox.exceptions import SsulboxException
|
||||||
|
|
||||||
|
@app.exception_handler(SsulboxException)
|
||||||
|
def ssulbox_exception_handler(request: Request, exc: SsulboxException) -> Response:
|
||||||
|
if exc.status_code < 500:
|
||||||
|
logger.warning(f"Handled SsulboxException: {exc.__class__.__name__} - {exc.message}")
|
||||||
|
else:
|
||||||
|
logger.error(f"Handled SsulboxException: {exc.__class__.__name__} - {exc.message}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content={
|
||||||
|
"detail": exc.message,
|
||||||
|
"code": exc.code,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
@app.exception_handler(status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
def internal_server_error_handler(request, exception):
|
def internal_server_error_handler(request, exception):
|
||||||
# 에러 메시지 로깅 (한글 포함 가능)
|
# 에러 메시지 로깅 (한글 포함 가능)
|
||||||
|
|||||||
@ -6,7 +6,9 @@ from app.core.exceptions import FastShipError
|
|||||||
class InsufficientCreditError(FastShipError):
|
class InsufficientCreditError(FastShipError):
|
||||||
"""크레딧이 부족합니다."""
|
"""크레딧이 부족합니다."""
|
||||||
|
|
||||||
status = status.HTTP_400_BAD_REQUEST
|
# 402 Payment Required. 사전차감 전환 전에는 라우터로 노출된 적이 없어(내부에서만
|
||||||
|
# raise/catch) 400 이었다. 프론트가 "충전 화면으로 유도"를 402 로 분기한다.
|
||||||
|
status = status.HTTP_402_PAYMENT_REQUIRED
|
||||||
|
|
||||||
|
|
||||||
class InvalidRequestStateError(FastShipError):
|
class InvalidRequestStateError(FastShipError):
|
||||||
|
|||||||
@ -2,7 +2,16 @@ from datetime import datetime
|
|||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, Index, Integer, String, func
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
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
|
||||||
@ -146,6 +155,10 @@ class CreditTransaction(Base):
|
|||||||
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
Index("idx_credit_tx_user_uuid_created", "user_uuid", "created_at"),
|
||||||
Index("idx_credit_tx_type", "type"),
|
Index("idx_credit_tx_type", "type"),
|
||||||
Index("idx_credit_tx_related_request", "related_request_id"),
|
Index("idx_credit_tx_related_request", "related_request_id"),
|
||||||
|
# 작업 1건당 consume 1행 / refund 1행을 DB 레벨에서 보장하는 멱등 키.
|
||||||
|
# MySQL 은 NULL 을 서로 다르게 취급하므로 job_type 이 NULL 인 기존
|
||||||
|
# charge/admin_adjust 행은 이 제약의 영향을 받지 않는다.
|
||||||
|
UniqueConstraint("job_type", "job_ref", "type", name="uq_credit_job"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
"mysql_charset": "utf8mb4",
|
"mysql_charset": "utf8mb4",
|
||||||
@ -206,6 +219,25 @@ class CreditTransaction(Base):
|
|||||||
comment="연관 충전 요청 ID",
|
comment="연관 충전 요청 ID",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 작업 기반 멱등 키 — (job_type, job_ref, type) 유니크
|
||||||
|
# ==========================================================================
|
||||||
|
# 사전차감 정책의 핵심. 생성 작업을 시작할 때 차감하고 실패 시 환불하는데,
|
||||||
|
# 재시도·중복 요청·크래시 후 재처리에도 "작업 1건당 정확히 1번"을 보장해야 한다.
|
||||||
|
# FK 를 걸지 않는 이유: 썰박스(ssul_task.id 숫자)와 영상(video.task_id UUID7 문자열)
|
||||||
|
# 이라는 이질적인 두 작업 테이블을 하나의 컬럼이 가리키기 때문이다.
|
||||||
|
job_type: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=True,
|
||||||
|
comment="차감 유발 작업 종류 (ssul/video). 충전·관리자 조정은 NULL",
|
||||||
|
)
|
||||||
|
|
||||||
|
job_ref: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(64),
|
||||||
|
nullable=True,
|
||||||
|
comment="작업 식별자 (ssul_task.id 문자열 또는 video.task_id)",
|
||||||
|
)
|
||||||
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime,
|
DateTime,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
|
|||||||
@ -32,6 +32,8 @@ async def record_transaction(
|
|||||||
reason: Optional[str] = None,
|
reason: Optional[str] = None,
|
||||||
admin_id: Optional[int] = None,
|
admin_id: Optional[int] = None,
|
||||||
related_request_id: Optional[int] = None,
|
related_request_id: Optional[int] = None,
|
||||||
|
job_type: Optional[str] = None,
|
||||||
|
job_ref: Optional[str] = None,
|
||||||
) -> CreditTransaction:
|
) -> CreditTransaction:
|
||||||
tx = CreditTransaction(
|
tx = CreditTransaction(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
@ -41,6 +43,8 @@ async def record_transaction(
|
|||||||
reason=reason,
|
reason=reason,
|
||||||
admin_id=admin_id,
|
admin_id=admin_id,
|
||||||
related_request_id=related_request_id,
|
related_request_id=related_request_id,
|
||||||
|
job_type=job_type,
|
||||||
|
job_ref=job_ref,
|
||||||
)
|
)
|
||||||
session.add(tx)
|
session.add(tx)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
@ -123,6 +127,189 @@ async def deduct_credit(
|
|||||||
return tx
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 작업 기반 차감/환불 (사전차감 정책)
|
||||||
|
# =============================================================================
|
||||||
|
# 위의 charge_credit / deduct_credit 은 멱등성이 없어 재시도 시 중복 반영된다.
|
||||||
|
# 생성 작업처럼 "시작할 때 차감하고 실패하면 환불"하는 경로에서는 아래 두 함수를 쓴다.
|
||||||
|
#
|
||||||
|
# 멱등 보장 방식은 2중이다:
|
||||||
|
# 1) 먼저 (job_type, job_ref, type) 로 기존 행을 조회해 있으면 그대로 반환
|
||||||
|
# 2) 경합으로 1)을 통과한 두 요청이 동시에 INSERT 하면 DB 유니크 제약이 막는다
|
||||||
|
# 잔액 자체는 User 행을 with_for_update() 로 잠근 뒤 읽기→갱신하므로 경합에 안전하다.
|
||||||
|
#
|
||||||
|
# 두 함수 모두 **자체 commit 하지 않는다.** 호출부가 트랜잭션을 소유하고,
|
||||||
|
# 작업 행 생성과 차감을 한 트랜잭션으로 묶어 마지막에 한 번만 커밋해야 한다.
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_job_transaction(
|
||||||
|
session: AsyncSession,
|
||||||
|
job_type: str,
|
||||||
|
job_ref: str,
|
||||||
|
type: CreditTransactionType,
|
||||||
|
) -> Optional[CreditTransaction]:
|
||||||
|
"""(job_type, job_ref, type) 에 해당하는 기존 원장 행 조회"""
|
||||||
|
result = await session.execute(
|
||||||
|
select(CreditTransaction).where(
|
||||||
|
CreditTransaction.job_type == job_type,
|
||||||
|
CreditTransaction.job_ref == job_ref,
|
||||||
|
CreditTransaction.type == type,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def deduct_credit_for_job(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_uuid: str,
|
||||||
|
amount: int,
|
||||||
|
job_type: str,
|
||||||
|
job_ref: str,
|
||||||
|
reason: Optional[str] = None,
|
||||||
|
) -> CreditTransaction:
|
||||||
|
"""작업 시작 시점에 크레딧을 선차감한다. (job_type, job_ref) 기준 멱등.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다.
|
||||||
|
user_uuid: 사용자 UUID
|
||||||
|
amount: 차감할 크레딧 (양수)
|
||||||
|
job_type: 작업 종류 ("video" | "ssul")
|
||||||
|
job_ref: 작업 식별자 (video.task_id 또는 str(ssul_task.id))
|
||||||
|
reason: 원장에 남길 사유
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
새로 만든 차감 원장 행. 이미 차감된 작업이면 기존 행을 그대로 반환한다.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
InsufficientCreditError: 잔액 부족 (호출부에서 402 로 변환할 것)
|
||||||
|
UserNotFoundError: 사용자 없음
|
||||||
|
"""
|
||||||
|
from app.user.models import User
|
||||||
|
|
||||||
|
existing = await _find_job_transaction(
|
||||||
|
session, job_type, job_ref, CreditTransactionType.CONSUME
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
logger.info(
|
||||||
|
f"[CREDIT] deduct skipped (already charged) "
|
||||||
|
f"job={job_type}:{job_ref} tx_id={existing.id}"
|
||||||
|
)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
from app.user.services.auth import UserNotFoundError
|
||||||
|
|
||||||
|
raise UserNotFoundError()
|
||||||
|
|
||||||
|
if user.credits < amount:
|
||||||
|
logger.warning(
|
||||||
|
f"[CREDIT] insufficient credits user_uuid={user_uuid} "
|
||||||
|
f"credits={user.credits} requested={amount} job={job_type}:{job_ref}"
|
||||||
|
)
|
||||||
|
raise InsufficientCreditError()
|
||||||
|
|
||||||
|
user.credits = user.credits - amount
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
tx = await record_transaction(
|
||||||
|
session=session,
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
amount=-amount,
|
||||||
|
balance_after=user.credits,
|
||||||
|
type=CreditTransactionType.CONSUME,
|
||||||
|
reason=reason,
|
||||||
|
job_type=job_type,
|
||||||
|
job_ref=job_ref,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[CREDIT] deduct user_uuid={user_uuid} amount=-{amount} "
|
||||||
|
f"balance_after={user.credits} job={job_type}:{job_ref}"
|
||||||
|
)
|
||||||
|
return tx
|
||||||
|
|
||||||
|
|
||||||
|
async def refund_credit_for_job(
|
||||||
|
*,
|
||||||
|
session: AsyncSession,
|
||||||
|
user_uuid: str,
|
||||||
|
amount: int,
|
||||||
|
job_type: str,
|
||||||
|
job_ref: str,
|
||||||
|
reason: Optional[str] = None,
|
||||||
|
) -> Optional[CreditTransaction]:
|
||||||
|
"""작업 실패 시 선차감한 크레딧을 환불한다. (job_type, job_ref) 기준 멱등.
|
||||||
|
|
||||||
|
차감 기록이 없으면 환불하지 않는다 — 애초에 차감되지 않은 작업(예: 정책 전환
|
||||||
|
이전에 시작된 in-flight 작업)에 환불을 얹으면 크레딧이 늘어나기 때문이다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: 호출부가 소유하는 세션. 이 함수는 commit 하지 않는다.
|
||||||
|
user_uuid: 사용자 UUID
|
||||||
|
amount: 환불할 크레딧 (양수)
|
||||||
|
job_type: 작업 종류 ("video" | "ssul")
|
||||||
|
job_ref: 작업 식별자
|
||||||
|
reason: 원장에 남길 사유
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
새로 만든 환불 원장 행. 이미 환불했거나 차감 기록이 없으면 None.
|
||||||
|
"""
|
||||||
|
from app.user.models import User
|
||||||
|
|
||||||
|
already = await _find_job_transaction(
|
||||||
|
session, job_type, job_ref, CreditTransactionType.REFUND
|
||||||
|
)
|
||||||
|
if already is not None:
|
||||||
|
logger.info(
|
||||||
|
f"[CREDIT] refund skipped (already refunded) "
|
||||||
|
f"job={job_type}:{job_ref} tx_id={already.id}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
consumed = await _find_job_transaction(
|
||||||
|
session, job_type, job_ref, CreditTransactionType.CONSUME
|
||||||
|
)
|
||||||
|
if consumed is None:
|
||||||
|
logger.info(
|
||||||
|
f"[CREDIT] refund skipped (never charged) job={job_type}:{job_ref}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await session.execute(
|
||||||
|
select(User).where(User.user_uuid == user_uuid).with_for_update()
|
||||||
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
logger.warning(
|
||||||
|
f"[CREDIT] refund skipped (user not found) "
|
||||||
|
f"user_uuid={user_uuid} job={job_type}:{job_ref}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
user.credits = user.credits + amount
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
tx = await record_transaction(
|
||||||
|
session=session,
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
amount=amount,
|
||||||
|
balance_after=user.credits,
|
||||||
|
type=CreditTransactionType.REFUND,
|
||||||
|
reason=reason,
|
||||||
|
job_type=job_type,
|
||||||
|
job_ref=job_ref,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[CREDIT] refund user_uuid={user_uuid} amount=+{amount} "
|
||||||
|
f"balance_after={user.credits} job={job_type}:{job_ref}"
|
||||||
|
)
|
||||||
|
return tx
|
||||||
|
|
||||||
|
|
||||||
async def approve_charge_request(
|
async def approve_charge_request(
|
||||||
*,
|
*,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
@ -5,11 +5,19 @@ Write-Behind 패턴 적용:
|
|||||||
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
- 토글 시 Redis를 즉시 업데이트하고 dirty SET에 표시
|
||||||
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
- 스케줄러가 1분마다 dirty 항목을 MySQL에 bulk write
|
||||||
|
|
||||||
|
**콘텐츠 종류(ctype)** 를 받아 ADO2 영상과 썰박스 콘텐츠 양쪽에 같은 로직을 쓴다.
|
||||||
|
`ctype` 기본값이 "video" 라서 기존 castad 호출부는 수정 없이 그대로 동작한다.
|
||||||
|
검증된 Lua 원자 토글은 키를 인자로 받으므로 변경하지 않았다.
|
||||||
|
|
||||||
Key 패턴:
|
Key 패턴:
|
||||||
- video:like:count:{video_id} INT — 좋아요 카운트
|
- {ctype}:like:count:{content_id} INT — 좋아요 카운트
|
||||||
- video:like:users:{video_id} SET — 좋아요 누른 user_uuid 목록
|
- {ctype}:like:users:{content_id} SET — 좋아요 누른 user_uuid 목록
|
||||||
- video:reaction:dirty SET — DB 동기화 대기 "{video_id}:{user_uuid}"
|
- video:reaction:dirty SET — DB 동기화 대기 "{ctype}:{content_id}:{user_uuid}"
|
||||||
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
- video:reaction:dirty:processing SET — 플러시 중 임시 (크래시 복구용)
|
||||||
|
|
||||||
|
ctype="video" 일 때 카운트/유저 키가 기존과 **완전히 동일**하므로 캐시 이관이 필요 없다.
|
||||||
|
dirty SET 키 이름도 "video:" 접두를 유지한다 — 배포 순간 큐에 남아 있는 항목을
|
||||||
|
잃지 않기 위해서다(이름을 바꾸면 그 항목들이 영구 미반영된다).
|
||||||
|
|
||||||
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
캐시 미스(Redis 재시작 등) 시 호출부에서 DB 조회 후 backfill_user_set() / set_like_count()로 복구합니다.
|
||||||
"""
|
"""
|
||||||
@ -44,6 +52,17 @@ end
|
|||||||
_DIRTY_KEY = "video:reaction:dirty"
|
_DIRTY_KEY = "video:reaction:dirty"
|
||||||
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
|
_DIRTY_PROCESSING_KEY = "video:reaction:dirty:processing"
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 콘텐츠 종류
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
#: ADO2 영상 (video 테이블 · video_reaction)
|
||||||
|
CT_VIDEO = "video"
|
||||||
|
#: 썰박스 콘텐츠 (ssul_content 테이블 · ssul_like)
|
||||||
|
CT_SSUL = "ssul"
|
||||||
|
|
||||||
|
#: dirty 항목 파싱 시 "종류 접두인지" 판별하는 데 쓴다
|
||||||
|
CONTENT_TYPES: tuple[str, ...] = (CT_VIDEO, CT_SSUL)
|
||||||
|
|
||||||
|
|
||||||
def get_like_cache() -> aioredis.Redis:
|
def get_like_cache() -> aioredis.Redis:
|
||||||
global _client
|
global _client
|
||||||
@ -68,61 +87,71 @@ async def close_like_cache() -> None:
|
|||||||
# Key 헬퍼
|
# Key 헬퍼
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
def _key(video_id: int) -> str:
|
def _key(content_id: int, ctype: str = CT_VIDEO) -> str:
|
||||||
return f"video:like:count:{video_id}"
|
return f"{ctype}:like:count:{content_id}"
|
||||||
|
|
||||||
|
|
||||||
def _user_key(video_id: int) -> str:
|
def _user_key(content_id: int, ctype: str = CT_VIDEO) -> str:
|
||||||
return f"video:like:users:{video_id}"
|
return f"{ctype}:like:users:{content_id}"
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# 카운트 (기존 API 유지)
|
# 카운트 (기존 API 유지)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def get_like_count(video_id: int) -> int | None:
|
async def get_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int | None:
|
||||||
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
"""Redis에서 like_count 조회. 캐시 미스 시 None 반환."""
|
||||||
val = await get_like_cache().get(_key(video_id))
|
val = await get_like_cache().get(_key(content_id, ctype))
|
||||||
if val is None:
|
if val is None:
|
||||||
return None
|
return None
|
||||||
return max(int(val), 0)
|
return max(int(val), 0)
|
||||||
|
|
||||||
|
|
||||||
async def get_like_counts(video_ids: list[int]) -> dict[int, int | None]:
|
async def get_like_counts(
|
||||||
"""여러 영상의 like_count를 한 번에 조회 (mget).
|
content_ids: list[int], *, ctype: str = CT_VIDEO
|
||||||
캐시 미스인 video_id는 None으로 반환."""
|
) -> dict[int, int | None]:
|
||||||
if not video_ids:
|
"""여러 콘텐츠의 like_count를 한 번에 조회 (mget).
|
||||||
|
캐시 미스인 content_id는 None으로 반환."""
|
||||||
|
if not content_ids:
|
||||||
return {}
|
return {}
|
||||||
keys = [_key(vid) for vid in video_ids]
|
keys = [_key(cid, ctype) for cid in content_ids]
|
||||||
values = await get_like_cache().mget(*keys)
|
values = await get_like_cache().mget(*keys)
|
||||||
return {
|
return {
|
||||||
vid: max(int(v), 0) if v is not None else None
|
cid: max(int(v), 0) if v is not None else None
|
||||||
for vid, v in zip(video_ids, values)
|
for cid, v in zip(content_ids, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def set_like_count(video_id: int, count: int) -> None:
|
async def set_like_count(
|
||||||
|
content_id: int, count: int, *, ctype: str = CT_VIDEO
|
||||||
|
) -> None:
|
||||||
"""like_count를 Redis에 저장 (음수 방지)."""
|
"""like_count를 Redis에 저장 (음수 방지)."""
|
||||||
await get_like_cache().set(_key(video_id), max(count, 0))
|
await get_like_cache().set(_key(content_id, ctype), max(count, 0))
|
||||||
|
|
||||||
|
|
||||||
async def mset_like_counts(counts: dict[int, int]) -> None:
|
async def mset_like_counts(
|
||||||
"""여러 영상의 like_count를 한 번에 저장 (mset)."""
|
counts: dict[int, int], *, ctype: str = CT_VIDEO
|
||||||
|
) -> None:
|
||||||
|
"""여러 콘텐츠의 like_count를 한 번에 저장 (mset)."""
|
||||||
if not counts:
|
if not counts:
|
||||||
return
|
return
|
||||||
await get_like_cache().mset({_key(vid): max(cnt, 0) for vid, cnt in counts.items()})
|
await get_like_cache().mset(
|
||||||
|
{_key(cid, ctype): max(cnt, 0) for cid, cnt in counts.items()}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def incr_like_count(video_id: int) -> int:
|
async def incr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||||
"""like_count를 1 증가 후 반환."""
|
"""like_count를 1 증가 후 반환."""
|
||||||
return max(int(await get_like_cache().incr(_key(video_id))), 0)
|
return max(int(await get_like_cache().incr(_key(content_id, ctype))), 0)
|
||||||
|
|
||||||
|
|
||||||
async def decr_like_count(video_id: int) -> int:
|
async def decr_like_count(content_id: int, *, ctype: str = CT_VIDEO) -> int:
|
||||||
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
"""like_count를 1 감소 후 반환 (음수 방지)."""
|
||||||
count = int(await get_like_cache().decr(_key(video_id)))
|
client = get_like_cache()
|
||||||
|
key = _key(content_id, ctype)
|
||||||
|
count = int(await client.decr(key))
|
||||||
if count < 0:
|
if count < 0:
|
||||||
await get_like_cache().set(_key(video_id), 0)
|
await client.set(key, 0)
|
||||||
return 0
|
return 0
|
||||||
return count
|
return count
|
||||||
|
|
||||||
@ -131,7 +160,9 @@ async def decr_like_count(video_id: int) -> int:
|
|||||||
# 유저 SET (is_liked_by_me source of truth)
|
# 유저 SET (is_liked_by_me source of truth)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
async def toggle_like_atomic(
|
||||||
|
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||||
|
) -> tuple[bool, int]:
|
||||||
"""Lua 스크립트로 원자적 좋아요 토글.
|
"""Lua 스크립트로 원자적 좋아요 토글.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -140,14 +171,16 @@ async def toggle_like_atomic(video_id: int, user_uuid: str) -> tuple[bool, int]:
|
|||||||
result = await get_like_cache().eval(
|
result = await get_like_cache().eval(
|
||||||
_TOGGLE_LIKE_SCRIPT,
|
_TOGGLE_LIKE_SCRIPT,
|
||||||
2,
|
2,
|
||||||
_user_key(video_id),
|
_user_key(content_id, ctype),
|
||||||
_key(video_id),
|
_key(content_id, ctype),
|
||||||
user_uuid,
|
user_uuid,
|
||||||
)
|
)
|
||||||
return bool(result[0]), int(result[1])
|
return bool(result[0]), int(result[1])
|
||||||
|
|
||||||
|
|
||||||
async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
async def is_user_liked(
|
||||||
|
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||||
|
) -> bool | None:
|
||||||
"""Redis user-set에서 좋아요 여부 조회.
|
"""Redis user-set에서 좋아요 여부 조회.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
@ -155,59 +188,101 @@ async def is_user_liked(video_id: int, user_uuid: str) -> bool | None:
|
|||||||
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
None: user-set 키가 없음 (cold-start backfill 필요 신호)
|
||||||
"""
|
"""
|
||||||
client = get_like_cache()
|
client = get_like_cache()
|
||||||
key = _user_key(video_id)
|
key = _user_key(content_id, ctype)
|
||||||
if not await client.exists(key):
|
if not await client.exists(key):
|
||||||
return None
|
return None
|
||||||
return bool(await client.sismember(key, user_uuid))
|
return bool(await client.sismember(key, user_uuid))
|
||||||
|
|
||||||
|
|
||||||
async def is_user_set_exists(video_id: int) -> bool:
|
async def is_user_set_exists(content_id: int, *, ctype: str = CT_VIDEO) -> bool:
|
||||||
"""Redis user-set 키 존재 여부 확인."""
|
"""Redis user-set 키 존재 여부 확인."""
|
||||||
return bool(await get_like_cache().exists(_user_key(video_id)))
|
return bool(await get_like_cache().exists(_user_key(content_id, ctype)))
|
||||||
|
|
||||||
|
|
||||||
async def bulk_is_user_liked(
|
async def bulk_is_user_liked(
|
||||||
video_ids: list[int], user_uuid: str
|
content_ids: list[int], user_uuid: str, *, ctype: str = CT_VIDEO
|
||||||
) -> dict[int, bool | None]:
|
) -> dict[int, bool | None]:
|
||||||
"""여러 영상의 is_liked 여부를 한 번에 조회 (pipeline).
|
"""여러 콘텐츠의 is_liked 여부를 한 번에 조회 (pipeline).
|
||||||
|
|
||||||
|
통합 목록처럼 두 종류가 섞인 경우에는 **종류별로 나눠 각각 호출한다** —
|
||||||
|
반환 키가 content_id 하나여서 종류가 다른 같은 id 를 구분할 수 없다.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{video_id: True/False} — user-set 키가 없는 영상은 None
|
{content_id: True/False} — user-set 키가 없는 항목은 None
|
||||||
"""
|
"""
|
||||||
if not video_ids:
|
if not content_ids:
|
||||||
return {}
|
return {}
|
||||||
client = get_like_cache()
|
client = get_like_cache()
|
||||||
async with client.pipeline(transaction=False) as pipe:
|
async with client.pipeline(transaction=False) as pipe:
|
||||||
for vid in video_ids:
|
for cid in content_ids:
|
||||||
pipe.exists(_user_key(vid))
|
pipe.exists(_user_key(cid, ctype))
|
||||||
pipe.sismember(_user_key(vid), user_uuid)
|
pipe.sismember(_user_key(cid, ctype), user_uuid)
|
||||||
responses = await pipe.execute()
|
responses = await pipe.execute()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
vid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
cid: (bool(responses[i * 2 + 1]) if responses[i * 2] else None)
|
||||||
for i, vid in enumerate(video_ids)
|
for i, cid in enumerate(content_ids)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async def backfill_user_set(video_id: int, user_uuids: list[str]) -> None:
|
async def backfill_user_set(
|
||||||
|
content_id: int, user_uuids: list[str], *, ctype: str = CT_VIDEO
|
||||||
|
) -> None:
|
||||||
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
"""DB에서 가져온 유저 목록을 Redis SET에 일괄 적재."""
|
||||||
if user_uuids:
|
if user_uuids:
|
||||||
await get_like_cache().sadd(_user_key(video_id), *user_uuids)
|
await get_like_cache().sadd(_user_key(content_id, ctype), *user_uuids)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
# Dirty SET (Write-Behind 큐)
|
# Dirty SET (Write-Behind 큐)
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def mark_dirty(video_id: int, user_uuid: str) -> None:
|
async def mark_dirty(
|
||||||
|
content_id: int, user_uuid: str, *, ctype: str = CT_VIDEO
|
||||||
|
) -> None:
|
||||||
"""DB 동기화 대기 목록에 추가."""
|
"""DB 동기화 대기 목록에 추가."""
|
||||||
await get_like_cache().sadd(_DIRTY_KEY, f"{video_id}:{user_uuid}")
|
await get_like_cache().sadd(_DIRTY_KEY, f"{ctype}:{content_id}:{user_uuid}")
|
||||||
|
|
||||||
|
|
||||||
async def drain_dirty() -> list[tuple[int, str]]:
|
def _parse_dirty(member: str) -> tuple[str, int, str] | None:
|
||||||
|
"""dirty 항목 문자열 → (ctype, content_id, user_uuid).
|
||||||
|
|
||||||
|
두 형식을 모두 받는다:
|
||||||
|
- 현재: "{ctype}:{content_id}:{user_uuid}"
|
||||||
|
- 구형: "{content_id}:{user_uuid}" ← 종류 도입 전에 큐에 들어간 항목
|
||||||
|
|
||||||
|
**구형 관용이 필요한 이유**: 배포 순간 dirty SET 에 구형 항목이 남아 있다.
|
||||||
|
새 파서가 이를 못 읽으면 그 좋아요는 DB 에 영구 미반영된다.
|
||||||
|
구형은 ADO2 영상뿐이었으므로 CT_VIDEO 로 해석한다.
|
||||||
|
|
||||||
|
형식이 깨진 항목은 None 을 돌려 호출부가 건너뛰게 한다 — 하나 때문에
|
||||||
|
플러시 전체가 죽으면 큐가 무한히 쌓인다.
|
||||||
|
"""
|
||||||
|
parts = member.split(":", 2)
|
||||||
|
|
||||||
|
# 길이가 아니라 **첫 토큰이 알려진 종류인지**로 판정한다.
|
||||||
|
# user_uuid 에 콜론이 있어도 구형이 3조각으로 보일 수 있다.
|
||||||
|
if len(parts) == 3 and parts[0] in CONTENT_TYPES:
|
||||||
|
ctype, id_str, user_uuid = parts
|
||||||
|
else:
|
||||||
|
ctype = CT_VIDEO
|
||||||
|
legacy = member.split(":", 1)
|
||||||
|
if len(legacy) != 2:
|
||||||
|
return None
|
||||||
|
id_str, user_uuid = legacy
|
||||||
|
|
||||||
|
if not id_str.isdigit() or not user_uuid:
|
||||||
|
return None
|
||||||
|
return ctype, int(id_str), user_uuid
|
||||||
|
|
||||||
|
|
||||||
|
async def drain_dirty() -> list[tuple[str, int, str]]:
|
||||||
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
"""dirty SET을 processing으로 RENAME 후 전체 반환.
|
||||||
|
|
||||||
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
이전 실행 중 크래시로 남은 processing 항목은 먼저 병합하여 유실 방지.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
[(ctype, content_id, user_uuid), ...]
|
||||||
"""
|
"""
|
||||||
client = get_like_cache()
|
client = get_like_cache()
|
||||||
|
|
||||||
@ -223,10 +298,12 @@ async def drain_dirty() -> list[tuple[int, str]]:
|
|||||||
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
await client.rename(_DIRTY_KEY, _DIRTY_PROCESSING_KEY)
|
||||||
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
members = await client.smembers(_DIRTY_PROCESSING_KEY)
|
||||||
|
|
||||||
result = []
|
result: list[tuple[str, int, str]] = []
|
||||||
for member in members:
|
for member in members:
|
||||||
vid_str, user_uuid = member.split(":", 1)
|
parsed = _parse_dirty(member)
|
||||||
result.append((int(vid_str), user_uuid))
|
if parsed is None:
|
||||||
|
continue
|
||||||
|
result.append(parsed)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -86,6 +86,10 @@ async def create_db_tables():
|
|||||||
from app.dashboard.models import Dashboard # noqa: F401
|
from app.dashboard.models import Dashboard # noqa: F401
|
||||||
from app.backoffice.admin.models import Admin # noqa: F401
|
from app.backoffice.admin.models import Admin # noqa: F401
|
||||||
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
|
from app.credit.models import CreditChargeRequest, CreditTransaction # noqa: F401
|
||||||
|
from app.ssulbox.models import ( # noqa: F401
|
||||||
|
SsulContent,
|
||||||
|
SsulSocialUpload,
|
||||||
|
)
|
||||||
|
|
||||||
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
|
# 생성할 테이블 목록 (FK 순서: 참조 대상 먼저)
|
||||||
tables_to_create = [
|
tables_to_create = [
|
||||||
@ -106,6 +110,9 @@ async def create_db_tables():
|
|||||||
Admin.__table__,
|
Admin.__table__,
|
||||||
CreditChargeRequest.__table__,
|
CreditChargeRequest.__table__,
|
||||||
CreditTransaction.__table__,
|
CreditTransaction.__table__,
|
||||||
|
# 썰박스 (FK 순서: ssul_content 를 나머지가 참조)
|
||||||
|
SsulContent.__table__,
|
||||||
|
SsulSocialUpload.__table__,
|
||||||
]
|
]
|
||||||
|
|
||||||
logger.info("Creating database tables...")
|
logger.info("Creating database tables...")
|
||||||
|
|||||||
9
app/ssulbox/__init__.py
Normal file
9
app/ssulbox/__init__.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
"""썰박스(ssulbox) 모듈.
|
||||||
|
|
||||||
|
네이버 지도 링크나 업장명을 입력하면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이)
|
||||||
|
중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성하는 병맛 역사 썰툰 쇼츠 파이프라인.
|
||||||
|
|
||||||
|
별도 저장소(o2o-ssulbox)로 개발되던 것을 castad 백엔드로 이식했다.
|
||||||
|
계정·크레딧·소셜 계정은 castad 것을 그대로 쓰고(User / CreditTransaction / SocialAccount),
|
||||||
|
썰박스 고유 도메인만 `ssul_` 접두 테이블로 신설한다.
|
||||||
|
"""
|
||||||
0
app/ssulbox/api/__init__.py
Normal file
0
app/ssulbox/api/__init__.py
Normal file
0
app/ssulbox/api/routers/__init__.py
Normal file
0
app/ssulbox/api/routers/__init__.py
Normal file
0
app/ssulbox/api/routers/v1/__init__.py
Normal file
0
app/ssulbox/api/routers/v1/__init__.py
Normal file
194
app/ssulbox/api/routers/v1/content.py
Normal file
194
app/ssulbox/api/routers/v1/content.py
Normal file
@ -0,0 +1,194 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""썰박스 API — 장소 검색 · 생성 요청 · 진행 폴링.
|
||||||
|
|
||||||
|
castad 는 `/api/*` prefix 를 쓰지 않고 도메인별 prefix 를 쓰므로 `/ssul` 로 노출한다.
|
||||||
|
인증은 castad `get_current_user` 를 그대로 쓴다(원본의 auth 라우터·JWT 는 폐기).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.credit.exceptions import InsufficientCreditError
|
||||||
|
from app.database.session import AsyncSessionLocal, get_session
|
||||||
|
from app.ssulbox.constants import ORPHAN_STATUSES, is_generation_available
|
||||||
|
from app.ssulbox.exceptions import GenerationUnavailableError, TaskNotFoundError
|
||||||
|
from app.ssulbox.models import SsulContent
|
||||||
|
from app.ssulbox.schemas.ssulbox_schema import (
|
||||||
|
SsulActiveTasksResponse,
|
||||||
|
SsulCreateRequest,
|
||||||
|
SsulCreateResponse,
|
||||||
|
SsulPlaceSearchResponse,
|
||||||
|
SsulTaskStatus,
|
||||||
|
)
|
||||||
|
from app.ssulbox.services import place_service, task_service
|
||||||
|
from app.ssulbox.worker import job_manager
|
||||||
|
from app.user.dependencies.auth import get_current_user
|
||||||
|
from app.user.models import User
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import ssulbox_settings
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
logger = get_logger("ssulbox")
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/ssul", tags=["Ssulbox"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/search/place",
|
||||||
|
response_model=SsulPlaceSearchResponse,
|
||||||
|
summary="업장 검색 (네이버 지도)",
|
||||||
|
description="""
|
||||||
|
업장명으로 네이버 지도 후보를 검색합니다.
|
||||||
|
|
||||||
|
castad `/search/accommodation`(네이버 **검색 API**)과 달리 **`place_url` 을 포함**합니다.
|
||||||
|
생성 파이프라인이 네이버 지도 place 페이지를 크롤링하므로 이 URL 이 필요합니다.
|
||||||
|
|
||||||
|
- 동명 업장은 주소로 구분됩니다.
|
||||||
|
- 검색 실패·타임아웃 시 빈 목록을 반환합니다(사용자는 네이버 링크를 직접 붙여넣을 수 있습니다).
|
||||||
|
- Playwright 크롤링이라 수 초 걸립니다.
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "검색 성공 (결과 없음도 200)"},
|
||||||
|
401: {"description": "인증 실패"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def search_place(
|
||||||
|
query: str = Query(..., min_length=2, description="업장명"),
|
||||||
|
limit: int = Query(default=8, ge=1, le=20),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> SsulPlaceSearchResponse:
|
||||||
|
items = await place_service.search_places(query, limit=limit)
|
||||||
|
return SsulPlaceSearchResponse(query=query, count=len(items), items=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/create",
|
||||||
|
response_model=SsulCreateResponse,
|
||||||
|
summary="썰박스 생성 요청",
|
||||||
|
description="""
|
||||||
|
썰박스 생성을 요청합니다.
|
||||||
|
|
||||||
|
## 크레딧
|
||||||
|
- **요청 시점에 크레딧이 선차감됩니다.** 완료 시점이 아닙니다.
|
||||||
|
- 생성이 실패하면 자동으로 환불됩니다.
|
||||||
|
- 잔액이 부족하면 402 를 반환하며 잡도 생성되지 않습니다.
|
||||||
|
|
||||||
|
## 진행 확인
|
||||||
|
응답의 `id` 로 `GET /ssul/tasks/{id}` 를 폴링하세요.
|
||||||
|
권장 간격은 응답의 `poll_interval_seconds` 입니다.
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "요청 접수"},
|
||||||
|
401: {"description": "인증 실패"},
|
||||||
|
402: {"description": "크레딧 부족"},
|
||||||
|
503: {"description": "생성 기능 비활성 (Gemini API 키 미설정)"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def create_ssul(
|
||||||
|
body: SsulCreateRequest,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
) -> SsulCreateResponse:
|
||||||
|
if not is_generation_available():
|
||||||
|
raise GenerationUnavailableError("Gemini API 키가 설정되지 않았습니다.")
|
||||||
|
|
||||||
|
# 행 삽입 + 크레딧 선차감을 한 트랜잭션으로 묶는다.
|
||||||
|
# 외부 API 호출이 없으므로 Depends(get_session) 대신 짧게 열고 닫는다.
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
row = await task_service.create_task(
|
||||||
|
session,
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
scenario=body.scenario,
|
||||||
|
input_text=body.input,
|
||||||
|
scenes=body.scenes,
|
||||||
|
seconds=body.seconds,
|
||||||
|
store_name=body.store_name,
|
||||||
|
road_address=body.road_address,
|
||||||
|
address=body.address,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
content_id = row.id
|
||||||
|
except InsufficientCreditError:
|
||||||
|
await session.rollback()
|
||||||
|
logger.info(
|
||||||
|
f"[create_ssul] INSUFFICIENT CREDIT user={current_user.user_uuid}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 커밋이 끝난 뒤에 큐잉한다 — 워커가 아직 없는 행을 조회하면 안 된다.
|
||||||
|
# create_job 은 앱 이벤트 루프를 캡처하므로 반드시 요청 핸들러에서 호출한다.
|
||||||
|
job_manager.create_job(
|
||||||
|
content_id, body.scenario, body.input, body.scenes, body.seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
return SsulCreateResponse(
|
||||||
|
id=content_id,
|
||||||
|
status="queued",
|
||||||
|
poll_interval_seconds=ssulbox_settings.SSULBOX_POLL_HINT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tasks/active",
|
||||||
|
response_model=SsulActiveTasksResponse,
|
||||||
|
summary="진행 중인 내 생성 잡",
|
||||||
|
description="""
|
||||||
|
새로고침·새 탭 진입 시 진행 상태를 복구하는 데 씁니다.
|
||||||
|
|
||||||
|
클라이언트의 localStorage 는 새 탭에서 초기화되므로 **서버가 권위**입니다.
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
async def get_active_tasks(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SsulActiveTasksResponse:
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(SsulContent)
|
||||||
|
.where(
|
||||||
|
SsulContent.user_uuid == current_user.user_uuid,
|
||||||
|
SsulContent.status.in_(ORPHAN_STATUSES),
|
||||||
|
SsulContent.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(SsulContent.created_at.desc())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return SsulActiveTasksResponse(
|
||||||
|
items=[SsulTaskStatus.model_validate(r) for r in rows]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/tasks/{content_id}",
|
||||||
|
response_model=SsulTaskStatus,
|
||||||
|
summary="생성 진행 상태 (폴링)",
|
||||||
|
description="""
|
||||||
|
생성 진행 상태를 반환합니다. 프론트가 3초마다 폴링합니다.
|
||||||
|
|
||||||
|
- `step` 은 완료한 단계 수(0~4)입니다. 0=준비, 4=영상 합성 완료.
|
||||||
|
- `video_url` 은 `status=done` 일 때만 채워집니다.
|
||||||
|
- 남의 잡은 404 로 처리합니다(존재 여부를 노출하지 않습니다).
|
||||||
|
""",
|
||||||
|
responses={
|
||||||
|
200: {"description": "조회 성공"},
|
||||||
|
401: {"description": "인증 실패"},
|
||||||
|
404: {"description": "잡을 찾을 수 없음"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_task(
|
||||||
|
content_id: int,
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> SsulTaskStatus:
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
# 남의 잡이면 존재 여부를 알리지 않고 동일하게 404
|
||||||
|
if row is None or row.user_uuid != current_user.user_uuid:
|
||||||
|
raise TaskNotFoundError()
|
||||||
|
return SsulTaskStatus.model_validate(row)
|
||||||
110
app/ssulbox/constants.py
Normal file
110
app/ssulbox/constants.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""썰박스 상수.
|
||||||
|
|
||||||
|
설정(SsulboxSettings)이 아니라 코드와 함께 고정되는 값들. 시나리오 ↔ 엔진 폴더 매핑,
|
||||||
|
생성 잡 stdout 마커 파싱 규칙, 태스크 상태 enum이 여기 있다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Final
|
||||||
|
|
||||||
|
from config import apikey_settings
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 시나리오 ↔ 생성 엔진 폴더
|
||||||
|
# =============================================================================
|
||||||
|
# 프론트가 보내는 시나리오 코드를 generator/ 하위 엔진 디렉터리명으로 옮긴다.
|
||||||
|
# 엔진을 추가하면 여기와 프론트 ssulData.ts 양쪽을 함께 고쳐야 한다.
|
||||||
|
SCENARIO_ENGINE: Final[dict[str, str]] = {
|
||||||
|
"joseon": "animation",
|
||||||
|
"samgukji": "animation_samgukji",
|
||||||
|
"greek": "animation_greekroman",
|
||||||
|
"odyssey": "animation_odyssey",
|
||||||
|
}
|
||||||
|
|
||||||
|
ENGINE_SCENARIO: Final[dict[str, str]] = {v: k for k, v in SCENARIO_ENGINE.items()}
|
||||||
|
|
||||||
|
SCENARIOS: Final[tuple[str, ...]] = tuple(SCENARIO_ENGINE)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 생성 잡 상태
|
||||||
|
# =============================================================================
|
||||||
|
class SsulTaskStatus(str, Enum):
|
||||||
|
"""생성 잡의 권위 상태. 인메모리 진행률(step)과 달리 DB가 진실 원천이다."""
|
||||||
|
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
DONE = "done"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
#: 프로세스 기동 시 고아로 판정해 환불·정리할 상태들
|
||||||
|
ORPHAN_STATUSES: Final[tuple[str, ...]] = (
|
||||||
|
SsulTaskStatus.QUEUED.value,
|
||||||
|
SsulTaskStatus.RUNNING.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 생성 엔진 stdout 파싱
|
||||||
|
# =============================================================================
|
||||||
|
#: 엔진이 뱉는 진행 마커. 예) "[2/4] 스토리보드 생성"
|
||||||
|
STEP_RE: Final[re.Pattern[str]] = re.compile(r"\[(\d)\s*/\s*4\]")
|
||||||
|
|
||||||
|
#: 완료 마커. 예) "완성: output/animation/xxx/final.mp4"
|
||||||
|
DONE_RE: Final[re.Pattern[str]] = re.compile(r"완성[::]\s*(.+\.mp4)")
|
||||||
|
|
||||||
|
#: 업장명 마커. 예) "■ 가게: 골목냉면 / 소재 5줄 → 키워드로 사용"
|
||||||
|
#: place URL 을 직접 붙여넣어 검색을 거치지 않은 경우, 업장명을 얻을 수 있는
|
||||||
|
#: 유일한 경로다(엔진이 네이버 브리핑에서 뽑아 찍는다).
|
||||||
|
#: 주소는 찍지 않으므로 이 경로에서는 region 을 채울 수 없다.
|
||||||
|
STORE_RE: Final[re.Pattern[str]] = re.compile(r"가게\s*[::]\s*([^/\n]+?)\s*(?:/|$)")
|
||||||
|
|
||||||
|
#: 작업 폴더 마커. 예) "■ 작업 폴더 : C:\...\output\animation\골목냉면20260729"
|
||||||
|
#: 실패 시 정리할 대상을 알아내는 유일한 수단이다 — 실패하면 완료 마커가
|
||||||
|
#: 없어 mp4 경로로 폴더를 역산할 수 없다.
|
||||||
|
JOB_DIR_RE: Final[re.Pattern[str]] = re.compile(r"작업\s*폴더\s*[::]\s*(.+)")
|
||||||
|
|
||||||
|
#: step 1~4에 대응하는 사람이 읽는 단계명 (프론트 진행 표시용)
|
||||||
|
STEP_NAMES: Final[tuple[str, ...]] = ("대본 생성", "스토리보드", "이미지·음성", "영상 합성")
|
||||||
|
|
||||||
|
TOTAL_STEPS: Final[int] = len(STEP_NAMES)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 크레딧 원장 멱등 키
|
||||||
|
# =============================================================================
|
||||||
|
#: credit_transaction.job_type 값. (job_type, job_ref, type) 유니크로 중복 차감을 막는다.
|
||||||
|
JOB_TYPE_SSUL: Final[str] = "ssul"
|
||||||
|
JOB_TYPE_VIDEO: Final[str] = "video"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Gemini API 키 판정
|
||||||
|
# =============================================================================
|
||||||
|
#: castad config 의 GEMINI_API_KEY 기본값이 플레이스홀더 문자열이라
|
||||||
|
#: `if not key` 형태의 가드가 항상 통과해버린다. 반드시 gemini_key() 로 판정할 것.
|
||||||
|
_PLACEHOLDER_KEYS: Final[frozenset[str]] = frozenset(
|
||||||
|
{
|
||||||
|
"",
|
||||||
|
"your-gemeni-api-key", # config.py 기본값 (오타 그대로)
|
||||||
|
"your-gemini-api-key",
|
||||||
|
"none",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def gemini_key() -> str | None:
|
||||||
|
"""실제로 사용 가능한 Gemini API 키를 반환한다. 미설정이면 None.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
설정된 키. 플레이스홀더이거나 비어 있으면 None.
|
||||||
|
"""
|
||||||
|
key = (apikey_settings.GEMINI_API_KEY or "").strip()
|
||||||
|
return None if key.lower() in _PLACEHOLDER_KEYS else key
|
||||||
|
|
||||||
|
|
||||||
|
def is_generation_available() -> bool:
|
||||||
|
"""생성 파이프라인을 돌릴 수 있는 상태인지 여부."""
|
||||||
|
return gemini_key() is not None
|
||||||
146
app/ssulbox/exceptions.py
Normal file
146
app/ssulbox/exceptions.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""썰박스 예외.
|
||||||
|
|
||||||
|
app/dashboard/exceptions.py 와 동일한 (message, status_code, code) 형태를 따른다.
|
||||||
|
전역 핸들러가 code 를 그대로 응답 본문에 실어 보내므로, 프론트가 분기에 쓰는
|
||||||
|
문자열이다 — 이미 나간 code 값은 함부로 바꾸지 말 것.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import status
|
||||||
|
|
||||||
|
|
||||||
|
class SsulboxException(Exception):
|
||||||
|
"""썰박스 기본 예외"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
code: str = "SSULBOX_ERROR",
|
||||||
|
):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.code = code
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 생성 요청 관련
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidScenarioError(SsulboxException):
|
||||||
|
"""지원하지 않는 시나리오 코드"""
|
||||||
|
|
||||||
|
def __init__(self, scenario: str = ""):
|
||||||
|
message = "지원하지 않는 시나리오입니다."
|
||||||
|
if scenario:
|
||||||
|
message += f" ({scenario})"
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
code="SSUL_INVALID_SCENARIO",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationUnavailableError(SsulboxException):
|
||||||
|
"""생성 엔진 사용 불가 (Gemini API 키 미설정 등)"""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = ""):
|
||||||
|
message = "썰박스 생성 기능이 현재 비활성화되어 있습니다."
|
||||||
|
if detail:
|
||||||
|
message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
code="SSUL_GENERATION_UNAVAILABLE",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskAlreadyRunningError(SsulboxException):
|
||||||
|
"""이미 진행 중인 생성 잡이 있음"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="이미 생성 중인 썰박스가 있습니다. 완료 후 다시 시도해주세요.",
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
code="SSUL_TASK_ALREADY_RUNNING",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 조회 관련
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class TaskNotFoundError(SsulboxException):
|
||||||
|
"""생성 잡을 찾을 수 없음 (없거나 남의 것)"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="생성 요청을 찾을 수 없습니다.",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
code="SSUL_TASK_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContentNotFoundError(SsulboxException):
|
||||||
|
"""콘텐츠를 찾을 수 없음"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="썰박스 콘텐츠를 찾을 수 없습니다.",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
code="SSUL_CONTENT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentNotFoundError(SsulboxException):
|
||||||
|
"""댓글을 찾을 수 없음"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="댓글을 찾을 수 없습니다.",
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
code="SSUL_COMMENT_NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CommentDepthExceededError(SsulboxException):
|
||||||
|
"""댓글은 2-depth(댓글 + 대댓글)까지만 허용"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="대댓글에는 답글을 달 수 없습니다.",
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
code="SSUL_COMMENT_DEPTH_EXCEEDED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 생성 실행 관련
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationFailedError(SsulboxException):
|
||||||
|
"""생성 엔진 실행 실패"""
|
||||||
|
|
||||||
|
def __init__(self, detail: str = ""):
|
||||||
|
message = "썰박스 생성에 실패했습니다."
|
||||||
|
if detail:
|
||||||
|
message += f" ({detail})"
|
||||||
|
super().__init__(
|
||||||
|
message=message,
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
code="SSUL_GENERATION_FAILED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationTimeoutError(SsulboxException):
|
||||||
|
"""생성 엔진 실행 시간 초과"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
message="썰박스 생성 시간이 초과되었습니다. 크레딧은 환불됩니다.",
|
||||||
|
status_code=status.HTTP_504_GATEWAY_TIMEOUT,
|
||||||
|
code="SSUL_GENERATION_TIMEOUT",
|
||||||
|
)
|
||||||
419
app/ssulbox/migration.py
Normal file
419
app/ssulbox/migration.py
Normal file
@ -0,0 +1,419 @@
|
|||||||
|
"""썰박스 스키마 보장.
|
||||||
|
|
||||||
|
**이 프로젝트에는 Alembic 이 없다.** 게다가 `create_db_tables()` 는
|
||||||
|
`prj_settings.DEBUG` 일 때만 호출되므로(app/core/common.py), 운영 환경에서는
|
||||||
|
신규 테이블이 조용히 만들어지지 않는다. 그래서 이 모듈이 DEBUG 여부와 무관하게
|
||||||
|
스키마를 책임진다.
|
||||||
|
|
||||||
|
두 가지 일을 한다:
|
||||||
|
|
||||||
|
1. **기존 테이블 ALTER** — `user.bio`, `credit_transaction.job_type/job_ref` +
|
||||||
|
`(job_type, job_ref, type)` 유니크. `create_all` 은 기존 테이블의 컬럼 변경을
|
||||||
|
하지 못하므로 information_schema 로 존재를 확인한 뒤 직접 DDL 을 친다.
|
||||||
|
2. **ssul_* 테이블 생성** — 신규 테이블이라 `create_all(checkfirst=True)` 로 안전하다.
|
||||||
|
|
||||||
|
모든 단계가 멱등이다. 두 번 연속 기동해도 두 번째에는 아무 DDL 도 실행되지 않는다.
|
||||||
|
|
||||||
|
app/dashboard/migration.py 의 information_schema 확인 패턴을 그대로 따른다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.database.session import Base, engine
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LOG_PREFIX = "[SSULBOX_MIGRATE]"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# information_schema 조회 헬퍼
|
||||||
|
# =============================================================================
|
||||||
|
async def _table_exists(conn, table: str) -> bool:
|
||||||
|
result = await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.tables "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t"
|
||||||
|
),
|
||||||
|
{"t": table},
|
||||||
|
)
|
||||||
|
return (result.scalar() or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _column_exists(conn, table: str, column: str) -> bool:
|
||||||
|
result = await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = DATABASE() "
|
||||||
|
"AND table_name = :t AND column_name = :c"
|
||||||
|
),
|
||||||
|
{"t": table, "c": column},
|
||||||
|
)
|
||||||
|
return (result.scalar() or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _column_def(conn, table: str, column: str) -> tuple[str, str] | None:
|
||||||
|
"""(column_type, is_nullable) 또는 없으면 None."""
|
||||||
|
result = await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT column_type, is_nullable FROM information_schema.columns "
|
||||||
|
"WHERE table_schema = DATABASE() "
|
||||||
|
"AND table_name = :t AND column_name = :c"
|
||||||
|
),
|
||||||
|
{"t": table, "c": column},
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return (row[0], row[1]) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_exists(conn, table: str, name: str) -> bool:
|
||||||
|
"""CHECK 제약 존재 여부. MySQL 8.0.16+ 의 information_schema 를 본다."""
|
||||||
|
result = await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.table_constraints "
|
||||||
|
"WHERE table_schema = DATABASE() AND table_name = :t "
|
||||||
|
"AND constraint_name = :c AND constraint_type = 'CHECK'"
|
||||||
|
),
|
||||||
|
{"t": table, "c": name},
|
||||||
|
)
|
||||||
|
return (result.scalar() or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
async def _index_exists(conn, table: str, index: str) -> bool:
|
||||||
|
result = await conn.execute(
|
||||||
|
text(
|
||||||
|
"SELECT COUNT(*) FROM information_schema.statistics "
|
||||||
|
"WHERE table_schema = DATABASE() "
|
||||||
|
"AND table_name = :t AND index_name = :i"
|
||||||
|
),
|
||||||
|
{"t": table, "i": index},
|
||||||
|
)
|
||||||
|
return (result.scalar() or 0) > 0
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 1) 기존 테이블 ALTER
|
||||||
|
# =============================================================================
|
||||||
|
async def _ensure_user_bio(conn) -> bool:
|
||||||
|
"""user.bio 컬럼 보장 (썰박스 프로필 한 줄 소개)"""
|
||||||
|
if not await _table_exists(conn, "user"):
|
||||||
|
logger.warning(f"{LOG_PREFIX} user 테이블 없음 - bio 추가 건너뜀")
|
||||||
|
return False
|
||||||
|
if await _column_exists(conn, "user", "bio"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE `user` "
|
||||||
|
"ADD COLUMN `bio` VARCHAR(200) NULL COMMENT '한 줄 소개 (썰박스 프로필)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} user.bio 컬럼 추가")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_credit_job_keys(conn) -> bool:
|
||||||
|
"""credit_transaction 에 크레딧 멱등 키 컬럼 + 유니크 제약 보장.
|
||||||
|
|
||||||
|
(job_type, job_ref, type) 유니크가 "작업 1건당 consume 1행 / refund 1행"을
|
||||||
|
DB 레벨에서 보장한다. MySQL 은 NULL 을 서로 다르게 취급하므로 기존
|
||||||
|
charge/admin_adjust 행(job_type NULL)은 이 제약의 영향을 받지 않는다.
|
||||||
|
|
||||||
|
job_ref 가 문자열인 이유: 썰박스는 ssul_content.id(숫자), castad 영상은
|
||||||
|
video.task_id(UUID7 문자열)를 앵커로 쓰기 때문에 하나로 담으려면 문자열이어야 한다.
|
||||||
|
"""
|
||||||
|
table = "credit_transaction"
|
||||||
|
if not await _table_exists(conn, table):
|
||||||
|
logger.warning(f"{LOG_PREFIX} {table} 테이블 없음 - 멱등 키 추가 건너뜀")
|
||||||
|
return False
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
if not await _column_exists(conn, table, "job_type"):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"ADD COLUMN `job_type` VARCHAR(20) NULL "
|
||||||
|
"COMMENT '차감 유발 작업 종류 (ssul/video)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.job_type 컬럼 추가")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not await _column_exists(conn, table, "job_ref"):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"ADD COLUMN `job_ref` VARCHAR(64) NULL "
|
||||||
|
"COMMENT '작업 식별자 (ssul_content.id 문자열 또는 video.task_id)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.job_ref 컬럼 추가")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not await _index_exists(conn, table, "uq_credit_job"):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"ADD UNIQUE KEY `uq_credit_job` (`job_type`, `job_ref`, `type`)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.uq_credit_job 유니크 제약 추가")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 2) ssul_* 테이블 생성
|
||||||
|
# =============================================================================
|
||||||
|
def _ssul_tables() -> list:
|
||||||
|
"""생성할 ssul_* 테이블 목록 (FK 순서: 참조 대상 먼저)
|
||||||
|
|
||||||
|
ssul_* 는 `user.user_uuid` 와 `social_account.id` 를 참조한다. create_all 은 FK 를
|
||||||
|
해석할 때 **참조 대상 테이블이 Base.metadata 에 등록되어 있어야** 하므로,
|
||||||
|
생성 목록에 넣지 않더라도 app.user.models 를 함께 import 해야 한다.
|
||||||
|
(없으면 NoReferencedTableError 로 실패한다.)
|
||||||
|
"""
|
||||||
|
from app.user.models import SocialAccount, User # noqa: F401 # 메타데이터 등록용
|
||||||
|
from app.ssulbox.models import SsulContent, SsulSocialUpload
|
||||||
|
|
||||||
|
# 좋아요·댓글은 castad `video_reaction` / `comment` 에 합쳤으므로
|
||||||
|
# ssul_like / ssul_comment 는 만들지 않는다(_drop_legacy_... 가 정리한다).
|
||||||
|
# ssul_content 를 나머지가 참조하므로 먼저 만든다.
|
||||||
|
return [
|
||||||
|
SsulContent.__table__,
|
||||||
|
SsulSocialUpload.__table__,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 진입점
|
||||||
|
# =============================================================================
|
||||||
|
async def _ensure_ssul_detail_region(conn) -> bool:
|
||||||
|
"""ssul_content 에 detail_region_info 컬럼 보장.
|
||||||
|
|
||||||
|
`create_all` 은 **없는 테이블만** 만들고 기존 테이블에 컬럼을 붙이지 않으므로,
|
||||||
|
이미 만들어진 환경을 위해 별도 ALTER 가 필요하다.
|
||||||
|
|
||||||
|
castad `project.detail_region_info` 와 같은 TEXT NULL 이다. 통합 목록의 지역
|
||||||
|
필터가 `region` 만 보지 않고 상세 주소를 별칭으로 부분 일치 검색하기 때문에,
|
||||||
|
이 컬럼이 없으면 썰박스 콘텐츠만 필터 결과가 달라진다.
|
||||||
|
"""
|
||||||
|
table = "ssul_content"
|
||||||
|
# 테이블이 아직 없으면 create_all 이 컬럼까지 포함해 만든다 → 여기서 할 일 없음
|
||||||
|
if not await _table_exists(conn, table):
|
||||||
|
return False
|
||||||
|
if await _column_exists(conn, table, "detail_region_info"):
|
||||||
|
return False
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"ADD COLUMN `detail_region_info` TEXT NULL "
|
||||||
|
"COMMENT '상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.detail_region_info 컬럼 추가")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_ssul_store_name(conn) -> bool:
|
||||||
|
"""ssul_content.store_name 을 castad `project.store_name` 과 같은 정의로 맞춘다.
|
||||||
|
|
||||||
|
목표: `VARCHAR(255) NOT NULL DEFAULT ''`
|
||||||
|
(초기 이식본은 `VARCHAR(200) NULL` 이었다)
|
||||||
|
|
||||||
|
통합 목록이 이 컬럼을 UNION 하므로 폭·널 허용이 어긋나면 정렬·비교가 미묘하게
|
||||||
|
달라진다. NOT NULL 로 바꾸면 UNION 결과에 NULL 이 섞이지 않아 응답 매핑에서
|
||||||
|
COALESCE 도 필요 없다.
|
||||||
|
|
||||||
|
**NULL 행을 먼저 빈 문자열로 바꾼다.** 그러지 않으면 MySQL 이 strict 모드에서
|
||||||
|
MODIFY 를 거부하고, 비-strict 모드에서는 경고만 내고 조용히 변환한다 —
|
||||||
|
어느 쪽이든 명시적으로 처리하는 편이 안전하다.
|
||||||
|
"""
|
||||||
|
table = "ssul_content"
|
||||||
|
if not await _table_exists(conn, table):
|
||||||
|
return False # create_all 이 올바른 정의로 만든다
|
||||||
|
|
||||||
|
current = await _column_def(conn, table, "store_name")
|
||||||
|
if current is None:
|
||||||
|
return False
|
||||||
|
if current == ("varchar(255)", "NO"):
|
||||||
|
return False # 이미 목표 정의
|
||||||
|
|
||||||
|
null_count = (
|
||||||
|
await conn.execute(
|
||||||
|
text(f"SELECT COUNT(*) FROM `{table}` WHERE `store_name` IS NULL")
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
if null_count:
|
||||||
|
await conn.execute(
|
||||||
|
text(f"UPDATE `{table}` SET `store_name` = '' WHERE `store_name` IS NULL")
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"{LOG_PREFIX} {table}.store_name NULL {null_count}건 → 빈 문자열"
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"MODIFY COLUMN `store_name` VARCHAR(255) NOT NULL DEFAULT '' "
|
||||||
|
"COMMENT '대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"{LOG_PREFIX} {table}.store_name {current} → ('varchar(255)', 'NO')"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_reaction_tables_merged(conn) -> bool:
|
||||||
|
"""`comment` / `video_reaction` 이 썰박스 콘텐츠도 담도록 확장한다.
|
||||||
|
|
||||||
|
원래는 `ssul_comment` / `ssul_like` 를 따로 뒀으나, `social_upload` 병합 결정과
|
||||||
|
맞춰 하나로 합쳤다(2026-07-30). 네 테이블이 모두 0행이던 시점에 수행했다.
|
||||||
|
|
||||||
|
각 테이블에 대해:
|
||||||
|
- `video_id` NOT NULL → NULL (썰박스 행은 비운다)
|
||||||
|
- `content_id BIGINT NULL` + FK ssul_content 추가
|
||||||
|
- "정확히 하나만 채워짐" CHECK 추가
|
||||||
|
- content_id 조회용 인덱스/유니크 추가
|
||||||
|
|
||||||
|
**CHECK 가 핵심이다.** 이게 없으면 둘 다 NULL 이거나 둘 다 채워진 행이 조용히
|
||||||
|
생긴다. MySQL 8.0.16+ 에서 실제로 강제된다(현재 8.4).
|
||||||
|
"""
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
specs = (
|
||||||
|
# (테이블, CHECK 이름, 추가 인덱스 SQL 목록)
|
||||||
|
(
|
||||||
|
"comment",
|
||||||
|
"ck_comment_one_target",
|
||||||
|
["ADD INDEX `idx_comment_content_id` (`content_id`)"],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"video_reaction",
|
||||||
|
"ck_video_reaction_one_target",
|
||||||
|
[
|
||||||
|
"ADD INDEX `idx_video_reaction_content_id` (`content_id`)",
|
||||||
|
"ADD UNIQUE KEY `uq_video_reaction_user_content` "
|
||||||
|
"(`user_uuid`, `content_id`)",
|
||||||
|
],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
for table, check_name, extra in specs:
|
||||||
|
if not await _table_exists(conn, table):
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not await _column_exists(conn, table, "content_id"):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"ADD COLUMN `content_id` BIGINT NULL "
|
||||||
|
"COMMENT '썰박스 콘텐츠 id (ADO2 대상이면 NULL)', "
|
||||||
|
f"ADD CONSTRAINT `fk_{table}_content` "
|
||||||
|
"FOREIGN KEY (`content_id`) REFERENCES `ssul_content` (`id`) "
|
||||||
|
"ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for sql in extra:
|
||||||
|
await conn.execute(text(f"ALTER TABLE `{table}` {sql}"))
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.content_id 추가 (+FK/인덱스)")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# video_id 를 nullable 로. 기존 행은 전부 ADO2 라 값이 있어 영향이 없다.
|
||||||
|
col = await _column_def(conn, table, "video_id")
|
||||||
|
if col and col[1] == "NO":
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` "
|
||||||
|
"MODIFY COLUMN `video_id` INT NULL "
|
||||||
|
"COMMENT 'ADO2 영상 id (썰박스 대상이면 NULL)'"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.video_id → NULL 허용")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not await _check_exists(conn, table, check_name):
|
||||||
|
await conn.execute(
|
||||||
|
text(
|
||||||
|
f"ALTER TABLE `{table}` ADD CONSTRAINT `{check_name}` "
|
||||||
|
"CHECK ((`video_id` IS NULL) <> (`content_id` IS NULL))"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
logger.info(f"{LOG_PREFIX} {table}.{check_name} CHECK 추가")
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def _drop_legacy_ssul_reaction_tables(conn) -> bool:
|
||||||
|
"""병합으로 쓰임이 없어진 `ssul_like` / `ssul_comment` 제거.
|
||||||
|
|
||||||
|
**비어 있을 때만 지운다.** 행이 남아 있으면 이관이 끝나지 않은 것이므로
|
||||||
|
조용히 데이터를 버리지 않고 경고만 남긴다.
|
||||||
|
"""
|
||||||
|
changed = False
|
||||||
|
for table in ("ssul_like", "ssul_comment"):
|
||||||
|
if not await _table_exists(conn, table):
|
||||||
|
continue
|
||||||
|
n = (await conn.execute(text(f"SELECT COUNT(*) FROM `{table}`"))).scalar() or 0
|
||||||
|
if n:
|
||||||
|
logger.warning(
|
||||||
|
f"{LOG_PREFIX} {table} 에 {n}행이 남아 있어 삭제하지 않는다 "
|
||||||
|
"(comment/video_reaction 으로 이관 후 수동 삭제할 것)"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
await conn.execute(text(f"DROP TABLE `{table}`"))
|
||||||
|
logger.info(f"{LOG_PREFIX} {table} 삭제 (병합 완료, 0행)")
|
||||||
|
changed = True
|
||||||
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_ssulbox_schema() -> None:
|
||||||
|
"""썰박스 스키마 보장. lifespan startup 에서 호출한다.
|
||||||
|
|
||||||
|
멱등이므로 매 기동마다 호출해도 안전하다. 실패는 호출부에서 삼켜야 한다 —
|
||||||
|
썰박스 스키마 문제로 castad 전체가 기동하지 못하면 안 된다.
|
||||||
|
"""
|
||||||
|
logger.info(f"{LOG_PREFIX} 스키마 확인 시작")
|
||||||
|
|
||||||
|
# 기존 테이블 ALTER (DDL 은 MySQL 에서 암묵적 커밋이라 개별 실행)
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
altered_bio = await _ensure_user_bio(conn)
|
||||||
|
altered_credit = await _ensure_credit_job_keys(conn)
|
||||||
|
# 테이블이 아직 없으면 아래 create_all 이 올바른 정의로 만든다.
|
||||||
|
altered_region = await _ensure_ssul_detail_region(conn)
|
||||||
|
altered_store = await _ensure_ssul_store_name(conn)
|
||||||
|
|
||||||
|
# 신규 테이블 생성 — DEBUG 여부와 무관하게 항상 보장한다.
|
||||||
|
# 신규 테이블이므로 create_all 이 기존 데이터를 위협하지 않는다.
|
||||||
|
tables = _ssul_tables()
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(
|
||||||
|
lambda sync_conn: Base.metadata.create_all(
|
||||||
|
sync_conn, tables=tables, checkfirst=True
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 반응 테이블 병합은 **ssul_content 가 존재한 뒤**에 해야 한다 —
|
||||||
|
# comment/video_reaction 이 그쪽으로 FK 를 건다.
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
altered_merge = await _ensure_reaction_tables_merged(conn)
|
||||||
|
dropped_legacy = await _drop_legacy_ssul_reaction_tables(conn)
|
||||||
|
|
||||||
|
if (
|
||||||
|
altered_bio
|
||||||
|
or altered_credit
|
||||||
|
or altered_region
|
||||||
|
or altered_store
|
||||||
|
or altered_merge
|
||||||
|
or dropped_legacy
|
||||||
|
):
|
||||||
|
logger.info(f"{LOG_PREFIX} 스키마 변경 적용 완료")
|
||||||
|
else:
|
||||||
|
logger.info(f"{LOG_PREFIX} 변경 없음 (이미 최신)")
|
||||||
445
app/ssulbox/models.py
Normal file
445
app/ssulbox/models.py
Normal file
@ -0,0 +1,445 @@
|
|||||||
|
"""썰박스 SQLAlchemy 모델.
|
||||||
|
|
||||||
|
castad 와 겹치는 테이블은 **신설하지 않고** castad 것을 그대로 쓴다
|
||||||
|
(user / credit_transaction / social_account, 그리고 2026-07-30 부터 comment /
|
||||||
|
video_reaction). 그 테이블들은 `video_id` 와 `content_id` 를 모두 nullable 로 두고
|
||||||
|
**정확히 하나만** 채우도록 CHECK 로 강제한다.
|
||||||
|
|
||||||
|
썰박스 고유 도메인(`ssul_content`)만 `ssul_` 접두로 남는다.
|
||||||
|
|
||||||
|
컨벤션은 castad 를 따른다: BigInteger PK, user_uuid(String36) 기준 FK,
|
||||||
|
mysql_engine/charset/collate 명시, 컬럼마다 comment.
|
||||||
|
`updated_at` 은 대응 castad 테이블이 가진 경우에만 둔다 — `video`/`comment`/`project`/
|
||||||
|
`lyric`/`song` 은 상태 전이를 겪으면서도 `created_at` 만 갖고, `social_upload` 만 예외다.
|
||||||
|
|
||||||
|
주의: Alembic 이 없다. 이 파일을 고친 뒤에는 app/ssulbox/migration.py 의
|
||||||
|
ensure_ssulbox_schema() 에 대응 DDL 을 함께 추가해야 운영 DB 에 반영된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
JSON,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.database.session import Base
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.user.models import SocialAccount
|
||||||
|
|
||||||
|
# MySQL 전용 테이블 옵션 (castad 공통)
|
||||||
|
_MYSQL_OPTS = {
|
||||||
|
"mysql_engine": "InnoDB",
|
||||||
|
"mysql_charset": "utf8mb4",
|
||||||
|
"mysql_collate": "utf8mb4_unicode_ci",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SsulContent(Base):
|
||||||
|
"""썰박스 1편 — 생성 요청부터 완성까지 한 행으로 관리한다.
|
||||||
|
|
||||||
|
**castad `Video` 와 같은 구조다.** castad 도 "영상 생성 잡"과 "완성된 영상"을
|
||||||
|
나누지 않고 `video` 한 테이블에 status / result_movie_url 을 함께 둔다.
|
||||||
|
원본 썰박스는 Task 와 Content 를 나눴지만, 1:1 이면서 목록의 필터(user_uuid,
|
||||||
|
scenario)와 정렬(created_at)이 서로 다른 테이블에 흩어져 조인 비용이 컸다
|
||||||
|
(측정: 소유 비율에 따라 18~22ms, 병합 시 1ms 수준).
|
||||||
|
|
||||||
|
라이프사이클:
|
||||||
|
1. 요청 → INSERT (status=queued, video_url=NULL) + 크레딧 선차감
|
||||||
|
2. 엔진 실행 → UPDATE status/step
|
||||||
|
3. 완료 → UPDATE video_url/store_name/region, status=done
|
||||||
|
4. 실패 → UPDATE status=error, error, 크레딧 환불
|
||||||
|
|
||||||
|
3번이 INSERT 가 아니라 UPDATE 라 finalize 가 자연히 멱등이다.
|
||||||
|
|
||||||
|
목록 조회는 castad `/video/all` 과 동일하게 완성분만 거른다:
|
||||||
|
WHERE is_deleted=0 AND status='done' AND video_url IS NOT NULL
|
||||||
|
|
||||||
|
id 가 크레딧 원장 멱등 키(job_type='ssul', job_ref=str(id))의 앵커다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "ssul_content"
|
||||||
|
__table_args__ = (
|
||||||
|
# 필터와 정렬이 같은 테이블에 있으므로 복합 인덱스 하나로 filesort 없이 처리된다.
|
||||||
|
# 전체 목록 (완성분만, 최신순)
|
||||||
|
Index("idx_ssul_content_list", "is_deleted", "status", "created_at"),
|
||||||
|
# 내 콘텐츠
|
||||||
|
Index("idx_ssul_content_user_created", "user_uuid", "created_at"),
|
||||||
|
# 시나리오 필터
|
||||||
|
Index("idx_ssul_content_scen_created", "scenario", "created_at"),
|
||||||
|
# 고아 스윕 (기동 시 queued/running 조회)
|
||||||
|
Index("idx_ssul_content_status", "status"),
|
||||||
|
_MYSQL_OPTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
autoincrement=True,
|
||||||
|
comment="고유 식별자 (크레딧 원장 job_ref 앵커)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# castad `project.user_uuid` 와 동일한 정책(SET NULL).
|
||||||
|
# 탈퇴해도 콘텐츠는 남고 소유자만 비워진다.
|
||||||
|
user_uuid: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("user.user_uuid", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
comment="생성 요청한 사용자 UUID (탈퇴 시 NULL)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 생성 요청 정보
|
||||||
|
# ==========================================================================
|
||||||
|
scenario: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
comment="시나리오 코드 (joseon/samgukji/greek/odyssey)",
|
||||||
|
)
|
||||||
|
|
||||||
|
input: Mapped[str] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=False,
|
||||||
|
comment="입력값 (네이버 지도 URL 또는 업장명)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위
|
||||||
|
# (4~20 / 20~90)는 Pydantic 요청 스키마에서 Field(default, ge, le)로 강제한다.
|
||||||
|
scenes: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="생성할 장면 수 (요청 스키마에서 4~20 제한, 기본 9)",
|
||||||
|
)
|
||||||
|
|
||||||
|
seconds: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
comment="장면당 초 길이 (요청 스키마에서 20~90 제한, 기본 30)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 생성 잡 상태
|
||||||
|
# ==========================================================================
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="queued",
|
||||||
|
server_default="queued",
|
||||||
|
comment="상태 (queued/running/done/error). 목록에는 done 만 노출",
|
||||||
|
)
|
||||||
|
|
||||||
|
step: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
server_default="0",
|
||||||
|
comment="진행 단계 0~4 (폴링 응답용. 0=준비, 4=영상 합성 완료)",
|
||||||
|
)
|
||||||
|
|
||||||
|
error: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="실패 사유",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 산출물 (완료 시 채워짐)
|
||||||
|
# ==========================================================================
|
||||||
|
video_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="완성 영상 URL (Azure Blob 공개 URL 또는 로컬 서빙 경로)",
|
||||||
|
)
|
||||||
|
|
||||||
|
thumbnail_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="썸네일 URL (없으면 프론트가 시나리오 표지로 대체)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 목록 표시 (크롤링 후 채워짐) — castad video 는 Project 에서 가져오는 값들
|
||||||
|
# ==========================================================================
|
||||||
|
# castad `project.store_name` 과 동일하게 varchar(255) NOT NULL.
|
||||||
|
# 통합 목록이 이 컬럼을 UNION 하므로 타입·널 허용이 어긋나면 정렬·비교에서
|
||||||
|
# 미묘한 차이가 생긴다.
|
||||||
|
#
|
||||||
|
# 단 하나 다른 점: **server_default 가 빈 문자열**이다. castad `project` 는
|
||||||
|
# 크롤링이 끝난 뒤 생성되어 업장명을 이미 알지만, 썰박스는 요청 즉시 행을 만들고
|
||||||
|
# (크레딧 선차감 때문) 업장명은 그 뒤 크롤링으로 채운다. 기본값이 없으면
|
||||||
|
# 생성 자체가 불가능하다. 빈 문자열은 "아직 모름"을 뜻하며, 채우는 쪽은
|
||||||
|
# falsy 검사로 판단한다(`set_place_info`).
|
||||||
|
store_name: Mapped[str] = mapped_column(
|
||||||
|
String(255),
|
||||||
|
nullable=False,
|
||||||
|
default="",
|
||||||
|
server_default="",
|
||||||
|
comment="대상 업장명 (통합 목록에서 castad video.store_name 자리에 대응)",
|
||||||
|
)
|
||||||
|
|
||||||
|
region: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="지역 (통합 목록의 지역 필터에 사용)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# castad `project.detail_region_info` 와 동일한 역할·타입(TEXT NULL).
|
||||||
|
# 지역 필터가 `region` 만 보지 않고 **상세 주소의 별칭까지 부분 일치**로 훑기
|
||||||
|
# 때문에(`/video/all` 의 SIDO_SEARCH_ALIASES), 이 값이 없으면 썰박스는
|
||||||
|
# `region IN (cities)` 경로로만 걸려 castad 와 필터 결과가 비대칭이 된다.
|
||||||
|
detail_region_info: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="상세 지역 정보 (도로명 우선, 없으면 지번). 지역 필터 별칭 매칭용",
|
||||||
|
)
|
||||||
|
|
||||||
|
# title / caption / views / like_count / comment_count 는 두지 않는다.
|
||||||
|
# - castad `video` 도 제목을 갖지 않고 목록 표시는 store_name 으로 한다.
|
||||||
|
# SNS 업로드 제목·설명은 업로드 시점에 작성해 ssul_social_upload 에 담고,
|
||||||
|
# 다운로드 파일명은 프론트가 정한다.
|
||||||
|
# - 좋아요/댓글 수는 castad `video_reaction` / `comment` 상관 서브쿼리로 집계한다
|
||||||
|
# (2026-07-30 병합. 썰박스 행은 content_id 가 채워진다).
|
||||||
|
# 카운터를 들면 쓰기 경로마다 갱신해야 하고 드리프트가 생긴다.
|
||||||
|
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(
|
||||||
|
Boolean,
|
||||||
|
nullable=False,
|
||||||
|
default=False,
|
||||||
|
server_default="0",
|
||||||
|
comment="소프트 삭제 여부",
|
||||||
|
)
|
||||||
|
|
||||||
|
# updated_at 은 **보류**다. 일반론으로는 이런 가변 테이블(queued→running→step→done)에
|
||||||
|
# 두는 것이 맞고, subprocess 가 멈출 수 있어 "오래 안 움직인 잡 찾기"에도 유용하다.
|
||||||
|
# 다만 castad `video`/`comment` 에 없어 썰박스만 갖는 게 비대칭이라 미뤘다.
|
||||||
|
# → ADO2 쪽에 추가할 때 여기도 함께 넣는다(nullable DDL 이라 무중단 가능).
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
comment="생성 요청 일시 (목록 정렬 기준)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<SsulContent(id={self.id}, scenario='{self.scenario}', "
|
||||||
|
f"status='{self.status}', store_name='{self.store_name}')>"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# 좋아요·댓글 모델은 여기 없다.
|
||||||
|
# castad `video_reaction` / `comment` 에 합쳤다(2026-07-30) — 그쪽 행은
|
||||||
|
# ADO2 면 video_id, 썰박스면 content_id 가 채워지고 CHECK 로 하나만 강제한다.
|
||||||
|
# 합친 이유: 네 테이블이 모두 0행이라 이관 비용이 없었고, `like_cache` 가 이미
|
||||||
|
# 종류별 키를 지원해 Redis write-behind 를 그대로 공유할 수 있었다.
|
||||||
|
|
||||||
|
|
||||||
|
class SsulSocialUpload(Base):
|
||||||
|
"""썰박스 콘텐츠의 SNS 업로드 기록.
|
||||||
|
|
||||||
|
castad `social_upload` 를 재사용하지 못하는 이유: 그쪽 `video_id` 가 NOT NULL 이고
|
||||||
|
`Video` 를 lazy="selectin" 으로 물고 있어, nullable 로 바꾸면
|
||||||
|
app/social/services/upload_service.py · app/dashboard/migration.py · 백오피스가
|
||||||
|
모두 영향을 받는다. 대신 구조를 그대로 본떠 신설한다 —
|
||||||
|
**컬럼 구성은 castad social_upload 와 완전히 동일하다(21개).**
|
||||||
|
차이는 두 가지뿐이다: content_id 가 bigint(ssul_content.id 를 따름), 그리고
|
||||||
|
DB 기본값(server_default)을 명시해 ORM 을 우회한 INSERT 도 안전하게 했다.
|
||||||
|
|
||||||
|
예약 업로드(`scheduled_at`)는 castad 에서 이미 동작한다
|
||||||
|
(app/social/services/upload_service.py 가 예약/즉시를 분기하고 충돌 검사도 한다).
|
||||||
|
이식 시 같은 서비스 로직을 재사용할 수 있다.
|
||||||
|
|
||||||
|
알려진 대가: app/dashboard/migration.py 가 SocialUpload 만 읽으므로 썰박스 업로드는
|
||||||
|
대시보드 통계에 잡히지 않는다. 통합 시점은 별도 결정 사항이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "ssul_social_upload"
|
||||||
|
__table_args__ = (
|
||||||
|
# (content_id, social_account_id, upload_seq) 가 앞 2개 컬럼 조회도 커버하므로
|
||||||
|
# (content_id, social_account_id) 를 따로 두지 않는다.
|
||||||
|
# 참고: castad social_upload 에는 이 중복이 남아 있다(video_account, video_id).
|
||||||
|
Index("idx_ssul_upload_seq", "content_id", "social_account_id", "upload_seq"),
|
||||||
|
Index("idx_ssul_upload_user", "user_uuid"),
|
||||||
|
Index("idx_ssul_upload_status", "status"),
|
||||||
|
Index("idx_ssul_upload_platform", "platform"),
|
||||||
|
Index("idx_ssul_upload_created_at", "created_at"),
|
||||||
|
_MYSQL_OPTS,
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
primary_key=True,
|
||||||
|
nullable=False,
|
||||||
|
autoincrement=True,
|
||||||
|
comment="고유 식별자",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_uuid: Mapped[str] = mapped_column(
|
||||||
|
String(36),
|
||||||
|
ForeignKey("user.user_uuid", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="업로드한 사용자 UUID",
|
||||||
|
)
|
||||||
|
|
||||||
|
content_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("ssul_content.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="업로드 대상 콘텐츠 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
social_account_id: Mapped[int] = mapped_column(
|
||||||
|
# social_account.id 는 BIGINT 가 아니라 INT 다(castad social_upload 도 동일).
|
||||||
|
# BigInteger 로 두면 MySQL 이 FK 타입 불일치(errno 3780)로 생성을 거부한다.
|
||||||
|
Integer,
|
||||||
|
ForeignKey("social_account.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
comment="연동 SNS 계정 ID (castad social_account 재사용)",
|
||||||
|
)
|
||||||
|
|
||||||
|
upload_seq: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=1,
|
||||||
|
server_default="1",
|
||||||
|
comment="(content, account) 조합 내 업로드 순번 — 재업로드 버전 관리",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
comment="플랫폼 (youtube/instagram/facebook/tiktok)",
|
||||||
|
)
|
||||||
|
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="pending",
|
||||||
|
server_default="pending",
|
||||||
|
comment="상태 (scheduled/pending/uploading/processing/completed/failed)",
|
||||||
|
)
|
||||||
|
|
||||||
|
upload_progress: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
server_default="0",
|
||||||
|
comment="업로드 진행률 (0~100)",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_video_id: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(100),
|
||||||
|
nullable=True,
|
||||||
|
comment="플랫폼 측 영상 ID",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(500),
|
||||||
|
nullable=True,
|
||||||
|
comment="플랫폼 측 영상 URL",
|
||||||
|
)
|
||||||
|
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(200),
|
||||||
|
nullable=False,
|
||||||
|
default="",
|
||||||
|
server_default="",
|
||||||
|
comment="업로드 제목",
|
||||||
|
)
|
||||||
|
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="업로드 설명",
|
||||||
|
)
|
||||||
|
|
||||||
|
tags: Mapped[Optional[list]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
comment="태그 목록",
|
||||||
|
)
|
||||||
|
|
||||||
|
privacy_status: Mapped[str] = mapped_column(
|
||||||
|
String(20),
|
||||||
|
nullable=False,
|
||||||
|
default="public",
|
||||||
|
server_default="public",
|
||||||
|
comment="공개 범위 (public/unlisted/private)",
|
||||||
|
)
|
||||||
|
|
||||||
|
scheduled_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=True,
|
||||||
|
comment="예약 업로드 시각 (NULL 이면 즉시 업로드)",
|
||||||
|
)
|
||||||
|
|
||||||
|
platform_options: Mapped[Optional[dict]] = mapped_column(
|
||||||
|
JSON,
|
||||||
|
nullable=True,
|
||||||
|
comment="플랫폼별 추가 옵션",
|
||||||
|
)
|
||||||
|
|
||||||
|
error_message: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text,
|
||||||
|
nullable=True,
|
||||||
|
comment="실패 사유",
|
||||||
|
)
|
||||||
|
|
||||||
|
retry_count: Mapped[int] = mapped_column(
|
||||||
|
Integer,
|
||||||
|
nullable=False,
|
||||||
|
default=0,
|
||||||
|
server_default="0",
|
||||||
|
comment="재시도 횟수",
|
||||||
|
)
|
||||||
|
|
||||||
|
uploaded_at: Mapped[Optional[datetime]] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=True,
|
||||||
|
comment="업로드 완료 일시",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
comment="생성 일시",
|
||||||
|
)
|
||||||
|
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime,
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
comment="수정 일시",
|
||||||
|
)
|
||||||
|
|
||||||
|
content: Mapped["SsulContent"] = relationship(
|
||||||
|
"SsulContent",
|
||||||
|
foreign_keys=[content_id],
|
||||||
|
lazy="noload",
|
||||||
|
)
|
||||||
|
|
||||||
|
social_account: Mapped["SocialAccount"] = relationship(
|
||||||
|
"SocialAccount",
|
||||||
|
foreign_keys=[social_account_id],
|
||||||
|
lazy="noload",
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return (
|
||||||
|
f"<SsulSocialUpload(id={self.id}, content_id={self.content_id}, "
|
||||||
|
f"platform='{self.platform}', status='{self.status}')>"
|
||||||
|
)
|
||||||
0
app/ssulbox/schemas/__init__.py
Normal file
0
app/ssulbox/schemas/__init__.py
Normal file
117
app/ssulbox/schemas/ssulbox_schema.py
Normal file
117
app/ssulbox/schemas/ssulbox_schema.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""썰박스 API 요청/응답 스키마 (Pydantic v2)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.ssulbox.constants import SCENARIOS
|
||||||
|
|
||||||
|
ScenarioLiteral = Literal["joseon", "samgukji", "greek", "odyssey"]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 장소 검색
|
||||||
|
# =============================================================================
|
||||||
|
class SsulPlaceItem(BaseModel):
|
||||||
|
"""네이버 지도 검색 후보.
|
||||||
|
|
||||||
|
castad `/search/accommodation` 응답과 달리 **place_url 을 포함**한다.
|
||||||
|
generator 가 이 URL 로 place 페이지를 크롤링하기 때문이다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
title: str = Field(..., description="업장명")
|
||||||
|
category: str = Field(default="", description="업종")
|
||||||
|
address: str = Field(default="", description="표시용 주소 (동명 업장 구분에 사용)")
|
||||||
|
roadAddress: str = Field(default="", description="도로명 주소")
|
||||||
|
place_url: str = Field(..., description="네이버 지도 place URL (생성 파이프라인 입력)")
|
||||||
|
|
||||||
|
|
||||||
|
class SsulPlaceSearchResponse(BaseModel):
|
||||||
|
query: str
|
||||||
|
count: int
|
||||||
|
items: list[SsulPlaceItem]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 생성 요청
|
||||||
|
# =============================================================================
|
||||||
|
class SsulCreateRequest(BaseModel):
|
||||||
|
"""생성 요청.
|
||||||
|
|
||||||
|
`scenes`/`seconds` 의 기본값과 허용 범위는 **여기서만** 강제한다.
|
||||||
|
DB 에 기본값을 두면 진실이 두 곳에 생기므로 모델에는 두지 않았다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
scenario: ScenarioLiteral = Field(..., description="시나리오 코드")
|
||||||
|
input: str = Field(
|
||||||
|
...,
|
||||||
|
min_length=2,
|
||||||
|
max_length=500,
|
||||||
|
description="네이버 지도 place URL 또는 업장명",
|
||||||
|
)
|
||||||
|
scenes: int = Field(default=9, ge=4, le=20, description="장면 수")
|
||||||
|
seconds: int = Field(default=30, ge=20, le=90, description="장면당 초 길이")
|
||||||
|
|
||||||
|
# 검색으로 업장을 고른 경우 프론트가 함께 보낸다(`/ssul/search/place` 결과).
|
||||||
|
# 통합 목록의 업장명 표시와 store_name/region 필터가 이 값에 의존한다.
|
||||||
|
# place URL 을 직접 붙여넣은 경우에는 없으며, 그때 store_name 은 생성 로그의
|
||||||
|
# `■ 가게:` 마커로 뒤늦게 채운다(region 은 주소가 없어 채울 수 없다).
|
||||||
|
store_name: str | None = Field(
|
||||||
|
default=None, max_length=200, description="업장명 (검색 선택 시)"
|
||||||
|
)
|
||||||
|
# 도로명·지번을 모두 받는다. castad `/home/crawl` 과 같이 도로명에서 시/군 추출이
|
||||||
|
# 실패하면 지번으로 재시도해야 지역이 비는 경우를 줄인다.
|
||||||
|
road_address: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=300,
|
||||||
|
description="도로명 주소 (검색 선택 시). region 추출에만 쓰고 저장하지 않는다",
|
||||||
|
)
|
||||||
|
address: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
max_length=300,
|
||||||
|
description="지번 주소 (검색 선택 시). 도로명 추출 실패 시 폴백",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SsulCreateResponse(BaseModel):
|
||||||
|
id: int = Field(..., description="생성 잡 ID (폴링·크레딧 원장 앵커)")
|
||||||
|
status: str = Field(..., description="queued")
|
||||||
|
poll_interval_seconds: int = Field(
|
||||||
|
..., description="권장 폴링 간격(초). 클라이언트가 참고한다"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 진행 상태 (폴링)
|
||||||
|
# =============================================================================
|
||||||
|
class SsulTaskStatus(BaseModel):
|
||||||
|
"""`GET /ssul/tasks/{id}` 응답. 프론트가 3초마다 폴링한다."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
scenario: str
|
||||||
|
status: Literal["queued", "running", "done", "error"]
|
||||||
|
step: int = Field(..., ge=0, le=4, description="완료한 단계 수 (0=준비, 4=합성 완료)")
|
||||||
|
error: Optional[str] = None
|
||||||
|
video_url: Optional[str] = Field(None, description="완료 시에만 채워진다")
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class SsulActiveTasksResponse(BaseModel):
|
||||||
|
"""진행 중인 내 잡. 새로고침·새 탭 복구에 쓴다"""
|
||||||
|
|
||||||
|
items: list[SsulTaskStatus]
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SCENARIOS",
|
||||||
|
"ScenarioLiteral",
|
||||||
|
"SsulActiveTasksResponse",
|
||||||
|
"SsulCreateRequest",
|
||||||
|
"SsulCreateResponse",
|
||||||
|
"SsulPlaceItem",
|
||||||
|
"SsulPlaceSearchResponse",
|
||||||
|
"SsulTaskStatus",
|
||||||
|
]
|
||||||
0
app/ssulbox/services/__init__.py
Normal file
0
app/ssulbox/services/__init__.py
Normal file
66
app/ssulbox/services/blob_service.py
Normal file
66
app/ssulbox/services/blob_service.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""썰박스 산출물 Blob 업로드.
|
||||||
|
|
||||||
|
castad `AzureBlobUploader` 를 그대로 재사용한다 — 원본 썰박스의 `blob_client.py`
|
||||||
|
는 이식하지 않는다. 같은 Azure 계정을 쓰므로 업로더를 두 벌 둘 이유가 없다.
|
||||||
|
|
||||||
|
경로는 `{user_uuid}/{task_id}/video/{file}` 형태가 되는데, ADO2 콘텐츠와 섞이지 않도록
|
||||||
|
task_id 자리에 `ssulbox-{id}` 접두를 붙인다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from app.utils.upload_blob_as_request import AzureBlobUploader
|
||||||
|
from config import azure_blob_settings, ssulbox_settings
|
||||||
|
|
||||||
|
logger = get_logger("ssulbox")
|
||||||
|
|
||||||
|
#: 설정되지 않았을 때의 플레이스홀더 (config.py 기본값)
|
||||||
|
_PLACEHOLDER_SAS = {"", "your-sas-token", "none"}
|
||||||
|
|
||||||
|
|
||||||
|
def blob_enabled() -> bool:
|
||||||
|
"""Blob 업로드가 가능한 상태인지.
|
||||||
|
|
||||||
|
비활성이면 로컬 파일을 그대로 서빙한다(개발 환경).
|
||||||
|
"""
|
||||||
|
token = (azure_blob_settings.AZURE_BLOB_SAS_TOKEN or "").strip()
|
||||||
|
return token.lower() not in _PLACEHOLDER_SAS
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_ssul_video(
|
||||||
|
mp4_path: Path, user_uuid: Optional[str], content_id: int
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""완성 영상을 Blob 에 올리고 공개 URL 을 반환. 실패·비활성이면 None.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
mp4_path: 로컬 mp4 경로
|
||||||
|
user_uuid: 소유자. 탈퇴로 NULL 이면 업로드하지 않는다
|
||||||
|
content_id: `ssul_content.id`
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SAS 토큰이 제외된 공개 URL, 또는 None
|
||||||
|
"""
|
||||||
|
if not blob_enabled():
|
||||||
|
logger.info("[upload_ssul_video] Blob 비활성 — 로컬 서빙")
|
||||||
|
return None
|
||||||
|
if not user_uuid:
|
||||||
|
logger.warning(f"[upload_ssul_video] user_uuid 없음 id={content_id}")
|
||||||
|
return None
|
||||||
|
if not mp4_path.exists():
|
||||||
|
logger.error(f"[upload_ssul_video] 파일 없음 {mp4_path}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ADO2 영상과 경로를 분리한다
|
||||||
|
task_id = f"{ssulbox_settings.SSULBOX_BLOB_PREFIX}-{content_id}"
|
||||||
|
uploader = AzureBlobUploader(user_uuid=user_uuid, task_id=task_id)
|
||||||
|
|
||||||
|
success = await uploader.upload_video(file_path=str(mp4_path))
|
||||||
|
if not success:
|
||||||
|
logger.error(f"[upload_ssul_video] 업로드 실패 id={content_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.info(f"[upload_ssul_video] OK id={content_id} url={uploader.public_url}")
|
||||||
|
return uploader.public_url
|
||||||
327
app/ssulbox/services/place_service.py
Normal file
327
app/ssulbox/services/place_service.py
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""네이버 지도 업장 검색 (키 불필요, Playwright).
|
||||||
|
|
||||||
|
`map.naver.com/p/search/{query}` 의 검색 리스트 iframe(`pcmap.place.naver.com/place/list`)
|
||||||
|
안에 있는 `__APOLLO_STATE__` 를 파싱해, 동명 업장들을 주소로 구분한 후보 목록을 만든다.
|
||||||
|
각 후보는 `place_url`(map.naver.com/p/entry/place/{id})을 가지므로, 사용자가 하나를 고르면
|
||||||
|
그 URL 을 그대로 생성 파이프라인(generator/naver.py)에 넘겨 정확히 그 가게를 크롤링한다.
|
||||||
|
|
||||||
|
**castad `/search/accommodation` 으로 대체할 수 없다.** 그쪽은 네이버 *검색 API* 라
|
||||||
|
`title`/`address`/`roadAddress` 만 주고 `place_url` 이 없는데, generator 가 place 페이지를
|
||||||
|
크롤링하므로 URL 이 반드시 필요하다. (castad `NvMapPwScraper` 는 후보=NAVER API,
|
||||||
|
place_id 해석=Playwright 로 2단계를 밟지만 여기서는 지도만으로 한 번에 얻는다.)
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
# URL 정규화(단축링크 해석·place_id 추출)는 castad 크롤러 것을 그대로 쓴다.
|
||||||
|
# 무거운 scrap() 은 쓰지 않는다 — fetch_place_detail docstring 참조.
|
||||||
|
from app.utils.nvMapScraper import NvMapScraper, URLNotFoundException
|
||||||
|
|
||||||
|
logger = get_logger("ssulbox")
|
||||||
|
|
||||||
|
DESKTOP_UA = (
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
|
"(KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_apollo_state(html: str) -> dict | None:
|
||||||
|
"""HTML 안의 `__APOLLO_STATE__ = { ... };` 객체를 중괄호 균형으로 안전하게 추출."""
|
||||||
|
i = html.find("__APOLLO_STATE__")
|
||||||
|
if i < 0:
|
||||||
|
return None
|
||||||
|
j = html.find("{", i)
|
||||||
|
if j < 0:
|
||||||
|
return None
|
||||||
|
depth = 0
|
||||||
|
in_str = False
|
||||||
|
esc = False
|
||||||
|
for k in range(j, len(html)):
|
||||||
|
c = html[k]
|
||||||
|
if in_str:
|
||||||
|
if esc:
|
||||||
|
esc = False
|
||||||
|
elif c == "\\":
|
||||||
|
esc = True
|
||||||
|
elif c == '"':
|
||||||
|
in_str = False
|
||||||
|
else:
|
||||||
|
if c == '"':
|
||||||
|
in_str = True
|
||||||
|
elif c == "{":
|
||||||
|
depth += 1
|
||||||
|
elif c == "}":
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
try:
|
||||||
|
return json.loads(html[j : k + 1])
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates_from_state(state: dict, limit: int) -> list[dict]:
|
||||||
|
out: list[dict] = []
|
||||||
|
for key, obj in state.items():
|
||||||
|
if not key.startswith("PlaceListBusinessesItem:"):
|
||||||
|
continue
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
continue
|
||||||
|
name = obj.get("name")
|
||||||
|
pid = obj.get("id")
|
||||||
|
if not (name and pid and str(pid).isdigit()):
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"title": name,
|
||||||
|
"category": obj.get("category") or "",
|
||||||
|
# 표시용 주소는 '서울 성동구 금호동3가' 같은 commonAddress 를 우선(동명 구분에 최적)
|
||||||
|
"address": obj.get("commonAddress") or obj.get("fullAddress") or "",
|
||||||
|
"roadAddress": obj.get("fullAddress") or obj.get("roadAddress") or "",
|
||||||
|
"place_url": f"https://map.naver.com/p/entry/place/{pid}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if len(out) >= limit:
|
||||||
|
break
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _search(query: str, limit: int) -> list[dict]:
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ctx = await browser.new_context(
|
||||||
|
user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul",
|
||||||
|
viewport={"width": 1280, "height": 800},
|
||||||
|
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"},
|
||||||
|
)
|
||||||
|
page = await ctx.new_page()
|
||||||
|
await page.goto(
|
||||||
|
f"https://map.naver.com/p/search/{quote(query)}",
|
||||||
|
wait_until="domcontentloaded", timeout=40000,
|
||||||
|
)
|
||||||
|
# 검색 리스트 iframe 이 뜰 때까지 대기(최대 ~12초)
|
||||||
|
frame = None
|
||||||
|
for _ in range(24):
|
||||||
|
for f in page.frames:
|
||||||
|
if "pcmap.place.naver.com/place/list" in f.url:
|
||||||
|
frame = f
|
||||||
|
break
|
||||||
|
if frame:
|
||||||
|
break
|
||||||
|
# 단일 결과면 곧바로 place 상세로 리다이렉트됨 → 후보 1개로 처리.
|
||||||
|
# 이때도 **주소를 반드시 채운다**. 예전에는 빈 문자열을 돌려줬는데,
|
||||||
|
# 그러면 프론트가 주소를 못 보내고 지역(region)이 NULL 이 되어
|
||||||
|
# 그 콘텐츠가 통합 목록의 지역 필터에서 영구 제외된다.
|
||||||
|
if "/place/" in page.url and "/search/" not in page.url:
|
||||||
|
detail = await _extract_detail_from_page(page)
|
||||||
|
if detail:
|
||||||
|
detail["place_url"] = page.url
|
||||||
|
return [detail]
|
||||||
|
# 상세 파싱까지 실패하면 최소 정보라도 준다(생성은 가능해야 한다)
|
||||||
|
return [{"title": query, "category": "", "address": "",
|
||||||
|
"roadAddress": "", "place_url": page.url}]
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
if not frame:
|
||||||
|
return []
|
||||||
|
# apollo state 가 채워질 시간을 조금 더 준다
|
||||||
|
html = await frame.content()
|
||||||
|
state = _extract_apollo_state(html)
|
||||||
|
for _ in range(6):
|
||||||
|
if state and any(k.startswith("PlaceListBusinessesItem:") for k in state):
|
||||||
|
break
|
||||||
|
await page.wait_for_timeout(600)
|
||||||
|
html = await frame.content()
|
||||||
|
state = _extract_apollo_state(html)
|
||||||
|
if not state:
|
||||||
|
return []
|
||||||
|
return _candidates_from_state(state, limit)
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_from_state(state: dict) -> dict | None:
|
||||||
|
"""place 상세 페이지 apollo state → {title, category, address, roadAddress}.
|
||||||
|
|
||||||
|
실측(2026-07-29, `zzz/_probe_place_detail.py`): 상세는 `pcmap.place.naver.com`
|
||||||
|
iframe 안에 `PlaceDetailBase:{place_id}` 키로 들어 있고 name/address/roadAddress/
|
||||||
|
category 를 모두 갖는다. iframe 경로에 업종 세그먼트가 끼므로
|
||||||
|
(`/restaurant/{id}/home`) 경로를 고정하면 안 된다.
|
||||||
|
"""
|
||||||
|
for key, obj in state.items():
|
||||||
|
if not key.startswith("PlaceDetailBase:") or not isinstance(obj, dict):
|
||||||
|
continue
|
||||||
|
name = (obj.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"title": name,
|
||||||
|
"category": (obj.get("category") or "").strip(),
|
||||||
|
"address": (obj.get("address") or "").strip(),
|
||||||
|
"roadAddress": (obj.get("roadAddress") or "").strip(),
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _extract_detail_from_page(page, tries: int = 12) -> dict | None:
|
||||||
|
"""열려 있는 place 상세 페이지에서 업장 정보를 뽑는다(iframe 탐색 + 재시도)."""
|
||||||
|
for _ in range(tries):
|
||||||
|
for frame in [page, *[f for f in page.frames if "pcmap" in f.url]]:
|
||||||
|
try:
|
||||||
|
html = await frame.content()
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
state = _extract_apollo_state(html)
|
||||||
|
if not state:
|
||||||
|
continue
|
||||||
|
detail = _detail_from_state(state)
|
||||||
|
if detail:
|
||||||
|
return detail
|
||||||
|
await page.wait_for_timeout(700)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _detail(place_url: str) -> dict | None:
|
||||||
|
"""place URL 하나를 열어 업장명·주소를 수집."""
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(
|
||||||
|
headless=True,
|
||||||
|
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"],
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ctx = await browser.new_context(
|
||||||
|
user_agent=DESKTOP_UA, locale="ko-KR", timezone_id="Asia/Seoul",
|
||||||
|
viewport={"width": 1280, "height": 800},
|
||||||
|
extra_http_headers={"Accept-Language": "ko-KR,ko;q=0.9"},
|
||||||
|
)
|
||||||
|
page = await ctx.new_page()
|
||||||
|
await page.goto(place_url, wait_until="domcontentloaded", timeout=40000)
|
||||||
|
detail = await _extract_detail_from_page(page)
|
||||||
|
if detail:
|
||||||
|
detail["place_url"] = page.url
|
||||||
|
return detail
|
||||||
|
finally:
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_blocking(place_url: str) -> dict | None:
|
||||||
|
"""`_search_blocking` 과 같은 이유로 스레드에서 자체 루프를 쓴다."""
|
||||||
|
loop = (
|
||||||
|
asyncio.ProactorEventLoop() if sys.platform == "win32"
|
||||||
|
else asyncio.new_event_loop()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(_detail(place_url))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_place_detail(
|
||||||
|
place_url: str, timeout: float = 45.0
|
||||||
|
) -> dict | None:
|
||||||
|
"""place URL 로 업장명·주소를 수집. 실패하면 None.
|
||||||
|
|
||||||
|
**업장명과 주소는 항상 함께 수집한다** — 주소가 없으면 지역(region)을 못 만들고,
|
||||||
|
그러면 통합 콘텐츠 목록의 지역 필터에서 그 콘텐츠가 영구히 제외된다.
|
||||||
|
|
||||||
|
URL 정규화는 castad `NvMapScraper.parse_url()` 을 **재사용**한다.
|
||||||
|
`naver.me` 단축링크를 브라우저 없이 HTTP 리다이렉트로 풀고
|
||||||
|
`place.naver.com/{업종}/{id}` 형식도 처리하므로, ADO2 크롤링과 **같은 URL 형식**을
|
||||||
|
받아들이게 된다. 형식이 아예 아니면 브라우저를 띄우기 전에 즉시 포기한다.
|
||||||
|
|
||||||
|
반면 `NvMapScraper.scrap()` 은 쓰지 않는다 — 사진 다중 페이지·리뷰 통계·
|
||||||
|
편의시설·메뉴까지 전부 긁어오므로 이름·주소만 필요한 여기에는 과하다.
|
||||||
|
|
||||||
|
실패해도 예외를 올리지 않는다: 이 정보는 목록 표시·필터용 부가 정보이고,
|
||||||
|
생성 자체는 place_url 만으로 진행되므로 크롤링 실패가 생성을 막아선 안 된다.
|
||||||
|
"""
|
||||||
|
place_url = (place_url or "").strip()
|
||||||
|
if not place_url:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 단축링크 해석 + place_id 추출 (브라우저 없이). 실패해도 원본 URL 로 계속 간다.
|
||||||
|
try:
|
||||||
|
place_id = await NvMapScraper(place_url).parse_url()
|
||||||
|
place_url = f"https://map.naver.com/p/entry/place/{place_id}"
|
||||||
|
except URLNotFoundException:
|
||||||
|
logger.warning(f"[fetch_place_detail] place URL 아님 - {place_url}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.info(
|
||||||
|
f"[fetch_place_detail] URL 정규화 실패, 원본으로 진행 - "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
detail = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(_detail_blocking, place_url), timeout=timeout
|
||||||
|
)
|
||||||
|
if detail:
|
||||||
|
logger.info(
|
||||||
|
f"[fetch_place_detail] {place_url} → "
|
||||||
|
f"title={detail['title']!r} road={detail['roadAddress']!r}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.warning(f"[fetch_place_detail] 정보 없음 - {place_url}")
|
||||||
|
return detail
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"[fetch_place_detail] TIMEOUT ({timeout}s) - {place_url}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[fetch_place_detail] FAILED {place_url} - {type(e).__name__}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _search_blocking(query: str, limit: int) -> list[dict]:
|
||||||
|
"""별도 스레드에서 자체 이벤트 루프로 Playwright 실행.
|
||||||
|
|
||||||
|
Windows 의 웹서버 루프(Selector)는 서브프로세스를 못 띄워 Playwright 가 즉시 실패한다.
|
||||||
|
→ 이 함수는 워커 스레드에서 Proactor 루프(윈도우) 를 새로 만들어 그 위에서 돌린다.
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
loop = asyncio.ProactorEventLoop()
|
||||||
|
else:
|
||||||
|
loop = asyncio.new_event_loop()
|
||||||
|
try:
|
||||||
|
return loop.run_until_complete(_search(query, limit))
|
||||||
|
finally:
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def search_places(query: str, limit: int = 8, timeout: float = 35.0) -> list[dict]:
|
||||||
|
"""업장명으로 네이버 지도 후보 목록을 반환. 실패/타임아웃이면 빈 리스트.
|
||||||
|
|
||||||
|
실패해도 예외를 올리지 않는다 — 사용자에게는 네이버 링크를 직접 붙여넣는
|
||||||
|
우회 경로가 있으므로 흐름을 막지 않는다. 다만 원본은 예외를 통째로 삼켜
|
||||||
|
디버깅이 불가능했으므로 로그는 남긴다.
|
||||||
|
"""
|
||||||
|
query = (query or "").strip()
|
||||||
|
if len(query) < 2:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
results = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(_search_blocking, query, limit), timeout=timeout
|
||||||
|
)
|
||||||
|
logger.info(f"[search_places] query='{query}' → {len(results)}건")
|
||||||
|
return results
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"[search_places] TIMEOUT ({timeout}s) query='{query}'")
|
||||||
|
return []
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[search_places] FAILED query='{query}' - {type(e).__name__}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return []
|
||||||
294
app/ssulbox/services/task_service.py
Normal file
294
app/ssulbox/services/task_service.py
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""썰박스 생성 잡 라이프사이클 — 생성(선차감) · 진행 · 완료 · 실패(환불) · 고아 스윕.
|
||||||
|
|
||||||
|
**모든 함수는 자체 commit 하지 않는다.** caller 가 트랜잭션을 소유하고 마지막에 한 번
|
||||||
|
커밋한다(둘 다 성공 or 둘 다 롤백). 되돌릴 수 없는 부수효과(로컬 파일 삭제)는
|
||||||
|
`finalize_task` 가 반환한 폴더를 caller 가 **커밋 성공 후에** 정리한다.
|
||||||
|
|
||||||
|
원본과 달라진 점: Task/Content 를 한 테이블(`ssul_content`)로 합쳤으므로
|
||||||
|
`finalize` 가 INSERT 가 아니라 **UPDATE** 다 — 멱등성이 자연히 확보된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.credit.services.credit_service import (
|
||||||
|
deduct_credit_for_job,
|
||||||
|
refund_credit_for_job,
|
||||||
|
)
|
||||||
|
from app.ssulbox.constants import JOB_TYPE_SSUL, ORPHAN_STATUSES, SsulTaskStatus
|
||||||
|
from app.ssulbox.models import SsulContent
|
||||||
|
# castad 와 **같은 규칙으로** 지역을 뽑는다. 통합 목록에서 한 필터가 양쪽을
|
||||||
|
# 걸러야 하므로 region 값의 형식이 일치해야 한다.
|
||||||
|
from app.utils.address_parser import extract_region_from_address
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import ssulbox_settings
|
||||||
|
|
||||||
|
logger = get_logger("ssulbox")
|
||||||
|
|
||||||
|
|
||||||
|
def _job_ref(content_id: int) -> str:
|
||||||
|
"""크레딧 원장 멱등 키. (job_type, job_ref, type) 유니크의 일부"""
|
||||||
|
return str(content_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_task(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_uuid: str,
|
||||||
|
scenario: str,
|
||||||
|
input_text: str,
|
||||||
|
scenes: int,
|
||||||
|
seconds: int,
|
||||||
|
store_name: Optional[str] = None,
|
||||||
|
road_address: Optional[str] = None,
|
||||||
|
address: Optional[str] = None,
|
||||||
|
) -> SsulContent:
|
||||||
|
"""행 삽입 + 크레딧 선차감을 **한 트랜잭션**으로 묶는다.
|
||||||
|
|
||||||
|
사후차감이면 차감 전에 동시 요청이 들어와 크레딧 1개로 여러 개를 만들 수 있다.
|
||||||
|
잔액 부족 시 `InsufficientCreditError` 가 전파되므로 caller 가 402 로 변환한다.
|
||||||
|
|
||||||
|
`store_name`/`address` 는 검색으로 업장을 고른 경우에만 들어온다.
|
||||||
|
**생성 시점에 넣는 이유**: 통합 목록의 store_name/region 필터가 이 값에 걸리고,
|
||||||
|
생성 중인 항목도 `내 콘텐츠`에서 업장명으로 보여야 한다. 완료를 기다리면
|
||||||
|
그 사이 목록에 이름 없는 카드가 뜬다.
|
||||||
|
주소는 region 추출에만 쓰고 저장하지 않는다(castad `project` 와 달리 상세 주소를
|
||||||
|
보관할 화면이 없다). castad `/home/crawl` 과 같이 **도로명·지번을 모두** 넘겨
|
||||||
|
도로명에서 시/군 추출이 실패하면 지번으로 재시도하게 한다.
|
||||||
|
"""
|
||||||
|
row = SsulContent(
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
scenario=scenario,
|
||||||
|
input=input_text,
|
||||||
|
scenes=scenes,
|
||||||
|
seconds=seconds,
|
||||||
|
status=SsulTaskStatus.QUEUED.value,
|
||||||
|
step=0,
|
||||||
|
# store_name 은 NOT NULL 이다. 아직 모르면 빈 문자열 —
|
||||||
|
# 채우는 쪽(`set_place_info`)이 falsy 검사로 "미확정"을 판단한다.
|
||||||
|
store_name=store_name or "",
|
||||||
|
region=extract_region_from_address(road_address or None, address or None)
|
||||||
|
or None,
|
||||||
|
# castad `/home/crawl` 과 동일: 도로명 우선, 없으면 지번
|
||||||
|
detail_region_info=(road_address or address or None),
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
await session.flush() # autoincrement id 확보 — 크레딧 멱등 키로 쓴다
|
||||||
|
|
||||||
|
await deduct_credit_for_job(
|
||||||
|
session=session,
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO,
|
||||||
|
job_type=JOB_TYPE_SSUL,
|
||||||
|
job_ref=_job_ref(row.id),
|
||||||
|
reason="썰박스 생성",
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"[create_task] id={row.id} user={user_uuid} scenario={scenario}"
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def mark_running(session: AsyncSession, content_id: int) -> None:
|
||||||
|
"""잡이 실제로 시작됐을 때"""
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
if row is None or row.status != SsulTaskStatus.QUEUED.value:
|
||||||
|
return
|
||||||
|
row.status = SsulTaskStatus.RUNNING.value
|
||||||
|
|
||||||
|
|
||||||
|
async def update_step(session: AsyncSession, content_id: int, step: int) -> None:
|
||||||
|
"""진행 단계 갱신.
|
||||||
|
|
||||||
|
원본은 step 을 인메모리에만 뒀지만 castad 는 SSE 대신 폴링을 쓰므로
|
||||||
|
**DB 에 영속**해야 `GET /ssul/tasks/{id}` 가 진행률을 돌려줄 수 있다.
|
||||||
|
"""
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
if row is None or row.status in (
|
||||||
|
SsulTaskStatus.DONE.value,
|
||||||
|
SsulTaskStatus.ERROR.value,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
row.status = SsulTaskStatus.RUNNING.value
|
||||||
|
row.step = max(row.step, step) # 뒤로 가지 않는다
|
||||||
|
|
||||||
|
|
||||||
|
async def finalize_task(
|
||||||
|
session: AsyncSession,
|
||||||
|
content_id: int,
|
||||||
|
mp4_path: Path,
|
||||||
|
*,
|
||||||
|
store_name: Optional[str] = None,
|
||||||
|
region: Optional[str] = None,
|
||||||
|
) -> tuple[Optional[SsulContent], Optional[Path]]:
|
||||||
|
"""완료 처리. 같은 행을 UPDATE 하므로 **멱등**이다.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(행, 커밋 성공 후 정리할 로컬 job 폴더 또는 None)
|
||||||
|
폴더 삭제는 되돌릴 수 없으므로 반드시 커밋이 성공한 뒤에 한다.
|
||||||
|
"""
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
if row is None:
|
||||||
|
logger.warning(f"[finalize_task] 행 없음 id={content_id}")
|
||||||
|
return None, None
|
||||||
|
if row.status == SsulTaskStatus.DONE.value:
|
||||||
|
return row, None # 이미 처리됨
|
||||||
|
|
||||||
|
job_dir = mp4_path.parent
|
||||||
|
cleanup: Optional[Path] = None
|
||||||
|
video_url: Optional[str] = None
|
||||||
|
|
||||||
|
# Blob 이 설정돼 있으면 업로드하고 로컬은 커밋 후 정리한다.
|
||||||
|
# 아니면 로컬 경로를 그대로 서빙한다.
|
||||||
|
try:
|
||||||
|
from app.ssulbox.services.blob_service import upload_ssul_video
|
||||||
|
|
||||||
|
video_url = await upload_ssul_video(mp4_path, row.user_uuid, content_id)
|
||||||
|
if video_url:
|
||||||
|
cleanup = job_dir
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[finalize_task] Blob 업로드 실패 id={content_id} - {type(e).__name__}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not video_url:
|
||||||
|
# 로컬 서빙 경로 (StaticFiles 마운트 기준 상대 경로)
|
||||||
|
try:
|
||||||
|
rel = mp4_path.resolve().relative_to(
|
||||||
|
ssulbox_settings.output_path.resolve()
|
||||||
|
)
|
||||||
|
video_url = f"/ssul-videos/{rel.as_posix()}"
|
||||||
|
except ValueError:
|
||||||
|
logger.error(f"[finalize_task] output 밖 경로 id={content_id} path={mp4_path}")
|
||||||
|
|
||||||
|
row.video_url = video_url
|
||||||
|
# 생성 시점에 이미 채워진 값(검색으로 사용자가 직접 고른 업장)이 우선이다.
|
||||||
|
# 여기 들어오는 값은 생성 로그에서 뒤늦게 주워온 것이므로 덮어쓰지 않는다.
|
||||||
|
if store_name and not row.store_name:
|
||||||
|
row.store_name = store_name
|
||||||
|
if region and not row.region:
|
||||||
|
row.region = region
|
||||||
|
row.status = SsulTaskStatus.DONE.value
|
||||||
|
row.step = 4
|
||||||
|
logger.info(f"[finalize_task] DONE id={content_id} url={video_url}")
|
||||||
|
return row, cleanup
|
||||||
|
|
||||||
|
|
||||||
|
async def fail_task(
|
||||||
|
session: AsyncSession, content_id: int, error: str
|
||||||
|
) -> Optional[SsulContent]:
|
||||||
|
"""실패 처리: 환불(멱등) + status=error. 이미 터미널이면 건너뛴다."""
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if row.status in (SsulTaskStatus.DONE.value, SsulTaskStatus.ERROR.value):
|
||||||
|
return row
|
||||||
|
|
||||||
|
if row.user_uuid: # 탈퇴로 NULL 이 된 경우 환불 대상이 없다
|
||||||
|
await refund_credit_for_job(
|
||||||
|
session=session,
|
||||||
|
user_uuid=row.user_uuid,
|
||||||
|
amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO,
|
||||||
|
job_type=JOB_TYPE_SSUL,
|
||||||
|
job_ref=_job_ref(content_id),
|
||||||
|
reason="썰박스 생성 실패 환불",
|
||||||
|
)
|
||||||
|
|
||||||
|
row.status = SsulTaskStatus.ERROR.value
|
||||||
|
row.error = (error or "생성 실패")[:2000]
|
||||||
|
logger.info(f"[fail_task] id={content_id} error={row.error[:80]}")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def get_place_info(
|
||||||
|
session: AsyncSession, content_id: int
|
||||||
|
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
|
"""(store_name, region, detail_region_info).
|
||||||
|
|
||||||
|
크롤링으로 채울 값이 남았는지 판단하는 데 쓴다.
|
||||||
|
"""
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
if row is None:
|
||||||
|
return None, None, None
|
||||||
|
return row.store_name, row.region, row.detail_region_info
|
||||||
|
|
||||||
|
|
||||||
|
async def set_place_info(
|
||||||
|
session: AsyncSession,
|
||||||
|
content_id: int,
|
||||||
|
*,
|
||||||
|
store_name: Optional[str] = None,
|
||||||
|
region: Optional[str] = None,
|
||||||
|
detail_region_info: Optional[str] = None,
|
||||||
|
) -> Optional[SsulContent]:
|
||||||
|
"""크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.**
|
||||||
|
|
||||||
|
사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다.
|
||||||
|
"""
|
||||||
|
row = await session.get(SsulContent, content_id)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
if store_name and not row.store_name:
|
||||||
|
row.store_name = store_name
|
||||||
|
if region and not row.region:
|
||||||
|
row.region = region
|
||||||
|
if detail_region_info and not row.detail_region_info:
|
||||||
|
row.detail_region_info = detail_region_info
|
||||||
|
logger.info(
|
||||||
|
f"[set_place_info] id={content_id} store={row.store_name!r} "
|
||||||
|
f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r}"
|
||||||
|
)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
async def sweep_orphans(session: AsyncSession) -> int:
|
||||||
|
"""기동 시 고아 잡 정리 — 환불 + error.
|
||||||
|
|
||||||
|
불변식: 프로세스 기동 직후 인메모리 잡은 0개이므로 DB 의 queued/running 은
|
||||||
|
**전부 이전 프로세스의 고아**다. finalize 가 단일 트랜잭션이라
|
||||||
|
"영상은 만들어졌는데 running" 같은 중간 상태는 존재하지 않는다.
|
||||||
|
|
||||||
|
⚠️ 이 불변식은 **단일 워커 전제**다. `--workers` 를 늘리면 워커 B 가 기동하며
|
||||||
|
워커 A 가 지금 돌리는 잡을 고아로 오판해 환불·error 처리한다.
|
||||||
|
"""
|
||||||
|
rows = (
|
||||||
|
(
|
||||||
|
await session.execute(
|
||||||
|
select(SsulContent).where(SsulContent.status.in_(ORPHAN_STATUSES))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.scalars()
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
if row.user_uuid:
|
||||||
|
await refund_credit_for_job(
|
||||||
|
session=session,
|
||||||
|
user_uuid=row.user_uuid,
|
||||||
|
amount=ssulbox_settings.SSULBOX_CREDITS_PER_VIDEO,
|
||||||
|
job_type=JOB_TYPE_SSUL,
|
||||||
|
job_ref=_job_ref(row.id),
|
||||||
|
reason="서버 재시작 환불",
|
||||||
|
)
|
||||||
|
row.status = SsulTaskStatus.ERROR.value
|
||||||
|
row.error = "서버 재시작으로 중단되었습니다. 크레딧은 환불되었습니다."
|
||||||
|
if rows:
|
||||||
|
logger.info(f"[sweep_orphans] 고아 {len(rows)}건 환불·정리")
|
||||||
|
return len(rows)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_job_dir(job_dir: Optional[Path]) -> None:
|
||||||
|
"""생성 산출물 폴더 삭제. **커밋이 성공한 뒤에만** 호출할 것."""
|
||||||
|
if not job_dir or not job_dir.exists():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
shutil.rmtree(job_dir)
|
||||||
|
logger.info(f"[cleanup_job_dir] 삭제 {job_dir}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[cleanup_job_dir] 삭제 실패 {job_dir} - {e}")
|
||||||
0
app/ssulbox/worker/__init__.py
Normal file
0
app/ssulbox/worker/__init__.py
Normal file
461
app/ssulbox/worker/job_manager.py
Normal file
461
app/ssulbox/worker/job_manager.py
Normal file
@ -0,0 +1,461 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""썰박스 생성 잡 매니저 — subprocess 감독 + 진행 단계 DB 영속화.
|
||||||
|
|
||||||
|
원본(o2o-ssulbox/app/jobs.py)에서 이식하되 4가지를 바꿨다:
|
||||||
|
|
||||||
|
1. **SSE 제거** — castad 는 폴링을 쓴다. `_subscribers`/`_emit` 을 통째로 걷어냈다.
|
||||||
|
2. **step 을 DB 에 영속화** — 원본은 인메모리 `_jobs` 에만 뒀지만, 폴링이 진행률을
|
||||||
|
돌려주려면 DB 에 있어야 한다. 나중에 워커를 늘려도 폴링은 그대로 동작한다.
|
||||||
|
3. **세션 팩토리를 BackgroundSessionLocal 로** — 요청용 풀(20+20)을 장시간 잡이
|
||||||
|
잠식하지 않게 한다. castad `video_task.py` 와 같은 관행.
|
||||||
|
4. **좀비 방지** — 원본은 `p.wait()` 에 타임아웃이 없어 엔진이 걸리면 스레드가
|
||||||
|
영구 블록되고 `_running` 이 안 줄어 큐가 멎었다. **감시견 타이머**로 데드라인에
|
||||||
|
프로세스를 죽인다. `wait(timeout=)` 만 걸면 안 된다 — stdout 읽기 루프가
|
||||||
|
EOF 까지 블록하므로 엔진이 조용히 매달리면 wait() 에 도달조차 못 한다
|
||||||
|
(2026-07-29 `zzz/_ssul_timeout_verify.py` 로 확인한 실제 결함).
|
||||||
|
|
||||||
|
⚠️ **단일 워커 전제** — `_jobs`/`_running` 이 프로세스 로컬이고, `sweep_orphans` 가
|
||||||
|
"기동 시 비터미널 잡은 전부 고아"라는 불변식에 의존한다. `--workers` 를 늘리면
|
||||||
|
워커 B 가 기동하며 워커 A 의 정상 잡을 환불·error 처리한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from app.database.session import BackgroundSessionLocal
|
||||||
|
from app.ssulbox.constants import (
|
||||||
|
DONE_RE,
|
||||||
|
JOB_DIR_RE,
|
||||||
|
SCENARIO_ENGINE,
|
||||||
|
STEP_NAMES,
|
||||||
|
STEP_RE,
|
||||||
|
STORE_RE,
|
||||||
|
gemini_key,
|
||||||
|
)
|
||||||
|
from app.ssulbox.services import place_service, task_service
|
||||||
|
from app.utils.address_parser import extract_region_from_address
|
||||||
|
from app.utils.logger import get_logger
|
||||||
|
from config import ssulbox_settings
|
||||||
|
|
||||||
|
logger = get_logger("ssulbox")
|
||||||
|
|
||||||
|
# ── 프로세스 로컬 상태 ────────────────────────────────────────
|
||||||
|
#: 진행 중인 잡의 로그·타이밍 (권위 아님 — 권위는 DB status/step)
|
||||||
|
_jobs: dict[int, dict[str, Any]] = {}
|
||||||
|
_queue: deque[int] = deque()
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_running = 0
|
||||||
|
_shutting_down = False
|
||||||
|
#: 앱 이벤트 루프. 워커 스레드가 DB 작업을 위임할 대상
|
||||||
|
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
|
|
||||||
|
|
||||||
|
def create_job(
|
||||||
|
content_id: int, scenario: str, input_text: str, scenes: int, seconds: int
|
||||||
|
) -> None:
|
||||||
|
"""잡을 큐에 넣는다.
|
||||||
|
|
||||||
|
**반드시 앱 이벤트 루프(요청 핸들러)에서 호출해야 한다** — 여기서 루프를 캡처해
|
||||||
|
워커 스레드가 DB 작업을 위임할 때 쓴다.
|
||||||
|
"""
|
||||||
|
global _loop
|
||||||
|
_loop = asyncio.get_running_loop()
|
||||||
|
_jobs[content_id] = {
|
||||||
|
"id": content_id,
|
||||||
|
"scenario": scenario,
|
||||||
|
"input": input_text,
|
||||||
|
"scenes": scenes,
|
||||||
|
"seconds": seconds,
|
||||||
|
"status": "queued",
|
||||||
|
"step": 0,
|
||||||
|
"log": [],
|
||||||
|
"output": None,
|
||||||
|
"job_dir": None, # generator 가 stdout 으로 알려준다. 실패 정리 대상
|
||||||
|
"store_name": None, # 검색을 안 거친 경우 생성 로그에서 주워온다
|
||||||
|
"error": None,
|
||||||
|
"timings": {},
|
||||||
|
}
|
||||||
|
with _lock:
|
||||||
|
_queue.append(content_id)
|
||||||
|
_pump()
|
||||||
|
|
||||||
|
|
||||||
|
def get_job(content_id: int) -> Optional[dict]:
|
||||||
|
"""인메모리 진행 정보. 로그 확인용이며 권위는 DB 다."""
|
||||||
|
return _jobs.get(content_id)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown() -> None:
|
||||||
|
"""신규 큐잉을 막는다. lifespan shutdown 에서 dispose_engine 전에 호출."""
|
||||||
|
global _shutting_down
|
||||||
|
_shutting_down = True
|
||||||
|
with _lock:
|
||||||
|
dropped = len(_queue)
|
||||||
|
_queue.clear()
|
||||||
|
if dropped:
|
||||||
|
logger.info(f"[job_manager] 종료 — 대기 중이던 {dropped}건은 다음 기동 스윕이 처리")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_db(coro, timeout: Optional[int] = None):
|
||||||
|
"""워커 스레드에서 앱 루프에 DB 코루틴을 위임하고 완료까지 대기한다.
|
||||||
|
|
||||||
|
asyncmy 커넥션 풀은 생성된 이벤트 루프에 바인딩되므로 스레드에서
|
||||||
|
`asyncio.run` 을 쓰면 풀이 깨진다. 반드시 앱 루프에 위임해야 한다.
|
||||||
|
"""
|
||||||
|
if _loop is None or _loop.is_closed():
|
||||||
|
# 셧다운 중이면 루프가 코루틴을 실행하지 않아 무한정 매달린다.
|
||||||
|
coro.close()
|
||||||
|
raise RuntimeError("event loop unavailable (shutting down)")
|
||||||
|
fut = asyncio.run_coroutine_threadsafe(coro, _loop)
|
||||||
|
return fut.result(timeout=timeout or ssulbox_settings.SSULBOX_DB_DELEGATE_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
async def _mark_running(content_id: int) -> None:
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
await task_service.mark_running(session, content_id)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _update_step(content_id: int, step: int) -> None:
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
await task_service.update_step(session, content_id, step)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_place_info(
|
||||||
|
content_id: int,
|
||||||
|
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
||||||
|
"""현재 저장된 (store_name, region, detail_region_info)."""
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
return await task_service.get_place_info(session, content_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _save_place(
|
||||||
|
content_id: int, store_name: str, region: str, detail: str
|
||||||
|
) -> None:
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
await task_service.set_place_info(
|
||||||
|
session,
|
||||||
|
content_id,
|
||||||
|
store_name=store_name,
|
||||||
|
region=region,
|
||||||
|
detail_region_info=detail,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _finalize(
|
||||||
|
content_id: int, mp4: Path, store_name: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
cleanup: Optional[Path] = None
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
# store_name 은 생성 시점에 비어 있을 때만 반영된다(finalize_task 가 판단).
|
||||||
|
_, cleanup = await task_service.finalize_task(
|
||||||
|
session, content_id, mp4, store_name=store_name
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
# 되돌릴 수 없는 삭제는 커밋이 성공한 뒤에만
|
||||||
|
task_service.cleanup_job_dir(cleanup)
|
||||||
|
|
||||||
|
|
||||||
|
async def _fail(content_id: int, error: str) -> None:
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
await task_service.fail_task(session, content_id, error)
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _pump() -> None:
|
||||||
|
"""동시 실행 한도 안에서 대기 중인 잡을 시작한다."""
|
||||||
|
global _running
|
||||||
|
if _shutting_down:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
while _queue and _running < ssulbox_settings.SSULBOX_MAX_CONCURRENT_JOBS:
|
||||||
|
content_id = _queue.popleft()
|
||||||
|
_running += 1
|
||||||
|
threading.Thread(target=_run, args=(content_id,), daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_command(job: dict) -> tuple[list[str], Path]:
|
||||||
|
"""generator 실행 커맨드와 작업 디렉터리."""
|
||||||
|
engine = SCENARIO_ENGINE[job["scenario"]]
|
||||||
|
engine_dir = ssulbox_settings.generator_path / engine
|
||||||
|
main_py = engine_dir / "main.py"
|
||||||
|
if not main_py.exists():
|
||||||
|
raise FileNotFoundError(f"generator not found: {main_py}")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
sys.executable,
|
||||||
|
"-u",
|
||||||
|
str(main_py),
|
||||||
|
job["input"],
|
||||||
|
"--scenes",
|
||||||
|
str(job["scenes"]),
|
||||||
|
"--seconds",
|
||||||
|
str(job["seconds"]),
|
||||||
|
]
|
||||||
|
return cmd, engine_dir
|
||||||
|
|
||||||
|
|
||||||
|
def _is_place_url(text: str) -> bool:
|
||||||
|
"""네이버 지도 링크로 보이는가 — 프론트 `isNaverUrl` 과 같은 판정."""
|
||||||
|
t = (text or "").strip().lower()
|
||||||
|
return t.startswith("http") and (
|
||||||
|
"naver.me" in t or "map.naver" in t or "place.naver" in t
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_place_info(content_id: int, job: dict) -> None:
|
||||||
|
"""비어 있는 업장명·지역을 크롤링으로 채운다(있는 값은 유지).
|
||||||
|
|
||||||
|
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 둘 다
|
||||||
|
채워져 있으므로 브라우저를 띄우지 않는다. 다만 검색 경로에서도 지역만 비는
|
||||||
|
경우(주소에서 시/군을 못 뽑는 등)가 있어, 존재 여부를 실제로 확인하고 판단한다.
|
||||||
|
|
||||||
|
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
|
||||||
|
"""
|
||||||
|
if not _is_place_url(job.get("input", "")):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
store_name, region, detail = _run_db(_get_place_info(content_id))
|
||||||
|
if store_name and region and detail:
|
||||||
|
return # 채울 것이 없다
|
||||||
|
|
||||||
|
detail = _run_db(
|
||||||
|
place_service.fetch_place_detail(job["input"]),
|
||||||
|
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if not detail:
|
||||||
|
return
|
||||||
|
|
||||||
|
title = detail.get("title") or ""
|
||||||
|
road = detail.get("roadAddress") or ""
|
||||||
|
jibun = detail.get("address") or ""
|
||||||
|
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
|
||||||
|
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
|
||||||
|
new_region = extract_region_from_address(road or None, jibun or None)
|
||||||
|
new_detail = road or jibun # 도로명 우선, 없으면 지번
|
||||||
|
if title:
|
||||||
|
job["store_name"] = title
|
||||||
|
if title or new_region or new_detail:
|
||||||
|
_run_db(_save_place(content_id, title, new_region, new_detail))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
|
||||||
|
f"{type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _run(content_id: int) -> None:
|
||||||
|
"""워커 스레드 본체. subprocess 를 감독하며 stdout 마커로 진행을 파싱한다."""
|
||||||
|
global _running
|
||||||
|
job = _jobs[content_id]
|
||||||
|
proc: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd, engine_dir = _build_command(job)
|
||||||
|
|
||||||
|
env = dict(os.environ)
|
||||||
|
env["PYTHONIOENCODING"] = "utf-8"
|
||||||
|
key = gemini_key()
|
||||||
|
if key:
|
||||||
|
env["GEMINI_API_KEY"] = key
|
||||||
|
|
||||||
|
_run_db(_mark_running(content_id))
|
||||||
|
job["status"] = "running"
|
||||||
|
|
||||||
|
# 업장명·주소를 **항상** 확보한다. 검색으로 고르지 않고 place URL 을
|
||||||
|
# 붙여넣은 경우 create 시점에 아무것도 없으므로 여기서 크롤링한다.
|
||||||
|
# 생성 **전에** 하는 이유: 목록에 이름 없는 카드가 뜨는 구간을 없앤다.
|
||||||
|
# 실패해도 생성은 계속한다(목록 표시·필터용 부가 정보다).
|
||||||
|
_collect_place_info(content_id, job)
|
||||||
|
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
cwd=str(engine_dir),
|
||||||
|
env=env,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
bufsize=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
started = step_started = time.monotonic()
|
||||||
|
timeout_s = ssulbox_settings.SSULBOX_JOB_TIMEOUT_SECONDS
|
||||||
|
|
||||||
|
# ⚠️ 벽시계 감시견이 필요한 이유 — `proc.wait(timeout=)` 만으로는 못 막는다.
|
||||||
|
# 아래 `for raw in proc.stdout` 은 EOF 까지 블록한다. 엔진이 출력 없이
|
||||||
|
# 매달리면(응답 없는 API 호출 등) EOF 가 오지 않아 워커 스레드가 영구
|
||||||
|
# 정지하고 `_running` 이 안 줄어 **큐 전체가 멎는다**. wait() 의 타임아웃은
|
||||||
|
# stdout 이 닫힌 뒤에야 평가되므로 정작 그 상황에는 도달하지 못한다.
|
||||||
|
# 데드라인에 프로세스를 죽여야 stdout 이 닫히고 루프가 풀린다.
|
||||||
|
timed_out = threading.Event()
|
||||||
|
|
||||||
|
def _on_deadline() -> None:
|
||||||
|
timed_out.set()
|
||||||
|
logger.error(f"[ssul {content_id}] TIMEOUT {timeout_s}s — 프로세스 종료")
|
||||||
|
_kill(proc)
|
||||||
|
|
||||||
|
watchdog = threading.Timer(timeout_s, _on_deadline)
|
||||||
|
watchdog.daemon = True
|
||||||
|
watchdog.start()
|
||||||
|
|
||||||
|
try:
|
||||||
|
for raw in proc.stdout: # type: ignore[union-attr]
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
logger.info(f"[ssul {content_id}] {line}")
|
||||||
|
job["log"].append(line)
|
||||||
|
if len(job["log"]) > 300:
|
||||||
|
del job["log"][:-300]
|
||||||
|
|
||||||
|
m = STEP_RE.search(line)
|
||||||
|
if m:
|
||||||
|
n = int(m.group(1))
|
||||||
|
if n != job["step"]:
|
||||||
|
now = time.monotonic()
|
||||||
|
prev = job["step"]
|
||||||
|
label = "준비·크롤링" if prev == 0 else STEP_NAMES[prev - 1]
|
||||||
|
job["timings"][label] = round(now - step_started, 1)
|
||||||
|
step_started = now
|
||||||
|
job["step"] = n
|
||||||
|
# 폴링이 읽을 수 있도록 DB 에 반영한다
|
||||||
|
try:
|
||||||
|
_run_db(_update_step(content_id, n))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ssul {content_id}] step 저장 실패: {e}")
|
||||||
|
|
||||||
|
d = DONE_RE.search(line)
|
||||||
|
if d:
|
||||||
|
job["output"] = d.group(1).strip()
|
||||||
|
|
||||||
|
# 실패 시 지울 대상. 완료 마커가 없어도 알 수 있는 유일한 경로다.
|
||||||
|
jd = JOB_DIR_RE.search(line)
|
||||||
|
if jd:
|
||||||
|
job["job_dir"] = jd.group(1).strip()
|
||||||
|
|
||||||
|
# place URL 을 붙여넣어 검색을 안 거친 경우의 업장명 확보 경로.
|
||||||
|
# '?' 는 엔진이 이름을 못 얻었을 때 찍는 placeholder 라 버린다.
|
||||||
|
sm = STORE_RE.search(line)
|
||||||
|
if sm:
|
||||||
|
name = sm.group(1).strip()
|
||||||
|
if name and name != "?":
|
||||||
|
job["store_name"] = name
|
||||||
|
|
||||||
|
# stdout 이 닫혔으니 종료는 임박했다. 짧은 여유만 준다.
|
||||||
|
code = proc.wait(timeout=30)
|
||||||
|
finally:
|
||||||
|
watchdog.cancel()
|
||||||
|
|
||||||
|
if timed_out.is_set():
|
||||||
|
# 감시견이 죽인 것이지 정상 종료가 아니다. 아래 except 로 넘긴다.
|
||||||
|
raise subprocess.TimeoutExpired(cmd, timeout_s)
|
||||||
|
|
||||||
|
summary = " · ".join(f"{k} {v}s" for k, v in job["timings"].items()) or "(마커 없음)"
|
||||||
|
logger.info(
|
||||||
|
f"[ssul {content_id}] 단계별 {summary} · 총 {time.monotonic() - started:.1f}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if code != 0:
|
||||||
|
raise RuntimeError(f"생성 프로세스 종료코드 {code}")
|
||||||
|
if not job["output"]:
|
||||||
|
raise RuntimeError("완성 마커를 찾지 못했습니다")
|
||||||
|
|
||||||
|
mp4 = Path(job["output"])
|
||||||
|
if not mp4.is_absolute():
|
||||||
|
mp4 = (engine_dir / mp4).resolve()
|
||||||
|
|
||||||
|
# finalize 는 Blob 업로드를 포함해 오래 걸리므로 위임 타임아웃을 넉넉히
|
||||||
|
_run_db(
|
||||||
|
_finalize(content_id, mp4, job.get("store_name")), timeout=600
|
||||||
|
)
|
||||||
|
job["status"] = "done"
|
||||||
|
job["step"] = 4
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired as e:
|
||||||
|
# 원본은 타임아웃이 없어 엔진이 걸리면 큐가 영구 정지했다.
|
||||||
|
# 두 경로로 들어온다: 감시견 데드라인, 또는 stdout 이 닫혔는데도
|
||||||
|
# 프로세스가 안 끝나는 경우(위 `wait(timeout=30)`).
|
||||||
|
job["status"] = "error"
|
||||||
|
job["error"] = f"생성 시간 초과 ({e.timeout}s)"
|
||||||
|
logger.error(f"[ssul {content_id}] TIMEOUT — 프로세스 종료")
|
||||||
|
_kill(proc)
|
||||||
|
_safe_fail(content_id, job["error"])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
job["status"] = "error"
|
||||||
|
job["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
logger.error(f"[ssul {content_id}] FAILED - {job['error']}", exc_info=True)
|
||||||
|
_kill(proc)
|
||||||
|
_safe_fail(content_id, job["error"])
|
||||||
|
|
||||||
|
finally:
|
||||||
|
with _lock:
|
||||||
|
_running -= 1
|
||||||
|
_pump()
|
||||||
|
|
||||||
|
|
||||||
|
def _kill(proc: Optional[subprocess.Popen]) -> None:
|
||||||
|
if proc is None or proc.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[job_manager] 프로세스 종료 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_fail(content_id: int, error: str) -> None:
|
||||||
|
"""환불·상태 갱신. 이것마저 실패하면 다음 기동의 고아 스윕이 재처리한다."""
|
||||||
|
try:
|
||||||
|
_run_db(_fail(content_id, error))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"[ssul {content_id}] 실패 처리 실패(스윕이 재처리): {type(e).__name__}: {e}"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# DB 처리 성패와 무관하게 디스크는 정리한다. 남겨봐야 쓸 곳이 없다.
|
||||||
|
_cleanup_failed_dir(content_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_failed_dir(content_id: int) -> None:
|
||||||
|
"""실패한 잡의 중간 산출물 삭제.
|
||||||
|
|
||||||
|
castad `video_task.py` 는 `finally` 로 성공·실패 양쪽을 정리한다. 썰박스도
|
||||||
|
맞춘다. 실패 시엔 generator 자신의 자산 정리(`main.py` 의 `out_mp4.exists()`
|
||||||
|
조건)마저 건너뛰므로 **오히려 실패가 가장 많이 남긴다** — 이미지·음성 전체.
|
||||||
|
"""
|
||||||
|
raw = (_jobs.get(content_id) or {}).get("job_dir")
|
||||||
|
if not raw:
|
||||||
|
# 폴더 마커 전에 죽었다면 만들어진 것도 없다
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
job_dir = Path(raw).resolve()
|
||||||
|
root = ssulbox_settings.output_path.resolve()
|
||||||
|
# stdout 에서 읽어온 경로다. output 밖은 무슨 일이 있어도 지우지 않는다.
|
||||||
|
# `job_dir.parent != root` 는 `output/<엔진>` 통째 삭제를 막는다
|
||||||
|
# (정상 경로는 항상 `output/<엔진>/<작업>` 이라 2단계 아래다).
|
||||||
|
if root not in job_dir.parents or job_dir.parent == root:
|
||||||
|
logger.warning(f"[ssul {content_id}] output 밖 경로라 정리 생략: {job_dir}")
|
||||||
|
return
|
||||||
|
task_service.cleanup_job_dir(job_dir)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"[ssul {content_id}] 실패 정리 실패: {type(e).__name__}: {e}")
|
||||||
@ -143,6 +143,12 @@ class User(Base):
|
|||||||
comment="카카오 썸네일 이미지 URL",
|
comment="카카오 썸네일 이미지 URL",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bio: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(200),
|
||||||
|
nullable=True,
|
||||||
|
comment="한 줄 소개 (썰박스 프로필). Alembic 부재로 수동 DDL 필요 — app/ssulbox/migration.py 참조",
|
||||||
|
)
|
||||||
|
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
# 추가 사용자 정보
|
# 추가 사용자 정보
|
||||||
# ==========================================================================
|
# ==========================================================================
|
||||||
|
|||||||
@ -10,7 +10,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
async def consume_credit(user_uuid: str, session: AsyncSession, *, reason: str = "video generation") -> bool:
|
||||||
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지."""
|
"""크레딧 1 차감. 기존 호출처와 시그니처 호환 유지.
|
||||||
|
|
||||||
|
.. deprecated::
|
||||||
|
사전차감 정책 전환으로 호출처가 사라졌다(video_task.py 의 사후차감 제거).
|
||||||
|
**새 코드에서 쓰지 말 것.** 이 함수는 멱등성이 없어 재시도 시 중복 차감된다.
|
||||||
|
생성 작업에는 ``credit_service.deduct_credit_for_job`` /
|
||||||
|
``refund_credit_for_job`` 을 쓴다.
|
||||||
|
다음 릴리스에서 제거 예정.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
await deduct_credit(
|
await deduct_credit(
|
||||||
session=session,
|
session=session,
|
||||||
|
|||||||
@ -12,11 +12,15 @@ from sqlalchemy import delete, insert, tuple_
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database.like_cache import (
|
from app.database.like_cache import (
|
||||||
|
CT_SSUL,
|
||||||
|
CT_VIDEO,
|
||||||
commit_dirty_processing,
|
commit_dirty_processing,
|
||||||
drain_dirty,
|
drain_dirty,
|
||||||
is_user_liked,
|
is_user_liked,
|
||||||
)
|
)
|
||||||
from app.database.session import get_session
|
from app.database.session import get_session
|
||||||
|
# 썰박스 좋아요도 같은 Redis write-behind 큐 + **같은 테이블**(video_reaction)을
|
||||||
|
# 쓰므로 여기서 함께 플러시한다. 큐가 하나라 스케줄러를 늘릴 필요가 없다.
|
||||||
from app.video.models import VideoReaction
|
from app.video.models import VideoReaction
|
||||||
from config import internal_settings
|
from config import internal_settings
|
||||||
|
|
||||||
@ -48,49 +52,68 @@ async def flush_reactions(
|
|||||||
detail="Invalid internal secret",
|
detail="Invalid internal secret",
|
||||||
)
|
)
|
||||||
|
|
||||||
pairs = await drain_dirty()
|
entries = await drain_dirty()
|
||||||
if not pairs:
|
if not entries:
|
||||||
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
|
logger.info("[REACTION_FLUSH] dirty 항목 없음, 종료")
|
||||||
return {"flushed": 0, "adds": 0, "dels": 0}
|
return {"flushed": 0, "adds": 0, "dels": 0}
|
||||||
|
|
||||||
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(pairs)}건")
|
logger.info(f"[REACTION_FLUSH] START - dirty 항목 {len(entries)}건")
|
||||||
|
|
||||||
|
# 두 종류가 **같은 테이블**(video_reaction)에 들어간다. 대상 컬럼만 다르다.
|
||||||
|
# ADO2 는 video_id, 썰박스는 content_id 를 채운다(나머지는 NULL, CHECK 로 강제).
|
||||||
|
id_col_of = {CT_VIDEO: "video_id", CT_SSUL: "content_id"}
|
||||||
adds: list[dict] = []
|
adds: list[dict] = []
|
||||||
dels: list[tuple[int, str]] = []
|
# (컬럼명, 대상 id, user_uuid) — 삭제는 컬럼이 달라 종류별로 묶어야 한다
|
||||||
|
dels: dict[str, list[tuple[int, str]]] = {"video_id": [], "content_id": []}
|
||||||
|
|
||||||
# Redis 현재 상태 기준으로 add / delete 분류
|
# Redis 현재 상태 기준으로 add / delete 분류
|
||||||
for video_id, user_uuid in pairs:
|
for ctype, content_id, user_uuid in entries:
|
||||||
liked = await is_user_liked(video_id, user_uuid)
|
id_col = id_col_of.get(ctype)
|
||||||
|
if id_col is None:
|
||||||
|
# 알 수 없는 종류 — 건너뛴다(파서가 걸러주지만 방어)
|
||||||
|
logger.warning(f"[REACTION_FLUSH] 알 수 없는 종류 무시 - ctype: {ctype}")
|
||||||
|
continue
|
||||||
|
liked = await is_user_liked(content_id, user_uuid, ctype=ctype)
|
||||||
if liked:
|
if liked:
|
||||||
adds.append({"video_id": video_id, "user_uuid": user_uuid})
|
adds.append({id_col: content_id, "user_uuid": user_uuid})
|
||||||
else:
|
else:
|
||||||
dels.append((video_id, user_uuid))
|
dels[id_col].append((content_id, user_uuid))
|
||||||
|
|
||||||
|
total_adds = len(adds)
|
||||||
|
total_dels = sum(len(v) for v in dels.values())
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Bulk INSERT IGNORE — UniqueConstraint 보장으로 멱등 처리
|
# 쓰기를 **하나의 트랜잭션**으로 묶는다.
|
||||||
if adds:
|
# 부분 반영이 나면 Redis processing SET 이 남아 다음 회차에 재시도되는데,
|
||||||
await session.execute(
|
# INSERT IGNORE + UNIQUE 로 멱등이라 중복 반영은 안전하다.
|
||||||
insert(VideoReaction).prefix_with("IGNORE").values(adds)
|
#
|
||||||
)
|
# ⚠️ adds 는 종류별로 키가 달라(video_id vs content_id) 한 번의
|
||||||
|
# `values(adds)` 로 묶으면 안 된다 — SQLAlchEmy 가 첫 dict 의 키로
|
||||||
# Bulk DELETE
|
# 컬럼 목록을 정하므로 다른 종류의 값이 누락된다. 종류별로 나눠 실행한다.
|
||||||
if dels:
|
for id_col in ("video_id", "content_id"):
|
||||||
await session.execute(
|
rows = [a for a in adds if id_col in a]
|
||||||
delete(VideoReaction).where(
|
if rows:
|
||||||
tuple_(
|
await session.execute(
|
||||||
VideoReaction.video_id,
|
insert(VideoReaction).prefix_with("IGNORE").values(rows)
|
||||||
VideoReaction.user_uuid,
|
)
|
||||||
).in_(dels)
|
|
||||||
|
if dels[id_col]:
|
||||||
|
await session.execute(
|
||||||
|
delete(VideoReaction).where(
|
||||||
|
tuple_(
|
||||||
|
getattr(VideoReaction, id_col),
|
||||||
|
VideoReaction.user_uuid,
|
||||||
|
).in_(dels[id_col])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await commit_dirty_processing()
|
await commit_dirty_processing()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[REACTION_FLUSH] SUCCESS - adds: {len(adds)}, dels: {len(dels)}"
|
f"[REACTION_FLUSH] SUCCESS - adds: {total_adds}, dels: {total_dels}"
|
||||||
)
|
)
|
||||||
return {"flushed": len(pairs), "adds": len(adds), "dels": len(dels)}
|
return {"flushed": len(entries), "adds": total_adds, "dels": total_dels}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
|
|||||||
@ -13,12 +13,10 @@ Video API Router
|
|||||||
app.include_router(router)
|
app.include_router(router)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
from collections import defaultdict
|
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, or_, select
|
from sqlalchemy import 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
|
||||||
@ -28,26 +26,26 @@ from app.user.models import User
|
|||||||
from app.utils.pagination import PaginatedResponse
|
from app.utils.pagination import PaginatedResponse
|
||||||
from app.home.models import Image, Project, MarketingIntel
|
from app.home.models import Image, Project, MarketingIntel
|
||||||
from app.home.api.routers.v1.home import _extract_region_from_address
|
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.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, LANGUAGE_FONT_MAP
|
||||||
|
|
||||||
from app.comment.models import Comment
|
|
||||||
from app.database.like_cache import (
|
from app.database.like_cache import (
|
||||||
backfill_user_set,
|
backfill_user_set,
|
||||||
bulk_is_user_liked,
|
|
||||||
get_like_count,
|
get_like_count,
|
||||||
get_like_counts,
|
|
||||||
is_user_liked,
|
is_user_liked,
|
||||||
is_user_set_exists,
|
is_user_set_exists,
|
||||||
mark_dirty,
|
mark_dirty,
|
||||||
mset_like_counts,
|
|
||||||
set_like_count,
|
set_like_count,
|
||||||
toggle_like_atomic,
|
toggle_like_atomic,
|
||||||
)
|
)
|
||||||
|
from app.credit.exceptions import InsufficientCreditError
|
||||||
|
from app.credit.services.credit_service import deduct_credit_for_job
|
||||||
|
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
|
||||||
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, VideoReaction
|
||||||
|
from app.video.services import unified_list
|
||||||
|
|
||||||
from app.video.schemas.video_schema import (
|
from app.video.schemas.video_schema import (
|
||||||
DownloadVideoResponse,
|
DownloadVideoResponse,
|
||||||
GenerateVideoResponse,
|
GenerateVideoResponse,
|
||||||
@ -64,6 +62,9 @@ from config import creatomate_settings
|
|||||||
|
|
||||||
logger = get_logger("video")
|
logger = get_logger("video")
|
||||||
|
|
||||||
|
#: 영상 1편 생성에 차감할 크레딧 (video_task.py 의 VIDEO_CREDIT_COST 와 동일해야 함)
|
||||||
|
VIDEO_CREDIT_COST = 1
|
||||||
|
|
||||||
router = APIRouter(prefix="/video", tags=["Video"])
|
router = APIRouter(prefix="/video", tags=["Video"])
|
||||||
|
|
||||||
|
|
||||||
@ -113,12 +114,19 @@ curl -X GET "http://localhost:8000/video/generate/0694b716-dbff-7219-8000-d08cb5
|
|||||||
- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다.
|
- Song의 song_result_url과 song_prompt가 있어야 영상 생성이 가능합니다.
|
||||||
- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
- creatomate_render_id를 사용하여 /status/{creatomate_render_id} 엔드포인트에서 생성 상태를 확인할 수 있습니다.
|
||||||
- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다.
|
- Video 테이블에 데이터가 저장되며, project_id, lyric_id, song_id가 자동으로 연결됩니다.
|
||||||
|
|
||||||
|
## 크레딧
|
||||||
|
- **요청 시점에 크레딧 1이 선차감됩니다.** (완료 시점 차감에서 변경)
|
||||||
|
- 생성이 실패하면 자동으로 환불됩니다.
|
||||||
|
- 잔액이 부족하면 402를 반환하며, Video 행도 생성되지 않습니다.
|
||||||
|
- 같은 task_id 로 재요청해도 중복 차감되지 않습니다.
|
||||||
""",
|
""",
|
||||||
response_model=GenerateVideoResponse,
|
response_model=GenerateVideoResponse,
|
||||||
responses={
|
responses={
|
||||||
200: {"description": "영상 생성 요청 성공"},
|
200: {"description": "영상 생성 요청 성공"},
|
||||||
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
|
400: {"description": "Song의 음악 URL, 가사(song_prompt) 또는 이미지가 없음"},
|
||||||
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
401: {"description": "인증 실패 (토큰 없음/만료)"},
|
||||||
|
402: {"description": "크레딧 부족 (충전 필요)"},
|
||||||
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
|
404: {"description": "Project, Lyric, Song 또는 Image를 찾을 수 없음"},
|
||||||
500: {"description": "영상 생성 요청 실패"},
|
500: {"description": "영상 생성 요청 실패"},
|
||||||
},
|
},
|
||||||
@ -313,7 +321,11 @@ async def generate_video(
|
|||||||
f"timestamps: {len(song_timestamp_list)}"
|
f"timestamps: {len(song_timestamp_list)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== Video 테이블에 초기 데이터 저장 및 커밋 =====
|
# ===== Video 테이블에 초기 데이터 저장 + 크레딧 선차감 (단일 트랜잭션) =====
|
||||||
|
# 렌더 완료 후가 아니라 "시작 시점"에 차감한다. 사후차감이던 시절에는
|
||||||
|
# 차감 전에 동시 요청이 들어오면 크레딧 1개로 영상 여러 개를 만들 수 있었다.
|
||||||
|
# Video insert 와 차감을 한 트랜잭션으로 묶어 한 번만 커밋해야
|
||||||
|
# "영상 행은 생겼는데 차감은 실패" 같은 반쪽 상태가 생기지 않는다.
|
||||||
video = Video(
|
video = Video(
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
lyric_id=lyric_id,
|
lyric_id=lyric_id,
|
||||||
@ -323,6 +335,21 @@ async def generate_video(
|
|||||||
status="processing",
|
status="processing",
|
||||||
)
|
)
|
||||||
session.add(video)
|
session.add(video)
|
||||||
|
await session.flush() # video.id 확보 (커밋은 차감 후 한 번만)
|
||||||
|
|
||||||
|
# job_ref 로 task_id 를 쓴다 — ADO2 에는 영상 재생성 버튼이 없어
|
||||||
|
# task_id 1건 = 생성 1건이기 때문이다.
|
||||||
|
# 재생성 UI 를 추가한다면 이 키를 f"{task_id}:{video.id}" 로 바꿔야
|
||||||
|
# 두 번째 생성이 공짜가 되지 않는다.
|
||||||
|
await deduct_credit_for_job(
|
||||||
|
session=session,
|
||||||
|
user_uuid=current_user.user_uuid,
|
||||||
|
amount=VIDEO_CREDIT_COST,
|
||||||
|
job_type=CREDIT_JOB_TYPE_VIDEO,
|
||||||
|
job_ref=task_id,
|
||||||
|
reason="영상 생성",
|
||||||
|
)
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
video_id = video.id
|
video_id = video.id
|
||||||
stage1_time = time.perf_counter()
|
stage1_time = time.perf_counter()
|
||||||
@ -334,6 +361,14 @@ async def generate_video(
|
|||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
except InsufficientCreditError:
|
||||||
|
# 크레딧 부족은 "요청 실패"가 아니라 402 로 올려야 프론트가 충전 화면으로 유도한다.
|
||||||
|
# 아래 except Exception 이 삼켜 200(success=False)으로 내리면 안 되므로 먼저 잡는다.
|
||||||
|
logger.info(
|
||||||
|
f"[generate_video] INSUFFICIENT CREDIT - task_id: {task_id}, "
|
||||||
|
f"user_uuid: {current_user.user_uuid}"
|
||||||
|
)
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
|
logger.error(f"[generate_video] DB EXCEPTION - task_id: {task_id}, error: {e}")
|
||||||
return GenerateVideoResponse(
|
return GenerateVideoResponse(
|
||||||
@ -850,7 +885,7 @@ async def get_all_videos(
|
|||||||
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
|
store_name: str | None = Query(default=None, description="업체명 검색 (부분 일치)"),
|
||||||
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
|
region: str | None = Query(default=None, description="지역명 검색 (부분 일치)"),
|
||||||
) -> PaginatedResponse[VideoThumbnailItem]:
|
) -> PaginatedResponse[VideoThumbnailItem]:
|
||||||
"""전체 사용자의 완료된 영상 갤러리를 반환합니다."""
|
"""전체 사용자의 완료된 콘텐츠(ADO2 영상 + 썰박스)를 반환합니다."""
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[get_all_videos] START - page: {pagination.page}, page_size: {pagination.page_size}, "
|
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}"
|
f"sort_by: {sort_by}, order: {order}, store_name: {store_name}, region: {region}"
|
||||||
@ -859,144 +894,31 @@ async def get_all_videos(
|
|||||||
try:
|
try:
|
||||||
offset = (pagination.page - 1) * pagination.page_size
|
offset = (pagination.page - 1) * pagination.page_size
|
||||||
|
|
||||||
where_clauses = [
|
items, total = await unified_list.fetch_gallery(
|
||||||
Video.status == "completed",
|
session,
|
||||||
Video.is_deleted == False, # noqa: E712
|
offset=offset,
|
||||||
Project.is_deleted == False, # noqa: E712
|
limit=pagination.page_size,
|
||||||
Video.result_movie_url.is_not(None),
|
sort_by=sort_by,
|
||||||
]
|
order=order,
|
||||||
if store_name:
|
store_name=store_name,
|
||||||
where_clauses.append(Project.store_name.ilike(f"%{store_name}%"))
|
region=region,
|
||||||
if region:
|
user_uuid=current_user.user_uuid if current_user else None,
|
||||||
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()}
|
|
||||||
|
|
||||||
items = [
|
|
||||||
VideoThumbnailItem(
|
|
||||||
video_id=v.id,
|
|
||||||
store_name=p.store_name,
|
|
||||||
result_movie_url=v.result_movie_url,
|
|
||||||
created_at=v.created_at,
|
|
||||||
like_count=like_count_map.get(v.id) or 0,
|
|
||||||
is_liked_by_me=liked_map.get(v.id, False),
|
|
||||||
comment_count=comment_count or 0,
|
|
||||||
)
|
|
||||||
for v, p, comment_count in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
response = PaginatedResponse.create(
|
response = PaginatedResponse.create(
|
||||||
items=items,
|
items=[
|
||||||
|
VideoThumbnailItem(
|
||||||
|
type=it.ctype,
|
||||||
|
video_id=it.id,
|
||||||
|
store_name=it.store_name,
|
||||||
|
result_movie_url=it.movie_url,
|
||||||
|
created_at=it.created_at,
|
||||||
|
like_count=it.like_count,
|
||||||
|
is_liked_by_me=it.is_liked_by_me,
|
||||||
|
comment_count=it.comment_count,
|
||||||
|
)
|
||||||
|
for it in items
|
||||||
|
],
|
||||||
total=total,
|
total=total,
|
||||||
page=pagination.page,
|
page=pagination.page,
|
||||||
page_size=pagination.page_size,
|
page_size=pagination.page_size,
|
||||||
@ -1009,6 +931,7 @@ async def get_all_videos(
|
|||||||
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"갤러리 조회에 실패했습니다: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/{video_id}/like",
|
"/{video_id}/like",
|
||||||
summary="영상 좋아요 토글",
|
summary="영상 좋아요 토글",
|
||||||
|
|||||||
@ -1,7 +1,18 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, List, Optional
|
from typing import TYPE_CHECKING, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, func
|
from sqlalchemy import (
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
UniqueConstraint,
|
||||||
|
func,
|
||||||
|
)
|
||||||
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
|
||||||
@ -136,8 +147,11 @@ class Video(Base):
|
|||||||
back_populates="videos",
|
back_populates="videos",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# comment/video_reaction 이 video_id 와 content_id 두 FK 를 갖게 되어
|
||||||
|
# 어느 쪽으로 조인할지 명시해야 한다(없으면 AmbiguousForeignKeysError).
|
||||||
comments: Mapped[List["Comment"]] = relationship(
|
comments: Mapped[List["Comment"]] = relationship(
|
||||||
"Comment",
|
"Comment",
|
||||||
|
foreign_keys="Comment.video_id",
|
||||||
back_populates="video",
|
back_populates="video",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="noload",
|
lazy="noload",
|
||||||
@ -145,6 +159,7 @@ class Video(Base):
|
|||||||
|
|
||||||
reactions: Mapped[List["VideoReaction"]] = relationship(
|
reactions: Mapped[List["VideoReaction"]] = relationship(
|
||||||
"VideoReaction",
|
"VideoReaction",
|
||||||
|
foreign_keys="VideoReaction.video_id",
|
||||||
back_populates="video",
|
back_populates="video",
|
||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
lazy="noload",
|
lazy="noload",
|
||||||
@ -170,14 +185,30 @@ class VideoReaction(Base):
|
|||||||
영상 반응 테이블
|
영상 반응 테이블
|
||||||
|
|
||||||
사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글).
|
사용자가 영상에 반응(현재는 좋아요)을 남기면 생성, 다시 누르면 삭제(토글).
|
||||||
(user_uuid, video_id) 유니크 제약으로 1인 1회 보장.
|
|
||||||
향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능.
|
향후 reaction_type 컬럼 추가로 다양한 반응 종류 확장 가능.
|
||||||
|
|
||||||
|
**ADO2 영상과 썰박스 콘텐츠를 모두 담는다.** 대상은 `video_id` 또는 `content_id`
|
||||||
|
중 **정확히 하나**만 채워지며 DB `CHECK` 로 강제한다.
|
||||||
|
|
||||||
|
1인 1회 보장은 유니크 두 개로 나눠서 한다. MySQL 은 NULL 을 서로 다른 값으로
|
||||||
|
취급하므로, 썰박스 행(video_id IS NULL)이 아무리 많아도
|
||||||
|
`uq_video_reaction_user_video` 에 걸리지 않는다 — 각자 자기 유니크만 지킨다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "video_reaction"
|
__tablename__ = "video_reaction"
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"(video_id IS NULL) <> (content_id IS NULL)",
|
||||||
|
name="ck_video_reaction_one_target",
|
||||||
|
),
|
||||||
UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"),
|
UniqueConstraint("user_uuid", "video_id", name="uq_video_reaction_user_video"),
|
||||||
|
UniqueConstraint(
|
||||||
|
"user_uuid", "content_id", name="uq_video_reaction_user_content"
|
||||||
|
),
|
||||||
Index("idx_video_reaction_video_id", "video_id"),
|
Index("idx_video_reaction_video_id", "video_id"),
|
||||||
|
# 카운트 집계가 content_id 로 묶으므로 선행 컬럼 인덱스가 필요하다
|
||||||
|
# (유니크는 user_uuid 가 앞이라 이 용도로 못 쓴다).
|
||||||
|
Index("idx_video_reaction_content_id", "content_id"),
|
||||||
Index("idx_video_reaction_user_uuid", "user_uuid"),
|
Index("idx_video_reaction_user_uuid", "user_uuid"),
|
||||||
{
|
{
|
||||||
"mysql_engine": "InnoDB",
|
"mysql_engine": "InnoDB",
|
||||||
@ -189,11 +220,19 @@ class VideoReaction(Base):
|
|||||||
id: Mapped[int] = mapped_column(
|
id: Mapped[int] = mapped_column(
|
||||||
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
Integer, primary_key=True, autoincrement=True, comment="고유 식별자"
|
||||||
)
|
)
|
||||||
video_id: Mapped[int] = mapped_column(
|
# 대상은 아래 둘 중 **정확히 하나**만 채워진다 (ck_video_reaction_one_target).
|
||||||
|
video_id: Mapped[Optional[int]] = mapped_column(
|
||||||
Integer,
|
Integer,
|
||||||
ForeignKey("video.id", ondelete="CASCADE"),
|
ForeignKey("video.id", ondelete="CASCADE"),
|
||||||
nullable=False,
|
nullable=True,
|
||||||
comment="연결된 Video의 id",
|
comment="ADO2 영상 id (썰박스 반응이면 NULL)",
|
||||||
|
)
|
||||||
|
content_id: Mapped[Optional[int]] = mapped_column(
|
||||||
|
# ssul_content.id 는 BIGINT 다. INT 로 두면 FK 타입 불일치(errno 3780).
|
||||||
|
BigInteger,
|
||||||
|
ForeignKey("ssul_content.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
comment="썰박스 콘텐츠 id (ADO2 반응이면 NULL)",
|
||||||
)
|
)
|
||||||
user_uuid: Mapped[str] = mapped_column(
|
user_uuid: Mapped[str] = mapped_column(
|
||||||
String(36),
|
String(36),
|
||||||
@ -208,5 +247,8 @@ class VideoReaction(Base):
|
|||||||
comment="반응 일시",
|
comment="반응 일시",
|
||||||
)
|
)
|
||||||
|
|
||||||
video: Mapped["Video"] = relationship("Video", back_populates="reactions")
|
# 썰박스 반응이면 None 이다. 접근하는 쪽에서 반드시 방어할 것.
|
||||||
|
video: Mapped[Optional["Video"]] = relationship(
|
||||||
|
"Video", foreign_keys=[video_id], back_populates="reactions"
|
||||||
|
)
|
||||||
user: Mapped["User"] = relationship("User", back_populates="video_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, Optional
|
from typing import Any, Dict, Literal, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
@ -152,10 +152,22 @@ class VideoListItem(BaseModel):
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID")
|
# ⚠️ `video_id` 는 type 안에서만 유일하다 — `video.id` 와 `ssul_content.id` 는
|
||||||
|
# 각각 1부터 시작하는 독립 시퀀스다. 식별·삭제·상세 열기 모두
|
||||||
|
# **`(type, video_id)` 쌍**으로 다뤄야 한다.
|
||||||
|
# 특히 `DELETE /archive/videos/{id}` 는 `Video.id` 로 지우므로,
|
||||||
|
# 썰박스 항목의 id 를 그대로 넘기면 **엉뚱한 ADO2 영상이 삭제된다.**
|
||||||
|
type: Literal["video", "ssul"] = Field(
|
||||||
|
default="video",
|
||||||
|
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스)",
|
||||||
|
)
|
||||||
|
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
|
||||||
store_name: Optional[str] = Field(None, description="업체명")
|
store_name: Optional[str] = Field(None, description="업체명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
task_id: str = Field(..., description="작업 고유 식별자")
|
task_id: str = Field(
|
||||||
|
default="",
|
||||||
|
description="작업 고유 식별자 (ADO2 전용. 썰박스는 개념이 없어 빈 문자열)",
|
||||||
|
)
|
||||||
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
result_movie_url: Optional[str] = Field(None, description="영상 결과 URL")
|
||||||
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
created_at: Optional[datetime] = Field(None, description="생성 일시")
|
||||||
like_count: int = Field(0, description="좋아요 수")
|
like_count: int = Field(0, description="좋아요 수")
|
||||||
@ -169,7 +181,14 @@ class VideoThumbnailItem(BaseModel):
|
|||||||
GET /video/all 응답의 개별 영상 정보
|
GET /video/all 응답의 개별 영상 정보
|
||||||
"""
|
"""
|
||||||
|
|
||||||
video_id: int = Field(..., description="영상 고유 ID (상세 페이지 라우팅 키)")
|
# ⚠️ `video_id` 는 종류 안에서만 유일하다. `video.id` 와 `ssul_content.id` 가
|
||||||
|
# **둘 다 1부터 시작**하므로 식별자는 반드시 `(type, video_id)` 쌍으로 다뤄야 한다.
|
||||||
|
# 한 곳이라도 id 만 쓰면 다른 종류의 콘텐츠가 열린다.
|
||||||
|
type: Literal["video", "ssul"] = Field(
|
||||||
|
default="video",
|
||||||
|
description="콘텐츠 종류 (video: ADO2 영상, ssul: 썰박스). video_id 와 쌍으로 식별한다",
|
||||||
|
)
|
||||||
|
video_id: int = Field(..., description="콘텐츠 고유 ID (type 안에서만 유일)")
|
||||||
store_name: str = Field(..., description="업체명")
|
store_name: str = Field(..., description="업체명")
|
||||||
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
|
result_movie_url: str = Field(..., description="영상 URL — 프론트에서 <video> 태그 첫 프레임을 썸네일로 사용")
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
|
|||||||
468
app/video/services/unified_list.py
Normal file
468
app/video/services/unified_list.py
Normal file
@ -0,0 +1,468 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""ADO2 영상 + 썰박스 콘텐츠 통합 목록.
|
||||||
|
|
||||||
|
`GET /video/all`(전체 갤러리)과 `GET /archive/videos/`(내 콘텐츠)가 두 종류를
|
||||||
|
**하나의 정렬·페이징된 목록**으로 내려주기 위한 공용 쿼리다.
|
||||||
|
|
||||||
|
**왜 프론트에서 못 합치나**: 정렬·검색·지역 필터·페이지네이션이 전부 서버사이드다.
|
||||||
|
두 API 를 각각 호출해 이어붙이면 1페이지에 ADO2 12개 + 썰박스 12개가 각자 안에서만
|
||||||
|
정렬된 채 섞이고 `total`/`has_next` 도 어긋난다.
|
||||||
|
|
||||||
|
**왜 테이블을 안 합치나**: `video` 는 `project_id`/`lyric_id`/`song_id` 가 NOT NULL 이고
|
||||||
|
소유자·업장명이 `project` 에 있다. 썰박스를 넣으려면 그 4개를 nullable 로 열고
|
||||||
|
`Video` 를 참조하는 코드 72곳을 전부 감사해야 한다(2026-07-30 조사).
|
||||||
|
|
||||||
|
여기 사는 이유: 두 엔드포인트(`app/video`, `app/archive`)가 함께 쓰는데 엔드포인트
|
||||||
|
소유가 video 쪽이라 여기 뒀다. `app/archive` 는 이미 `app.video.models` 를 import 한다.
|
||||||
|
|
||||||
|
## 성능 설계 — 카운트를 UNION 안에 넣지 않는다
|
||||||
|
|
||||||
|
UNION 은 양쪽 브랜치를 **먼저 구체화**하므로, select 목록에 상관 서브쿼리를 두면
|
||||||
|
페이지 12건이 아니라 **매칭된 전체 행**에 대해 평가된다. 그래서
|
||||||
|
`created_at` 정렬(기본)에서는 카운트를 빼고, 페이지가 확정된 뒤 그 12건만 집계한다.
|
||||||
|
좋아요·댓글 수로 **정렬할 때만** 어쩔 수 없이 브랜치 안에서 계산한다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Select, func, literal, or_, select, union_all
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.comment.models import Comment
|
||||||
|
from app.database.like_cache import (
|
||||||
|
CT_SSUL,
|
||||||
|
CT_VIDEO,
|
||||||
|
backfill_user_set,
|
||||||
|
bulk_is_user_liked,
|
||||||
|
get_like_counts,
|
||||||
|
mset_like_counts,
|
||||||
|
)
|
||||||
|
from app.home.models import Project
|
||||||
|
from app.ssulbox.models import SsulContent
|
||||||
|
from app.utils.address_parser import SIDO_CITIES, SIDO_SEARCH_ALIASES
|
||||||
|
from app.video.models import Video, VideoReaction
|
||||||
|
|
||||||
|
ContentType = Literal["video", "ssul"]
|
||||||
|
|
||||||
|
#: 정렬 가능한 키. 그 외 값은 created_at 으로 떨어진다.
|
||||||
|
SORT_CREATED = "created_at"
|
||||||
|
SORT_LIKE = "like_count"
|
||||||
|
SORT_COMMENT = "comment_count"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class UnifiedItem:
|
||||||
|
"""통합 목록의 한 항목.
|
||||||
|
|
||||||
|
`id` 는 종류마다 **독립적인 시퀀스**다(`video.id` 와 `ssul_content.id` 모두 1부터
|
||||||
|
시작한다). 따라서 식별자는 반드시 `(ctype, id)` 쌍으로 다뤄야 한다 —
|
||||||
|
한 곳이라도 id 만 쓰면 다른 콘텐츠가 열린다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ctype: ContentType
|
||||||
|
id: int
|
||||||
|
store_name: str
|
||||||
|
region: Optional[str]
|
||||||
|
movie_url: str
|
||||||
|
created_at: datetime
|
||||||
|
#: ADO2 전용. 썰박스는 task_id 개념이 없어 빈 문자열이다
|
||||||
|
#: (`ssul_content` 는 task/content 를 한 테이블로 합쳤고 크레딧 앵커는 id 다).
|
||||||
|
task_id: str = ""
|
||||||
|
like_count: int = 0
|
||||||
|
comment_count: int = 0
|
||||||
|
is_liked_by_me: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 상관 서브쿼리 (정렬용)
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _video_like_subq():
|
||||||
|
return (
|
||||||
|
select(func.count(VideoReaction.id))
|
||||||
|
.where(VideoReaction.video_id == Video.id)
|
||||||
|
.correlate(Video)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _video_comment_subq():
|
||||||
|
return (
|
||||||
|
select(func.count(Comment.id))
|
||||||
|
.where(Comment.video_id == Video.id, Comment.is_deleted.is_(False))
|
||||||
|
.correlate(Video)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssul_like_subq():
|
||||||
|
# 좋아요·댓글은 castad 테이블에 합쳤다(2026-07-30). 썰박스 행은 video_id 대신
|
||||||
|
# content_id 가 채워져 있다 — 테이블은 같고 컬럼만 다르다.
|
||||||
|
return (
|
||||||
|
select(func.count(VideoReaction.id))
|
||||||
|
.where(VideoReaction.content_id == SsulContent.id)
|
||||||
|
.correlate(SsulContent)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssul_comment_subq():
|
||||||
|
return (
|
||||||
|
select(func.count(Comment.id))
|
||||||
|
.where(
|
||||||
|
Comment.content_id == SsulContent.id,
|
||||||
|
Comment.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.correlate(SsulContent)
|
||||||
|
.scalar_subquery()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 필터
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _region_clause(region_col, detail_col, region: str):
|
||||||
|
"""castad `/video/all` 과 **동일한 규칙**으로 지역을 거른다.
|
||||||
|
|
||||||
|
시/도 이름이면 그 안의 시·군 목록으로 매칭하고, 동시에 상세 주소를 별칭으로
|
||||||
|
부분 일치 검색한다. 이 두 경로를 맞추지 않으면 같은 검색어에 ADO2 는 나오고
|
||||||
|
썰박스는 안 나오는 비대칭이 생긴다.
|
||||||
|
"""
|
||||||
|
cities = SIDO_CITIES.get(region)
|
||||||
|
if cities:
|
||||||
|
aliases = SIDO_SEARCH_ALIASES.get(region, [region])
|
||||||
|
return or_(
|
||||||
|
region_col.in_(cities),
|
||||||
|
*[detail_col.ilike(f"%{a}%") for a in aliases],
|
||||||
|
)
|
||||||
|
return or_(
|
||||||
|
region_col.ilike(f"%{region}%"),
|
||||||
|
detail_col.ilike(f"%{region}%"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _video_where(store_name: Optional[str], region: Optional[str]) -> list:
|
||||||
|
clauses = [
|
||||||
|
Video.status == "completed",
|
||||||
|
Video.is_deleted.is_(False),
|
||||||
|
Project.is_deleted.is_(False),
|
||||||
|
Video.result_movie_url.is_not(None),
|
||||||
|
]
|
||||||
|
if store_name:
|
||||||
|
clauses.append(Project.store_name.ilike(f"%{store_name}%"))
|
||||||
|
if region:
|
||||||
|
clauses.append(
|
||||||
|
_region_clause(Project.region, Project.detail_region_info, region)
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
|
||||||
|
def _ssul_where(store_name: Optional[str], region: Optional[str]) -> list:
|
||||||
|
clauses = [
|
||||||
|
SsulContent.status == "done",
|
||||||
|
SsulContent.is_deleted.is_(False),
|
||||||
|
SsulContent.video_url.is_not(None),
|
||||||
|
]
|
||||||
|
if store_name:
|
||||||
|
clauses.append(SsulContent.store_name.ilike(f"%{store_name}%"))
|
||||||
|
if region:
|
||||||
|
clauses.append(
|
||||||
|
_region_clause(
|
||||||
|
SsulContent.region, SsulContent.detail_region_info, region
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 브랜치
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _video_branch(where: list, sort_by: str) -> Select:
|
||||||
|
cols = [
|
||||||
|
literal(CT_VIDEO).label("ctype"),
|
||||||
|
Video.id.label("cid"),
|
||||||
|
Project.store_name.label("store_name"),
|
||||||
|
Project.region.label("region"),
|
||||||
|
Video.result_movie_url.label("movie_url"),
|
||||||
|
Video.created_at.label("created_at"),
|
||||||
|
Video.task_id.label("task_id"),
|
||||||
|
]
|
||||||
|
if sort_by == SORT_LIKE:
|
||||||
|
cols.append(_video_like_subq().label("sort_value"))
|
||||||
|
elif sort_by == SORT_COMMENT:
|
||||||
|
cols.append(_video_comment_subq().label("sort_value"))
|
||||||
|
return select(*cols).join(Project, Video.project_id == Project.id).where(*where)
|
||||||
|
|
||||||
|
|
||||||
|
def _ssul_branch(where: list, sort_by: str) -> Select:
|
||||||
|
cols = [
|
||||||
|
literal(CT_SSUL).label("ctype"),
|
||||||
|
SsulContent.id.label("cid"),
|
||||||
|
SsulContent.store_name.label("store_name"),
|
||||||
|
SsulContent.region.label("region"),
|
||||||
|
SsulContent.video_url.label("movie_url"),
|
||||||
|
SsulContent.created_at.label("created_at"),
|
||||||
|
# UNION 은 컬럼 수·순서가 양쪽 같아야 한다. 썰박스에는 task_id 가 없다.
|
||||||
|
literal("").label("task_id"),
|
||||||
|
]
|
||||||
|
if sort_by == SORT_LIKE:
|
||||||
|
cols.append(_ssul_like_subq().label("sort_value"))
|
||||||
|
elif sort_by == SORT_COMMENT:
|
||||||
|
cols.append(_ssul_comment_subq().label("sort_value"))
|
||||||
|
return select(*cols).where(*where)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 페이지 확정 후 집계
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _reaction_target(ctype: str):
|
||||||
|
"""반응 테이블에서 이 종류가 쓰는 대상 컬럼.
|
||||||
|
|
||||||
|
`comment` / `video_reaction` 은 두 종류를 함께 담고 `video_id` 또는 `content_id`
|
||||||
|
중 하나만 채운다(CHECK 로 강제). 어느 컬럼을 볼지만 갈아끼우면 된다.
|
||||||
|
"""
|
||||||
|
return VideoReaction.video_id if ctype == CT_VIDEO else VideoReaction.content_id
|
||||||
|
|
||||||
|
|
||||||
|
def _comment_target(ctype: str):
|
||||||
|
return Comment.video_id if ctype == CT_VIDEO else Comment.content_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _like_counts_for(
|
||||||
|
session: AsyncSession, ctype: str, ids: list[int]
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""Redis 우선, 미스는 DB 로 보정하고 캐시에 채운다.
|
||||||
|
|
||||||
|
castad 기존 목록 로직과 동일한 절차다. 종류별 키 접두를 쓰므로
|
||||||
|
`video.id` 와 `ssul_content.id` 가 겹쳐도 섞이지 않는다.
|
||||||
|
"""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
counts = await get_like_counts(ids, ctype=ctype)
|
||||||
|
missing = [cid for cid, cnt in counts.items() if cnt is None]
|
||||||
|
if missing:
|
||||||
|
col = _reaction_target(ctype)
|
||||||
|
stmt = (
|
||||||
|
select(col, func.count(VideoReaction.id))
|
||||||
|
.where(col.in_(missing))
|
||||||
|
.group_by(col)
|
||||||
|
)
|
||||||
|
found = {cid: cnt for cid, cnt in (await session.execute(stmt)).all()}
|
||||||
|
await mset_like_counts(found, ctype=ctype)
|
||||||
|
for cid in missing:
|
||||||
|
counts[cid] = found.get(cid, 0)
|
||||||
|
return {cid: (cnt or 0) for cid, cnt in counts.items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def _comment_counts_for(
|
||||||
|
session: AsyncSession, ctype: str, ids: list[int]
|
||||||
|
) -> dict[int, int]:
|
||||||
|
"""페이지에 포함된 항목만 집계한다(UNION 안에서 계산하지 않는 이유는 모듈 docstring 참조)."""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
col = _comment_target(ctype)
|
||||||
|
stmt = (
|
||||||
|
select(col, func.count(Comment.id))
|
||||||
|
.where(col.in_(ids), Comment.is_deleted.is_(False))
|
||||||
|
.group_by(col)
|
||||||
|
)
|
||||||
|
found = {cid: cnt for cid, cnt in (await session.execute(stmt)).all()}
|
||||||
|
return {cid: found.get(cid, 0) for cid in ids}
|
||||||
|
|
||||||
|
|
||||||
|
async def _liked_map_for(
|
||||||
|
session: AsyncSession,
|
||||||
|
ctype: str,
|
||||||
|
ids: list[int],
|
||||||
|
user_uuid: str,
|
||||||
|
like_counts: dict[int, int],
|
||||||
|
) -> dict[int, bool]:
|
||||||
|
"""Redis user-set 기준. cold-start 인 항목만 DB 에서 backfill 한다."""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
raw = await bulk_is_user_liked(ids, user_uuid, ctype=ctype)
|
||||||
|
# user-set 키가 없고(None) 카운트가 0 보다 큰 것만 채우면 된다 —
|
||||||
|
# 좋아요가 0 이면 backfill 해도 결과가 같다.
|
||||||
|
needs = [cid for cid, liked in raw.items() if liked is None and like_counts.get(cid, 0) > 0]
|
||||||
|
if needs:
|
||||||
|
col = _reaction_target(ctype)
|
||||||
|
stmt = select(col, VideoReaction.user_uuid).where(col.in_(needs))
|
||||||
|
by_content: dict[int, list[str]] = {cid: [] for cid in needs}
|
||||||
|
for cid, uuid in (await session.execute(stmt)).all():
|
||||||
|
by_content[cid].append(uuid)
|
||||||
|
for cid in needs:
|
||||||
|
await backfill_user_set(cid, by_content[cid], ctype=ctype)
|
||||||
|
raw.update(await bulk_is_user_liked(needs, user_uuid, ctype=ctype))
|
||||||
|
return {cid: bool(liked) for cid, liked in raw.items()}
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich(
|
||||||
|
session: AsyncSession,
|
||||||
|
items: list[UnifiedItem],
|
||||||
|
user_uuid: Optional[str],
|
||||||
|
) -> list[UnifiedItem]:
|
||||||
|
"""페이지에 실린 항목에만 좋아요·댓글 수와 내 좋아요 여부를 채운다."""
|
||||||
|
for ctype in (CT_VIDEO, CT_SSUL):
|
||||||
|
ids = [it.id for it in items if it.ctype == ctype]
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
likes = await _like_counts_for(session, ctype, ids)
|
||||||
|
comments = await _comment_counts_for(session, ctype, ids)
|
||||||
|
liked = (
|
||||||
|
await _liked_map_for(session, ctype, ids, user_uuid, likes)
|
||||||
|
if user_uuid
|
||||||
|
else {}
|
||||||
|
)
|
||||||
|
for it in items:
|
||||||
|
if it.ctype != ctype:
|
||||||
|
continue
|
||||||
|
it.like_count = likes.get(it.id, 0)
|
||||||
|
it.comment_count = comments.get(it.id, 0)
|
||||||
|
it.is_liked_by_me = liked.get(it.id, False)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
# 공개 API
|
||||||
|
# ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def fetch_gallery(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
sort_by: str = SORT_CREATED,
|
||||||
|
order: str = "desc",
|
||||||
|
store_name: Optional[str] = None,
|
||||||
|
region: Optional[str] = None,
|
||||||
|
user_uuid: Optional[str] = None,
|
||||||
|
include_ssul: bool = True,
|
||||||
|
) -> tuple[list[UnifiedItem], int]:
|
||||||
|
"""전체 갤러리(`/video/all`). (항목, 전체 개수) 반환."""
|
||||||
|
v_where = _video_where(store_name, region)
|
||||||
|
s_where = _ssul_where(store_name, region)
|
||||||
|
|
||||||
|
# 전체 개수는 브랜치별로 센다 — UNION 을 만들어 세는 것보다 싸다.
|
||||||
|
total = (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(Video.id))
|
||||||
|
.join(Project, Video.project_id == Project.id)
|
||||||
|
.where(*v_where)
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
if include_ssul:
|
||||||
|
total += (
|
||||||
|
await session.execute(
|
||||||
|
select(func.count(SsulContent.id)).where(*s_where)
|
||||||
|
)
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
branches = [_video_branch(v_where, sort_by)]
|
||||||
|
if include_ssul:
|
||||||
|
branches.append(_ssul_branch(s_where, sort_by))
|
||||||
|
|
||||||
|
u = union_all(*branches).subquery() if len(branches) > 1 else branches[0].subquery()
|
||||||
|
|
||||||
|
sort_col = (
|
||||||
|
u.c.sort_value if sort_by in (SORT_LIKE, SORT_COMMENT) else u.c.created_at
|
||||||
|
)
|
||||||
|
order_by = [sort_col.asc() if order == "asc" else sort_col.desc()]
|
||||||
|
# 정렬 키가 같을 때 페이지 경계에서 순서가 흔들리면 같은 항목이 두 번 보이거나
|
||||||
|
# 아예 빠진다. 안정적인 2차 키를 반드시 둔다.
|
||||||
|
if sort_by in (SORT_LIKE, SORT_COMMENT):
|
||||||
|
order_by.append(u.c.created_at.desc())
|
||||||
|
order_by.extend([u.c.ctype.asc(), u.c.cid.desc()])
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(u).order_by(*order_by).offset(offset).limit(limit)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
items = _to_items(rows)
|
||||||
|
await enrich(session, items, user_uuid)
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
|
||||||
|
def _to_items(rows) -> list[UnifiedItem]:
|
||||||
|
return [
|
||||||
|
UnifiedItem(
|
||||||
|
ctype=r.ctype,
|
||||||
|
id=r.cid,
|
||||||
|
store_name=r.store_name or "",
|
||||||
|
region=r.region,
|
||||||
|
movie_url=r.movie_url,
|
||||||
|
created_at=r.created_at,
|
||||||
|
task_id=r.task_id or "",
|
||||||
|
)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_my_contents(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
user_uuid: str,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
) -> tuple[list[UnifiedItem], int]:
|
||||||
|
"""내 콘텐츠(`/archive/videos/`). (항목, 전체 개수) 반환.
|
||||||
|
|
||||||
|
갤러리와 다른 점 둘:
|
||||||
|
- 소유자로 거른다 (ADO2 는 `project.user_uuid`, 썰박스는 `ssul_content.user_uuid`)
|
||||||
|
- ADO2 는 **task_id 당 최신 1건만** 남긴다(같은 작업으로 여러 영상이 생길 수
|
||||||
|
있다). 썰박스는 task_id 개념이 없어 중복 제거가 필요 없다.
|
||||||
|
|
||||||
|
⚠️ **프론트는 반드시 `ctype` 으로 분기해야 한다.** 이 목록에는 삭제 버튼이 붙는데
|
||||||
|
`DELETE /archive/videos/{id}` 는 `Video.id` 로 지운다. 썰박스 항목의 id 를 그대로
|
||||||
|
넘기면 **id 가 겹치는 ADO2 영상이 삭제된다**(둘 다 1부터 시작하는 독립 시퀀스).
|
||||||
|
"""
|
||||||
|
# ADO2: task_id 별 최신 영상만 (기존 동작 보존)
|
||||||
|
latest_ids = (
|
||||||
|
select(func.max(Video.id).label("latest_id"))
|
||||||
|
.join(Project, Video.project_id == Project.id)
|
||||||
|
.where(
|
||||||
|
Project.user_uuid == user_uuid,
|
||||||
|
Video.status == "completed",
|
||||||
|
Video.is_deleted.is_(False),
|
||||||
|
Project.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.group_by(Video.task_id)
|
||||||
|
.subquery()
|
||||||
|
)
|
||||||
|
v_where = [Video.id.in_(select(latest_ids.c.latest_id))]
|
||||||
|
s_where = [
|
||||||
|
SsulContent.user_uuid == user_uuid,
|
||||||
|
SsulContent.status == "done",
|
||||||
|
SsulContent.is_deleted.is_(False),
|
||||||
|
SsulContent.video_url.is_not(None),
|
||||||
|
]
|
||||||
|
|
||||||
|
total = ((await session.execute(
|
||||||
|
select(func.count(Video.id)).where(*v_where)
|
||||||
|
)).scalar() or 0) + ((await session.execute(
|
||||||
|
select(func.count(SsulContent.id)).where(*s_where)
|
||||||
|
)).scalar() or 0)
|
||||||
|
|
||||||
|
u = union_all(
|
||||||
|
_video_branch(v_where, SORT_CREATED),
|
||||||
|
_ssul_branch(s_where, SORT_CREATED),
|
||||||
|
).subquery()
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
select(u)
|
||||||
|
.order_by(u.c.created_at.desc(), u.c.ctype.asc(), u.c.cid.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
items = _to_items(rows)
|
||||||
|
await enrich(session, items, user_uuid)
|
||||||
|
return items, total
|
||||||
@ -1,13 +1,7 @@
|
|||||||
import random
|
|
||||||
from typing import List
|
|
||||||
|
|
||||||
from fastapi import Request, status
|
|
||||||
from fastapi.exceptions import HTTPException
|
|
||||||
from sqlalchemy import Connection, text
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
from sqlalchemy import select, func
|
from sqlalchemy import select, func
|
||||||
from app.database.session import AsyncSessionLocal
|
from app.database.session import AsyncSessionLocal
|
||||||
from app.home.models import Image, ImageTag, Project
|
from app.home.models import Image, ImageTag
|
||||||
|
|
||||||
# from app.lyric.schemas.lyrics_schema import (
|
# from app.lyric.schemas.lyrics_schema import (
|
||||||
# AttributeData,
|
# AttributeData,
|
||||||
@ -837,7 +831,6 @@ logger = get_logger("video")
|
|||||||
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
# status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
# detail="서비스 처리 중 오류가 발생했습니다.",
|
# detail="서비스 처리 중 오류가 발생했습니다.",
|
||||||
# )
|
# )
|
||||||
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:
|
||||||
|
|||||||
@ -4,7 +4,6 @@ Video Background Tasks
|
|||||||
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
영상 생성 관련 백그라운드 태스크를 정의합니다.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import traceback
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
@ -12,8 +11,9 @@ import httpx
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from app.credit.services.credit_service import refund_credit_for_job
|
||||||
from app.database.session import BackgroundSessionLocal
|
from app.database.session import BackgroundSessionLocal
|
||||||
from app.user.services.credit import consume_credit
|
from app.ssulbox.constants import JOB_TYPE_VIDEO as CREDIT_JOB_TYPE_VIDEO
|
||||||
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
|
||||||
@ -24,6 +24,57 @@ logger = get_logger("video")
|
|||||||
# HTTP 요청 설정
|
# HTTP 요청 설정
|
||||||
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
|
REQUEST_TIMEOUT = 300.0 # 초 (영상은 용량이 크므로 5분)
|
||||||
|
|
||||||
|
#: 영상 1편 생성에 차감/환불할 크레딧 (generate_video 의 VIDEO_CREDIT_COST 와 동일해야 함)
|
||||||
|
VIDEO_CREDIT_COST = 1
|
||||||
|
|
||||||
|
|
||||||
|
async def _fail_and_refund(
|
||||||
|
task_id: str | None,
|
||||||
|
creatomate_render_id: str | None = None,
|
||||||
|
user_uuid: str | None = None,
|
||||||
|
*,
|
||||||
|
reason: str,
|
||||||
|
) -> None:
|
||||||
|
"""영상 실패 처리 + 선차감한 크레딧 환불.
|
||||||
|
|
||||||
|
크레딧은 generate_video 에서 선차감되므로 실패 시 돌려줘야 한다.
|
||||||
|
refund_credit_for_job 은 (job_type, job_ref) 기준 멱등이라 실패 콜백이
|
||||||
|
여러 번 와도 환불은 한 번만 일어난다. 차감 기록이 없으면(정책 전환 이전에
|
||||||
|
시작된 in-flight 영상) 아무것도 하지 않는다.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
task_id: 프로젝트 task_id (크레딧 멱등 키이기도 하다)
|
||||||
|
creatomate_render_id: Creatomate 렌더 ID
|
||||||
|
user_uuid: 환불 대상 사용자. None 이면 환불을 건너뛴다
|
||||||
|
reason: 원장에 남길 사유
|
||||||
|
"""
|
||||||
|
if task_id:
|
||||||
|
await _update_video_status(
|
||||||
|
task_id, "failed", creatomate_render_id=creatomate_render_id
|
||||||
|
)
|
||||||
|
|
||||||
|
if not task_id or not user_uuid:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with BackgroundSessionLocal() as session:
|
||||||
|
await refund_credit_for_job(
|
||||||
|
session=session,
|
||||||
|
user_uuid=user_uuid,
|
||||||
|
amount=VIDEO_CREDIT_COST,
|
||||||
|
job_type=CREDIT_JOB_TYPE_VIDEO,
|
||||||
|
job_ref=task_id,
|
||||||
|
reason=reason,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
# 환불 실패로 실패 처리 자체가 막히면 안 된다. 로그를 남기고 넘어간다.
|
||||||
|
logger.error(
|
||||||
|
f"[_fail_and_refund] REFUND FAILED - task_id: {task_id}, "
|
||||||
|
f"user_uuid: {user_uuid}, error: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _update_video_status(
|
async def _update_video_status(
|
||||||
task_id: str,
|
task_id: str,
|
||||||
@ -156,24 +207,21 @@ async def download_and_upload_video_to_blob(
|
|||||||
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
# Video 테이블 업데이트 (creatomate_render_id로 특정 Video 식별)
|
||||||
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
await _update_video_status(task_id, "completed", blob_url, creatomate_render_id)
|
||||||
|
|
||||||
# 영상 생성 완료 시 크레딧 1 차감 (credits > 0 조건으로 음수 방지)
|
# 크레딧은 generate_video 에서 이미 선차감했다. 여기서 차감하지 않는다.
|
||||||
async with BackgroundSessionLocal() as session:
|
|
||||||
await consume_credit(user_uuid, session)
|
|
||||||
await session.commit()
|
|
||||||
|
|
||||||
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
|
logger.info(f"[download_and_upload_video_to_blob] SUCCESS - task_id: {task_id}, creatomate_render_id: {creatomate_render_id}")
|
||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
logger.error(f"[download_and_upload_video_to_blob] DOWNLOAD ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
logger.error(f"[download_and_upload_video_to_blob] DB ERROR - task_id: {task_id}, error: {e}", exc_info=True)
|
||||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
logger.error(f"[download_and_upload_video_to_blob] EXCEPTION - task_id: {task_id}, error: {e}", exc_info=True)
|
||||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
@ -270,18 +318,15 @@ async def download_and_upload_video_by_creatomate_render_id(
|
|||||||
|
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DOWNLOAD ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||||
if task_id:
|
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 다운로드 실패")
|
||||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
|
||||||
|
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
logger.error(f"[download_and_upload_video_by_creatomate_render_id] DB ERROR - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||||
if task_id:
|
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 저장 실패")
|
||||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
logger.error(f"[download_and_upload_video_by_creatomate_render_id] EXCEPTION - creatomate_render_id: {creatomate_render_id}, error: {e}", exc_info=True)
|
||||||
if task_id:
|
await _fail_and_refund(task_id, creatomate_render_id, user_uuid, reason="영상 처리 실패")
|
||||||
await _update_video_status(task_id, "failed", creatomate_render_id=creatomate_render_id)
|
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
# 임시 파일 삭제
|
# 임시 파일 삭제
|
||||||
|
|||||||
85
config.py
85
config.py
@ -641,6 +641,90 @@ class SocialUploadSettings(BaseSettings):
|
|||||||
model_config = _base_config
|
model_config = _base_config
|
||||||
|
|
||||||
|
|
||||||
|
class SsulboxSettings(BaseSettings):
|
||||||
|
"""썰박스(병맛 역사 썰툰 쇼츠) 생성 설정.
|
||||||
|
|
||||||
|
GEMINI/NAVER/AZURE/JWT/KAKAO 등은 **castad 기존 설정을 재사용**하므로 여기 두지
|
||||||
|
않는다. 썰박스 고유 동작만 담는다.
|
||||||
|
|
||||||
|
⚠️ `GEMINI_API_KEY` 는 `APIKeySettings` 의 것을 쓰되, 기본값이 플레이스홀더
|
||||||
|
(`"your-gemeni-api-key"`)라 `bool()` 검사가 항상 True 다.
|
||||||
|
반드시 `app/ssulbox/constants.py` 의 `gemini_key()` 를 통해 읽을 것.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SSULBOX_ENABLED: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="썰박스 기능 활성화. False 면 라우터 등록·스키마 보장·고아 스윕을 모두 건너뛴다",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 생성 엔진 경로
|
||||||
|
# ============================================================
|
||||||
|
SSULBOX_GENERATOR_DIR: str = Field(
|
||||||
|
default="generator",
|
||||||
|
description="생성 엔진 디렉토리 (프로젝트 루트 기준 상대 경로). app/ 패키지 밖에 둔다",
|
||||||
|
)
|
||||||
|
SSULBOX_OUTPUT_DIR: str = Field(
|
||||||
|
default="generator/output",
|
||||||
|
description="생성 산출물 디렉토리 (프로젝트 루트 기준 상대 경로)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 잡 실행
|
||||||
|
# ============================================================
|
||||||
|
SSULBOX_MAX_CONCURRENT_JOBS: int = Field(
|
||||||
|
default=1,
|
||||||
|
description=(
|
||||||
|
"동시 실행 생성 잡 수. 영상 렌더가 API 프로세스와 CPU를 공유하므로 1을 권장. "
|
||||||
|
"높이면 요청 응답 지연이 커진다"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SSULBOX_JOB_TIMEOUT_SECONDS: int = Field(
|
||||||
|
default=900,
|
||||||
|
description=(
|
||||||
|
"생성 subprocess 최대 실행 시간(초). 초과 시 kill 후 환불 처리. "
|
||||||
|
"실측: scenes=4/seconds=20 이 75초. 기본값 scenes=9 는 씬 수에 "
|
||||||
|
"선형 비례하므로 3~5분대 예상 → 900초는 약 3배 여유. "
|
||||||
|
"프론트 폴링 한도(30분)보다 짧아야 사용자가 먼저 포기하는 일이 없다"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
SSULBOX_DB_DELEGATE_TIMEOUT: int = Field(
|
||||||
|
default=60,
|
||||||
|
description=(
|
||||||
|
"워커 스레드가 앱 이벤트 루프에 DB 작업을 위임할 때의 대기 한도(초). "
|
||||||
|
"asyncmy 풀이 루프에 바인딩되므로 스레드에서 직접 쓸 수 없다"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 크레딧 · 저장소 · 폴링
|
||||||
|
# ============================================================
|
||||||
|
SSULBOX_CREDITS_PER_VIDEO: int = Field(
|
||||||
|
default=1,
|
||||||
|
description="생성 1건당 차감 크레딧 (요청 시점에 선차감, 실패 시 환불)",
|
||||||
|
)
|
||||||
|
SSULBOX_BLOB_PREFIX: str = Field(
|
||||||
|
default="ssulbox",
|
||||||
|
description="Azure Blob 경로 접두. castad 영상과 저장 경로를 분리한다",
|
||||||
|
)
|
||||||
|
SSULBOX_POLL_HINT_SECONDS: int = Field(
|
||||||
|
default=3,
|
||||||
|
description="클라이언트에 알려줄 권장 폴링 간격(초). castad waitForVideoComplete 와 동일",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_config = _base_config
|
||||||
|
|
||||||
|
@property
|
||||||
|
def generator_path(self) -> Path:
|
||||||
|
"""생성 엔진 절대 경로"""
|
||||||
|
return PROJECT_DIR / self.SSULBOX_GENERATOR_DIR
|
||||||
|
|
||||||
|
@property
|
||||||
|
def output_path(self) -> Path:
|
||||||
|
"""생성 산출물 절대 경로"""
|
||||||
|
return PROJECT_DIR / self.SSULBOX_OUTPUT_DIR
|
||||||
|
|
||||||
|
|
||||||
prj_settings = ProjectSettings()
|
prj_settings = ProjectSettings()
|
||||||
cors_settings = CORSSettings()
|
cors_settings = CORSSettings()
|
||||||
apikey_settings = APIKeySettings()
|
apikey_settings = APIKeySettings()
|
||||||
@ -658,3 +742,4 @@ social_oauth_settings = SocialOAuthSettings()
|
|||||||
meta_conversion_settings = MetaConversionSettings()
|
meta_conversion_settings = MetaConversionSettings()
|
||||||
internal_settings = InternalSettings()
|
internal_settings = InternalSettings()
|
||||||
social_upload_settings = SocialUploadSettings()
|
social_upload_settings = SocialUploadSettings()
|
||||||
|
ssulbox_settings = SsulboxSettings()
|
||||||
|
|||||||
15
generator/animation/character/README.txt
Normal file
15
generator/animation/character/README.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
|
||||||
|
|
||||||
|
이 폴더에 고양이 왕 기준 이미지를 1장 넣어두면(예: king_ref.png),
|
||||||
|
모든 쇼츠 영상의 고양이 왕이 '그 눈과 표정'을 그대로 따라갑니다.
|
||||||
|
|
||||||
|
- 고정되는 것 : 눈 모양 / 눈동자 / 표정 (귀여운 순한 눈으로 통일)
|
||||||
|
- 고정 안 됨 : 곤룡포 색, 관모, 포즈, 배경 → 왕별 character_sheet / 장면 프롬프트대로
|
||||||
|
(즉 광해군·순종 등 왕마다 복장은 다르게 나옴)
|
||||||
|
|
||||||
|
- 파일명은 자유 (폴더 안 '첫 번째' 이미지 파일을 사용. png/jpg/jpeg/webp)
|
||||||
|
- 한 장만 두는 걸 권장 (여러 장이면 정렬상 첫 파일만 사용)
|
||||||
|
- 빼고 싶으면 이미지를 폴더 밖으로 빼거나 폴더를 비우면 됨
|
||||||
|
→ 그럼 예전처럼 영상마다 캐릭터를 새로 그림(눈은 EYE_STYLE 텍스트 지시로 귀엽게 유지)
|
||||||
|
|
||||||
|
연결 코드: make_short.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||||
BIN
generator/animation/character/image.png
Normal file
BIN
generator/animation/character/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
394
generator/animation/data/joseon_keywords_20260601_092737.csv
Normal file
394
generator/animation/data/joseon_keywords_20260601_092737.csv
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
대수,왕,문서제목,썰후보여부,키워드,URL
|
||||||
|
1,태조,태조(조선),,태조(조선),https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,개요,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,생애,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),Y,이름,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,묘호(廟號)와 시호(諡號),https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,휘(諱),https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),Y,가족 관계,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,사용한 무구,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,어궁구,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,전어도,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,화살보다 빠른 말,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,팔준마,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,직접 쓴 글과 시,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),Y,평가,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,어진,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),Y,여담,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),Y,대중매체,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,관련 문서,https://namu.wiki/w/태조(조선)
|
||||||
|
1,태조,태조(조선),,둘러보기,https://namu.wiki/w/태조(조선)
|
||||||
|
2,정종,정종(조선),,정종(조선),https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,개요,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,생애,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),Y,가족 관계,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,선대 가계,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,배우자 / 자녀,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),Y,평가,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),Y,여담,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,정종 무인정사 배후설?,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),Y,대중매체,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,관련 문서,https://namu.wiki/w/정종(조선)
|
||||||
|
2,정종,정종(조선),,둘러보기,https://namu.wiki/w/정종(조선)
|
||||||
|
3,태종,태종(조선),,태종(조선),https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,개요,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,묘호와 시호,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),Y,이름과 작위,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,생애,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),Y,평가,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),Y,일화,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,직접 쓴 글과 시,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),Y,기타,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),Y,가족 관계,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),Y,대중매체,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,관련 문서,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,외부 링크,https://namu.wiki/w/태종(조선)
|
||||||
|
3,태종,태종(조선),,둘러보기,https://namu.wiki/w/태종(조선)
|
||||||
|
4,세종,세종(조선),,세종(조선),https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,개요,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,생애,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),Y,업적,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),Y,비판과 반론,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),Y,특이한 기록들,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,가계,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,조상,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,배우자 / 자녀,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,영릉,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,어진,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),Y,대중매체,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,직접 쓴 글과 시,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),Y,여담,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),Y,어록,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,관련 단체,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,관련 문서,https://namu.wiki/w/세종(조선)
|
||||||
|
4,세종,세종(조선),,둘러보기,https://namu.wiki/w/세종(조선)
|
||||||
|
5,문종,문종(조선),,문종(조선),https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,개요,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,생애,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),Y,평가,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,가계,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,조상,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,배우자 / 자녀,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,직접 쓴 글과 시,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),Y,기타,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),Y,대중매체,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,드라마,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,영화,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,소설,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,게임,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,관련 문서,https://namu.wiki/w/문종(조선)
|
||||||
|
5,문종,문종(조선),,둘러보기,https://namu.wiki/w/문종(조선)
|
||||||
|
6,단종,단종(조선),,단종(조선),https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,개요,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,휘,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,생애,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),Y,평가,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),Y,가족관계,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,조상,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,형제자매,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,배우자,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,직접 쓴 글과 시,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,어진,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),Y,대중매체,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,만화,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,연극,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,영화,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,드라마,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,다큐멘터리,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,TV조선왕조실록,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,고운님 여의옵고,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,EBS 특집 다큐멘터리,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,MBC 다큐프라임,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,역사스페셜 시간여행자,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,소설,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),Y,여담,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,관련 문서,https://namu.wiki/w/단종(조선)
|
||||||
|
6,단종,단종(조선),,둘러보기,https://namu.wiki/w/단종(조선)
|
||||||
|
7,세조,세조(조선),,세조(조선),https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,개요,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,생애,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),Y,평가,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,어진,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,어필,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,수결(서명),https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,직접 쓴 글과 시,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,가계,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,조상,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,배우자 / 자녀,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),Y,대중매체,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),Y,여담,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,관련 유물,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,관련 문서,https://namu.wiki/w/세조(조선)
|
||||||
|
7,세조,세조(조선),,둘러보기,https://namu.wiki/w/세조(조선)
|
||||||
|
8,예종,예종(조선),,예종(조선),https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,개요,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,생애,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,직접 쓴 글과 시,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),Y,평가,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,가계,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,조상,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,배우자 / 자녀,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),Y,기타,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),Y,대중매체,https://namu.wiki/w/예종(조선)
|
||||||
|
8,예종,예종(조선),,둘러보기,https://namu.wiki/w/예종(조선)
|
||||||
|
9,성종,성종(조선),,성종(조선),https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,개요,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,생애,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,가계,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,조상,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,배우자 / 자녀,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),Y,평가,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),Y,기타,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),Y,대중매체,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,영화,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,드라마,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,관련 문서,https://namu.wiki/w/성종(조선)
|
||||||
|
9,성종,성종(조선),,둘러보기,https://namu.wiki/w/성종(조선)
|
||||||
|
10,연산군,연산군,,연산군,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,개요,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,생애,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,Y,여담,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,가계,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,조상,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,배우자/자녀,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,Y,평가,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,Y,대중매체,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,관련 문서,https://namu.wiki/w/연산군
|
||||||
|
10,연산군,연산군,,둘러보기,https://namu.wiki/w/연산군
|
||||||
|
11,중종,중종(조선),,중종(조선),https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,개요,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,생애,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),Y,평가,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),Y,기타,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,가계,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,조상,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,배우자 / 자녀,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),Y,대중매체,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,드라마,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,영화,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,관련 문서,https://namu.wiki/w/중종(조선)
|
||||||
|
11,중종,중종(조선),,둘러보기,https://namu.wiki/w/중종(조선)
|
||||||
|
12,인종,인종(조선),,인종(조선),https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,개요,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,생애,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,가계,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,친가(전주 이씨),https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,조상,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,외가(파평 윤씨),https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,처가(반남 박씨),https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,배우자 / 자녀,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),Y,기타,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),Y,대중매체,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,만화,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,영화,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,드라마,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,소설,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,관련 문서,https://namu.wiki/w/인종(조선)
|
||||||
|
12,인종,인종(조선),,둘러보기,https://namu.wiki/w/인종(조선)
|
||||||
|
13,명종,명종(조선),,명종(조선),https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,개요,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,생애,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,가계,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,조상,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,배우자 / 자녀,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),Y,평가,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),Y,기타,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),Y,대중매체,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,관련 문서,https://namu.wiki/w/명종(조선)
|
||||||
|
13,명종,명종(조선),,둘러보기,https://namu.wiki/w/명종(조선)
|
||||||
|
14,선조,선조(조선),,선조(조선),https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,개요,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,생애,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),Y,평가,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),Y,가족 관계,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,조상,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,배우자/자녀,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),Y,기타,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,어진(御眞),https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),Y,대중매체,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,소설,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,만화,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,게임,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,영화,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,드라마,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,교양·다큐멘터리,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,관련 문서,https://namu.wiki/w/선조(조선)
|
||||||
|
14,선조,선조(조선),,둘러보기,https://namu.wiki/w/선조(조선)
|
||||||
|
15,광해군,광해군,,광해군,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,개요,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,생애,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,Y,평가,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,Y,여담,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,Y,가족 관계,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,조상,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,배우자/자녀,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,광해군묘,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,Y,대중매체,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,관련 다큐,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,한국사 傳,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,역사스페셜,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,역사스페셜 시간여행자,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,같이 보기,https://namu.wiki/w/광해군
|
||||||
|
15,광해군,광해군,,둘러보기,https://namu.wiki/w/광해군
|
||||||
|
17,효종,효종(조선),,효종(조선),https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,개요,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,생애,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,즉위 과정,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,북벌 준비와 군비 확장,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),Y,사망 징조와 죽음,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,후일담,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),Y,평가,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,북벌론,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,정통성에 대한 태도,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),Y,기타,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),Y,가족,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,조상,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,배우자/자녀,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),Y,대중매체,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,드라마,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,영화,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,관련 문서,https://namu.wiki/w/효종(조선)
|
||||||
|
17,효종,효종(조선),,둘러보기,https://namu.wiki/w/효종(조선)
|
||||||
|
18,현종,현종(조선),,현종(조선),https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,개요,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,생애,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),Y,평가,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),Y,기타,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,가계,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,조상,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,배우자 / 자녀,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),Y,대중매체,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,관련 문서,https://namu.wiki/w/현종(조선)
|
||||||
|
18,현종,현종(조선),,둘러보기,https://namu.wiki/w/현종(조선)
|
||||||
|
19,숙종,숙종(조선),,숙종(조선),https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,개요,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,생애,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,가계,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,조상,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,배우자 / 자녀,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),Y,여담,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,"금덕, 금손, 애묘인",https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,어진,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,직접 쓴 글과 시,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),Y,평가,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),Y,긍정적 평가,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),Y,부정적 평가,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),Y,대중매체,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,영화,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,드라마,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,소설,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,관련 문서,https://namu.wiki/w/숙종(조선)
|
||||||
|
19,숙종,숙종(조선),,둘러보기,https://namu.wiki/w/숙종(조선)
|
||||||
|
20,경종,경종(조선),,경종(조선),https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,개요,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,생애,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,조상,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),Y,여담,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,불임 의혹,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,이복동생 연잉군과의 관계,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),Y,대중매체,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,영화,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,드라마,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,소설,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,뮤지컬,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),Y,기타,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,관련 문서,https://namu.wiki/w/경종(조선)
|
||||||
|
20,경종,경종(조선),,둘러보기,https://namu.wiki/w/경종(조선)
|
||||||
|
22,정조,정조(조선),,정조(조선),https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,개요,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,생애,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),Y,업적과 정책,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,"묘호, 시호, 휘",https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),Y,평가,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,가계,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,조상,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,어진,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),Y,기타,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,엄친아,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),Y,유성한 사건,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),Y,의빈 성씨와의 일화,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,술과 담배 사랑,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,정조의 비밀 편지들,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,과인은 사도세자의 아들이다?,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),Y,대중매체,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,관련 문서,https://namu.wiki/w/정조(조선)
|
||||||
|
22,정조,정조(조선),,둘러보기,https://namu.wiki/w/정조(조선)
|
||||||
|
23,순조,순조,,순조,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,개요,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,생애,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,Y,평가,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,묘호와 시호,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,어진,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,Y,기타,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,가계,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,조상,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,형제자매,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,배우자/자녀,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,Y,대중매체,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,관련 문서,https://namu.wiki/w/순조
|
||||||
|
23,순조,순조,,둘러보기,https://namu.wiki/w/순조
|
||||||
|
24,헌종,헌종(조선),,헌종(조선),https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,개요,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,생애,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),Y,평가,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,가계,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,조상,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,친가(전주 이씨),https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,외가(풍양 조씨),https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,처가,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,안동 김씨,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,남양 홍씨,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,배우자 / 자녀,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,어진,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),Y,여담,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),Y,대중매체,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,관련 문서,https://namu.wiki/w/헌종(조선)
|
||||||
|
24,헌종,헌종(조선),,둘러보기,https://namu.wiki/w/헌종(조선)
|
||||||
|
25,철종,철종(조선),,철종(조선),https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,개요,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,생애,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),Y,평가,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,어진,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,외부 링크,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,보물 (舊 보물 제1492호),https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,가계,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,조상,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,친가(전주 이씨),https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,외가(용담 염씨),https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,처가(안동 김씨),https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,배우자 / 자녀,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),Y,여담,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),Y,대중매체,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,소설,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,영화,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,드라마,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,만화,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,게임,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,관련 문서,https://namu.wiki/w/철종(조선)
|
||||||
|
25,철종,철종(조선),,둘러보기,https://namu.wiki/w/철종(조선)
|
||||||
|
26,고종,고종(대한제국),,고종(대한제국),https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,개요,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,호칭,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,즉위 배경,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,생애,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),Y,평가,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,청과의 영토 분쟁,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),Y,가족 관계,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,조상,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),Y,"사진과 어진, 기타 그림",https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,사진,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,어진,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),Y,기타 그림,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),Y,여담,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),Y,대중매체,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,관련 문서,https://namu.wiki/w/고종(대한제국)
|
||||||
|
26,고종,고종(대한제국),,둘러보기,https://namu.wiki/w/고종(대한제국)
|
||||||
|
27,순종,순종(대한제국),,순종(대한제국),https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),,개요,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),,생애,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),Y,평가,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),,어진,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),Y,기타,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),Y,대중매체,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),,관련 문서,https://namu.wiki/w/순종(대한제국)
|
||||||
|
27,순종,순종(대한제국),,둘러보기,https://namu.wiki/w/순종(대한제국)
|
||||||
|
BIN
generator/animation/fonts/brand_logo.png
Normal file
BIN
generator/animation/fonts/brand_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
294
generator/animation/gen.py
Normal file
294
generator/animation/gen.py
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
animation 생성 단계 (나레이션 + 스토리보드) — 병합본
|
||||||
|
==================================================================
|
||||||
|
조선왕 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
|
||||||
|
(이전 gen_narration.py + gen_storyboard.py 를 main.py 가 한 모듈로 쓰도록 합침.)
|
||||||
|
scraper.py 가 만든 joseon_keywords_*.csv 를 입력으로 한다.
|
||||||
|
|
||||||
|
· 나레이션: latest_csv / load_rows / pick_seed / generate
|
||||||
|
· 스토리보드: clamp_scenes / make_storyboard
|
||||||
|
|
||||||
|
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
DATA_DIR = HERE / "data" # 조선왕 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
|
||||||
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
|
||||||
|
_TYPO = {"…": "...", "‘": "'", "’": "'", "“": '"',
|
||||||
|
"”": '"', "·": " ", "~": "~", "〜": "~"}
|
||||||
|
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
|
||||||
|
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
|
||||||
|
|
||||||
|
|
||||||
|
def _no_emoji(s):
|
||||||
|
s = s or ""
|
||||||
|
for _k, _v in _TYPO.items():
|
||||||
|
s = s.replace(_k, _v)
|
||||||
|
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ① 나레이션 (조선왕 썰 → 병맛 대본) ==============================
|
||||||
|
|
||||||
|
def latest_csv() -> Path:
|
||||||
|
files = sorted(DATA_DIR.glob("joseon_keywords_*.csv"))
|
||||||
|
if not files:
|
||||||
|
sys.exit("[!] animation/data/ 에 joseon_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
|
||||||
|
return files[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(csv_path: Path):
|
||||||
|
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...]"""
|
||||||
|
rows = []
|
||||||
|
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||||||
|
r = csv.reader(f)
|
||||||
|
next(r, None) # 헤더 스킵
|
||||||
|
for line in r:
|
||||||
|
if len(line) < 6:
|
||||||
|
continue
|
||||||
|
order, king, doc, anec, kw, url = line[:6]
|
||||||
|
rows.append({
|
||||||
|
"order": int(order) if order.isdigit() else 0,
|
||||||
|
"king": king, "doc": doc,
|
||||||
|
"is_anecdote": anec.strip().upper() == "Y",
|
||||||
|
"keyword": kw, "url": url,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def pick_seed(rows, king=None, keyword=None, prefer_anecdote=True):
|
||||||
|
"""대본의 씨앗이 될 (왕, 썰키워드) 한 건 선택."""
|
||||||
|
cand = rows
|
||||||
|
if king:
|
||||||
|
cand = [r for r in cand if r["king"] == king]
|
||||||
|
if not cand:
|
||||||
|
kings = sorted({r["king"] for r in rows})
|
||||||
|
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
|
||||||
|
if keyword:
|
||||||
|
kw_match = [r for r in cand if keyword in r["keyword"]]
|
||||||
|
if not kw_match:
|
||||||
|
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
|
||||||
|
return random.choice(kw_match)
|
||||||
|
if prefer_anecdote:
|
||||||
|
anec = [r for r in cand if r["is_anecdote"]]
|
||||||
|
if anec:
|
||||||
|
cand = anec
|
||||||
|
return random.choice(cand)
|
||||||
|
|
||||||
|
|
||||||
|
NARR_SYSTEM = """\
|
||||||
|
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
|
||||||
|
조선 왕들의 역사 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
|
||||||
|
|
||||||
|
[톤 규칙]
|
||||||
|
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
|
||||||
|
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
|
||||||
|
- 역사적 사실(왕 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
|
||||||
|
단, 시청자가 '진짜 역사'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
|
||||||
|
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
|
||||||
|
|
||||||
|
[구조] (반드시 이 4단)
|
||||||
|
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
|
||||||
|
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
|
||||||
|
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
|
||||||
|
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
|
||||||
|
|
||||||
|
[분량]
|
||||||
|
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
|
||||||
|
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
|
||||||
|
|
||||||
|
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
|
||||||
|
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
|
||||||
|
- 형식:
|
||||||
|
1) 첫 줄: 후킹 카피 한 줄 (예: "세종대왕이 사실 OO 단골이었다는 썰 풉니다.")
|
||||||
|
2) 본문 1~2줄: 조선왕 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
|
||||||
|
3) 셀링 한 줄: 왕이 반할 식으로 가게 핵심 (예: "세종도 반할 OO시장 숨은 맛집 'OOO' 🍲")
|
||||||
|
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
|
||||||
|
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
|
||||||
|
6) 👉 방문 팁 또는 행동유도 한 줄
|
||||||
|
7) 마지막 줄: 해시태그 나열. #조선왕 #역사썰 을 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
|
||||||
|
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
|
||||||
|
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
|
||||||
|
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_USER = """\
|
||||||
|
[이번 쇼츠 소재]
|
||||||
|
- 왕: {king} ({doc})
|
||||||
|
- 썰 키워드: "{keyword}"
|
||||||
|
- 참고 출처: {url}
|
||||||
|
|
||||||
|
[마케팅 대상 (마지막에 이걸로 갈아타기)]
|
||||||
|
- 가게명: {store}
|
||||||
|
- 소개/키워드: {product}
|
||||||
|
|
||||||
|
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
|
||||||
|
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 왕의 유명한 일화로 살을 붙여도 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
|
||||||
|
"hook": {"type": "string"},
|
||||||
|
"story": {"type": "string"},
|
||||||
|
"pivot": {"type": "string"},
|
||||||
|
"cta": {"type": "string"},
|
||||||
|
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
|
||||||
|
"estimated_seconds": {"type": "integer"},
|
||||||
|
"hashtags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
|
||||||
|
},
|
||||||
|
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate(client, model, seed, product, seconds, store=""):
|
||||||
|
"""조선왕 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
|
||||||
|
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
|
||||||
|
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
|
||||||
|
user = NARR_USER.format(
|
||||||
|
king=seed["king"], doc=seed["doc"],
|
||||||
|
keyword=seed["keyword"], url=seed["url"], product=product,
|
||||||
|
store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
|
||||||
|
)
|
||||||
|
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=user,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=sys_prompt,
|
||||||
|
temperature=1.1, # 병맛 = 약간 높은 창의성
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=NARR_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
data = json.loads(resp.text)
|
||||||
|
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
|
||||||
|
data["_product"] = product
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
|
||||||
|
|
||||||
|
SB_SYSTEM = """\
|
||||||
|
너는 숏폼 영상 디렉터다. 주어진 '병맛 한국사 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
|
||||||
|
|
||||||
|
[핵심 규칙]
|
||||||
|
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
|
||||||
|
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
|
||||||
|
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
|
||||||
|
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
|
||||||
|
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
|
||||||
|
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the cat king sweating while supervising palace construction)
|
||||||
|
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
|
||||||
|
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
|
||||||
|
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
|
||||||
|
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
|
||||||
|
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
|
||||||
|
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||||
|
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||||
|
|
||||||
|
[전역 설정]
|
||||||
|
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 한국사 인물을 '귀여운 통통한 고양이'로 의인화.
|
||||||
|
복식/색/특징을 못박아 컷마다 동일하게. (예: a chubby cute cat as Joseon King Gwanghaegun, wearing red royal gonryongpo robe and black ikseongwan hat, round face)
|
||||||
|
★ 눈 그림체: 눈을 떴을 때는 large round eyes with big round solid black pupils and a small
|
||||||
|
soft white highlight, gentle innocent look (귀엽고 둥근 눈)으로 통일. 단 이 '그림체'만 유지하고,
|
||||||
|
장면 감정에 따라 눈을 감거나(eyes closed) 실눈(squint)·윙크·졸린 눈 등 표정은 바꿔도 된다.
|
||||||
|
절대 날카롭거나 찢어진/화난/매서운/사실적인 맹수 눈은 금지.
|
||||||
|
- style_guide: 그림 스타일을 영어로. 한국 웹툰풍 플랫 일러스트, 굵은 외곽선, 따뜻한 색, 단순 배경, 세로 9:16.
|
||||||
|
|
||||||
|
[출력] 지정된 JSON 스키마로만.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_USER = """\
|
||||||
|
[마케팅 대상] {product}
|
||||||
|
[소재 왕] {king}
|
||||||
|
[목표 장면 수] 약 {scenes}개
|
||||||
|
|
||||||
|
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
|
||||||
|
{narration}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_sheet": {"type": "string"},
|
||||||
|
"style_guide": {"type": "string"},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"narration": {"type": "string"},
|
||||||
|
"caption": {"type": "string"},
|
||||||
|
"image_prompt": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["narration", "caption", "image_prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["character_sheet", "style_guide", "scenes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_scenes(scene_list, target):
|
||||||
|
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||||
|
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||||
|
def clen(s):
|
||||||
|
return len(s.get("narration", "").strip())
|
||||||
|
|
||||||
|
while len(scene_list) > target:
|
||||||
|
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||||
|
i = min(range(len(scene_list) - 1),
|
||||||
|
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||||
|
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||||
|
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||||
|
base["narration"] = (a + " " + b).strip()
|
||||||
|
scene_list[i:i + 2] = [base]
|
||||||
|
return scene_list
|
||||||
|
|
||||||
|
|
||||||
|
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||||
|
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=SB_USER.format(product=product, king=king,
|
||||||
|
scenes=scenes, narration=narration),
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=SB_SYSTEM,
|
||||||
|
temperature=0.8,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=SB_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sb = json.loads(resp.text)
|
||||||
|
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||||
|
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||||
|
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||||
|
raw = sb.get("scenes", [])
|
||||||
|
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||||
|
if len(kept) < len(raw):
|
||||||
|
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||||
|
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||||
|
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||||
|
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||||
|
sb["_product"] = product
|
||||||
|
sb["_king"] = king
|
||||||
|
return sb
|
||||||
275
generator/animation/main.py
Normal file
275
generator/animation/main.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
키워드 → 병맛 한국사 마케팅 쇼츠 (완전 자동, 원커맨드)
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
||||||
|
1) 랜덤으로 조선왕 '썰' 매칭
|
||||||
|
2) Gemini 로 병맛 마케팅 나레이션 생성
|
||||||
|
3) 장면 분할(스토리보드)
|
||||||
|
4) 장면별 이미지 + 음성 생성
|
||||||
|
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
|
||||||
|
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
||||||
|
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
||||||
|
|
||||||
|
선행: namu_joseon_scraper.py 로 만든 output/joseon_keywords_*.csv 가 있어야 함.
|
||||||
|
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
||||||
|
|
||||||
|
★ 실행 (Shorts 폴더 안에서, 키워드는 자유롭게):
|
||||||
|
python auto_short.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
||||||
|
python auto_short.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
||||||
|
python auto_short.py 배달앱 왕의밥상 --label 왕의밥상
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
|
||||||
|
--king 광해군 특정 왕 고정 (기본: 랜덤)
|
||||||
|
--scenes 12 장면 수=이미지 수 (기본 12)
|
||||||
|
--seconds 45 목표 길이 초 (기본 45)
|
||||||
|
--voice Puck TTS 목소리
|
||||||
|
--keep 중간 파일(assets) 삭제하지 않고 보존
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
|
||||||
|
|
||||||
|
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
|
||||||
|
import render as MS # animation 영상합성 엔진(이전 make_short.py)
|
||||||
|
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
|
||||||
|
TEXT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(s: str) -> str:
|
||||||
|
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
|
||||||
|
return s[:30] or "short"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="키워드 또는 네이버 링크 → 병맛 한국사 쇼츠 (완전 자동)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
|
||||||
|
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
|
||||||
|
)
|
||||||
|
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
|
||||||
|
ap.add_argument("keywords", nargs="*",
|
||||||
|
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
|
||||||
|
# 결과 폴더 라벨
|
||||||
|
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
|
||||||
|
# 생성 옵션
|
||||||
|
ap.add_argument("--king", help="특정 왕 고정 (기본: 랜덤)")
|
||||||
|
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
|
||||||
|
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
|
||||||
|
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
|
||||||
|
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
|
||||||
|
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
|
||||||
|
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
|
||||||
|
ap.add_argument("--images", default="load_image",
|
||||||
|
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
|
||||||
|
"(예: --images 연산골막국수). 비었으면 전부 AI")
|
||||||
|
ap.add_argument("--max-real", type=int, default=0,
|
||||||
|
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
|
||||||
|
ap.add_argument("--real-scenes",
|
||||||
|
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
|
||||||
|
ap.add_argument("--no-match", action="store_true",
|
||||||
|
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
|
||||||
|
ap.add_argument("--photos", type=int, default=4,
|
||||||
|
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
|
||||||
|
ap.add_argument("--no-photos", action="store_true",
|
||||||
|
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
|
||||||
|
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
|
||||||
|
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
|
||||||
|
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
|
||||||
|
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
|
||||||
|
ap.add_argument("--img-model", default=MS.IMG_MODEL,
|
||||||
|
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
|
||||||
|
ap.add_argument("--allow-text", action="store_true",
|
||||||
|
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
|
||||||
|
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
|
||||||
|
ap.add_argument("--speed", type=float, default=1.3,
|
||||||
|
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
|
||||||
|
ap.add_argument("--no-trim", action="store_true",
|
||||||
|
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
|
||||||
|
ap.add_argument("--tail", type=float, default=0.0,
|
||||||
|
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
|
||||||
|
ap.add_argument("--workers", type=int, default=4,
|
||||||
|
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
|
||||||
|
ap.add_argument("--bgm",
|
||||||
|
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
|
||||||
|
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
|
||||||
|
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
|
||||||
|
ap.add_argument("--bgm-volume", type=float, default=0.3,
|
||||||
|
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
|
||||||
|
ap.add_argument("--api-key")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
|
||||||
|
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
|
||||||
|
if not args.link:
|
||||||
|
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
|
||||||
|
if link_tok:
|
||||||
|
args.link = link_tok
|
||||||
|
args.keywords = [k for k in args.keywords if k != link_tok]
|
||||||
|
|
||||||
|
product = " ".join(args.keywords).strip()
|
||||||
|
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
|
||||||
|
brief_store = None
|
||||||
|
if not product and NV.looks_like_naver_place(args.link or ""):
|
||||||
|
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
|
||||||
|
try:
|
||||||
|
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if not bullets:
|
||||||
|
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||||
|
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||||
|
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not _key:
|
||||||
|
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
try:
|
||||||
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if rstore and not brief_store:
|
||||||
|
brief_store = rstore
|
||||||
|
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
|
if not bullets:
|
||||||
|
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||||
|
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||||
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
|
product = " ".join(bullets)
|
||||||
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
|
for b in bullets:
|
||||||
|
print(f" • {b}")
|
||||||
|
if not product:
|
||||||
|
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
|
||||||
|
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
|
||||||
|
|
||||||
|
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
|
||||||
|
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
|
||||||
|
rows = gen.load_rows(csv_path)
|
||||||
|
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
|
||||||
|
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
|
||||||
|
and args.images == "load_image"):
|
||||||
|
store_for_dir = (brief_store or args.store or args.label
|
||||||
|
or " ".join(args.keywords[:2]) or "naver_store")
|
||||||
|
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
|
||||||
|
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
|
||||||
|
if existing:
|
||||||
|
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
else:
|
||||||
|
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
|
||||||
|
try:
|
||||||
|
_, finals = NV.fetch_and_select(args.link, photo_dir,
|
||||||
|
n=args.photos, client=client)
|
||||||
|
if finals:
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
|
||||||
|
else:
|
||||||
|
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
|
||||||
|
|
||||||
|
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
|
||||||
|
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
|
||||||
|
stem = f"{safe_name(label)}_{ts}"
|
||||||
|
job_dir = OUT_DIR / stem
|
||||||
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) 랜덤(또는 지정) 왕 썰 매칭
|
||||||
|
seed = gen.pick_seed(rows, king=args.king)
|
||||||
|
print(f"■ 작업 폴더 : {job_dir}")
|
||||||
|
print(f"■ 마케팅 대상 : {product}")
|
||||||
|
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
|
||||||
|
|
||||||
|
# 2) 병맛 나레이션
|
||||||
|
print("[1/4] 나레이션 생성...")
|
||||||
|
store_name = brief_store or getattr(args, "store", None) or label
|
||||||
|
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
|
||||||
|
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
|
||||||
|
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
|
||||||
|
(job_dir / "narration.json").write_text(
|
||||||
|
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
|
||||||
|
lyric = (f"[제목] {narr.get('title','')}\n"
|
||||||
|
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
|
||||||
|
f"[마케팅] {product}\n"
|
||||||
|
f"{'-'*40}\n"
|
||||||
|
f"{narr.get('full_narration','')}\n")
|
||||||
|
if narr.get("hashtags"):
|
||||||
|
lyric += f"\n{' '.join(narr['hashtags'])}\n"
|
||||||
|
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
|
||||||
|
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
|
||||||
|
caption = (narr.get("sns_caption") or "").strip()
|
||||||
|
if caption:
|
||||||
|
(job_dir / "caption.txt").write_text(caption, encoding="utf-8")
|
||||||
|
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
|
||||||
|
|
||||||
|
# 3) 스토리보드(장면 분할)
|
||||||
|
print("[2/4] 스토리보드 분할...")
|
||||||
|
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
|
||||||
|
product, seed["king"], scenes=args.scenes)
|
||||||
|
(job_dir / "storyboard.json").write_text(
|
||||||
|
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f" 장면 {len(sb['scenes'])}개")
|
||||||
|
|
||||||
|
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
|
||||||
|
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
|
||||||
|
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
|
||||||
|
if not real_images:
|
||||||
|
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
|
||||||
|
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
|
||||||
|
bgm = MS.resolve_bgm(args.bgm or "bgm")
|
||||||
|
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
|
||||||
|
if args.bgm and not _off and not bgm:
|
||||||
|
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
|
||||||
|
elif bgm:
|
||||||
|
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
|
||||||
|
print("[3/4] 이미지·음성 생성...")
|
||||||
|
store = args.store or brief_store or product.split(",")[0].strip() or None
|
||||||
|
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
|
||||||
|
real_images=real_images, real_scenes=real_scenes,
|
||||||
|
max_real=args.max_real, match_content=not args.no_match,
|
||||||
|
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
|
||||||
|
img_model=args.img_model, allow_text=args.allow_text,
|
||||||
|
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
|
||||||
|
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
|
||||||
|
|
||||||
|
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
|
||||||
|
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
|
||||||
|
shutil.rmtree(job_dir / "assets", ignore_errors=True)
|
||||||
|
for junk in ("narration.json", "storyboard.json"):
|
||||||
|
(job_dir / junk).unlink(missing_ok=True)
|
||||||
|
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
|
||||||
|
|
||||||
|
print(f"\n✅ 완성: {out_mp4}")
|
||||||
|
print(f" {seed['king']} 썰 · {product}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
970
generator/animation/render.py
Normal file
970
generator/animation/render.py
Normal file
@ -0,0 +1,970 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
스토리보드 → 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션]
|
||||||
|
=====================================================================
|
||||||
|
|
||||||
|
gen_storyboard.py 가 만든 스토리보드를 받아 장면별로:
|
||||||
|
1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1번 컷을 레퍼런스로 물림)
|
||||||
|
2) Gemini TTS 로 그 장면 나레이션 음성 생성 (장면 길이 = 음성 길이)
|
||||||
|
3) ffmpeg 로 Ken Burns(zoompan 줌) + 자막(PNG 오버레이) + 음성 합성 → 세로 1080x1920 mp4
|
||||||
|
(Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast)
|
||||||
|
|
||||||
|
이미지/음성은 output/ 에 장면별로 캐싱한다. 재실행 시 이미 있으면 재사용(재과금 X).
|
||||||
|
다시 그리고 싶으면 --force.
|
||||||
|
|
||||||
|
설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 — ffmpeg 직접 렌더)
|
||||||
|
실행:
|
||||||
|
python make_short.py # 최신 *_storyboard.json
|
||||||
|
python make_short.py --storyboard output/xxx_storyboard.json --voice Puck
|
||||||
|
python make_short.py --force # 이미지/음성 새로 생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import media as MU
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation" # 통합 출력: shorts_all/output/animation/
|
||||||
|
W, H = 1080, 1920 # 세로 쇼츠
|
||||||
|
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
|
||||||
|
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
|
||||||
|
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
|
||||||
|
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
|
||||||
|
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
|
||||||
|
IMG_MODEL = "gemini-2.5-flash-image"
|
||||||
|
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
|
||||||
|
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
|
||||||
|
TTS_VOICE = "Puck"
|
||||||
|
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
|
||||||
|
"능청스럽게 약간 오버해서 읽어줘:")
|
||||||
|
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
|
||||||
|
def _find_font(name):
|
||||||
|
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
|
||||||
|
if c.exists():
|
||||||
|
return str(c)
|
||||||
|
return str(HERE / "fonts" / name)
|
||||||
|
FONT = _find_font("Jua-Regular.ttf")
|
||||||
|
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||||
|
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
|
||||||
|
TITLE_FONT = _find_font("SongMyung-Regular.ttf")
|
||||||
|
TITLE_SIZE = 58
|
||||||
|
|
||||||
|
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
|
||||||
|
# → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기)
|
||||||
|
NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, "
|
||||||
|
"Korean hangul, logos, or signage text. Keep every sign, banner, board and menu "
|
||||||
|
"completely BLANK with no writing on them.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 데이터
|
||||||
|
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_real_images(images_dir, real_scenes_arg):
|
||||||
|
"""--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트.
|
||||||
|
|
||||||
|
images_dir 해석 순서 (가게마다 load_image/<가게>/ 로 나눠 담는 걸 지원):
|
||||||
|
1) 절대경로면 그대로
|
||||||
|
2) 상대경로/이름이면 HERE/<값> (예: load_image/연산골막국수)
|
||||||
|
3) 그래도 없으면 HERE/load_image/<값> (예: --images 연산골막국수)
|
||||||
|
가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 빈 리스트.
|
||||||
|
"""
|
||||||
|
real_images, used = [], None
|
||||||
|
if images_dir:
|
||||||
|
d = Path(images_dir)
|
||||||
|
candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d]
|
||||||
|
for c in candidates:
|
||||||
|
if c.exists() and c.is_dir():
|
||||||
|
imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS)
|
||||||
|
if imgs:
|
||||||
|
real_images, used = imgs, c
|
||||||
|
break
|
||||||
|
if used:
|
||||||
|
print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)")
|
||||||
|
real_scenes = None
|
||||||
|
if real_scenes_arg:
|
||||||
|
real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x]
|
||||||
|
return real_images, real_scenes
|
||||||
|
|
||||||
|
|
||||||
|
# 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트
|
||||||
|
PRESENT_POSES = [
|
||||||
|
"Place the cat king in the UPPER part of the frame, smiling and pointing down toward the "
|
||||||
|
"lower-center, leaving the lower-center area visually simple (a photo will be placed there).",
|
||||||
|
"Put the cat king on the LEFT side presenting with one paw to the right-center, keeping the "
|
||||||
|
"right-center area simple and uncluttered (a photo will be placed there).",
|
||||||
|
"Show the cat king at the BOTTOM looking up with sparkling eyes toward the upper-center, "
|
||||||
|
"keeping the upper-center area simple (a photo will be placed there).",
|
||||||
|
]
|
||||||
|
# PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율
|
||||||
|
PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)]
|
||||||
|
|
||||||
|
# 고양이 왕의 '눈' 그림체를 귀엽게 유지하되, 눈을 '떴을 때'의 모양만 고정한다.
|
||||||
|
# (모든 장면을 동그란 뜬눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐)
|
||||||
|
EYE_STYLE = (
|
||||||
|
"When the cat's eyes are open, they are the same cute friendly eyes: large round eyes "
|
||||||
|
"with big round solid black pupils and a small soft white highlight, gentle innocent "
|
||||||
|
"look. Keep this cute rounded eye style; never sharp, angry, fierce, glaring or "
|
||||||
|
"realistic predatory eyes."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지.
|
||||||
|
EYE_FREE = (
|
||||||
|
"In this scene the cat's eye EXPRESSION follows the moment — the eyes may be gently "
|
||||||
|
"closed, squinting, blinking, winking, teary or sleepy half-closed as appropriate. "
|
||||||
|
"Keep the same cute, soft, rounded drawing style (never sharp, angry or realistic), "
|
||||||
|
"but do NOT force wide-open round eyes here."
|
||||||
|
)
|
||||||
|
|
||||||
|
# image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다.
|
||||||
|
_EYE_EXPR_KW = (
|
||||||
|
"closed eye", "eyes closed", "close its eye", "close his eye", "shut eye",
|
||||||
|
"shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed",
|
||||||
|
"sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy",
|
||||||
|
"crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl",
|
||||||
|
"wince", "grimac", "blink",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scene_needs_eye_expression(sc) -> bool:
|
||||||
|
"""장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True."""
|
||||||
|
text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower()
|
||||||
|
return any(k in text for k in _EYE_EXPR_KW)
|
||||||
|
|
||||||
|
# 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: king_ref.png),
|
||||||
|
# 모든 영상의 모든 컷이 그 고양이 왕의 얼굴/눈/복장/화풍을 그대로 따라간다.
|
||||||
|
CHAR_REF_DIR = HERE / "character"
|
||||||
|
|
||||||
|
|
||||||
|
def load_char_ref():
|
||||||
|
"""Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다."""
|
||||||
|
if not CHAR_REF_DIR.is_dir():
|
||||||
|
return None
|
||||||
|
for p in sorted(CHAR_REF_DIR.iterdir()):
|
||||||
|
if p.suffix.lower() in IMG_EXTS:
|
||||||
|
return p.read_bytes()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 이미지
|
||||||
|
def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True,
|
||||||
|
ref_mode="full") -> bytes:
|
||||||
|
"""Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용.
|
||||||
|
|
||||||
|
model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수.
|
||||||
|
no_text: True 면 '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지).
|
||||||
|
ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지.
|
||||||
|
'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
if ref_bytes:
|
||||||
|
parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png"))
|
||||||
|
if ref_mode == "eyes":
|
||||||
|
prompt = ("Use the reference image ONLY to copy the cat character's EYES and "
|
||||||
|
"facial expression exactly: the same large round eyes with big round "
|
||||||
|
"solid black pupils and a small soft white highlight, gentle innocent "
|
||||||
|
"look. Do NOT copy the outfit, hat, robe color, pose, framing or "
|
||||||
|
"background from the reference — those follow the scene description. "
|
||||||
|
"New scene: " + prompt)
|
||||||
|
else:
|
||||||
|
prompt = ("Keep the EXACT same character design, outfit and art style as the "
|
||||||
|
"reference image. New scene: " + prompt)
|
||||||
|
parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else "")))
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model or IMG_MODEL,
|
||||||
|
contents=parts,
|
||||||
|
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
|
||||||
|
)
|
||||||
|
for p in resp.candidates[0].content.parts:
|
||||||
|
if getattr(p, "inline_data", None):
|
||||||
|
return p.inline_data.data
|
||||||
|
raise RuntimeError("이미지 파트 없음")
|
||||||
|
|
||||||
|
|
||||||
|
def composite_card(base_path, photo_path, out_path,
|
||||||
|
rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22):
|
||||||
|
"""AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장.
|
||||||
|
|
||||||
|
사진은 원본 그대로(왜곡 X) 축소만 하고, 흰 테두리+그림자+살짝 기울기로 자연스럽게 올린다.
|
||||||
|
rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기(도).
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
base = Image.open(base_path).convert("RGBA")
|
||||||
|
photo = Image.open(photo_path).convert("RGB")
|
||||||
|
|
||||||
|
cw = int(W * rel_w)
|
||||||
|
ph = int(photo.height * (cw / photo.width))
|
||||||
|
max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한
|
||||||
|
if ph > max_h:
|
||||||
|
ph, cw = max_h, int(photo.width * (max_h / photo.height))
|
||||||
|
photo = photo.resize((cw, ph), Image.LANCZOS)
|
||||||
|
|
||||||
|
# 폴라로이드 흰 카드 (아래쪽 여백 좀 더)
|
||||||
|
card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)),
|
||||||
|
(255, 255, 255, 255))
|
||||||
|
card.paste(photo, (border, border))
|
||||||
|
card = card.rotate(angle, expand=True, resample=Image.BICUBIC)
|
||||||
|
|
||||||
|
cx, cy = int(W * center[0]), int(H * center[1])
|
||||||
|
x, y = cx - card.width // 2, cy - card.height // 2
|
||||||
|
|
||||||
|
# 그림자
|
||||||
|
alpha = card.split()[3]
|
||||||
|
shadow = Image.new("RGBA", card.size, (0, 0, 0, 0))
|
||||||
|
shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha)
|
||||||
|
shadow = shadow.filter(ImageFilter.GaussianBlur(18))
|
||||||
|
base.alpha_composite(shadow, (x + 10, y + 16))
|
||||||
|
base.alpha_composite(card, (x, y))
|
||||||
|
base.convert("RGB").save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def cover_crop(src: Path, dst: Path):
|
||||||
|
"""이미지를 1080x1920 꽉 차게 cover-crop 해서 저장."""
|
||||||
|
im = Image.open(src).convert("RGB")
|
||||||
|
scale = max(W / im.width, H / im.height)
|
||||||
|
nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5)
|
||||||
|
im = im.resize((nw, nh), Image.LANCZOS)
|
||||||
|
left, top = (nw - W) // 2, (nh - H) // 2
|
||||||
|
im.crop((left, top, left + W, top + H)).save(dst)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 음성
|
||||||
|
def _extract_audio(resp):
|
||||||
|
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
|
||||||
|
cands = getattr(resp, "candidates", None) or []
|
||||||
|
for c in cands:
|
||||||
|
content = getattr(c, "content", None)
|
||||||
|
parts = getattr(content, "parts", None) or [] if content else []
|
||||||
|
for p in parts:
|
||||||
|
inline = getattr(p, "inline_data", None)
|
||||||
|
if inline and inline.data:
|
||||||
|
m = re.search(r"rate=(\d+)", inline.mime_type or "")
|
||||||
|
return inline.data, (int(m.group(1)) if m else 24000)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gen_tts(client, text, voice, retries=4) -> bytes:
|
||||||
|
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
|
||||||
|
last = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=TTS_MODEL,
|
||||||
|
contents=f"{TTS_STYLE}\n\n{text}",
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["AUDIO"],
|
||||||
|
speech_config=types.SpeechConfig(
|
||||||
|
voice_config=types.VoiceConfig(
|
||||||
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
got = _extract_audio(resp)
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
|
||||||
|
fr = ""
|
||||||
|
try:
|
||||||
|
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
|
||||||
|
except Exception:
|
||||||
|
fr = "no-candidates"
|
||||||
|
last = f"빈 응답(finish_reason={fr or '?'})"
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{type(e).__name__}: {e}"
|
||||||
|
if attempt < retries:
|
||||||
|
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
|
||||||
|
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
|
||||||
|
end="", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}…")
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_to_wav(pcm, path, rate):
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
|
||||||
|
w.writeframes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def wav_seconds(path: Path) -> float:
|
||||||
|
with wave.open(str(path), "rb") as w:
|
||||||
|
return w.getnframes() / w.getframerate()
|
||||||
|
|
||||||
|
|
||||||
|
def _atempo_chain(speed: float) -> list:
|
||||||
|
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
|
||||||
|
chain, s = [], speed
|
||||||
|
while s > 2.0:
|
||||||
|
chain.append("atempo=2.0"); s /= 2.0
|
||||||
|
while s < 0.5:
|
||||||
|
chain.append("atempo=0.5"); s *= 2.0
|
||||||
|
chain.append(f"atempo={s:.4f}")
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
|
||||||
|
threshold="-38dB", keep=0.06):
|
||||||
|
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
|
||||||
|
|
||||||
|
- trim_silence: 앞/뒤 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
|
||||||
|
silenceremove 로 앞을 자르고, areverse 로 뒤집어 다시 앞(=원래 뒤)을 자른 뒤 복원.
|
||||||
|
keep: 잘라낸 끝에 남길 여유(초). 0 이면 너무 칼같이 잘려 어색할 수 있어 약간 남긴다.
|
||||||
|
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
|
||||||
|
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
|
||||||
|
"""
|
||||||
|
af = []
|
||||||
|
if trim_silence:
|
||||||
|
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
|
||||||
|
f"start_threshold={threshold}:detection=peak")
|
||||||
|
af += [one, "areverse", one, "areverse"]
|
||||||
|
if speed and abs(speed - 1.0) > 1e-3:
|
||||||
|
af += _atempo_chain(speed)
|
||||||
|
if not af:
|
||||||
|
shutil.copyfile(src_wav, dst_wav); return
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
|
||||||
|
"-ar", "24000", "-ac", "1", str(dst_wav)])
|
||||||
|
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
|
||||||
|
raise RuntimeError("가공 후 음성이 너무 짧음")
|
||||||
|
except (RuntimeError, OSError):
|
||||||
|
shutil.copyfile(src_wav, dst_wav)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더)
|
||||||
|
FPS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg() -> str:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd):
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def pick_encoder(exe) -> list:
|
||||||
|
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
|
||||||
|
test = subprocess.run(
|
||||||
|
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
|
||||||
|
"-c:v", "h264_qsv", "-f", "null", "-"],
|
||||||
|
capture_output=True, text=True, errors="replace")
|
||||||
|
if test.returncode == 0:
|
||||||
|
print(" 인코더: h264_qsv (Intel 하드웨어)")
|
||||||
|
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
|
||||||
|
print(" 인코더: libx264 (superfast)")
|
||||||
|
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_lines(draw, text, font, max_w):
|
||||||
|
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
|
||||||
|
lines, cur = [], ""
|
||||||
|
for ch in text:
|
||||||
|
if ch == "\n":
|
||||||
|
lines.append(cur); cur = ""; continue
|
||||||
|
if draw.textlength(cur + ch, font=font) <= max_w:
|
||||||
|
cur += ch
|
||||||
|
else:
|
||||||
|
lines.append(cur); cur = ch
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
return lines or [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _blossom(d, cx, cy, r, color):
|
||||||
|
"""간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬)."""
|
||||||
|
import math
|
||||||
|
pr = r * 0.62 # 꽃잎 반지름
|
||||||
|
for k in range(5):
|
||||||
|
a = math.radians(-90 + k * 72)
|
||||||
|
px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a)
|
||||||
|
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
|
||||||
|
d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32],
|
||||||
|
fill=(255, 238, 175, 255)) # 꽃심
|
||||||
|
|
||||||
|
|
||||||
|
_LOGO_RGBA = None
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_logo():
|
||||||
|
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
|
||||||
|
global _LOGO_RGBA
|
||||||
|
if _LOGO_RGBA is not None:
|
||||||
|
return _LOGO_RGBA
|
||||||
|
if not Path(BRAND_LOGO).exists():
|
||||||
|
return None
|
||||||
|
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
|
||||||
|
g = Image.open(BRAND_LOGO).convert("L")
|
||||||
|
alpha = g.point(lambda p: 255 - p)
|
||||||
|
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
|
||||||
|
logo.putalpha(alpha)
|
||||||
|
_LOGO_RGBA = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
||||||
|
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||||
|
밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
band_h = int(H * BAND_RATIO)
|
||||||
|
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
|
||||||
|
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
|
||||||
|
|
||||||
|
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
|
||||||
|
title_y = int(H * 0.012)
|
||||||
|
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
|
||||||
|
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
|
||||||
|
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
|
||||||
|
ink = (30, 30, 30, 255)
|
||||||
|
sep = "×" # 곱셈기호(×) — 콜라보 표기
|
||||||
|
gap = 26
|
||||||
|
|
||||||
|
w_title = d.textlength(title, font=tf)
|
||||||
|
w_sep = d.textlength(sep, font=tf)
|
||||||
|
|
||||||
|
logo = _brand_logo()
|
||||||
|
logo_h = 0
|
||||||
|
if logo is not None:
|
||||||
|
logo_h = int(TITLE_SIZE * 1.05)
|
||||||
|
logo_w = max(1, int(logo.width * logo_h / logo.height))
|
||||||
|
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
||||||
|
else:
|
||||||
|
logo_w = 0
|
||||||
|
|
||||||
|
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
|
||||||
|
group_left = (W - total) / 2
|
||||||
|
x = group_left
|
||||||
|
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
|
||||||
|
x += w_title + gap
|
||||||
|
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
|
||||||
|
x += w_sep + gap
|
||||||
|
if logo is not None:
|
||||||
|
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
|
||||||
|
|
||||||
|
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
|
||||||
|
rose = (228, 150, 168, 255)
|
||||||
|
bgap = 46
|
||||||
|
_blossom(d, group_left - bgap, cy, 17, rose)
|
||||||
|
_blossom(d, group_left + total + bgap, cy, 17, rose)
|
||||||
|
|
||||||
|
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
|
||||||
|
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
|
||||||
|
|
||||||
|
# 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
|
||||||
|
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
|
||||||
|
top = rule_y + 22
|
||||||
|
avail = band_h - top - 24
|
||||||
|
size = NARR_SIZE
|
||||||
|
while size > NARR_MIN_SIZE:
|
||||||
|
f = ImageFont.truetype(font_path, size)
|
||||||
|
lines = _wrap_lines(d, text, f, W - 150)
|
||||||
|
lh = int(size * 1.4)
|
||||||
|
if len(lines) * lh <= avail:
|
||||||
|
break
|
||||||
|
size -= 2
|
||||||
|
else:
|
||||||
|
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
|
||||||
|
lines = _wrap_lines(d, text, f, W - 150)
|
||||||
|
lh = int(NARR_MIN_SIZE * 1.4)
|
||||||
|
y = top + max(0, (avail - len(lines) * lh) // 2)
|
||||||
|
for ln in lines:
|
||||||
|
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
|
||||||
|
y += lh
|
||||||
|
img.save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만
|
||||||
|
|
||||||
|
|
||||||
|
def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None):
|
||||||
|
"""배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG."""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
base = Image.open(base_img_path).convert("RGB")
|
||||||
|
base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55)
|
||||||
|
d = ImageDraw.Draw(base)
|
||||||
|
|
||||||
|
def centered(text, y, size, fill, fit_one_line=False):
|
||||||
|
if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소
|
||||||
|
while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140:
|
||||||
|
size -= 4
|
||||||
|
f = ImageFont.truetype(font_path, size)
|
||||||
|
for ln in _wrap_lines(d, text, f, W - 140):
|
||||||
|
w = d.textlength(ln, font=f)
|
||||||
|
d.text(((W - w) / 2, y), ln, font=f, fill=fill,
|
||||||
|
stroke_width=5, stroke_fill="black")
|
||||||
|
y += int(size * 1.25)
|
||||||
|
|
||||||
|
if store:
|
||||||
|
centered(store, int(H * 0.33), 88, "white", fit_one_line=True)
|
||||||
|
centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True)
|
||||||
|
centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True)
|
||||||
|
base.save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
# 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다.
|
||||||
|
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- BGM
|
||||||
|
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgm(bgm_arg):
|
||||||
|
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
|
||||||
|
|
||||||
|
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||||
|
1) 파일이면 그대로
|
||||||
|
2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게)
|
||||||
|
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||||
|
"""
|
||||||
|
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||||
|
return None
|
||||||
|
import random
|
||||||
|
p = Path(bgm_arg)
|
||||||
|
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||||
|
for c in cands:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
if c.is_dir():
|
||||||
|
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
|
||||||
|
if files:
|
||||||
|
return random.choice(files)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
|
||||||
|
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
|
||||||
|
|
||||||
|
- BGM 이 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
|
||||||
|
- 나레이션[0:a] + BGM[1:a]*volume 를 amix(duration=first)로 섞어 영상 길이에 맞춘다.
|
||||||
|
- 영상은 재인코딩하지 않는다(-c:v copy) → 빠름. 시작에 짧은 페이드인.
|
||||||
|
"""
|
||||||
|
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
|
||||||
|
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
||||||
|
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
|
||||||
|
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
||||||
|
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
|
||||||
|
str(out_path)])
|
||||||
|
|
||||||
|
|
||||||
|
# Shared low-level media utilities. Keep local render.py for per-feature
|
||||||
|
# layout, prompts, local fonts, BGM folder resolution, and produce().
|
||||||
|
_ffmpeg = MU._ffmpeg
|
||||||
|
_run = MU._run
|
||||||
|
pick_encoder = MU.pick_encoder
|
||||||
|
pcm_to_wav = MU.pcm_to_wav
|
||||||
|
wav_seconds = MU.wav_seconds
|
||||||
|
process_audio = MU.process_audio
|
||||||
|
_wrap_lines = MU._wrap_lines
|
||||||
|
mix_bgm = MU.mix_bgm
|
||||||
|
|
||||||
|
|
||||||
|
def _scene_motion(idx: int, n: int, frames: int) -> str:
|
||||||
|
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
|
||||||
|
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
|
||||||
|
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
|
||||||
|
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
|
||||||
|
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
|
||||||
|
"""
|
||||||
|
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||||
|
f = max(frames, 1)
|
||||||
|
if idx == 1: # 훅: 리빌(살짝 당김→전체)
|
||||||
|
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
|
||||||
|
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
|
||||||
|
return ("zoompan=z='1.06':"
|
||||||
|
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
|
||||||
|
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
|
||||||
|
kind = idx % 4
|
||||||
|
if kind == 0: # 줌아웃(끝에 전체)
|
||||||
|
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
|
||||||
|
if kind == 1: # 줌인(시작에 전체)
|
||||||
|
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
|
||||||
|
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
|
||||||
|
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
|
||||||
|
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
|
||||||
|
|
||||||
|
|
||||||
|
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
|
||||||
|
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
|
||||||
|
bgm=None, bgm_volume=0.3):
|
||||||
|
"""장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4.
|
||||||
|
|
||||||
|
speed/trim_silence: 컷편집 느낌 — 음성을 빠르게 + 앞뒤 침묵 제거(→ 컷이 타이트).
|
||||||
|
tail: 각 컷 음성 끝에 붙일 정적(초). 0 이면 말 끝나자마자 바로 다음 컷.
|
||||||
|
bgm: 배경음악 파일 경로. 주면 concat 후 BGM 을 작은 볼륨으로 깔아 한 번 더 믹스.
|
||||||
|
bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 안 묻히게 12%.
|
||||||
|
"""
|
||||||
|
print("[4/4] 영상 합성...", flush=True)
|
||||||
|
exe = _ffmpeg()
|
||||||
|
enc = pick_encoder(exe)
|
||||||
|
font = FONT if Path(FONT).exists() else FONT_FALLBACK
|
||||||
|
out_path = Path(out_path)
|
||||||
|
tmp = out_path.parent / "_render"
|
||||||
|
tmp.mkdir(exist_ok=True)
|
||||||
|
segs = []
|
||||||
|
total = 0.0
|
||||||
|
|
||||||
|
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
|
||||||
|
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
|
||||||
|
proc_ap = tmp / f"aud{idx:02d}.wav"
|
||||||
|
process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence)
|
||||||
|
speech = wav_seconds(proc_ap)
|
||||||
|
dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만
|
||||||
|
frames = max(1, round(dur * FPS))
|
||||||
|
sub_png = tmp / f"sub{idx:02d}.png"
|
||||||
|
# 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달
|
||||||
|
render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font)
|
||||||
|
seg = tmp / f"seg{idx:02d}.mp4"
|
||||||
|
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
|
||||||
|
band_h = int(H * BAND_RATIO)
|
||||||
|
img_h = H - band_h
|
||||||
|
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
|
||||||
|
motion = _scene_motion(idx, len(scenes), frames)
|
||||||
|
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
|
||||||
|
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
|
||||||
|
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.
|
||||||
|
fades = "" if idx == 1 else f"fade=t=in:st=0:d={fd:.2f}:color=white,"
|
||||||
|
fades += f"fade=t=out:st={max(dur - fd, 0):.2f}:d={fd:.2f}:color=white"
|
||||||
|
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
|
||||||
|
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||||
|
f"crop={bw}:{bh},"
|
||||||
|
f"{motion}:d={frames}:s={W}x{img_h}:fps={FPS}[img];"
|
||||||
|
f"[bg][img]overlay=0:{band_h},{fades}[base];"
|
||||||
|
f"[base][1:v]overlay=0:0[v]")
|
||||||
|
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
|
||||||
|
_run([exe, "-y", "-loop", "1", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap),
|
||||||
|
"-filter_complex", fc, "-map", "[v]", "-map", "2:a",
|
||||||
|
"-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
|
||||||
|
"-pix_fmt", "yuv420p", str(seg)])
|
||||||
|
segs.append(seg)
|
||||||
|
total += dur
|
||||||
|
print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)")
|
||||||
|
print(f" ▶ 본편 합계 {total:.1f}초" + (f" + 링크 {link_seconds:.1f}초" if link else ""))
|
||||||
|
|
||||||
|
# 링크 카드 (무음)
|
||||||
|
if link and img_paths:
|
||||||
|
card_png = tmp / "linkcard.png"
|
||||||
|
render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta)
|
||||||
|
seg = tmp / "seg_link.mp4"
|
||||||
|
_run([exe, "-y", "-loop", "1", "-i", str(card_png),
|
||||||
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
|
||||||
|
"-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
|
||||||
|
"-pix_fmt", "yuv420p", str(seg)])
|
||||||
|
segs.append(seg)
|
||||||
|
print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)")
|
||||||
|
|
||||||
|
# concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백.
|
||||||
|
# BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다.
|
||||||
|
listf = tmp / "concat.txt"
|
||||||
|
# concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지)
|
||||||
|
listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8")
|
||||||
|
concat_out = (tmp / "_body.mp4") if bgm else out_path
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
|
||||||
|
"-c", "copy",
|
||||||
|
# AAC priming(1024샘플) 탓에 concat 이 비디오 시작 pts 를 +0.023s 밀어버리면
|
||||||
|
# 페북/인스타(Buffer) 썸네일 추출(t=0)이 빈 구간=검은 화면을 잡는다 → pts 0 으로 재작성.
|
||||||
|
"-bsf:v", "setts=ts=TS-STARTDTS:pts=PTS-STARTPTS",
|
||||||
|
"-movflags", "+faststart", str(concat_out)])
|
||||||
|
except RuntimeError:
|
||||||
|
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
|
||||||
|
*enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
||||||
|
str(concat_out)])
|
||||||
|
|
||||||
|
if bgm:
|
||||||
|
mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume)
|
||||||
|
print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})")
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _mime_of(path) -> str:
|
||||||
|
return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_photo(client, photo_path) -> str:
|
||||||
|
"""실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시')."""
|
||||||
|
data = Path(photo_path).read_bytes()
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL,
|
||||||
|
contents=[
|
||||||
|
types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)),
|
||||||
|
types.Part.from_text(text=(
|
||||||
|
"이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. "
|
||||||
|
"음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', "
|
||||||
|
"메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (resp.text or "").strip().splitlines()[0][:20] if resp.text else ""
|
||||||
|
|
||||||
|
|
||||||
|
_MATCH_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"assignments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"photo_index": {"type": "integer"},
|
||||||
|
"scene_number": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["photo_index", "scene_number"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["assignments"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def match_real_scenes(client, scenes, real_images, max_real=3) -> dict:
|
||||||
|
"""각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환.
|
||||||
|
|
||||||
|
실패하면 위치 기반(plan_real_scenes)으로 폴백한다.
|
||||||
|
"""
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
if not real_images:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 1) 사진별 내용 라벨
|
||||||
|
labels = []
|
||||||
|
for p in real_images:
|
||||||
|
try:
|
||||||
|
lab = analyze_photo(client, p)
|
||||||
|
except Exception:
|
||||||
|
lab = ""
|
||||||
|
labels.append(lab)
|
||||||
|
print(f" · {Path(p).name} → '{lab or '?'}'")
|
||||||
|
|
||||||
|
# 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회)
|
||||||
|
scene_lines = "\n".join(
|
||||||
|
f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}"
|
||||||
|
for i, s in enumerate(scenes))
|
||||||
|
photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images)))
|
||||||
|
prompt = (
|
||||||
|
"아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n"
|
||||||
|
"각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n"
|
||||||
|
"[규칙]\n"
|
||||||
|
"- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n"
|
||||||
|
"- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n"
|
||||||
|
"- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n"
|
||||||
|
f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n"
|
||||||
|
f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n")
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL,
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.2,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=_MATCH_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assigns = json.loads(resp.text).get("assignments", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})")
|
||||||
|
return plan_real_scenes(scenes, real_images, None, max_real)
|
||||||
|
|
||||||
|
real_map, used_photo = {}, set()
|
||||||
|
for a in assigns:
|
||||||
|
pi, sn = a.get("photo_index"), a.get("scene_number")
|
||||||
|
if pi is None or sn is None:
|
||||||
|
continue
|
||||||
|
if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \
|
||||||
|
and pi not in used_photo and sn not in real_map:
|
||||||
|
real_map[sn] = Path(real_images[pi])
|
||||||
|
used_photo.add(pi)
|
||||||
|
if len(real_map) >= max_real:
|
||||||
|
break
|
||||||
|
return real_map or plan_real_scenes(scenes, real_images, None, max_real)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict:
|
||||||
|
"""어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환.
|
||||||
|
|
||||||
|
- real_scenes 가 주어지면 그 장면 번호들에 사진을 순서대로 매핑.
|
||||||
|
- 없으면 '뒤쪽(마케팅 전환부) 장면들'에 사진 개수만큼(최대 max_real) 자동 배치.
|
||||||
|
"""
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
real_map = {}
|
||||||
|
if not real_images:
|
||||||
|
return real_map
|
||||||
|
if real_scenes:
|
||||||
|
for no, img in zip(real_scenes, real_images):
|
||||||
|
if 1 <= no <= len(scenes):
|
||||||
|
real_map[no] = Path(img)
|
||||||
|
else:
|
||||||
|
n = min(len(real_images), max_real, len(scenes))
|
||||||
|
start = len(scenes) - n + 1 # 뒤에서 n개
|
||||||
|
for k in range(n):
|
||||||
|
real_map[start + k] = Path(real_images[k])
|
||||||
|
return real_map
|
||||||
|
|
||||||
|
|
||||||
|
def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False,
|
||||||
|
real_images=None, real_scenes=None, max_real=3, match_content=True,
|
||||||
|
link=None, link_seconds=3.0, store=None, cta=None,
|
||||||
|
img_model=None, allow_text=False,
|
||||||
|
speed=1.0, trim_silence=True, tail=0.15, workers=4,
|
||||||
|
bgm=None, bgm_volume=0.3) -> Path:
|
||||||
|
"""스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환.
|
||||||
|
|
||||||
|
workdir: 이 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ 에,
|
||||||
|
완성 mp4 는 workdir/{stem}.mp4 로 저장한다. (기본: output/{stem})
|
||||||
|
real_images: 실제 사진(가게/메뉴판 등) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다.
|
||||||
|
real_scenes: 실제 사진을 깔 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동.
|
||||||
|
max_real: 자동 배치 시 실제 사진으로 채울 최대 장면 수.
|
||||||
|
link: 주면 맨 마지막에 링크 카드 1장을 붙인다.
|
||||||
|
"""
|
||||||
|
scenes = sb["scenes"]
|
||||||
|
style = sb.get("style_guide", "")
|
||||||
|
char = sb.get("character_sheet", "")
|
||||||
|
workdir = Path(workdir) if workdir else (OUT_DIR / stem)
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
assets = workdir / "assets"
|
||||||
|
assets.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 실제 사진 → 장면 배치 결정
|
||||||
|
# · real_scenes 수동지정이 최우선
|
||||||
|
# · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략)
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
# max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내)
|
||||||
|
eff_max = max_real if (max_real and max_real > 0) else len(real_images)
|
||||||
|
need_gen = force or any(not (assets / f"scene{i:02d}.png").exists()
|
||||||
|
for i in range(1, len(scenes) + 1))
|
||||||
|
if not real_images:
|
||||||
|
real_map = {}
|
||||||
|
elif real_scenes:
|
||||||
|
real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max)
|
||||||
|
elif match_content and client is not None and need_gen:
|
||||||
|
print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)")
|
||||||
|
real_map = match_real_scenes(client, scenes, real_images, eff_max)
|
||||||
|
else:
|
||||||
|
real_map = plan_real_scenes(scenes, real_images, None, eff_max)
|
||||||
|
real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용
|
||||||
|
if real_map:
|
||||||
|
picks = ", ".join(f"{no}←{real_map[no].name}" for no in sorted(real_map))
|
||||||
|
print(f"■ 실제 사진 합성 장면: {picks}")
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)]
|
||||||
|
audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)]
|
||||||
|
|
||||||
|
def make_image(i, ref, ref_mode="full"):
|
||||||
|
"""장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트."""
|
||||||
|
sc = scenes[i - 1]
|
||||||
|
raw_png = assets / f"scene{i:02d}_raw.png"
|
||||||
|
img_png = assets / f"scene{i:02d}.png"
|
||||||
|
# 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다.
|
||||||
|
# · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐.
|
||||||
|
free_eyes = scene_needs_eye_expression(sc)
|
||||||
|
eye = EYE_FREE if free_eyes else EYE_STYLE
|
||||||
|
use_ref = None if (free_eyes and ref_mode == "eyes") else ref
|
||||||
|
if i in real_map:
|
||||||
|
k = real_order[i]
|
||||||
|
pose = PRESENT_POSES[k % len(PRESENT_POSES)]
|
||||||
|
spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)]
|
||||||
|
prompt = (f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. {pose} "
|
||||||
|
f"Vertical 9:16 composition.")
|
||||||
|
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
|
||||||
|
no_text=not allow_text, ref_mode=ref_mode)
|
||||||
|
raw_png.write_bytes(data)
|
||||||
|
cover_crop(raw_png, img_png) # 1) AI 배경
|
||||||
|
composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드
|
||||||
|
else:
|
||||||
|
prompt = f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. Vertical 9:16 composition."
|
||||||
|
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
|
||||||
|
no_text=not allow_text, ref_mode=ref_mode)
|
||||||
|
raw_png.write_bytes(data)
|
||||||
|
cover_crop(raw_png, img_png)
|
||||||
|
|
||||||
|
import time as _tm; _t_img = _tm.monotonic()
|
||||||
|
# --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 ---
|
||||||
|
# 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다.
|
||||||
|
# → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립).
|
||||||
|
need_img = [i for i in range(1, len(scenes) + 1)
|
||||||
|
if force or not img_paths[i - 1].exists()]
|
||||||
|
cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img]
|
||||||
|
for i in cached_img:
|
||||||
|
print(f"[{i:2d}] 이미지 캐시 사용")
|
||||||
|
|
||||||
|
# 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제.
|
||||||
|
# 곤룡포·관모 등 복장은 복제하지 않고 왕별 character_sheet/장면 프롬프트대로 그려진다.
|
||||||
|
char_ref = load_char_ref()
|
||||||
|
if char_ref is not None:
|
||||||
|
print("■ 캐릭터 레퍼런스(눈만 고정) 사용: Shorts/character/ "
|
||||||
|
"→ 눈/표정은 통일, 복장은 왕별로 다르게")
|
||||||
|
ref_bytes, ref_mode, anchor = char_ref, "eyes", None
|
||||||
|
else:
|
||||||
|
ref_mode = "full"
|
||||||
|
ref_bytes = None
|
||||||
|
for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용
|
||||||
|
raw = assets / f"scene{i:02d}_raw.png"
|
||||||
|
if raw.exists() and not force:
|
||||||
|
ref_bytes = raw.read_bytes(); break
|
||||||
|
anchor = None
|
||||||
|
if ref_bytes is None:
|
||||||
|
anchor = next((i for i in need_img if i not in real_map), None)
|
||||||
|
if anchor is not None:
|
||||||
|
print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True)
|
||||||
|
make_image(anchor, None)
|
||||||
|
ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes()
|
||||||
|
|
||||||
|
rest = [i for i in need_img if i != anchor]
|
||||||
|
if rest:
|
||||||
|
print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
i = futs[fut]; fut.result()
|
||||||
|
tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지"
|
||||||
|
print(f"[{i:2d}] {tag} done", flush=True)
|
||||||
|
|
||||||
|
print(f"[⏱] 이미지 {_tm.monotonic() - _t_img:.1f}초", flush=True); _t_tts = _tm.monotonic()
|
||||||
|
# --- 음성: 전부 독립 → 병렬 ---
|
||||||
|
def make_tts(i):
|
||||||
|
text = (scenes[i - 1].get("narration") or "").strip()
|
||||||
|
if not text:
|
||||||
|
# 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체.
|
||||||
|
# (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치)
|
||||||
|
pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000)
|
||||||
|
return
|
||||||
|
pcm, rate = gen_tts(client, text, voice)
|
||||||
|
pcm_to_wav(pcm, audio_paths[i - 1], rate)
|
||||||
|
|
||||||
|
need_wav = [i for i in range(1, len(scenes) + 1)
|
||||||
|
if force or not audio_paths[i - 1].exists()]
|
||||||
|
for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav):
|
||||||
|
print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)")
|
||||||
|
if need_wav:
|
||||||
|
print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(make_tts, i): i for i in need_wav}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
i = futs[fut]; fut.result()
|
||||||
|
print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}초", flush=True)
|
||||||
|
|
||||||
|
print(f"[⏱] 음성 {_tm.monotonic() - _t_tts:.1f}초", flush=True)
|
||||||
|
out = workdir / f"{stem}.mp4"
|
||||||
|
print(f"\n영상 합성 → {out.name}")
|
||||||
|
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
|
||||||
|
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
|
||||||
|
bgm=bgm, bgm_volume=bgm_volume)
|
||||||
|
print(f"완성: {out}")
|
||||||
|
return out
|
||||||
270
generator/animation/scraper.py
Normal file
270
generator/animation/scraper.py
Normal file
@ -0,0 +1,270 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
나무위키 조선 왕 '썰 키워드' 스크래퍼
|
||||||
|
====================================
|
||||||
|
|
||||||
|
조선 27대 왕의 나무위키 문서에서 목차(섹션 제목)를 긁어와
|
||||||
|
숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
|
||||||
|
|
||||||
|
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
|
||||||
|
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
|
||||||
|
|
||||||
|
설치:
|
||||||
|
pip install playwright beautifulsoup4
|
||||||
|
playwright install chromium # (이미 설치돼 있으면 생략)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
python namu_joseon_scraper.py
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
python namu_joseon_scraper.py --headful # 브라우저 창 보이게
|
||||||
|
python namu_joseon_scraper.py --only 세종 광해군 # 특정 왕만
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 설정
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
BASE = "https://namu.wiki/w/"
|
||||||
|
OUT_DIR = Path(__file__).parent / "data" # 조선왕 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
|
||||||
|
|
||||||
|
# 조선 27대 왕: (대수, 묘호/군호, 나무위키 문서 제목)
|
||||||
|
# 군주는 보통 "묘호(조선)" 형태, 폐위된 임금(연산군/광해군)은 군호 단독.
|
||||||
|
JOSEON_KINGS = [
|
||||||
|
(1, "태조", "태조(조선)"),
|
||||||
|
(2, "정종", "정종(조선)"),
|
||||||
|
(3, "태종", "태종(조선)"),
|
||||||
|
(4, "세종", "세종(조선)"),
|
||||||
|
(5, "문종", "문종(조선)"),
|
||||||
|
(6, "단종", "단종(조선)"),
|
||||||
|
(7, "세조", "세조(조선)"),
|
||||||
|
(8, "예종", "예종(조선)"),
|
||||||
|
(9, "성종", "성종(조선)"),
|
||||||
|
(10, "연산군", "연산군"),
|
||||||
|
(11, "중종", "중종(조선)"),
|
||||||
|
(12, "인종", "인종(조선)"),
|
||||||
|
(13, "명종", "명종(조선)"),
|
||||||
|
(14, "선조", "선조(조선)"),
|
||||||
|
(15, "광해군", "광해군"),
|
||||||
|
(16, "인조", "인조(조선)"),
|
||||||
|
(17, "효종", "효종(조선)"),
|
||||||
|
(18, "현종", "현종(조선)"),
|
||||||
|
(19, "숙종", "숙종(조선)"),
|
||||||
|
(20, "경종", "경종(조선)"),
|
||||||
|
(21, "영조", "영조(조선)"),
|
||||||
|
(22, "정조", "정조(조선)"),
|
||||||
|
(23, "순조", "순조"),
|
||||||
|
(24, "헌종", "헌종(조선)"),
|
||||||
|
(25, "철종", "철종(조선)"),
|
||||||
|
(26, "고종", "고종(대한제국)"),
|
||||||
|
(27, "순종", "순종(대한제국)"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
|
||||||
|
ANECDOTE_HINTS = [
|
||||||
|
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
|
||||||
|
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
|
||||||
|
"독살", "암살", "치세", "업적", "가족", "후궁", "이름", "별명",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_heading(text: str) -> str:
|
||||||
|
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
|
||||||
|
t = text.replace("[편집]", "").strip()
|
||||||
|
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
|
||||||
|
t = re.sub(r"^\s*[\d.]+\s*", "", t)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_anecdote(keyword: str) -> bool:
|
||||||
|
return any(h in keyword for h in ANECDOTE_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_king(page, title: str):
|
||||||
|
"""한 왕 문서를 열고 목차 키워드 목록을 반환."""
|
||||||
|
url = BASE + title
|
||||||
|
result = {"title": title, "url": url, "page_title": None,
|
||||||
|
"keywords": [], "anecdote_keywords": [], "error": None}
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
|
||||||
|
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
# 문서 실제 제목
|
||||||
|
try:
|
||||||
|
result["page_title"] = (await page.title()).split(" - ")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
result["error"] = f"HTTP {resp.status}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
|
||||||
|
keywords = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const clean = s => s.replace('[편집]', '').trim();
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
// 1순위: 목차(table of contents)
|
||||||
|
const tocLinks = document.querySelectorAll(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
|
||||||
|
);
|
||||||
|
if (tocLinks.length) {
|
||||||
|
items = Array.from(tocLinks).map(a => clean(a.textContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2순위(목차가 없으면): 본문 헤딩 태그
|
||||||
|
if (items.length === 0) {
|
||||||
|
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
||||||
|
items = Array.from(hs).map(h => clean(h.textContent));
|
||||||
|
}
|
||||||
|
return items.filter(Boolean);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 중복 제거 + 번호 정리
|
||||||
|
seen, cleaned = set(), []
|
||||||
|
for raw in keywords:
|
||||||
|
kw = clean_heading(raw)
|
||||||
|
if kw and kw not in seen and len(kw) <= 60:
|
||||||
|
seen.add(kw)
|
||||||
|
cleaned.append(kw)
|
||||||
|
|
||||||
|
result["keywords"] = cleaned
|
||||||
|
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
|
||||||
|
parser.add_argument("--only", nargs="*", help="특정 왕만 (묘호/군호로 지정)")
|
||||||
|
parser.add_argument("--concurrency", type=int, default=4,
|
||||||
|
help="동시에 열 페이지 수 (기본 4)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
kings = JOSEON_KINGS
|
||||||
|
if args.only:
|
||||||
|
wanted = set(args.only)
|
||||||
|
kings = [k for k in JOSEON_KINGS if k[1] in wanted]
|
||||||
|
if not kings:
|
||||||
|
print(f"[!] 매칭되는 왕 없음: {args.only}")
|
||||||
|
print(" 가능한 값:", ", ".join(k[1] for k in JOSEON_KINGS))
|
||||||
|
return
|
||||||
|
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
all_results = []
|
||||||
|
|
||||||
|
total = len(kings)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=not args.headful)
|
||||||
|
context = await browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
locale="ko-KR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
|
||||||
|
async def _block(route):
|
||||||
|
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
|
||||||
|
await route.abort()
|
||||||
|
else:
|
||||||
|
await route.continue_()
|
||||||
|
await context.route("**/*", _block)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(max(1, args.concurrency))
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
async def worker(idx, name, title):
|
||||||
|
nonlocal done
|
||||||
|
async with sem:
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
|
||||||
|
# 실패하면 백오프하며 최대 3회까지 재시도
|
||||||
|
r = await scrape_king(page, title)
|
||||||
|
for attempt in range(3):
|
||||||
|
if not r["error"]:
|
||||||
|
break
|
||||||
|
await page.wait_for_timeout(1500 * (attempt + 1))
|
||||||
|
r = await scrape_king(page, title)
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
r["order"] = idx
|
||||||
|
r["name"] = name
|
||||||
|
done += 1
|
||||||
|
if r["error"]:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
|
||||||
|
else:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) "
|
||||||
|
f"키워드 {len(r['keywords'])}개 "
|
||||||
|
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
|
||||||
|
return r
|
||||||
|
|
||||||
|
all_results = await asyncio.gather(
|
||||||
|
*(worker(idx, name, title) for idx, name, title in kings)
|
||||||
|
)
|
||||||
|
all_results = sorted(all_results, key=lambda r: r["order"])
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
# 저장 ---------------------------------------------------------------
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
json_path = OUT_DIR / f"joseon_keywords_{ts}.json"
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_path = OUT_DIR / f"joseon_keywords_{ts}.csv"
|
||||||
|
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["대수", "왕", "문서제목", "썰후보여부", "키워드", "URL"])
|
||||||
|
for r in all_results:
|
||||||
|
anec = set(r["anecdote_keywords"])
|
||||||
|
for kw in r["keywords"]:
|
||||||
|
w.writerow([r["order"], r["name"], r["title"],
|
||||||
|
"Y" if kw in anec else "", kw, r["url"]])
|
||||||
|
|
||||||
|
total_kw = sum(len(r["keywords"]) for r in all_results)
|
||||||
|
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
|
||||||
|
fails = [r["name"] for r in all_results if r["error"]]
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"완료: 왕 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}개")
|
||||||
|
if fails:
|
||||||
|
print(f"수집 실패: {', '.join(fails)}")
|
||||||
|
print(f"JSON -> {json_path}")
|
||||||
|
print(f"CSV -> {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
asyncio.run(main())
|
||||||
13
generator/animation_greekroman/character/README.txt
Normal file
13
generator/animation_greekroman/character/README.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[캐릭터 공식 레퍼런스 폴더 — greekroman 은 기본 '비움']
|
||||||
|
|
||||||
|
greekroman(그리스 로마 신화) 버전은 고양이 의인화가 아니라 '실제 신의 모습'
|
||||||
|
(근육질 남신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
|
||||||
|
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
|
||||||
|
|
||||||
|
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
|
||||||
|
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
|
||||||
|
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
|
||||||
|
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
|
||||||
|
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
|
||||||
|
|
||||||
|
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||||
@ -0,0 +1,466 @@
|
|||||||
|
번호,인물,문서제목,썰후보여부,키워드,URL
|
||||||
|
1,제우스,제우스,,제우스,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,개요,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,특징,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,명칭,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,기원과 발전,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,행적,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,탄생,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,신들의 왕이 되다: 티타노마키아,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,티포노마키아,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,기간토마키아,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,트로이 전쟁,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,Y,로마에서의 숭배,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,기나긴 통치의 끝,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,그리스도교와 제우스,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,Y,신화의 해석,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,강간범 및 난봉꾼,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,"제우스의 구애 및 사통, 강간 목록",https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,능력,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,권능 / 전투력,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,무기,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,Y,기타,https://namu.wiki/w/제우스
|
||||||
|
1,제우스,제우스,,매체에서의 등장,https://namu.wiki/w/제우스
|
||||||
|
2,헤라,헤라,,헤라,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,개요,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,신격의 기원,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,행적,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,특징,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,외모,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,자식들,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,Y,기타,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,Y,대중매체,https://namu.wiki/w/헤라
|
||||||
|
2,헤라,헤라,,외부 링크,https://namu.wiki/w/헤라
|
||||||
|
3,포세이돈,포세이돈,,포세이돈,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,개요,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,신격의 기원,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,어원,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,특징,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,외모,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,가계도,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,포세이돈의 구애 목록,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,위상,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,Y,창작물에서의 포세이돈,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,Y,만화로 읽는 초등 인문학 그리스 로마 신화,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,에이지 오브 미쏠로지,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,수학도둑,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,최강 시리즈,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,외부 링크,https://namu.wiki/w/포세이돈
|
||||||
|
3,포세이돈,포세이돈,,넵튠,https://namu.wiki/w/포세이돈
|
||||||
|
4,데메테르,데메테르,,데메테르,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,개요,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,신격의 기원,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,Y,가족 관계,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,특징,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,납치된 딸 페르세포네를 찾아서,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,Y,엘레우시스 설화,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,도마뱀이 된 아스칼라보스,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,엘레우시스 비교(秘敎),https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,아리온과 데스포이나,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,Y,에뤼시크톤 이야기,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,Y,이아시온과의 사랑,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,에리뉘스 테르포우시아,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,Y,대중매체,https://namu.wiki/w/데메테르
|
||||||
|
4,데메테르,데메테르,,외부 링크,https://namu.wiki/w/데메테르
|
||||||
|
5,아테나,아테나,,아테나,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,개요,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,신격의 기원,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,특징,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,다른 신들과의 관계,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,헤라,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,아레스,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,아프로디테,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,제우스,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,포세이돈,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,아르테미스,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,헤르메스,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,다른 신들,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,Y,그 외 신화 속 행적,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,Y,대중매체,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,외부 링크,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,한국어,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,영어,https://namu.wiki/w/아테나
|
||||||
|
5,아테나,아테나,,관련 문서,https://namu.wiki/w/아테나
|
||||||
|
6,아폴론,아폴론,,아폴론,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,개요,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,신격의 기원,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,특징,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,태양신으로서,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,별칭,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,연애사,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,아폴론의 구애 목록,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,Y,기타,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,Y,대중매체,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,외부 링크,https://namu.wiki/w/아폴론
|
||||||
|
6,아폴론,아폴론,,관련 문서,https://namu.wiki/w/아폴론
|
||||||
|
7,아르테미스,아르테미스,,아르테미스,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,개요,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,신격의 기원,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,특징,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,Y,탄생 신화,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,신격,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,달의 여신,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,사냥과 궁술의 여신,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,순결한 처녀신,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,남성과의 관계,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,미모,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,Y,그리스 로마 신화에서의 행적들,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,악타이온,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,오리온,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,키오네,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,힙폴뤼토스,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,칼리돈의 멧돼지 사냥,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,헤라클레스의 12과업,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,기간토마키아,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,파리스의 심판,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,트로이 전쟁,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,아트레이드의 비극,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,오디세이아,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,아이네이스,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,오토스와 에피알테스,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,니오베의 자식 학살,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,아레스의 재판,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,브리토마르티스,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,티포노마키아,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,Y,기타 신앙,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,소아시아의 아르테미스 신앙,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,,별칭,https://namu.wiki/w/아르테미스
|
||||||
|
7,아르테미스,아르테미스,Y,대중매체,https://namu.wiki/w/아르테미스
|
||||||
|
8,아레스,아레스,,아레스,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,개요,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,신격의 기원,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,위상,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,고대 그리스,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,아테나와의 비교,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,헬레니즘 시대,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,고대 로마,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,여러 전승,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,여자들과 자녀,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,외모와 연애사,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,아프로디테 설화,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,아버지로서,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,알킵페와 아레오파고스(Areopagus) 설화,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,그 외의 설화,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,알로아다이 설화,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,현대의 재평가?,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,기타,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,Y,대중매체,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,외부 링크,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,영어,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,한국어,https://namu.wiki/w/아레스
|
||||||
|
8,아레스,아레스,,관련 문서,https://namu.wiki/w/아레스
|
||||||
|
9,아프로디테,아프로디테,,아프로디테,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,개요,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,신화에서의 묘사,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,기원 및 해석,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,탄생 신화,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,아레스와의 관계,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,헤르메스와의 관계,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,퓌그말리온 신화,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,이피스와 아낙사레테,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,탄호이저 전설,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,프쉬케 이야기,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,아도니스 이야기,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,아탈란테와 힙포메네스,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,아이네이스,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,네리테스 이야기,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,,남자들과 자녀,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,여담,https://namu.wiki/w/아프로디테
|
||||||
|
9,아프로디테,아프로디테,Y,창작물,https://namu.wiki/w/아프로디테
|
||||||
|
10,헤파이스토스,헤파이스토스,,헤파이스토스,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,개요,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,신격의 기원,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,특징,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,헤파이스토스의 부인들,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,능력,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,기술자,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,전투력,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,Y,기타,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,매체에서,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,망치하르방,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,Y,그리스 로마 신화 - 신들의 사생활,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,소울 칼리버 시리즈,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,외부 링크,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,한국어,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,영어,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,일본어,https://namu.wiki/w/헤파이스토스
|
||||||
|
10,헤파이스토스,헤파이스토스,,중국어,https://namu.wiki/w/헤파이스토스
|
||||||
|
11,헤르메스,헤르메스,,헤르메스,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,개요,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,신격의 기원,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,특징,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,성장 과정,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,Y,신화에서,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,현실에서,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,Y,창작물,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,Y,해당 신에게서 이름을 딴 것들,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,해당 신이 모델이 된 인물들,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,신을 죽이는 방법,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,쿠키런 시리즈,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,쿠키런: 킹덤,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,Y,해당 신이 등장하는 창작물,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,원더우먼,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,하데스타운,https://namu.wiki/w/헤르메스
|
||||||
|
11,헤르메스,헤르메스,,관련 문서,https://namu.wiki/w/헤르메스
|
||||||
|
12,디오니소스,디오니소스,,디오니소스,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,개요,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,신격의 기원,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,상세,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,신화 속 외모 묘사,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,탄생과 성장,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,제우스와 세멜레,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,제우스와 페르세포네,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,어린 시절,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,숭배,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,신화 속 디오니소스,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,디오니소스의 별칭들,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,대중 매체에서,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,홍은영의 그리스 로마 신화,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,만화로 읽는 초등 인문학 그리스 로마 신화,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,올림포스 가디언,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,로어 올림푸스,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,던전에서 만남을 추구하면 안 되는 걸까,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,트랜스포머 시리즈,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,2024 파리 올림픽,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,,디지몬 시리즈,https://namu.wiki/w/디오니소스
|
||||||
|
12,디오니소스,디오니소스,Y,기타,https://namu.wiki/w/디오니소스
|
||||||
|
13,하데스,하데스,,하데스,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,개요,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,신격의 기원,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,특징,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,가계도,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,Y,페르세포네 납치와 사랑,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,Y,기타,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,대중문화 속의 하데스,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,목록,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,Y,"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,에이지 오브 미쏠로지,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,삼국지 천명 시리즈에서의 하데스,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,가수 강수지의 노래,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,드라마 유령에 등장하는 천재 해커,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,관련 문서,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,외부 링크,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,한국어,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,영어,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,일본어,https://namu.wiki/w/하데스
|
||||||
|
13,하데스,하데스,,중국어,https://namu.wiki/w/하데스
|
||||||
|
14,헤스티아,헤스티아,,헤스티아,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,개요,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,신격의 기원,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,특징,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,지위와 역할,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,최고의 선신,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,올림포스 최초의 처녀신,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,너무 적은 비중,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,Y,대중매체,https://namu.wiki/w/헤스티아
|
||||||
|
14,헤스티아,헤스티아,,관련 문서,https://namu.wiki/w/헤스티아
|
||||||
|
15,프로메테우스,프로메테우스,,프로메테우스,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,개요,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,특징,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,행적,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,고전,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,매체,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,올림포스 가디언,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,Y,장난꾸러기 신 이야기 코로코로 포론,https://namu.wiki/w/프로메테우스
|
||||||
|
15,프로메테우스,프로메테우스,,관련 문서,https://namu.wiki/w/프로메테우스
|
||||||
|
16,크로노스,크로노스,,크로노스,https://namu.wiki/w/크로노스
|
||||||
|
16,크로노스,크로노스,,개요,https://namu.wiki/w/크로노스
|
||||||
|
16,크로노스,크로노스,,농경의 신,https://namu.wiki/w/크로노스
|
||||||
|
16,크로노스,크로노스,,매체에서의 모습,https://namu.wiki/w/크로노스
|
||||||
|
16,크로노스,크로노스,,시간의 신,https://namu.wiki/w/크로노스
|
||||||
|
17,에로스,에로스,,에로스,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,,개요,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,Y,전승,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,,카오스와 동시대의 신으로 묘사되는 경우,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,,아프로디테의 아들로 묘사되는 경우,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,Y,프시케 설화,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,Y,기타,https://namu.wiki/w/에로스
|
||||||
|
17,에로스,에로스,Y,대중매체,https://namu.wiki/w/에로스
|
||||||
|
18,헤라클레스,헤라클레스,,헤라클레스,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,개요,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,특징,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,이름,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,주요 전승들,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,탄생,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,성장기,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,헤라클레스의 선택,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,모험의 시작,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,12과업,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,네메아의 사자 처치,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,히드라 처치,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,황금뿔 사슴 타이게테 생포,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,에리만토스의 멧돼지 생포,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,아우게이아스 왕의 우리 청소,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,스팀팔로스의 새 사냥,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,크레타의 황소 생포,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,디오메데스 왕의 식인 말 생포,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,히폴리테의 허리띠 구해오기,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,게리온의 소를 데려오기,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,황금사과를 구해오기,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,케르베로스 생포,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,옴팔레 여왕의 노예,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,그 이후,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,최후,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,불사의 신이 되다,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,다양한 전승,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,스파르타의 전승,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,아르고 호 원정,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,VS 신,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,테세우스 구출,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,트로이 침공,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,해학,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,헤라클레스의 기둥,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,헤라클레스의 대제단,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,기타 전승 및 언급,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,알레고리적 해석,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,,현대인의 관점,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,가족관계,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,대중매체에서,https://namu.wiki/w/헤라클레스
|
||||||
|
18,헤라클레스,헤라클레스,Y,여담,https://namu.wiki/w/헤라클레스
|
||||||
|
19,페르세우스,페르세우스,,페르세우스,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,개요,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,생애,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,탄생,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,메두사를 처단하다,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,안드로메다와의 만남,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,어머니의 원수인 폴리덱테스를 처단하다,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,결국 이루어진 신탁,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,아르고스를 떠나다,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,미케네의 초대 왕,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,Y,최후,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,Y,평가,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,가계 및 혈통,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,Y,기타,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,Y,대중매체,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,관련 문서,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,동음이의어,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,마케도니아의 군주,https://namu.wiki/w/페르세우스
|
||||||
|
19,페르세우스,페르세우스,,소련의 스파이,https://namu.wiki/w/페르세우스
|
||||||
|
20,테세우스,테세우스,,테세우스,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,개요,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,일대기,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,아버지를 찾아,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,미노타우로스 퇴치,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,오이디푸스와의 만남,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,몰락,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,해설,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,Y,평가,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,Y,기타,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,대중 문화에서,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,그 외,https://namu.wiki/w/테세우스
|
||||||
|
20,테세우스,테세우스,,관련 문서,https://namu.wiki/w/테세우스
|
||||||
|
21,오디세우스,오디세우스,,오디세우스,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,개요,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,어원,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,특징,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,일대기,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,트로이 전쟁,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,병역기피 시도,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,결국은 참전하다,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,전쟁 이후,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,《오디세이아》,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,키클롭스의 동굴,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,아이올로스의 섬,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,키르케의 섬,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,헬리오스의 소,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,칼립소의 섬,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,파이에케스 족(族),https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,구혼자들 처단,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,말년,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,《텔레고네이아》,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,《비블리오테케》,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,그 외,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,가계도,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,Y,평가,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,Y,긍정적 평가,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,Y,부정적 평가,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,,총평,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,Y,대중매체에서의 오디세우스,https://namu.wiki/w/오디세우스
|
||||||
|
21,오디세우스,오디세우스,Y,기타,https://namu.wiki/w/오디세우스
|
||||||
|
22,아킬레우스,아킬레우스,,아킬레우스,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,개요,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,생애,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,《일리아스》,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,아가멤논과의 불화,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,Y,파트로클로스의 죽음,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,헥토르와의 결투,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,《아이디오피스》,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,Y,사후 전승,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,인간 관계,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,파트로클로스,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,그 외,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,가계도,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,Y,평가,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,인품,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,Y,긍정적 평가,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,Y,부정적 평가,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,현대인의 관점에서,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,옹호,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,총평,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,다른 영웅들과의 비교,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,Y,여담,https://namu.wiki/w/아킬레우스
|
||||||
|
22,아킬레우스,아킬레우스,,관련 문서,https://namu.wiki/w/아킬레우스
|
||||||
|
23,오르페우스,오르페우스,,오르페우스,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,,개요,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,Y,신화에서의 행적,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,Y,평가,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,,오르페우스 밀교,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,Y,유사한 신화,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,Y,미디어,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,,올림포스 가디언,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,,그 외,https://namu.wiki/w/오르페우스
|
||||||
|
23,오르페우스,오르페우스,,모티브로 삼은 작품,https://namu.wiki/w/오르페우스
|
||||||
|
24,이아손,이아손,,이아손,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,개요,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,일대기,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,Y,기타,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,Y,이름에 대하여,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,각종 매체에서의 이아손,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,아르고 황금 대탐험,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,신곡 지옥편,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,갓 오브 워 2,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,올림포스 가디언,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,Fate 시리즈,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,Rise of the Argonauts,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,하루만 네가 되고 싶어,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,Y,초등 인문학 그리스 로마 신화,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,Y,신화 속 양치기 노예가 되었다,https://namu.wiki/w/이아손
|
||||||
|
24,이아손,이아손,,R-TYPE 시리즈,https://namu.wiki/w/이아손
|
||||||
|
25,메두사,메두사,,메두사,https://namu.wiki/w/메두사
|
||||||
|
25,메두사,메두사,,개요,https://namu.wiki/w/메두사
|
||||||
|
25,메두사,메두사,Y,전승,https://namu.wiki/w/메두사
|
||||||
|
25,메두사,메두사,Y,대중매체,https://namu.wiki/w/메두사
|
||||||
|
25,메두사,메두사,Y,기타,https://namu.wiki/w/메두사
|
||||||
|
25,메두사,메두사,,외부 링크,https://namu.wiki/w/메두사
|
||||||
|
26,미다스,미다스,,미다스,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,,개요,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,,행적,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,Y,여담,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,Y,대중매체에서,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,Y,만화로 보는 그리스 로마 신화,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,,올림포스 가디언,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,,갓 오브 워: 고스트 오브 스파르타,https://namu.wiki/w/미다스
|
||||||
|
26,미다스,미다스,Y,그리스 로마 신화 : 전설의 수호자들,https://namu.wiki/w/미다스
|
||||||
|
27,시시포스,시시포스,,시시포스,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,,개요,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,Y,가족관계,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,,일대기,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,,아우톨리코스와의 대결,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,,에뤼시크톤과의 악연,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,,영원한 형벌,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,Y,대중매체,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,Y,여담,https://namu.wiki/w/시시포스
|
||||||
|
27,시시포스,시시포스,,관련 문서,https://namu.wiki/w/시시포스
|
||||||
|
28,이카로스,이카로스,,이카로스,https://namu.wiki/w/이카로스
|
||||||
|
28,이카로스,이카로스,,개요,https://namu.wiki/w/이카로스
|
||||||
|
28,이카로스,이카로스,Y,이름을 딴 것들,https://namu.wiki/w/이카로스
|
||||||
|
28,이카로스,이카로스,Y,대중매체,https://namu.wiki/w/이카로스
|
||||||
|
28,이카로스,이카로스,,서방 프로젝트의 등장인물,https://namu.wiki/w/이카로스
|
||||||
|
28,이카로스,이카로스,,파이브 스타 스토리의 등장인물 발란셰 파티마,https://namu.wiki/w/이카로스
|
||||||
|
@ -0,0 +1,913 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "제우스",
|
||||||
|
"url": "https://namu.wiki/w/제우스",
|
||||||
|
"page_title": "제우스",
|
||||||
|
"keywords": [
|
||||||
|
"제우스",
|
||||||
|
"개요",
|
||||||
|
"특징",
|
||||||
|
"명칭",
|
||||||
|
"기원과 발전",
|
||||||
|
"행적",
|
||||||
|
"탄생",
|
||||||
|
"신들의 왕이 되다: 티타노마키아",
|
||||||
|
"티포노마키아",
|
||||||
|
"기간토마키아",
|
||||||
|
"트로이 전쟁",
|
||||||
|
"로마에서의 숭배",
|
||||||
|
"기나긴 통치의 끝",
|
||||||
|
"그리스도교와 제우스",
|
||||||
|
"신화의 해석",
|
||||||
|
"강간범 및 난봉꾼",
|
||||||
|
"제우스의 구애 및 사통, 강간 목록",
|
||||||
|
"능력",
|
||||||
|
"권능 / 전투력",
|
||||||
|
"무기",
|
||||||
|
"기타",
|
||||||
|
"매체에서의 등장"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"로마에서의 숭배",
|
||||||
|
"신화의 해석",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 1,
|
||||||
|
"name": "제우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "헤라",
|
||||||
|
"url": "https://namu.wiki/w/헤라",
|
||||||
|
"page_title": "헤라",
|
||||||
|
"keywords": [
|
||||||
|
"헤라",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"행적",
|
||||||
|
"특징",
|
||||||
|
"외모",
|
||||||
|
"자식들",
|
||||||
|
"기타",
|
||||||
|
"대중매체",
|
||||||
|
"외부 링크"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"기타",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 2,
|
||||||
|
"name": "헤라"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "포세이돈",
|
||||||
|
"url": "https://namu.wiki/w/포세이돈",
|
||||||
|
"page_title": "포세이돈",
|
||||||
|
"keywords": [
|
||||||
|
"포세이돈",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"어원",
|
||||||
|
"특징",
|
||||||
|
"외모",
|
||||||
|
"가계도",
|
||||||
|
"포세이돈의 구애 목록",
|
||||||
|
"위상",
|
||||||
|
"창작물에서의 포세이돈",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
|
||||||
|
"만화로 읽는 초등 인문학 그리스 로마 신화",
|
||||||
|
"에이지 오브 미쏠로지",
|
||||||
|
"수학도둑",
|
||||||
|
"최강 시리즈",
|
||||||
|
"외부 링크",
|
||||||
|
"넵튠"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"창작물에서의 포세이돈",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
|
||||||
|
"만화로 읽는 초등 인문학 그리스 로마 신화"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 3,
|
||||||
|
"name": "포세이돈"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "데메테르",
|
||||||
|
"url": "https://namu.wiki/w/데메테르",
|
||||||
|
"page_title": "데메테르",
|
||||||
|
"keywords": [
|
||||||
|
"데메테르",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"가족 관계",
|
||||||
|
"특징",
|
||||||
|
"납치된 딸 페르세포네를 찾아서",
|
||||||
|
"엘레우시스 설화",
|
||||||
|
"도마뱀이 된 아스칼라보스",
|
||||||
|
"엘레우시스 비교(秘敎)",
|
||||||
|
"아리온과 데스포이나",
|
||||||
|
"에뤼시크톤 이야기",
|
||||||
|
"이아시온과의 사랑",
|
||||||
|
"에리뉘스 테르포우시아",
|
||||||
|
"대중매체",
|
||||||
|
"외부 링크"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"가족 관계",
|
||||||
|
"엘레우시스 설화",
|
||||||
|
"에뤼시크톤 이야기",
|
||||||
|
"이아시온과의 사랑",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 4,
|
||||||
|
"name": "데메테르"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "아테나",
|
||||||
|
"url": "https://namu.wiki/w/아테나",
|
||||||
|
"page_title": "아테나",
|
||||||
|
"keywords": [
|
||||||
|
"아테나",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"다른 신들과의 관계",
|
||||||
|
"헤라",
|
||||||
|
"아레스",
|
||||||
|
"아프로디테",
|
||||||
|
"제우스",
|
||||||
|
"포세이돈",
|
||||||
|
"아르테미스",
|
||||||
|
"헤르메스",
|
||||||
|
"다른 신들",
|
||||||
|
"그 외 신화 속 행적",
|
||||||
|
"대중매체",
|
||||||
|
"외부 링크",
|
||||||
|
"한국어",
|
||||||
|
"영어",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"그 외 신화 속 행적",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 5,
|
||||||
|
"name": "아테나"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "아폴론",
|
||||||
|
"url": "https://namu.wiki/w/아폴론",
|
||||||
|
"page_title": "아폴론",
|
||||||
|
"keywords": [
|
||||||
|
"아폴론",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"태양신으로서",
|
||||||
|
"별칭",
|
||||||
|
"연애사",
|
||||||
|
"아폴론의 구애 목록",
|
||||||
|
"기타",
|
||||||
|
"대중매체",
|
||||||
|
"외부 링크",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"기타",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 6,
|
||||||
|
"name": "아폴론"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "아르테미스",
|
||||||
|
"url": "https://namu.wiki/w/아르테미스",
|
||||||
|
"page_title": "아르테미스",
|
||||||
|
"keywords": [
|
||||||
|
"아르테미스",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"탄생 신화",
|
||||||
|
"신격",
|
||||||
|
"달의 여신",
|
||||||
|
"사냥과 궁술의 여신",
|
||||||
|
"순결한 처녀신",
|
||||||
|
"남성과의 관계",
|
||||||
|
"미모",
|
||||||
|
"그리스 로마 신화에서의 행적들",
|
||||||
|
"악타이온",
|
||||||
|
"오리온",
|
||||||
|
"키오네",
|
||||||
|
"힙폴뤼토스",
|
||||||
|
"칼리돈의 멧돼지 사냥",
|
||||||
|
"헤라클레스의 12과업",
|
||||||
|
"기간토마키아",
|
||||||
|
"파리스의 심판",
|
||||||
|
"트로이 전쟁",
|
||||||
|
"아트레이드의 비극",
|
||||||
|
"오디세이아",
|
||||||
|
"아이네이스",
|
||||||
|
"오토스와 에피알테스",
|
||||||
|
"니오베의 자식 학살",
|
||||||
|
"아레스의 재판",
|
||||||
|
"브리토마르티스",
|
||||||
|
"티포노마키아",
|
||||||
|
"기타 신앙",
|
||||||
|
"소아시아의 아르테미스 신앙",
|
||||||
|
"별칭",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"탄생 신화",
|
||||||
|
"그리스 로마 신화에서의 행적들",
|
||||||
|
"기타 신앙",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 7,
|
||||||
|
"name": "아르테미스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "아레스",
|
||||||
|
"url": "https://namu.wiki/w/아레스",
|
||||||
|
"page_title": "아레스",
|
||||||
|
"keywords": [
|
||||||
|
"아레스",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"위상",
|
||||||
|
"고대 그리스",
|
||||||
|
"아테나와의 비교",
|
||||||
|
"헬레니즘 시대",
|
||||||
|
"고대 로마",
|
||||||
|
"여러 전승",
|
||||||
|
"여자들과 자녀",
|
||||||
|
"외모와 연애사",
|
||||||
|
"아프로디테 설화",
|
||||||
|
"아버지로서",
|
||||||
|
"알킵페와 아레오파고스(Areopagus) 설화",
|
||||||
|
"그 외의 설화",
|
||||||
|
"알로아다이 설화",
|
||||||
|
"현대의 재평가?",
|
||||||
|
"기타",
|
||||||
|
"대중매체",
|
||||||
|
"외부 링크",
|
||||||
|
"영어",
|
||||||
|
"한국어",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"여러 전승",
|
||||||
|
"아프로디테 설화",
|
||||||
|
"알킵페와 아레오파고스(Areopagus) 설화",
|
||||||
|
"그 외의 설화",
|
||||||
|
"알로아다이 설화",
|
||||||
|
"현대의 재평가?",
|
||||||
|
"기타",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 8,
|
||||||
|
"name": "아레스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "아프로디테",
|
||||||
|
"url": "https://namu.wiki/w/아프로디테",
|
||||||
|
"page_title": "아프로디테",
|
||||||
|
"keywords": [
|
||||||
|
"아프로디테",
|
||||||
|
"개요",
|
||||||
|
"신화에서의 묘사",
|
||||||
|
"기원 및 해석",
|
||||||
|
"탄생 신화",
|
||||||
|
"아레스와의 관계",
|
||||||
|
"헤르메스와의 관계",
|
||||||
|
"퓌그말리온 신화",
|
||||||
|
"이피스와 아낙사레테",
|
||||||
|
"탄호이저 전설",
|
||||||
|
"프쉬케 이야기",
|
||||||
|
"아도니스 이야기",
|
||||||
|
"아탈란테와 힙포메네스",
|
||||||
|
"아이네이스",
|
||||||
|
"네리테스 이야기",
|
||||||
|
"남자들과 자녀",
|
||||||
|
"여담",
|
||||||
|
"창작물"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"신화에서의 묘사",
|
||||||
|
"탄생 신화",
|
||||||
|
"퓌그말리온 신화",
|
||||||
|
"탄호이저 전설",
|
||||||
|
"프쉬케 이야기",
|
||||||
|
"아도니스 이야기",
|
||||||
|
"네리테스 이야기",
|
||||||
|
"여담",
|
||||||
|
"창작물"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 9,
|
||||||
|
"name": "아프로디테"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "헤파이스토스",
|
||||||
|
"url": "https://namu.wiki/w/헤파이스토스",
|
||||||
|
"page_title": "헤파이스토스",
|
||||||
|
"keywords": [
|
||||||
|
"헤파이스토스",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"헤파이스토스의 부인들",
|
||||||
|
"능력",
|
||||||
|
"기술자",
|
||||||
|
"전투력",
|
||||||
|
"기타",
|
||||||
|
"매체에서",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
|
||||||
|
"망치하르방",
|
||||||
|
"그리스 로마 신화 - 신들의 사생활",
|
||||||
|
"소울 칼리버 시리즈",
|
||||||
|
"외부 링크",
|
||||||
|
"한국어",
|
||||||
|
"영어",
|
||||||
|
"일본어",
|
||||||
|
"중국어"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"기타",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
|
||||||
|
"그리스 로마 신화 - 신들의 사생활"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 10,
|
||||||
|
"name": "헤파이스토스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "헤르메스",
|
||||||
|
"url": "https://namu.wiki/w/헤르메스",
|
||||||
|
"page_title": "헤르메스",
|
||||||
|
"keywords": [
|
||||||
|
"헤르메스",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"성장 과정",
|
||||||
|
"신화에서",
|
||||||
|
"현실에서",
|
||||||
|
"창작물",
|
||||||
|
"해당 신에게서 이름을 딴 것들",
|
||||||
|
"해당 신이 모델이 된 인물들",
|
||||||
|
"신을 죽이는 방법",
|
||||||
|
"쿠키런 시리즈",
|
||||||
|
"쿠키런: 킹덤",
|
||||||
|
"해당 신이 등장하는 창작물",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
|
||||||
|
"원더우먼",
|
||||||
|
"하데스타운",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"신화에서",
|
||||||
|
"창작물",
|
||||||
|
"해당 신에게서 이름을 딴 것들",
|
||||||
|
"해당 신이 등장하는 창작물",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 11,
|
||||||
|
"name": "헤르메스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "디오니소스",
|
||||||
|
"url": "https://namu.wiki/w/디오니소스",
|
||||||
|
"page_title": "디오니소스",
|
||||||
|
"keywords": [
|
||||||
|
"디오니소스",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"상세",
|
||||||
|
"신화 속 외모 묘사",
|
||||||
|
"탄생과 성장",
|
||||||
|
"제우스와 세멜레",
|
||||||
|
"제우스와 페르세포네",
|
||||||
|
"어린 시절",
|
||||||
|
"숭배",
|
||||||
|
"신화 속 디오니소스",
|
||||||
|
"디오니소스의 별칭들",
|
||||||
|
"대중 매체에서",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"홍은영의 그리스 로마 신화",
|
||||||
|
"만화로 읽는 초등 인문학 그리스 로마 신화",
|
||||||
|
"올림포스 가디언",
|
||||||
|
"로어 올림푸스",
|
||||||
|
"던전에서 만남을 추구하면 안 되는 걸까",
|
||||||
|
"트랜스포머 시리즈",
|
||||||
|
"2024 파리 올림픽",
|
||||||
|
"디지몬 시리즈",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"신화 속 외모 묘사",
|
||||||
|
"숭배",
|
||||||
|
"신화 속 디오니소스",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"홍은영의 그리스 로마 신화",
|
||||||
|
"만화로 읽는 초등 인문학 그리스 로마 신화",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 12,
|
||||||
|
"name": "디오니소스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "하데스",
|
||||||
|
"url": "https://namu.wiki/w/하데스",
|
||||||
|
"page_title": "하데스",
|
||||||
|
"keywords": [
|
||||||
|
"하데스",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"가계도",
|
||||||
|
"페르세포네 납치와 사랑",
|
||||||
|
"기타",
|
||||||
|
"대중문화 속의 하데스",
|
||||||
|
"목록",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화",
|
||||||
|
"에이지 오브 미쏠로지",
|
||||||
|
"삼국지 천명 시리즈에서의 하데스",
|
||||||
|
"가수 강수지의 노래",
|
||||||
|
"드라마 유령에 등장하는 천재 해커",
|
||||||
|
"관련 문서",
|
||||||
|
"외부 링크",
|
||||||
|
"한국어",
|
||||||
|
"영어",
|
||||||
|
"일본어",
|
||||||
|
"중국어"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"페르세포네 납치와 사랑",
|
||||||
|
"기타",
|
||||||
|
"만화로 보는 그리스 로마 신화, 홍은영의 그리스 로마 신화"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 13,
|
||||||
|
"name": "하데스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "헤스티아",
|
||||||
|
"url": "https://namu.wiki/w/헤스티아",
|
||||||
|
"page_title": "헤스티아",
|
||||||
|
"keywords": [
|
||||||
|
"헤스티아",
|
||||||
|
"개요",
|
||||||
|
"신격의 기원",
|
||||||
|
"특징",
|
||||||
|
"지위와 역할",
|
||||||
|
"최고의 선신",
|
||||||
|
"올림포스 최초의 처녀신",
|
||||||
|
"너무 적은 비중",
|
||||||
|
"대중매체",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 14,
|
||||||
|
"name": "헤스티아"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "프로메테우스",
|
||||||
|
"url": "https://namu.wiki/w/프로메테우스",
|
||||||
|
"page_title": "프로메테우스",
|
||||||
|
"keywords": [
|
||||||
|
"프로메테우스",
|
||||||
|
"개요",
|
||||||
|
"특징",
|
||||||
|
"행적",
|
||||||
|
"고전",
|
||||||
|
"매체",
|
||||||
|
"올림포스 가디언",
|
||||||
|
"장난꾸러기 신 이야기 코로코로 포론",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"장난꾸러기 신 이야기 코로코로 포론"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 15,
|
||||||
|
"name": "프로메테우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "크로노스",
|
||||||
|
"url": "https://namu.wiki/w/크로노스",
|
||||||
|
"page_title": "크로노스",
|
||||||
|
"keywords": [
|
||||||
|
"크로노스",
|
||||||
|
"개요",
|
||||||
|
"농경의 신",
|
||||||
|
"매체에서의 모습",
|
||||||
|
"시간의 신"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [],
|
||||||
|
"error": null,
|
||||||
|
"order": 16,
|
||||||
|
"name": "크로노스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "에로스",
|
||||||
|
"url": "https://namu.wiki/w/에로스",
|
||||||
|
"page_title": "에로스",
|
||||||
|
"keywords": [
|
||||||
|
"에로스",
|
||||||
|
"개요",
|
||||||
|
"전승",
|
||||||
|
"카오스와 동시대의 신으로 묘사되는 경우",
|
||||||
|
"아프로디테의 아들로 묘사되는 경우",
|
||||||
|
"프시케 설화",
|
||||||
|
"기타",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"전승",
|
||||||
|
"프시케 설화",
|
||||||
|
"기타",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 17,
|
||||||
|
"name": "에로스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "헤라클레스",
|
||||||
|
"url": "https://namu.wiki/w/헤라클레스",
|
||||||
|
"page_title": "헤라클레스",
|
||||||
|
"keywords": [
|
||||||
|
"헤라클레스",
|
||||||
|
"개요",
|
||||||
|
"특징",
|
||||||
|
"이름",
|
||||||
|
"주요 전승들",
|
||||||
|
"탄생",
|
||||||
|
"성장기",
|
||||||
|
"헤라클레스의 선택",
|
||||||
|
"모험의 시작",
|
||||||
|
"12과업",
|
||||||
|
"네메아의 사자 처치",
|
||||||
|
"히드라 처치",
|
||||||
|
"황금뿔 사슴 타이게테 생포",
|
||||||
|
"에리만토스의 멧돼지 생포",
|
||||||
|
"아우게이아스 왕의 우리 청소",
|
||||||
|
"스팀팔로스의 새 사냥",
|
||||||
|
"크레타의 황소 생포",
|
||||||
|
"디오메데스 왕의 식인 말 생포",
|
||||||
|
"히폴리테의 허리띠 구해오기",
|
||||||
|
"게리온의 소를 데려오기",
|
||||||
|
"황금사과를 구해오기",
|
||||||
|
"케르베로스 생포",
|
||||||
|
"옴팔레 여왕의 노예",
|
||||||
|
"그 이후",
|
||||||
|
"최후",
|
||||||
|
"불사의 신이 되다",
|
||||||
|
"다양한 전승",
|
||||||
|
"스파르타의 전승",
|
||||||
|
"아르고 호 원정",
|
||||||
|
"VS 신",
|
||||||
|
"테세우스 구출",
|
||||||
|
"트로이 침공",
|
||||||
|
"해학",
|
||||||
|
"헤라클레스의 기둥",
|
||||||
|
"헤라클레스의 대제단",
|
||||||
|
"기타 전승 및 언급",
|
||||||
|
"알레고리적 해석",
|
||||||
|
"현대인의 관점",
|
||||||
|
"가족관계",
|
||||||
|
"대중매체에서",
|
||||||
|
"여담"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"이름",
|
||||||
|
"주요 전승들",
|
||||||
|
"최후",
|
||||||
|
"다양한 전승",
|
||||||
|
"스파르타의 전승",
|
||||||
|
"기타 전승 및 언급",
|
||||||
|
"가족관계",
|
||||||
|
"대중매체에서",
|
||||||
|
"여담"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 18,
|
||||||
|
"name": "헤라클레스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "페르세우스",
|
||||||
|
"url": "https://namu.wiki/w/페르세우스",
|
||||||
|
"page_title": "페르세우스",
|
||||||
|
"keywords": [
|
||||||
|
"페르세우스",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"탄생",
|
||||||
|
"메두사를 처단하다",
|
||||||
|
"안드로메다와의 만남",
|
||||||
|
"어머니의 원수인 폴리덱테스를 처단하다",
|
||||||
|
"결국 이루어진 신탁",
|
||||||
|
"아르고스를 떠나다",
|
||||||
|
"미케네의 초대 왕",
|
||||||
|
"최후",
|
||||||
|
"평가",
|
||||||
|
"가계 및 혈통",
|
||||||
|
"기타",
|
||||||
|
"대중매체",
|
||||||
|
"관련 문서",
|
||||||
|
"동음이의어",
|
||||||
|
"마케도니아의 군주",
|
||||||
|
"소련의 스파이"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"최후",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 19,
|
||||||
|
"name": "페르세우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "테세우스",
|
||||||
|
"url": "https://namu.wiki/w/테세우스",
|
||||||
|
"page_title": "테세우스",
|
||||||
|
"keywords": [
|
||||||
|
"테세우스",
|
||||||
|
"개요",
|
||||||
|
"일대기",
|
||||||
|
"아버지를 찾아",
|
||||||
|
"미노타우로스 퇴치",
|
||||||
|
"오이디푸스와의 만남",
|
||||||
|
"몰락",
|
||||||
|
"해설",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"대중 문화에서",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"그 외",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"만화로 보는 그리스 로마 신화"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 20,
|
||||||
|
"name": "테세우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "오디세우스",
|
||||||
|
"url": "https://namu.wiki/w/오디세우스",
|
||||||
|
"page_title": "오디세우스",
|
||||||
|
"keywords": [
|
||||||
|
"오디세우스",
|
||||||
|
"개요",
|
||||||
|
"어원",
|
||||||
|
"특징",
|
||||||
|
"일대기",
|
||||||
|
"트로이 전쟁",
|
||||||
|
"병역기피 시도",
|
||||||
|
"결국은 참전하다",
|
||||||
|
"전쟁 이후",
|
||||||
|
"《오디세이아》",
|
||||||
|
"키클롭스의 동굴",
|
||||||
|
"아이올로스의 섬",
|
||||||
|
"키르케의 섬",
|
||||||
|
"헬리오스의 소",
|
||||||
|
"칼립소의 섬",
|
||||||
|
"파이에케스 족(族)",
|
||||||
|
"구혼자들 처단",
|
||||||
|
"말년",
|
||||||
|
"《텔레고네이아》",
|
||||||
|
"《비블리오테케》",
|
||||||
|
"그 외",
|
||||||
|
"가계도",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가",
|
||||||
|
"총평",
|
||||||
|
"대중매체에서의 오디세우스",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가",
|
||||||
|
"대중매체에서의 오디세우스",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 21,
|
||||||
|
"name": "오디세우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "아킬레우스",
|
||||||
|
"url": "https://namu.wiki/w/아킬레우스",
|
||||||
|
"page_title": "아킬레우스",
|
||||||
|
"keywords": [
|
||||||
|
"아킬레우스",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"《일리아스》",
|
||||||
|
"아가멤논과의 불화",
|
||||||
|
"파트로클로스의 죽음",
|
||||||
|
"헥토르와의 결투",
|
||||||
|
"《아이디오피스》",
|
||||||
|
"사후 전승",
|
||||||
|
"인간 관계",
|
||||||
|
"파트로클로스",
|
||||||
|
"그 외",
|
||||||
|
"가계도",
|
||||||
|
"평가",
|
||||||
|
"인품",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가",
|
||||||
|
"현대인의 관점에서",
|
||||||
|
"옹호",
|
||||||
|
"총평",
|
||||||
|
"다른 영웅들과의 비교",
|
||||||
|
"여담",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"파트로클로스의 죽음",
|
||||||
|
"사후 전승",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가",
|
||||||
|
"여담"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 22,
|
||||||
|
"name": "아킬레우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "오르페우스",
|
||||||
|
"url": "https://namu.wiki/w/오르페우스",
|
||||||
|
"page_title": "오르페우스",
|
||||||
|
"keywords": [
|
||||||
|
"오르페우스",
|
||||||
|
"개요",
|
||||||
|
"신화에서의 행적",
|
||||||
|
"평가",
|
||||||
|
"오르페우스 밀교",
|
||||||
|
"유사한 신화",
|
||||||
|
"미디어",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"올림포스 가디언",
|
||||||
|
"그 외",
|
||||||
|
"모티브로 삼은 작품"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"신화에서의 행적",
|
||||||
|
"평가",
|
||||||
|
"유사한 신화",
|
||||||
|
"미디어",
|
||||||
|
"만화로 보는 그리스 로마 신화"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 23,
|
||||||
|
"name": "오르페우스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "이아손",
|
||||||
|
"url": "https://namu.wiki/w/이아손",
|
||||||
|
"page_title": "이아손",
|
||||||
|
"keywords": [
|
||||||
|
"이아손",
|
||||||
|
"개요",
|
||||||
|
"일대기",
|
||||||
|
"기타",
|
||||||
|
"이름에 대하여",
|
||||||
|
"각종 매체에서의 이아손",
|
||||||
|
"아르고 황금 대탐험",
|
||||||
|
"신곡 지옥편",
|
||||||
|
"갓 오브 워 2",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"올림포스 가디언",
|
||||||
|
"Fate 시리즈",
|
||||||
|
"Rise of the Argonauts",
|
||||||
|
"하루만 네가 되고 싶어",
|
||||||
|
"초등 인문학 그리스 로마 신화",
|
||||||
|
"신화 속 양치기 노예가 되었다",
|
||||||
|
"R-TYPE 시리즈"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"기타",
|
||||||
|
"이름에 대하여",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"초등 인문학 그리스 로마 신화",
|
||||||
|
"신화 속 양치기 노예가 되었다"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 24,
|
||||||
|
"name": "이아손"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "메두사",
|
||||||
|
"url": "https://namu.wiki/w/메두사",
|
||||||
|
"page_title": "메두사",
|
||||||
|
"keywords": [
|
||||||
|
"메두사",
|
||||||
|
"개요",
|
||||||
|
"전승",
|
||||||
|
"대중매체",
|
||||||
|
"기타",
|
||||||
|
"외부 링크"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"전승",
|
||||||
|
"대중매체",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 25,
|
||||||
|
"name": "메두사"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "미다스",
|
||||||
|
"url": "https://namu.wiki/w/미다스",
|
||||||
|
"page_title": "미다스",
|
||||||
|
"keywords": [
|
||||||
|
"미다스",
|
||||||
|
"개요",
|
||||||
|
"행적",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"올림포스 가디언",
|
||||||
|
"갓 오브 워: 고스트 오브 스파르타",
|
||||||
|
"그리스 로마 신화 : 전설의 수호자들"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"여담",
|
||||||
|
"대중매체에서",
|
||||||
|
"만화로 보는 그리스 로마 신화",
|
||||||
|
"그리스 로마 신화 : 전설의 수호자들"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 26,
|
||||||
|
"name": "미다스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "시시포스",
|
||||||
|
"url": "https://namu.wiki/w/시시포스",
|
||||||
|
"page_title": "시시포스",
|
||||||
|
"keywords": [
|
||||||
|
"시시포스",
|
||||||
|
"개요",
|
||||||
|
"가족관계",
|
||||||
|
"일대기",
|
||||||
|
"아우톨리코스와의 대결",
|
||||||
|
"에뤼시크톤과의 악연",
|
||||||
|
"영원한 형벌",
|
||||||
|
"대중매체",
|
||||||
|
"여담",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"가족관계",
|
||||||
|
"대중매체",
|
||||||
|
"여담"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 27,
|
||||||
|
"name": "시시포스"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "이카로스",
|
||||||
|
"url": "https://namu.wiki/w/이카로스",
|
||||||
|
"page_title": "이카로스",
|
||||||
|
"keywords": [
|
||||||
|
"이카로스",
|
||||||
|
"개요",
|
||||||
|
"이름을 딴 것들",
|
||||||
|
"대중매체",
|
||||||
|
"서방 프로젝트의 등장인물",
|
||||||
|
"파이브 스타 스토리의 등장인물 발란셰 파티마"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"이름을 딴 것들",
|
||||||
|
"대중매체"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 28,
|
||||||
|
"name": "이카로스"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
generator/animation_greekroman/fonts/brand_logo.png
Normal file
BIN
generator/animation_greekroman/fonts/brand_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
308
generator/animation_greekroman/gen.py
Normal file
308
generator/animation_greekroman/gen.py
Normal file
@ -0,0 +1,308 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
animation_greekroman 생성 단계 (나레이션 + 스토리보드) — 병합본
|
||||||
|
==================================================================
|
||||||
|
그리스 로마 신화 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
|
||||||
|
(animation(조선왕) 폴더와 동일 구조 — 소재만 '조선왕' → '그리스 로마 신화 신·영웅'.)
|
||||||
|
scraper.py 가 만든 greek_keywords_*.csv 를 입력으로 한다.
|
||||||
|
|
||||||
|
· 나레이션: latest_csv / load_rows / pick_seed / generate
|
||||||
|
· 스토리보드: clamp_scenes / make_storyboard
|
||||||
|
|
||||||
|
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
DATA_DIR = HERE / "data" # 신화 인물 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
|
||||||
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
|
||||||
|
_TYPO = {"…": "...", "‘": "'", "’": "'", "“": '"',
|
||||||
|
"”": '"', "·": " ", "~": "~", "〜": "~"}
|
||||||
|
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
|
||||||
|
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
|
||||||
|
|
||||||
|
|
||||||
|
def _no_emoji(s):
|
||||||
|
s = s or ""
|
||||||
|
for _k, _v in _TYPO.items():
|
||||||
|
s = s.replace(_k, _v)
|
||||||
|
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ① 나레이션 (신화 썰 → 병맛 대본) ==============================
|
||||||
|
|
||||||
|
def latest_csv() -> Path:
|
||||||
|
files = sorted(DATA_DIR.glob("greek_keywords_*.csv"))
|
||||||
|
if not files:
|
||||||
|
sys.exit("[!] animation_greekroman/data/ 에 greek_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
|
||||||
|
return files[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(csv_path: Path):
|
||||||
|
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...] (king = 신화 인물 이름)"""
|
||||||
|
rows = []
|
||||||
|
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||||||
|
r = csv.reader(f)
|
||||||
|
next(r, None) # 헤더 스킵
|
||||||
|
for line in r:
|
||||||
|
if len(line) < 6:
|
||||||
|
continue
|
||||||
|
order, king, doc, anec, kw, url = line[:6]
|
||||||
|
rows.append({
|
||||||
|
"order": int(order) if order.isdigit() else 0,
|
||||||
|
"king": king, "doc": doc,
|
||||||
|
"is_anecdote": anec.strip().upper() == "Y",
|
||||||
|
"keyword": kw, "url": url,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def pick_seed(rows, king=None, keyword=None, prefer_anecdote=True):
|
||||||
|
"""대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택."""
|
||||||
|
cand = rows
|
||||||
|
if king:
|
||||||
|
cand = [r for r in cand if r["king"] == king]
|
||||||
|
if not cand:
|
||||||
|
kings = sorted({r["king"] for r in rows})
|
||||||
|
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
|
||||||
|
if keyword:
|
||||||
|
kw_match = [r for r in cand if keyword in r["keyword"]]
|
||||||
|
if not kw_match:
|
||||||
|
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
|
||||||
|
return random.choice(kw_match)
|
||||||
|
if prefer_anecdote:
|
||||||
|
anec = [r for r in cand if r["is_anecdote"]]
|
||||||
|
if anec:
|
||||||
|
cand = anec
|
||||||
|
return random.choice(cand)
|
||||||
|
|
||||||
|
|
||||||
|
NARR_SYSTEM = """\
|
||||||
|
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
|
||||||
|
그리스 로마 신화 속 신·영웅들의 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
|
||||||
|
|
||||||
|
[톤 규칙]
|
||||||
|
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
|
||||||
|
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
|
||||||
|
- 신화 내용(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
|
||||||
|
유명 신화 장면(판도라의 상자, 트로이 전쟁, 12과업, 미다스의 손, 시시포스의 바위 등)을 드립 소재로 적극 활용해도 된다.
|
||||||
|
제우스의 바람기, 헤라의 질투 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라.
|
||||||
|
단, 시청자가 '진짜 역사'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
|
||||||
|
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
|
||||||
|
|
||||||
|
[구조] (반드시 이 4단)
|
||||||
|
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
|
||||||
|
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
|
||||||
|
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
|
||||||
|
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
|
||||||
|
|
||||||
|
[분량]
|
||||||
|
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
|
||||||
|
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
|
||||||
|
|
||||||
|
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
|
||||||
|
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
|
||||||
|
- 형식:
|
||||||
|
1) 첫 줄: 후킹 카피 한 줄 (예: "제우스가 사실 OO 단골이었다는 썰 풉니다.")
|
||||||
|
2) 본문 1~2줄: 신화 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
|
||||||
|
3) 셀링 한 줄: 신이 반할 식으로 가게 핵심 (예: "제우스도 반할 OO시장 숨은 맛집 'OOO' 🍲")
|
||||||
|
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
|
||||||
|
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
|
||||||
|
6) 👉 방문 팁 또는 행동유도 한 줄
|
||||||
|
7) 마지막 줄: 해시태그 나열. #그리스로마 #신화썰 을 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
|
||||||
|
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
|
||||||
|
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
|
||||||
|
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_USER = """\
|
||||||
|
[이번 쇼츠 소재]
|
||||||
|
- 인물: {king} ({doc})
|
||||||
|
- 썰 키워드: "{keyword}"
|
||||||
|
- 참고 출처: {url}
|
||||||
|
|
||||||
|
[마케팅 대상 (마지막에 이걸로 갈아타기)]
|
||||||
|
- 가게명: {store}
|
||||||
|
- 소개/키워드: {product}
|
||||||
|
|
||||||
|
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
|
||||||
|
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 신/영웅의 유명한 신화 일화로 살을 붙여도 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
|
||||||
|
"hook": {"type": "string"},
|
||||||
|
"story": {"type": "string"},
|
||||||
|
"pivot": {"type": "string"},
|
||||||
|
"cta": {"type": "string"},
|
||||||
|
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
|
||||||
|
"estimated_seconds": {"type": "integer"},
|
||||||
|
"hashtags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
|
||||||
|
},
|
||||||
|
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate(client, model, seed, product, seconds, store=""):
|
||||||
|
"""신화 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
|
||||||
|
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
|
||||||
|
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
|
||||||
|
user = NARR_USER.format(
|
||||||
|
king=seed["king"], doc=seed["doc"],
|
||||||
|
keyword=seed["keyword"], url=seed["url"], product=product,
|
||||||
|
store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
|
||||||
|
)
|
||||||
|
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=user,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=sys_prompt,
|
||||||
|
temperature=1.1, # 병맛 = 약간 높은 창의성
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=NARR_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
data = json.loads(resp.text)
|
||||||
|
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
|
||||||
|
data["_product"] = product
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
|
||||||
|
|
||||||
|
SB_SYSTEM = """\
|
||||||
|
너는 숏폼 영상 디렉터다. 주어진 '병맛 그리스 로마 신화 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
|
||||||
|
|
||||||
|
[핵심 규칙]
|
||||||
|
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
|
||||||
|
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
|
||||||
|
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
|
||||||
|
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
|
||||||
|
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
|
||||||
|
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the muscular god dramatically throwing a lightning bolt from a cloud, sweating)
|
||||||
|
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
|
||||||
|
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
|
||||||
|
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
|
||||||
|
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
|
||||||
|
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
|
||||||
|
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||||
|
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||||
|
|
||||||
|
[전역 설정]
|
||||||
|
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
|
||||||
|
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
|
||||||
|
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
|
||||||
|
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
|
||||||
|
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
|
||||||
|
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
|
||||||
|
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
|
||||||
|
복식/색/머리/상징물(번개·투구·부엉이·삼지창·월계관 등 그 신의 아이콘)을 못박아 컷마다 동일하게.
|
||||||
|
(예: Zeus as a tall handsome god with a heroic muscular greek-statue physique, long white hair and
|
||||||
|
majestic white beard, wearing a white toga with gold trim and a laurel wreath, holding a glowing
|
||||||
|
golden lightning bolt / Athena as a beautiful slender goddess with long dark hair, wearing a bronze
|
||||||
|
helmet and flowing white chiton with a golden owl emblem, holding a spear and round shield)
|
||||||
|
★ 눈 그림체: 순정만화/한국 신화만화풍의 크고 또렷한 눈(large expressive sparkling manhwa eyes,
|
||||||
|
detailed irises with light reflections)으로 통일. 이 '그림체'만 유지하고, 장면 감정에 따라
|
||||||
|
눈을 감거나(eyes closed) 실눈(squint)·윙크·부릅뜸(wide dramatic glare) 등 표정은 바꿔도 된다.
|
||||||
|
- style_guide: 그림 스타일을 영어로. 한국 그리스로마신화 학습만화풍(classic Korean mythology manhwa
|
||||||
|
style) — 깔끔하고 또렷한 외곽선, 화사하고 선명한 색, 반짝이는 하이라이트, 극적인 연출,
|
||||||
|
'성인 비율의 캐릭터(tall adult proportions, never chibi or super-deformed)',
|
||||||
|
단순 배경(고대 그리스 신전·올림포스·구름·대리석 기둥 분위기), 세로 9:16.
|
||||||
|
|
||||||
|
[출력] 지정된 JSON 스키마로만.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_USER = """\
|
||||||
|
[마케팅 대상] {product}
|
||||||
|
[소재 인물] {king}
|
||||||
|
[목표 장면 수] 약 {scenes}개
|
||||||
|
|
||||||
|
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
|
||||||
|
{narration}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_sheet": {"type": "string"},
|
||||||
|
"style_guide": {"type": "string"},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"narration": {"type": "string"},
|
||||||
|
"caption": {"type": "string"},
|
||||||
|
"image_prompt": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["narration", "caption", "image_prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["character_sheet", "style_guide", "scenes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_scenes(scene_list, target):
|
||||||
|
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||||
|
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||||
|
def clen(s):
|
||||||
|
return len(s.get("narration", "").strip())
|
||||||
|
|
||||||
|
while len(scene_list) > target:
|
||||||
|
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||||
|
i = min(range(len(scene_list) - 1),
|
||||||
|
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||||
|
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||||
|
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||||
|
base["narration"] = (a + " " + b).strip()
|
||||||
|
scene_list[i:i + 2] = [base]
|
||||||
|
return scene_list
|
||||||
|
|
||||||
|
|
||||||
|
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||||
|
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=SB_USER.format(product=product, king=king,
|
||||||
|
scenes=scenes, narration=narration),
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=SB_SYSTEM,
|
||||||
|
temperature=0.8,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=SB_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sb = json.loads(resp.text)
|
||||||
|
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||||
|
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||||
|
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||||
|
raw = sb.get("scenes", [])
|
||||||
|
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||||
|
if len(kept) < len(raw):
|
||||||
|
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||||
|
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||||
|
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||||
|
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||||
|
sb["_product"] = product
|
||||||
|
sb["_king"] = king
|
||||||
|
return sb
|
||||||
275
generator/animation_greekroman/main.py
Normal file
275
generator/animation_greekroman/main.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
키워드 → 병맛 그리스 로마 신화 마케팅 쇼츠 (완전 자동, 원커맨드)
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
||||||
|
1) 랜덤으로 그리스 로마 신화 신·영웅 '썰' 매칭
|
||||||
|
2) Gemini 로 병맛 마케팅 나레이션 생성
|
||||||
|
3) 장면 분할(스토리보드)
|
||||||
|
4) 장면별 이미지 + 음성 생성
|
||||||
|
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
|
||||||
|
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
||||||
|
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
||||||
|
|
||||||
|
선행: scraper.py 로 만든 data/greek_keywords_*.csv 가 있어야 함.
|
||||||
|
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
||||||
|
|
||||||
|
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
|
||||||
|
python animation_greekroman\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
||||||
|
python animation_greekroman\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
||||||
|
python animation_greekroman\\main.py https://naver.me/xxxx
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
|
||||||
|
--king 제우스 특정 신/영웅 고정 (기본: 랜덤)
|
||||||
|
--scenes 12 장면 수=이미지 수 (기본 12)
|
||||||
|
--seconds 45 목표 길이 초 (기본 45)
|
||||||
|
--voice Puck TTS 목소리
|
||||||
|
--keep 중간 파일(assets) 삭제하지 않고 보존
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
|
||||||
|
|
||||||
|
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
|
||||||
|
import render as MS # animation 영상합성 엔진(이전 make_short.py)
|
||||||
|
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
|
||||||
|
TEXT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(s: str) -> str:
|
||||||
|
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
|
||||||
|
return s[:30] or "short"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="키워드 또는 네이버 링크 → 병맛 그리스 로마 신화 쇼츠 (완전 자동)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
|
||||||
|
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
|
||||||
|
)
|
||||||
|
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
|
||||||
|
ap.add_argument("keywords", nargs="*",
|
||||||
|
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
|
||||||
|
# 결과 폴더 라벨
|
||||||
|
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
|
||||||
|
# 생성 옵션
|
||||||
|
ap.add_argument("--king", help="특정 신/영웅 고정 (예: 제우스, 기본: 랜덤)")
|
||||||
|
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
|
||||||
|
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
|
||||||
|
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
|
||||||
|
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
|
||||||
|
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
|
||||||
|
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
|
||||||
|
ap.add_argument("--images", default="load_image",
|
||||||
|
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
|
||||||
|
"(예: --images 연산골막국수). 비었으면 전부 AI")
|
||||||
|
ap.add_argument("--max-real", type=int, default=0,
|
||||||
|
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
|
||||||
|
ap.add_argument("--real-scenes",
|
||||||
|
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
|
||||||
|
ap.add_argument("--no-match", action="store_true",
|
||||||
|
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
|
||||||
|
ap.add_argument("--photos", type=int, default=4,
|
||||||
|
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
|
||||||
|
ap.add_argument("--no-photos", action="store_true",
|
||||||
|
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
|
||||||
|
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
|
||||||
|
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
|
||||||
|
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
|
||||||
|
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
|
||||||
|
ap.add_argument("--img-model", default=MS.IMG_MODEL,
|
||||||
|
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
|
||||||
|
ap.add_argument("--allow-text", action="store_true",
|
||||||
|
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
|
||||||
|
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
|
||||||
|
ap.add_argument("--speed", type=float, default=1.3,
|
||||||
|
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
|
||||||
|
ap.add_argument("--no-trim", action="store_true",
|
||||||
|
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
|
||||||
|
ap.add_argument("--tail", type=float, default=0.0,
|
||||||
|
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
|
||||||
|
ap.add_argument("--workers", type=int, default=4,
|
||||||
|
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
|
||||||
|
ap.add_argument("--bgm",
|
||||||
|
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
|
||||||
|
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
|
||||||
|
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
|
||||||
|
ap.add_argument("--bgm-volume", type=float, default=0.3,
|
||||||
|
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
|
||||||
|
ap.add_argument("--api-key")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
|
||||||
|
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
|
||||||
|
if not args.link:
|
||||||
|
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
|
||||||
|
if link_tok:
|
||||||
|
args.link = link_tok
|
||||||
|
args.keywords = [k for k in args.keywords if k != link_tok]
|
||||||
|
|
||||||
|
product = " ".join(args.keywords).strip()
|
||||||
|
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
|
||||||
|
brief_store = None
|
||||||
|
if not product and NV.looks_like_naver_place(args.link or ""):
|
||||||
|
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
|
||||||
|
try:
|
||||||
|
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if not bullets:
|
||||||
|
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||||
|
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||||
|
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not _key:
|
||||||
|
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
try:
|
||||||
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if rstore and not brief_store:
|
||||||
|
brief_store = rstore
|
||||||
|
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
|
if not bullets:
|
||||||
|
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||||
|
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||||
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
|
product = " ".join(bullets)
|
||||||
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
|
for b in bullets:
|
||||||
|
print(f" • {b}")
|
||||||
|
if not product:
|
||||||
|
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
|
||||||
|
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
|
||||||
|
|
||||||
|
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
|
||||||
|
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
|
||||||
|
rows = gen.load_rows(csv_path)
|
||||||
|
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
|
||||||
|
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
|
||||||
|
and args.images == "load_image"):
|
||||||
|
store_for_dir = (brief_store or args.store or args.label
|
||||||
|
or " ".join(args.keywords[:2]) or "naver_store")
|
||||||
|
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
|
||||||
|
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
|
||||||
|
if existing:
|
||||||
|
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
else:
|
||||||
|
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
|
||||||
|
try:
|
||||||
|
_, finals = NV.fetch_and_select(args.link, photo_dir,
|
||||||
|
n=args.photos, client=client)
|
||||||
|
if finals:
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
|
||||||
|
else:
|
||||||
|
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
|
||||||
|
|
||||||
|
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
|
||||||
|
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
|
||||||
|
stem = f"{safe_name(label)}_{ts}"
|
||||||
|
job_dir = OUT_DIR / stem
|
||||||
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) 랜덤(또는 지정) 인물 썰 매칭
|
||||||
|
seed = gen.pick_seed(rows, king=args.king)
|
||||||
|
print(f"■ 작업 폴더 : {job_dir}")
|
||||||
|
print(f"■ 마케팅 대상 : {product}")
|
||||||
|
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
|
||||||
|
|
||||||
|
# 2) 병맛 나레이션
|
||||||
|
print("[1/4] 나레이션 생성...")
|
||||||
|
store_name = brief_store or getattr(args, "store", None) or label
|
||||||
|
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
|
||||||
|
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
|
||||||
|
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
|
||||||
|
(job_dir / "narration.json").write_text(
|
||||||
|
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
|
||||||
|
lyric = (f"[제목] {narr.get('title','')}\n"
|
||||||
|
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
|
||||||
|
f"[마케팅] {product}\n"
|
||||||
|
f"{'-'*40}\n"
|
||||||
|
f"{narr.get('full_narration','')}\n")
|
||||||
|
if narr.get("hashtags"):
|
||||||
|
lyric += f"\n{' '.join(narr['hashtags'])}\n"
|
||||||
|
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
|
||||||
|
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
|
||||||
|
caption = (narr.get("sns_caption") or "").strip()
|
||||||
|
if caption:
|
||||||
|
(job_dir / "caption.txt").write_text(caption, encoding="utf-8")
|
||||||
|
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
|
||||||
|
|
||||||
|
# 3) 스토리보드(장면 분할)
|
||||||
|
print("[2/4] 스토리보드 분할...")
|
||||||
|
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
|
||||||
|
product, seed["king"], scenes=args.scenes)
|
||||||
|
(job_dir / "storyboard.json").write_text(
|
||||||
|
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f" 장면 {len(sb['scenes'])}개")
|
||||||
|
|
||||||
|
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
|
||||||
|
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
|
||||||
|
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
|
||||||
|
if not real_images:
|
||||||
|
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
|
||||||
|
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
|
||||||
|
bgm = MS.resolve_bgm(args.bgm or "bgm")
|
||||||
|
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
|
||||||
|
if args.bgm and not _off and not bgm:
|
||||||
|
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
|
||||||
|
elif bgm:
|
||||||
|
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
|
||||||
|
print("[3/4] 이미지·음성 생성...")
|
||||||
|
store = args.store or brief_store or product.split(",")[0].strip() or None
|
||||||
|
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
|
||||||
|
real_images=real_images, real_scenes=real_scenes,
|
||||||
|
max_real=args.max_real, match_content=not args.no_match,
|
||||||
|
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
|
||||||
|
img_model=args.img_model, allow_text=args.allow_text,
|
||||||
|
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
|
||||||
|
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
|
||||||
|
|
||||||
|
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
|
||||||
|
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
|
||||||
|
shutil.rmtree(job_dir / "assets", ignore_errors=True)
|
||||||
|
for junk in ("narration.json", "storyboard.json"):
|
||||||
|
(job_dir / junk).unlink(missing_ok=True)
|
||||||
|
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
|
||||||
|
|
||||||
|
print(f"\n✅ 완성: {out_mp4}")
|
||||||
|
print(f" {seed['king']} 썰 · {product}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
980
generator/animation_greekroman/render.py
Normal file
980
generator/animation_greekroman/render.py
Normal file
@ -0,0 +1,980 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
스토리보드 → 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션]
|
||||||
|
=====================================================================
|
||||||
|
|
||||||
|
gen_storyboard.py 가 만든 스토리보드를 받아 장면별로:
|
||||||
|
1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1번 컷을 레퍼런스로 물림)
|
||||||
|
2) Gemini TTS 로 그 장면 나레이션 음성 생성 (장면 길이 = 음성 길이)
|
||||||
|
3) ffmpeg 로 Ken Burns(zoompan 줌) + 자막(PNG 오버레이) + 음성 합성 → 세로 1080x1920 mp4
|
||||||
|
(Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast)
|
||||||
|
|
||||||
|
이미지/음성은 output/ 에 장면별로 캐싱한다. 재실행 시 이미 있으면 재사용(재과금 X).
|
||||||
|
다시 그리고 싶으면 --force.
|
||||||
|
|
||||||
|
설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 — ffmpeg 직접 렌더)
|
||||||
|
실행:
|
||||||
|
python make_short.py # 최신 *_storyboard.json
|
||||||
|
python make_short.py --storyboard output/xxx_storyboard.json --voice Puck
|
||||||
|
python make_short.py --force # 이미지/음성 새로 생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import media as MU
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation_greekroman" # 통합 출력: shorts_all/output/animation_greekroman/
|
||||||
|
W, H = 1080, 1920 # 세로 쇼츠
|
||||||
|
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
|
||||||
|
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
|
||||||
|
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
|
||||||
|
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
|
||||||
|
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
|
||||||
|
IMG_MODEL = "gemini-3-pro-image" # 최상위 이미지 모델(인체·화풍 품질 우선). 저렴하게: gemini-3.1-flash-image
|
||||||
|
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
|
||||||
|
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
|
||||||
|
TTS_VOICE = "Puck"
|
||||||
|
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
|
||||||
|
"능청스럽게 약간 오버해서 읽어줘:")
|
||||||
|
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
|
||||||
|
def _find_font(name):
|
||||||
|
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
|
||||||
|
if c.exists():
|
||||||
|
return str(c)
|
||||||
|
return str(HERE / "fonts" / name)
|
||||||
|
FONT = _find_font("Jua-Regular.ttf")
|
||||||
|
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||||
|
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
|
||||||
|
TITLE_FONT = _find_font("SongMyung-Regular.ttf")
|
||||||
|
TITLE_SIZE = 58
|
||||||
|
|
||||||
|
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
|
||||||
|
# → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기)
|
||||||
|
NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, "
|
||||||
|
"Korean hangul, logos, or signage text. Keep every sign, banner, board and menu "
|
||||||
|
"completely BLANK with no writing on them.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 데이터
|
||||||
|
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_real_images(images_dir, real_scenes_arg):
|
||||||
|
"""--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트.
|
||||||
|
|
||||||
|
images_dir 해석 순서 (가게마다 load_image/<가게>/ 로 나눠 담는 걸 지원):
|
||||||
|
1) 절대경로면 그대로
|
||||||
|
2) 상대경로/이름이면 HERE/<값> (예: load_image/연산골막국수)
|
||||||
|
3) 그래도 없으면 HERE/load_image/<값> (예: --images 연산골막국수)
|
||||||
|
가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 빈 리스트.
|
||||||
|
"""
|
||||||
|
real_images, used = [], None
|
||||||
|
if images_dir:
|
||||||
|
d = Path(images_dir)
|
||||||
|
candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d]
|
||||||
|
for c in candidates:
|
||||||
|
if c.exists() and c.is_dir():
|
||||||
|
imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS)
|
||||||
|
if imgs:
|
||||||
|
real_images, used = imgs, c
|
||||||
|
break
|
||||||
|
if used:
|
||||||
|
print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)")
|
||||||
|
real_scenes = None
|
||||||
|
if real_scenes_arg:
|
||||||
|
real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x]
|
||||||
|
return real_images, real_scenes
|
||||||
|
|
||||||
|
|
||||||
|
# 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트
|
||||||
|
PRESENT_POSES = [
|
||||||
|
"Place the god character in the UPPER part of the frame, smiling and pointing down toward the "
|
||||||
|
"lower-center, leaving the lower-center area visually simple (a photo will be placed there).",
|
||||||
|
"Put the god character on the LEFT side presenting with one hand to the right-center, keeping the "
|
||||||
|
"right-center area simple and uncluttered (a photo will be placed there).",
|
||||||
|
"Show the god character at the BOTTOM looking up with sparkling eyes toward the upper-center, "
|
||||||
|
"keeping the upper-center area simple (a photo will be placed there).",
|
||||||
|
]
|
||||||
|
# PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율
|
||||||
|
PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)]
|
||||||
|
|
||||||
|
# 신화만화풍 '눈' 그림체를 유지하되, 눈을 '떴을 때'의 모양만 고정한다.
|
||||||
|
# (모든 장면을 부릅뜬 눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐)
|
||||||
|
EYE_STYLE = (
|
||||||
|
"When the character's eyes are open, they are the same beautiful expressive manhwa "
|
||||||
|
"eyes: large sparkling eyes with detailed irises and bright light reflections, in the "
|
||||||
|
"style of classic Korean mythology comics. Keep this consistent stylized eye art."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 인체 해부학 가드: 레퍼런스 오염/인체 오류로 팔·손·소품이 중복 생성되는 것 방지.
|
||||||
|
ANATOMY_GUARD = (
|
||||||
|
"The main character must have correct human anatomy: exactly two arms and two hands, "
|
||||||
|
"one head, no extra or duplicated limbs, no duplicated props or instruments."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지.
|
||||||
|
EYE_FREE = (
|
||||||
|
"In this scene the character's eye EXPRESSION follows the moment — the eyes may be "
|
||||||
|
"gently closed, squinting, blinking, winking, teary, sleepy half-closed or dramatically "
|
||||||
|
"widened as appropriate. Keep the same sparkling manhwa drawing style, but do NOT "
|
||||||
|
"force wide-open eyes here."
|
||||||
|
)
|
||||||
|
|
||||||
|
# image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다.
|
||||||
|
_EYE_EXPR_KW = (
|
||||||
|
"closed eye", "eyes closed", "close its eye", "close his eye", "shut eye",
|
||||||
|
"shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed",
|
||||||
|
"sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy",
|
||||||
|
"crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl",
|
||||||
|
"wince", "grimac", "blink",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scene_needs_eye_expression(sc) -> bool:
|
||||||
|
"""장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True."""
|
||||||
|
text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower()
|
||||||
|
return any(k in text for k in _EYE_EXPR_KW)
|
||||||
|
|
||||||
|
# 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: god_ref.png),
|
||||||
|
# 모든 영상의 모든 컷이 그 캐릭터의 얼굴/눈/화풍을 그대로 따라간다.
|
||||||
|
# ★ greekroman 은 '실제 신 모습'이라 조선/삼국지의 고양이 레퍼런스를 쓰지 않는다(폴더 비움).
|
||||||
|
# 비어 있으면 각 영상의 첫 컷이 자동 앵커가 되어 컷 간 일관성을 유지한다.
|
||||||
|
CHAR_REF_DIR = HERE / "character"
|
||||||
|
|
||||||
|
|
||||||
|
def load_char_ref():
|
||||||
|
"""Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다."""
|
||||||
|
if not CHAR_REF_DIR.is_dir():
|
||||||
|
return None
|
||||||
|
for p in sorted(CHAR_REF_DIR.iterdir()):
|
||||||
|
if p.suffix.lower() in IMG_EXTS:
|
||||||
|
return p.read_bytes()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 이미지
|
||||||
|
def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True,
|
||||||
|
ref_mode="full") -> bytes:
|
||||||
|
"""Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용.
|
||||||
|
|
||||||
|
model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수.
|
||||||
|
no_text: True 면 '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지).
|
||||||
|
ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지.
|
||||||
|
'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
if ref_bytes:
|
||||||
|
parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png"))
|
||||||
|
if ref_mode == "eyes":
|
||||||
|
prompt = ("Use the reference image ONLY to copy the character's EYES and "
|
||||||
|
"facial expression exactly: the same large sparkling expressive eyes "
|
||||||
|
"with detailed irises and light reflections. Do NOT copy the outfit, "
|
||||||
|
"hair, pose, framing or background from the reference — those follow "
|
||||||
|
"the scene description. New scene: " + prompt)
|
||||||
|
else:
|
||||||
|
# ★ '디자인만' 복사 — 포즈/소품까지 복사하면 팔·악기가 중복돼 그려진다(다지증 컷 원인)
|
||||||
|
prompt = ("Use the reference image ONLY for the character's IDENTITY: the same "
|
||||||
|
"face, hair, outfit design, colors and art style. Do NOT copy the "
|
||||||
|
"pose, arms, hands, props, objects or composition from the reference "
|
||||||
|
"image — draw the new scene completely fresh. New scene: " + prompt)
|
||||||
|
parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else "")))
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model or IMG_MODEL,
|
||||||
|
contents=parts,
|
||||||
|
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
|
||||||
|
)
|
||||||
|
for p in resp.candidates[0].content.parts:
|
||||||
|
if getattr(p, "inline_data", None):
|
||||||
|
return p.inline_data.data
|
||||||
|
raise RuntimeError("이미지 파트 없음")
|
||||||
|
|
||||||
|
|
||||||
|
def composite_card(base_path, photo_path, out_path,
|
||||||
|
rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22):
|
||||||
|
"""AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장.
|
||||||
|
|
||||||
|
사진은 원본 그대로(왜곡 X) 축소만 하고, 흰 테두리+그림자+살짝 기울기로 자연스럽게 올린다.
|
||||||
|
rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기(도).
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
base = Image.open(base_path).convert("RGBA")
|
||||||
|
photo = Image.open(photo_path).convert("RGB")
|
||||||
|
|
||||||
|
cw = int(W * rel_w)
|
||||||
|
ph = int(photo.height * (cw / photo.width))
|
||||||
|
max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한
|
||||||
|
if ph > max_h:
|
||||||
|
ph, cw = max_h, int(photo.width * (max_h / photo.height))
|
||||||
|
photo = photo.resize((cw, ph), Image.LANCZOS)
|
||||||
|
|
||||||
|
# 폴라로이드 흰 카드 (아래쪽 여백 좀 더)
|
||||||
|
card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)),
|
||||||
|
(255, 255, 255, 255))
|
||||||
|
card.paste(photo, (border, border))
|
||||||
|
card = card.rotate(angle, expand=True, resample=Image.BICUBIC)
|
||||||
|
|
||||||
|
cx, cy = int(W * center[0]), int(H * center[1])
|
||||||
|
x, y = cx - card.width // 2, cy - card.height // 2
|
||||||
|
|
||||||
|
# 그림자
|
||||||
|
alpha = card.split()[3]
|
||||||
|
shadow = Image.new("RGBA", card.size, (0, 0, 0, 0))
|
||||||
|
shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha)
|
||||||
|
shadow = shadow.filter(ImageFilter.GaussianBlur(18))
|
||||||
|
base.alpha_composite(shadow, (x + 10, y + 16))
|
||||||
|
base.alpha_composite(card, (x, y))
|
||||||
|
base.convert("RGB").save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def cover_crop(src: Path, dst: Path):
|
||||||
|
"""이미지를 1080x1920 꽉 차게 cover-crop 해서 저장."""
|
||||||
|
im = Image.open(src).convert("RGB")
|
||||||
|
scale = max(W / im.width, H / im.height)
|
||||||
|
nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5)
|
||||||
|
im = im.resize((nw, nh), Image.LANCZOS)
|
||||||
|
left, top = (nw - W) // 2, (nh - H) // 2
|
||||||
|
im.crop((left, top, left + W, top + H)).save(dst)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 음성
|
||||||
|
def _extract_audio(resp):
|
||||||
|
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
|
||||||
|
cands = getattr(resp, "candidates", None) or []
|
||||||
|
for c in cands:
|
||||||
|
content = getattr(c, "content", None)
|
||||||
|
parts = getattr(content, "parts", None) or [] if content else []
|
||||||
|
for p in parts:
|
||||||
|
inline = getattr(p, "inline_data", None)
|
||||||
|
if inline and inline.data:
|
||||||
|
m = re.search(r"rate=(\d+)", inline.mime_type or "")
|
||||||
|
return inline.data, (int(m.group(1)) if m else 24000)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gen_tts(client, text, voice, retries=4) -> bytes:
|
||||||
|
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
|
||||||
|
last = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=TTS_MODEL,
|
||||||
|
contents=f"{TTS_STYLE}\n\n{text}",
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["AUDIO"],
|
||||||
|
speech_config=types.SpeechConfig(
|
||||||
|
voice_config=types.VoiceConfig(
|
||||||
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
got = _extract_audio(resp)
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
|
||||||
|
fr = ""
|
||||||
|
try:
|
||||||
|
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
|
||||||
|
except Exception:
|
||||||
|
fr = "no-candidates"
|
||||||
|
last = f"빈 응답(finish_reason={fr or '?'})"
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{type(e).__name__}: {e}"
|
||||||
|
if attempt < retries:
|
||||||
|
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
|
||||||
|
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
|
||||||
|
end="", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}…")
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_to_wav(pcm, path, rate):
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
|
||||||
|
w.writeframes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def wav_seconds(path: Path) -> float:
|
||||||
|
with wave.open(str(path), "rb") as w:
|
||||||
|
return w.getnframes() / w.getframerate()
|
||||||
|
|
||||||
|
|
||||||
|
def _atempo_chain(speed: float) -> list:
|
||||||
|
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
|
||||||
|
chain, s = [], speed
|
||||||
|
while s > 2.0:
|
||||||
|
chain.append("atempo=2.0"); s /= 2.0
|
||||||
|
while s < 0.5:
|
||||||
|
chain.append("atempo=0.5"); s *= 2.0
|
||||||
|
chain.append(f"atempo={s:.4f}")
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
|
||||||
|
threshold="-38dB", keep=0.06):
|
||||||
|
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
|
||||||
|
|
||||||
|
- trim_silence: 앞/뒤 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
|
||||||
|
silenceremove 로 앞을 자르고, areverse 로 뒤집어 다시 앞(=원래 뒤)을 자른 뒤 복원.
|
||||||
|
keep: 잘라낸 끝에 남길 여유(초). 0 이면 너무 칼같이 잘려 어색할 수 있어 약간 남긴다.
|
||||||
|
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
|
||||||
|
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
|
||||||
|
"""
|
||||||
|
af = []
|
||||||
|
if trim_silence:
|
||||||
|
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
|
||||||
|
f"start_threshold={threshold}:detection=peak")
|
||||||
|
af += [one, "areverse", one, "areverse"]
|
||||||
|
if speed and abs(speed - 1.0) > 1e-3:
|
||||||
|
af += _atempo_chain(speed)
|
||||||
|
if not af:
|
||||||
|
shutil.copyfile(src_wav, dst_wav); return
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
|
||||||
|
"-ar", "24000", "-ac", "1", str(dst_wav)])
|
||||||
|
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
|
||||||
|
raise RuntimeError("가공 후 음성이 너무 짧음")
|
||||||
|
except (RuntimeError, OSError):
|
||||||
|
shutil.copyfile(src_wav, dst_wav)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더)
|
||||||
|
FPS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg() -> str:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd):
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def pick_encoder(exe) -> list:
|
||||||
|
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
|
||||||
|
test = subprocess.run(
|
||||||
|
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
|
||||||
|
"-c:v", "h264_qsv", "-f", "null", "-"],
|
||||||
|
capture_output=True, text=True, errors="replace")
|
||||||
|
if test.returncode == 0:
|
||||||
|
print(" 인코더: h264_qsv (Intel 하드웨어)")
|
||||||
|
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
|
||||||
|
print(" 인코더: libx264 (superfast)")
|
||||||
|
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_lines(draw, text, font, max_w):
|
||||||
|
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
|
||||||
|
lines, cur = [], ""
|
||||||
|
for ch in text:
|
||||||
|
if ch == "\n":
|
||||||
|
lines.append(cur); cur = ""; continue
|
||||||
|
if draw.textlength(cur + ch, font=font) <= max_w:
|
||||||
|
cur += ch
|
||||||
|
else:
|
||||||
|
lines.append(cur); cur = ch
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
return lines or [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _blossom(d, cx, cy, r, color):
|
||||||
|
"""간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬)."""
|
||||||
|
import math
|
||||||
|
pr = r * 0.62 # 꽃잎 반지름
|
||||||
|
for k in range(5):
|
||||||
|
a = math.radians(-90 + k * 72)
|
||||||
|
px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a)
|
||||||
|
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
|
||||||
|
d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32],
|
||||||
|
fill=(255, 238, 175, 255)) # 꽃심
|
||||||
|
|
||||||
|
|
||||||
|
_LOGO_RGBA = None
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_logo():
|
||||||
|
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
|
||||||
|
global _LOGO_RGBA
|
||||||
|
if _LOGO_RGBA is not None:
|
||||||
|
return _LOGO_RGBA
|
||||||
|
if not Path(BRAND_LOGO).exists():
|
||||||
|
return None
|
||||||
|
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
|
||||||
|
g = Image.open(BRAND_LOGO).convert("L")
|
||||||
|
alpha = g.point(lambda p: 255 - p)
|
||||||
|
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
|
||||||
|
logo.putalpha(alpha)
|
||||||
|
_LOGO_RGBA = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
||||||
|
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||||
|
밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
band_h = int(H * BAND_RATIO)
|
||||||
|
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
|
||||||
|
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
|
||||||
|
|
||||||
|
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
|
||||||
|
title_y = int(H * 0.012)
|
||||||
|
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
|
||||||
|
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
|
||||||
|
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
|
||||||
|
ink = (30, 30, 30, 255)
|
||||||
|
sep = "×" # 곱셈기호(×) — 콜라보 표기
|
||||||
|
gap = 26
|
||||||
|
|
||||||
|
w_title = d.textlength(title, font=tf)
|
||||||
|
w_sep = d.textlength(sep, font=tf)
|
||||||
|
|
||||||
|
logo = _brand_logo()
|
||||||
|
logo_h = 0
|
||||||
|
if logo is not None:
|
||||||
|
logo_h = int(TITLE_SIZE * 1.05)
|
||||||
|
logo_w = max(1, int(logo.width * logo_h / logo.height))
|
||||||
|
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
||||||
|
else:
|
||||||
|
logo_w = 0
|
||||||
|
|
||||||
|
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
|
||||||
|
group_left = (W - total) / 2
|
||||||
|
x = group_left
|
||||||
|
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
|
||||||
|
x += w_title + gap
|
||||||
|
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
|
||||||
|
x += w_sep + gap
|
||||||
|
if logo is not None:
|
||||||
|
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
|
||||||
|
|
||||||
|
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
|
||||||
|
rose = (228, 150, 168, 255)
|
||||||
|
bgap = 46
|
||||||
|
_blossom(d, group_left - bgap, cy, 17, rose)
|
||||||
|
_blossom(d, group_left + total + bgap, cy, 17, rose)
|
||||||
|
|
||||||
|
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
|
||||||
|
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
|
||||||
|
|
||||||
|
# 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
|
||||||
|
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
|
||||||
|
top = rule_y + 22
|
||||||
|
avail = band_h - top - 24
|
||||||
|
size = NARR_SIZE
|
||||||
|
while size > NARR_MIN_SIZE:
|
||||||
|
f = ImageFont.truetype(font_path, size)
|
||||||
|
lines = _wrap_lines(d, text, f, W - 150)
|
||||||
|
lh = int(size * 1.4)
|
||||||
|
if len(lines) * lh <= avail:
|
||||||
|
break
|
||||||
|
size -= 2
|
||||||
|
else:
|
||||||
|
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
|
||||||
|
lines = _wrap_lines(d, text, f, W - 150)
|
||||||
|
lh = int(NARR_MIN_SIZE * 1.4)
|
||||||
|
y = top + max(0, (avail - len(lines) * lh) // 2)
|
||||||
|
for ln in lines:
|
||||||
|
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
|
||||||
|
y += lh
|
||||||
|
img.save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만
|
||||||
|
|
||||||
|
|
||||||
|
def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None):
|
||||||
|
"""배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG."""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
base = Image.open(base_img_path).convert("RGB")
|
||||||
|
base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55)
|
||||||
|
d = ImageDraw.Draw(base)
|
||||||
|
|
||||||
|
def centered(text, y, size, fill, fit_one_line=False):
|
||||||
|
if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소
|
||||||
|
while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140:
|
||||||
|
size -= 4
|
||||||
|
f = ImageFont.truetype(font_path, size)
|
||||||
|
for ln in _wrap_lines(d, text, f, W - 140):
|
||||||
|
w = d.textlength(ln, font=f)
|
||||||
|
d.text(((W - w) / 2, y), ln, font=f, fill=fill,
|
||||||
|
stroke_width=5, stroke_fill="black")
|
||||||
|
y += int(size * 1.25)
|
||||||
|
|
||||||
|
if store:
|
||||||
|
centered(store, int(H * 0.33), 88, "white", fit_one_line=True)
|
||||||
|
centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True)
|
||||||
|
centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True)
|
||||||
|
base.save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
# 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다.
|
||||||
|
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- BGM
|
||||||
|
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgm(bgm_arg):
|
||||||
|
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
|
||||||
|
|
||||||
|
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||||
|
1) 파일이면 그대로
|
||||||
|
2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게)
|
||||||
|
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||||
|
"""
|
||||||
|
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||||
|
return None
|
||||||
|
import random
|
||||||
|
p = Path(bgm_arg)
|
||||||
|
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||||
|
for c in cands:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
if c.is_dir():
|
||||||
|
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
|
||||||
|
if files:
|
||||||
|
return random.choice(files)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
|
||||||
|
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
|
||||||
|
|
||||||
|
- BGM 이 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
|
||||||
|
- 나레이션[0:a] + BGM[1:a]*volume 를 amix(duration=first)로 섞어 영상 길이에 맞춘다.
|
||||||
|
- 영상은 재인코딩하지 않는다(-c:v copy) → 빠름. 시작에 짧은 페이드인.
|
||||||
|
"""
|
||||||
|
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
|
||||||
|
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
||||||
|
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
|
||||||
|
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
||||||
|
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
|
||||||
|
str(out_path)])
|
||||||
|
|
||||||
|
|
||||||
|
# Shared low-level media utilities. Keep local render.py for per-feature
|
||||||
|
# layout, prompts, local fonts, BGM folder resolution, and produce().
|
||||||
|
_ffmpeg = MU._ffmpeg
|
||||||
|
_run = MU._run
|
||||||
|
pick_encoder = MU.pick_encoder
|
||||||
|
pcm_to_wav = MU.pcm_to_wav
|
||||||
|
wav_seconds = MU.wav_seconds
|
||||||
|
process_audio = MU.process_audio
|
||||||
|
_wrap_lines = MU._wrap_lines
|
||||||
|
mix_bgm = MU.mix_bgm
|
||||||
|
|
||||||
|
|
||||||
|
def _scene_motion(idx: int, n: int, frames: int) -> str:
|
||||||
|
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
|
||||||
|
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
|
||||||
|
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
|
||||||
|
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
|
||||||
|
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
|
||||||
|
"""
|
||||||
|
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||||
|
f = max(frames, 1)
|
||||||
|
if idx == 1: # 훅: 리빌(살짝 당김→전체)
|
||||||
|
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
|
||||||
|
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
|
||||||
|
return ("zoompan=z='1.06':"
|
||||||
|
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
|
||||||
|
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
|
||||||
|
kind = idx % 4
|
||||||
|
if kind == 0: # 줌아웃(끝에 전체)
|
||||||
|
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
|
||||||
|
if kind == 1: # 줌인(시작에 전체)
|
||||||
|
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
|
||||||
|
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
|
||||||
|
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
|
||||||
|
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
|
||||||
|
|
||||||
|
|
||||||
|
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
|
||||||
|
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
|
||||||
|
bgm=None, bgm_volume=0.3):
|
||||||
|
"""장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4.
|
||||||
|
|
||||||
|
speed/trim_silence: 컷편집 느낌 — 음성을 빠르게 + 앞뒤 침묵 제거(→ 컷이 타이트).
|
||||||
|
tail: 각 컷 음성 끝에 붙일 정적(초). 0 이면 말 끝나자마자 바로 다음 컷.
|
||||||
|
bgm: 배경음악 파일 경로. 주면 concat 후 BGM 을 작은 볼륨으로 깔아 한 번 더 믹스.
|
||||||
|
bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 안 묻히게 12%.
|
||||||
|
"""
|
||||||
|
print("[4/4] 영상 합성...", flush=True)
|
||||||
|
exe = _ffmpeg()
|
||||||
|
enc = pick_encoder(exe)
|
||||||
|
font = FONT if Path(FONT).exists() else FONT_FALLBACK
|
||||||
|
out_path = Path(out_path)
|
||||||
|
tmp = out_path.parent / "_render"
|
||||||
|
tmp.mkdir(exist_ok=True)
|
||||||
|
segs = []
|
||||||
|
total = 0.0
|
||||||
|
|
||||||
|
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
|
||||||
|
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
|
||||||
|
proc_ap = tmp / f"aud{idx:02d}.wav"
|
||||||
|
process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence)
|
||||||
|
speech = wav_seconds(proc_ap)
|
||||||
|
dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만
|
||||||
|
frames = max(1, round(dur * FPS))
|
||||||
|
sub_png = tmp / f"sub{idx:02d}.png"
|
||||||
|
# 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달
|
||||||
|
render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font)
|
||||||
|
seg = tmp / f"seg{idx:02d}.mp4"
|
||||||
|
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
|
||||||
|
band_h = int(H * BAND_RATIO)
|
||||||
|
img_h = H - band_h
|
||||||
|
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
|
||||||
|
motion = _scene_motion(idx, len(scenes), frames)
|
||||||
|
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
|
||||||
|
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
|
||||||
|
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.
|
||||||
|
fades = "" if idx == 1 else f"fade=t=in:st=0:d={fd:.2f}:color=white,"
|
||||||
|
fades += f"fade=t=out:st={max(dur - fd, 0):.2f}:d={fd:.2f}:color=white"
|
||||||
|
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
|
||||||
|
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||||
|
f"crop={bw}:{bh},"
|
||||||
|
f"{motion}:d={frames}:s={W}x{img_h}:fps={FPS}[img];"
|
||||||
|
f"[bg][img]overlay=0:{band_h},{fades}[base];"
|
||||||
|
f"[base][1:v]overlay=0:0[v]")
|
||||||
|
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
|
||||||
|
_run([exe, "-y", "-loop", "1", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap),
|
||||||
|
"-filter_complex", fc, "-map", "[v]", "-map", "2:a",
|
||||||
|
"-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
|
||||||
|
"-pix_fmt", "yuv420p", str(seg)])
|
||||||
|
segs.append(seg)
|
||||||
|
total += dur
|
||||||
|
print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)")
|
||||||
|
print(f" ▶ 본편 합계 {total:.1f}초" + (f" + 링크 {link_seconds:.1f}초" if link else ""))
|
||||||
|
|
||||||
|
# 링크 카드 (무음)
|
||||||
|
if link and img_paths:
|
||||||
|
card_png = tmp / "linkcard.png"
|
||||||
|
render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta)
|
||||||
|
seg = tmp / "seg_link.mp4"
|
||||||
|
_run([exe, "-y", "-loop", "1", "-i", str(card_png),
|
||||||
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
|
||||||
|
"-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
|
||||||
|
"-pix_fmt", "yuv420p", str(seg)])
|
||||||
|
segs.append(seg)
|
||||||
|
print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)")
|
||||||
|
|
||||||
|
# concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백.
|
||||||
|
# BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다.
|
||||||
|
listf = tmp / "concat.txt"
|
||||||
|
# concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지)
|
||||||
|
listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8")
|
||||||
|
concat_out = (tmp / "_body.mp4") if bgm else out_path
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
|
||||||
|
"-c", "copy",
|
||||||
|
# AAC priming(1024샘플) 탓에 concat 이 비디오 시작 pts 를 +0.023s 밀어버리면
|
||||||
|
# 페북/인스타(Buffer) 썸네일 추출(t=0)이 빈 구간=검은 화면을 잡는다 → pts 0 으로 재작성.
|
||||||
|
"-bsf:v", "setts=ts=TS-STARTDTS:pts=PTS-STARTPTS",
|
||||||
|
"-movflags", "+faststart", str(concat_out)])
|
||||||
|
except RuntimeError:
|
||||||
|
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
|
||||||
|
*enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
||||||
|
str(concat_out)])
|
||||||
|
|
||||||
|
if bgm:
|
||||||
|
mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume)
|
||||||
|
print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})")
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _mime_of(path) -> str:
|
||||||
|
return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_photo(client, photo_path) -> str:
|
||||||
|
"""실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시')."""
|
||||||
|
data = Path(photo_path).read_bytes()
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL,
|
||||||
|
contents=[
|
||||||
|
types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)),
|
||||||
|
types.Part.from_text(text=(
|
||||||
|
"이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. "
|
||||||
|
"음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', "
|
||||||
|
"메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (resp.text or "").strip().splitlines()[0][:20] if resp.text else ""
|
||||||
|
|
||||||
|
|
||||||
|
_MATCH_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"assignments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"photo_index": {"type": "integer"},
|
||||||
|
"scene_number": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["photo_index", "scene_number"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["assignments"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def match_real_scenes(client, scenes, real_images, max_real=3) -> dict:
|
||||||
|
"""각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환.
|
||||||
|
|
||||||
|
실패하면 위치 기반(plan_real_scenes)으로 폴백한다.
|
||||||
|
"""
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
if not real_images:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 1) 사진별 내용 라벨
|
||||||
|
labels = []
|
||||||
|
for p in real_images:
|
||||||
|
try:
|
||||||
|
lab = analyze_photo(client, p)
|
||||||
|
except Exception:
|
||||||
|
lab = ""
|
||||||
|
labels.append(lab)
|
||||||
|
print(f" · {Path(p).name} → '{lab or '?'}'")
|
||||||
|
|
||||||
|
# 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회)
|
||||||
|
scene_lines = "\n".join(
|
||||||
|
f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}"
|
||||||
|
for i, s in enumerate(scenes))
|
||||||
|
photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images)))
|
||||||
|
prompt = (
|
||||||
|
"아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n"
|
||||||
|
"각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n"
|
||||||
|
"[규칙]\n"
|
||||||
|
"- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n"
|
||||||
|
"- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n"
|
||||||
|
"- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n"
|
||||||
|
f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n"
|
||||||
|
f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n")
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL,
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.2,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=_MATCH_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assigns = json.loads(resp.text).get("assignments", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})")
|
||||||
|
return plan_real_scenes(scenes, real_images, None, max_real)
|
||||||
|
|
||||||
|
real_map, used_photo = {}, set()
|
||||||
|
for a in assigns:
|
||||||
|
pi, sn = a.get("photo_index"), a.get("scene_number")
|
||||||
|
if pi is None or sn is None:
|
||||||
|
continue
|
||||||
|
if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \
|
||||||
|
and pi not in used_photo and sn not in real_map:
|
||||||
|
real_map[sn] = Path(real_images[pi])
|
||||||
|
used_photo.add(pi)
|
||||||
|
if len(real_map) >= max_real:
|
||||||
|
break
|
||||||
|
return real_map or plan_real_scenes(scenes, real_images, None, max_real)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict:
|
||||||
|
"""어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환.
|
||||||
|
|
||||||
|
- real_scenes 가 주어지면 그 장면 번호들에 사진을 순서대로 매핑.
|
||||||
|
- 없으면 '뒤쪽(마케팅 전환부) 장면들'에 사진 개수만큼(최대 max_real) 자동 배치.
|
||||||
|
"""
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
real_map = {}
|
||||||
|
if not real_images:
|
||||||
|
return real_map
|
||||||
|
if real_scenes:
|
||||||
|
for no, img in zip(real_scenes, real_images):
|
||||||
|
if 1 <= no <= len(scenes):
|
||||||
|
real_map[no] = Path(img)
|
||||||
|
else:
|
||||||
|
n = min(len(real_images), max_real, len(scenes))
|
||||||
|
start = len(scenes) - n + 1 # 뒤에서 n개
|
||||||
|
for k in range(n):
|
||||||
|
real_map[start + k] = Path(real_images[k])
|
||||||
|
return real_map
|
||||||
|
|
||||||
|
|
||||||
|
def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False,
|
||||||
|
real_images=None, real_scenes=None, max_real=3, match_content=True,
|
||||||
|
link=None, link_seconds=3.0, store=None, cta=None,
|
||||||
|
img_model=None, allow_text=False,
|
||||||
|
speed=1.0, trim_silence=True, tail=0.15, workers=4,
|
||||||
|
bgm=None, bgm_volume=0.3) -> Path:
|
||||||
|
"""스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환.
|
||||||
|
|
||||||
|
workdir: 이 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ 에,
|
||||||
|
완성 mp4 는 workdir/{stem}.mp4 로 저장한다. (기본: output/{stem})
|
||||||
|
real_images: 실제 사진(가게/메뉴판 등) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다.
|
||||||
|
real_scenes: 실제 사진을 깔 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동.
|
||||||
|
max_real: 자동 배치 시 실제 사진으로 채울 최대 장면 수.
|
||||||
|
link: 주면 맨 마지막에 링크 카드 1장을 붙인다.
|
||||||
|
"""
|
||||||
|
scenes = sb["scenes"]
|
||||||
|
style = sb.get("style_guide", "")
|
||||||
|
char = sb.get("character_sheet", "")
|
||||||
|
workdir = Path(workdir) if workdir else (OUT_DIR / stem)
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
assets = workdir / "assets"
|
||||||
|
assets.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 실제 사진 → 장면 배치 결정
|
||||||
|
# · real_scenes 수동지정이 최우선
|
||||||
|
# · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략)
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
# max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내)
|
||||||
|
eff_max = max_real if (max_real and max_real > 0) else len(real_images)
|
||||||
|
need_gen = force or any(not (assets / f"scene{i:02d}.png").exists()
|
||||||
|
for i in range(1, len(scenes) + 1))
|
||||||
|
if not real_images:
|
||||||
|
real_map = {}
|
||||||
|
elif real_scenes:
|
||||||
|
real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max)
|
||||||
|
elif match_content and client is not None and need_gen:
|
||||||
|
print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)")
|
||||||
|
real_map = match_real_scenes(client, scenes, real_images, eff_max)
|
||||||
|
else:
|
||||||
|
real_map = plan_real_scenes(scenes, real_images, None, eff_max)
|
||||||
|
real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용
|
||||||
|
if real_map:
|
||||||
|
picks = ", ".join(f"{no}←{real_map[no].name}" for no in sorted(real_map))
|
||||||
|
print(f"■ 실제 사진 합성 장면: {picks}")
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)]
|
||||||
|
audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)]
|
||||||
|
|
||||||
|
def make_image(i, ref, ref_mode="full"):
|
||||||
|
"""장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트."""
|
||||||
|
sc = scenes[i - 1]
|
||||||
|
raw_png = assets / f"scene{i:02d}_raw.png"
|
||||||
|
img_png = assets / f"scene{i:02d}.png"
|
||||||
|
# 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다.
|
||||||
|
# · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐.
|
||||||
|
free_eyes = scene_needs_eye_expression(sc)
|
||||||
|
eye = EYE_FREE if free_eyes else EYE_STYLE
|
||||||
|
use_ref = None if (free_eyes and ref_mode == "eyes") else ref
|
||||||
|
if i in real_map:
|
||||||
|
k = real_order[i]
|
||||||
|
pose = PRESENT_POSES[k % len(PRESENT_POSES)]
|
||||||
|
spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)]
|
||||||
|
prompt = (f"{style}. {char}. {eye} {ANATOMY_GUARD} Scene: {sc['image_prompt']}. {pose} "
|
||||||
|
f"Vertical 9:16 composition.")
|
||||||
|
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
|
||||||
|
no_text=not allow_text, ref_mode=ref_mode)
|
||||||
|
raw_png.write_bytes(data)
|
||||||
|
cover_crop(raw_png, img_png) # 1) AI 배경
|
||||||
|
composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드
|
||||||
|
else:
|
||||||
|
prompt = (f"{style}. {char}. {eye} {ANATOMY_GUARD} "
|
||||||
|
f"Scene: {sc['image_prompt']}. Vertical 9:16 composition.")
|
||||||
|
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
|
||||||
|
no_text=not allow_text, ref_mode=ref_mode)
|
||||||
|
raw_png.write_bytes(data)
|
||||||
|
cover_crop(raw_png, img_png)
|
||||||
|
|
||||||
|
import time as _tm; _t_img = _tm.monotonic()
|
||||||
|
# --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 ---
|
||||||
|
# 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다.
|
||||||
|
# → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립).
|
||||||
|
need_img = [i for i in range(1, len(scenes) + 1)
|
||||||
|
if force or not img_paths[i - 1].exists()]
|
||||||
|
cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img]
|
||||||
|
for i in cached_img:
|
||||||
|
print(f"[{i:2d}] 이미지 캐시 사용")
|
||||||
|
|
||||||
|
# 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제.
|
||||||
|
# 토가·월계관 등 복장은 복제하지 않고 인물별 character_sheet/장면 프롬프트대로 그려진다.
|
||||||
|
char_ref = load_char_ref()
|
||||||
|
if char_ref is not None:
|
||||||
|
print("■ 캐릭터 레퍼런스(눈만 고정) 사용: character/ "
|
||||||
|
"→ 눈/표정은 통일, 복장은 인물별로 다르게")
|
||||||
|
ref_bytes, ref_mode, anchor = char_ref, "eyes", None
|
||||||
|
else:
|
||||||
|
ref_mode = "full"
|
||||||
|
ref_bytes = None
|
||||||
|
for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용
|
||||||
|
raw = assets / f"scene{i:02d}_raw.png"
|
||||||
|
if raw.exists() and not force:
|
||||||
|
ref_bytes = raw.read_bytes(); break
|
||||||
|
anchor = None
|
||||||
|
if ref_bytes is None:
|
||||||
|
anchor = next((i for i in need_img if i not in real_map), None)
|
||||||
|
if anchor is not None:
|
||||||
|
print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True)
|
||||||
|
make_image(anchor, None)
|
||||||
|
ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes()
|
||||||
|
|
||||||
|
rest = [i for i in need_img if i != anchor]
|
||||||
|
if rest:
|
||||||
|
print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
i = futs[fut]; fut.result()
|
||||||
|
tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지"
|
||||||
|
print(f"[{i:2d}] {tag} done", flush=True)
|
||||||
|
|
||||||
|
print(f"[⏱] 이미지 {_tm.monotonic() - _t_img:.1f}초", flush=True); _t_tts = _tm.monotonic()
|
||||||
|
# --- 음성: 전부 독립 → 병렬 ---
|
||||||
|
def make_tts(i):
|
||||||
|
text = (scenes[i - 1].get("narration") or "").strip()
|
||||||
|
if not text:
|
||||||
|
# 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체.
|
||||||
|
# (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치)
|
||||||
|
pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000)
|
||||||
|
return
|
||||||
|
pcm, rate = gen_tts(client, text, voice)
|
||||||
|
pcm_to_wav(pcm, audio_paths[i - 1], rate)
|
||||||
|
|
||||||
|
need_wav = [i for i in range(1, len(scenes) + 1)
|
||||||
|
if force or not audio_paths[i - 1].exists()]
|
||||||
|
for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav):
|
||||||
|
print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)")
|
||||||
|
if need_wav:
|
||||||
|
print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(make_tts, i): i for i in need_wav}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
i = futs[fut]; fut.result()
|
||||||
|
print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}초", flush=True)
|
||||||
|
|
||||||
|
print(f"[⏱] 음성 {_tm.monotonic() - _t_tts:.1f}초", flush=True)
|
||||||
|
out = workdir / f"{stem}.mp4"
|
||||||
|
print(f"\n영상 합성 → {out.name}")
|
||||||
|
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
|
||||||
|
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
|
||||||
|
bgm=bgm, bgm_volume=bgm_volume)
|
||||||
|
print(f"완성: {out}")
|
||||||
|
return out
|
||||||
287
generator/animation_greekroman/scraper.py
Normal file
287
generator/animation_greekroman/scraper.py
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
나무위키 그리스 로마 신화 신·영웅 '썰 키워드' 스크래퍼
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
그리스 로마 신화의 주요 신·영웅 28명의 나무위키 문서에서 목차(섹션 제목)를 긁어와
|
||||||
|
숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
|
||||||
|
|
||||||
|
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
|
||||||
|
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
|
||||||
|
|
||||||
|
설치:
|
||||||
|
pip install playwright beautifulsoup4
|
||||||
|
playwright install chromium # (이미 설치돼 있으면 생략)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
python scraper.py
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
python scraper.py --headful # 브라우저 창 보이게
|
||||||
|
python scraper.py --only 제우스 헤라클레스 # 특정 인물만
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 설정
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
BASE = "https://namu.wiki/w/"
|
||||||
|
OUT_DIR = Path(__file__).parent / "data" # 신화 인물 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
|
||||||
|
|
||||||
|
# 그리스 로마 신화 주요 신·영웅 28명: (번호, 이름, 나무위키 문서 제목)
|
||||||
|
# 동음이의어 문서는 worker 가 "이름" ↔ "이름(그리스 로마 신화)" ↔ "이름(그리스 신화)" 를 자동 재시도한다.
|
||||||
|
GREEK_FIGURES = [
|
||||||
|
# 올림포스 12신
|
||||||
|
(1, "제우스", "제우스"),
|
||||||
|
(2, "헤라", "헤라"),
|
||||||
|
(3, "포세이돈", "포세이돈"),
|
||||||
|
(4, "데메테르", "데메테르"),
|
||||||
|
(5, "아테나", "아테나"),
|
||||||
|
(6, "아폴론", "아폴론"),
|
||||||
|
(7, "아르테미스", "아르테미스"),
|
||||||
|
(8, "아레스", "아레스"),
|
||||||
|
(9, "아프로디테", "아프로디테"),
|
||||||
|
(10, "헤파이스토스", "헤파이스토스"),
|
||||||
|
(11, "헤르메스", "헤르메스"),
|
||||||
|
(12, "디오니소스", "디오니소스"),
|
||||||
|
# 그 외 주요 신
|
||||||
|
(13, "하데스", "하데스"),
|
||||||
|
(14, "헤스티아", "헤스티아"),
|
||||||
|
(15, "프로메테우스", "프로메테우스"),
|
||||||
|
(16, "크로노스", "크로노스(그리스 로마 신화)"),
|
||||||
|
(17, "에로스", "에로스"),
|
||||||
|
# 영웅·인간
|
||||||
|
(18, "헤라클레스", "헤라클레스"),
|
||||||
|
(19, "페르세우스", "페르세우스"),
|
||||||
|
(20, "테세우스", "테세우스"),
|
||||||
|
(21, "오디세우스", "오디세우스"),
|
||||||
|
(22, "아킬레우스", "아킬레우스"),
|
||||||
|
(23, "오르페우스", "오르페우스"),
|
||||||
|
(24, "이아손", "이아손"),
|
||||||
|
# 유명 괴물·일화 인물
|
||||||
|
(25, "메두사", "메두사"),
|
||||||
|
(26, "미다스", "미다스"),
|
||||||
|
(27, "시시포스", "시시포스"),
|
||||||
|
(28, "이카로스", "이카로스"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
|
||||||
|
ANECDOTE_HINTS = [
|
||||||
|
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
|
||||||
|
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
|
||||||
|
"업적", "가족", "이름", "별명",
|
||||||
|
"신화", "전승", "전설", "설화", "사랑", "연인", "상징", "숭배", "창작물", "최후",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_heading(text: str) -> str:
|
||||||
|
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
|
||||||
|
t = text.replace("[편집]", "").strip()
|
||||||
|
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
|
||||||
|
t = re.sub(r"^\s*[\d.]+\s*", "", t)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_anecdote(keyword: str) -> bool:
|
||||||
|
return any(h in keyword for h in ANECDOTE_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_king(page, title: str):
|
||||||
|
"""한 인물 문서를 열고 목차 키워드 목록을 반환."""
|
||||||
|
url = BASE + title
|
||||||
|
result = {"title": title, "url": url, "page_title": None,
|
||||||
|
"keywords": [], "anecdote_keywords": [], "error": None}
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
|
||||||
|
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
# 문서 실제 제목
|
||||||
|
try:
|
||||||
|
result["page_title"] = (await page.title()).split(" - ")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
result["error"] = f"HTTP {resp.status}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
|
||||||
|
keywords = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const clean = s => s.replace('[편집]', '').trim();
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
// 1순위: 목차(table of contents)
|
||||||
|
const tocLinks = document.querySelectorAll(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
|
||||||
|
);
|
||||||
|
if (tocLinks.length) {
|
||||||
|
items = Array.from(tocLinks).map(a => clean(a.textContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2순위(목차가 없으면): 본문 헤딩 태그
|
||||||
|
if (items.length === 0) {
|
||||||
|
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
||||||
|
items = Array.from(hs).map(h => clean(h.textContent));
|
||||||
|
}
|
||||||
|
return items.filter(Boolean);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 중복 제거 + 번호 정리
|
||||||
|
seen, cleaned = set(), []
|
||||||
|
for raw in keywords:
|
||||||
|
kw = clean_heading(raw)
|
||||||
|
if kw and kw not in seen and len(kw) <= 60:
|
||||||
|
seen.add(kw)
|
||||||
|
cleaned.append(kw)
|
||||||
|
|
||||||
|
result["keywords"] = cleaned
|
||||||
|
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
|
||||||
|
parser.add_argument("--only", nargs="*", help="특정 인물만 (이름으로 지정)")
|
||||||
|
parser.add_argument("--concurrency", type=int, default=4,
|
||||||
|
help="동시에 열 페이지 수 (기본 4)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
kings = GREEK_FIGURES
|
||||||
|
if args.only:
|
||||||
|
wanted = set(args.only)
|
||||||
|
kings = [k for k in GREEK_FIGURES if k[1] in wanted]
|
||||||
|
if not kings:
|
||||||
|
print(f"[!] 매칭되는 인물 없음: {args.only}")
|
||||||
|
print(" 가능한 값:", ", ".join(k[1] for k in GREEK_FIGURES))
|
||||||
|
return
|
||||||
|
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
all_results = []
|
||||||
|
|
||||||
|
total = len(kings)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=not args.headful)
|
||||||
|
context = await browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
locale="ko-KR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
|
||||||
|
async def _block(route):
|
||||||
|
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
|
||||||
|
await route.abort()
|
||||||
|
else:
|
||||||
|
await route.continue_()
|
||||||
|
await context.route("**/*", _block)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(max(1, args.concurrency))
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
async def worker(idx, name, title):
|
||||||
|
nonlocal done
|
||||||
|
# 문서 제목 후보: 지정 제목 → "이름" → "이름(그리스 로마 신화)" → "이름(그리스 신화)" 순으로 자동 재시도
|
||||||
|
# (나무위키 동음이의어 처리 방식이 인물마다 달라 제목을 못 맞출 수 있어서)
|
||||||
|
titles = [title]
|
||||||
|
for alt in (name, f"{name}(그리스 로마 신화)", f"{name}(그리스 신화)"):
|
||||||
|
if alt not in titles:
|
||||||
|
titles.append(alt)
|
||||||
|
async with sem:
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
r = None
|
||||||
|
for t in titles:
|
||||||
|
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
|
||||||
|
# 실패하면 백오프하며 최대 3회까지 재시도
|
||||||
|
r = await scrape_king(page, t)
|
||||||
|
for attempt in range(3):
|
||||||
|
if not r["error"]:
|
||||||
|
break
|
||||||
|
await page.wait_for_timeout(1500 * (attempt + 1))
|
||||||
|
r = await scrape_king(page, t)
|
||||||
|
# 성공했고 목차도 나왔으면 이 제목으로 확정
|
||||||
|
if not r["error"] and r["keywords"]:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
r["order"] = idx
|
||||||
|
r["name"] = name
|
||||||
|
done += 1
|
||||||
|
if r["error"]:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
|
||||||
|
else:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) "
|
||||||
|
f"키워드 {len(r['keywords'])}개 "
|
||||||
|
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
|
||||||
|
return r
|
||||||
|
|
||||||
|
all_results = await asyncio.gather(
|
||||||
|
*(worker(idx, name, title) for idx, name, title in kings)
|
||||||
|
)
|
||||||
|
all_results = sorted(all_results, key=lambda r: r["order"])
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
# 저장 ---------------------------------------------------------------
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
json_path = OUT_DIR / f"greek_keywords_{ts}.json"
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_path = OUT_DIR / f"greek_keywords_{ts}.csv"
|
||||||
|
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
|
||||||
|
for r in all_results:
|
||||||
|
anec = set(r["anecdote_keywords"])
|
||||||
|
for kw in r["keywords"]:
|
||||||
|
w.writerow([r["order"], r["name"], r["title"],
|
||||||
|
"Y" if kw in anec else "", kw, r["url"]])
|
||||||
|
|
||||||
|
total_kw = sum(len(r["keywords"]) for r in all_results)
|
||||||
|
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
|
||||||
|
fails = [r["name"] for r in all_results if r["error"]]
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}개")
|
||||||
|
if fails:
|
||||||
|
print(f"수집 실패: {', '.join(fails)}")
|
||||||
|
print(f"JSON -> {json_path}")
|
||||||
|
print(f"CSV -> {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
asyncio.run(main())
|
||||||
13
generator/animation_odyssey/character/README.txt
Normal file
13
generator/animation_odyssey/character/README.txt
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
[캐릭터 공식 레퍼런스 폴더 — odyssey 는 기본 '비움']
|
||||||
|
|
||||||
|
odyssey(오디세이) 버전은 고양이 의인화가 아니라 '실제 사람'
|
||||||
|
(근육질 영웅·신 / 아름답고 늘씬한 여신, 한국 신화만화풍)으로 그리므로,
|
||||||
|
조선/삼국지에서 쓰던 고양이 눈 레퍼런스 이미지를 넣지 않습니다.
|
||||||
|
|
||||||
|
- 폴더가 비어 있으면: 각 영상의 '첫 컷'이 자동 앵커가 되어
|
||||||
|
나머지 컷들이 그 캐릭터 디자인/화풍을 그대로 따라갑니다(영상 내 일관성 유지).
|
||||||
|
- 특정 그림체를 전 영상에 걸쳐 고정하고 싶으면 기준 이미지를 1장 넣으세요
|
||||||
|
(폴더 안 '첫 번째' 이미지 파일 사용. png/jpg/jpeg/webp).
|
||||||
|
단 이 경우 '눈만 고정' 모드라 눈/표정만 통일되고 복장은 신별로 다르게 나옵니다.
|
||||||
|
|
||||||
|
연결 코드: render.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||||
@ -0,0 +1,91 @@
|
|||||||
|
번호,인물,문서제목,썰후보여부,키워드,URL
|
||||||
|
1,오디세우스,오디세우스,Y,트로이 목마 계략,https://namu.wiki/w/오디세우스
|
||||||
|
2,오디세우스,오디세우스,Y,10년 귀향길 표류,https://namu.wiki/w/오디세우스
|
||||||
|
3,오디세우스,오디세우스,Y,아무도 아니다 라는 이름 사기,https://namu.wiki/w/폴리페모스
|
||||||
|
4,폴리페모스,폴리페모스,Y,외눈박이 거인의 동굴 감금,https://namu.wiki/w/폴리페모스
|
||||||
|
5,폴리페모스,폴리페모스,Y,포도주 먹여 눈 찌르기,https://namu.wiki/w/폴리페모스
|
||||||
|
6,폴리페모스,폴리페모스,Y,양 배에 매달려 탈출,https://namu.wiki/w/폴리페모스
|
||||||
|
7,아이올로스,아이올로스,Y,모든 역풍을 가둔 바람주머니 선물,https://namu.wiki/w/아이올로스
|
||||||
|
8,아이올로스,아이올로스,Y,선원들이 몰래 열어본 바람주머니,https://namu.wiki/w/아이올로스
|
||||||
|
9,로토파고이,로토파고이,Y,망각의 열매를 먹고 귀향을 잊은 선원들,https://namu.wiki/w/로토파고이
|
||||||
|
10,키르케,키르케,Y,선원들을 돼지로 만든 마법,https://namu.wiki/w/키르케
|
||||||
|
11,키르케,키르케,Y,헤르메스가 준 마법 약초로 저항,https://namu.wiki/w/키르케
|
||||||
|
12,키르케,키르케,Y,1년간 섬에 눌러앉은 오디세우스,https://namu.wiki/w/키르케
|
||||||
|
13,오디세우스,오디세우스,Y,저승에서 들은 테이레시아스의 귀향 예언,https://namu.wiki/w/오디세우스
|
||||||
|
14,세이렌,세이렌,Y,아름다운 노래로 선원을 유혹해 죽이는 괴물,https://namu.wiki/w/세이렌
|
||||||
|
15,세이렌,세이렌,Y,선원 귀 밀랍 막고 자신만 돛대에 결박,https://namu.wiki/w/세이렌
|
||||||
|
16,스킬라,스킬라,Y,여섯 개 머리로 선원을 잡아먹는 괴물,https://namu.wiki/w/스킬라
|
||||||
|
17,카립디스,카립디스,Y,배를 통째로 삼키는 거대한 소용돌이,https://namu.wiki/w/카립디스
|
||||||
|
18,오디세우스,오디세우스,Y,헬리오스의 신성한 소를 잡아먹은 선원들,https://namu.wiki/w/오디세우스
|
||||||
|
19,오디세우스,오디세우스,Y,제우스의 벼락으로 배 전체가 난파,https://namu.wiki/w/오디세우스
|
||||||
|
20,칼립소,칼립소,Y,7년간 섬에 억류하며 청혼한 님프,https://namu.wiki/w/칼립소
|
||||||
|
21,칼립소,칼립소,Y,불멸을 미끼로 붙잡았지만 결국 놓아준 사연,https://namu.wiki/w/칼립소
|
||||||
|
22,오디세우스,오디세우스,Y,아들 폴리페모스 실명에 분노한 포세이돈의 저주,https://namu.wiki/w/오디세우스
|
||||||
|
23,오디세우스,오디세우스,Y,오디세우스를 몰래 도운 수호신 아테나,https://namu.wiki/w/오디세우스
|
||||||
|
24,나우시카,나우시카(그리스 로마 신화),Y,해변에 표류한 오디세우스를 구해준 공주,https://namu.wiki/w/나우시카(그리스 로마 신화)
|
||||||
|
25,페넬로페,페넬로페,Y,낮에 짜고 밤에 풀며 구혼자를 버틴 수의,https://namu.wiki/w/페넬로페
|
||||||
|
26,페넬로페,페넬로페,Y,20년을 기다린 정절의 상징,https://namu.wiki/w/페넬로페
|
||||||
|
27,텔레마코스,텔레마코스,Y,아버지를 찾아 떠난 성장 여정,https://namu.wiki/w/텔레마코스
|
||||||
|
28,안티노오스,안티노오스,Y,페넬로페 재산을 축내며 눌러앉은 구혼자들,https://namu.wiki/w/안티노오스
|
||||||
|
29,오디세우스,오디세우스,Y,거지로 변장해 몰래 귀향,https://namu.wiki/w/오디세우스
|
||||||
|
30,오디세우스,오디세우스,Y,오디세우스만 당길 수 있는 활 시합,https://namu.wiki/w/오디세우스
|
||||||
|
31,오디세우스,오디세우스,Y,구혼자 전원 응징하고 왕좌 탈환,https://namu.wiki/w/오디세우스
|
||||||
|
32,오디세우스,오디세우스,Y,참전 피하려고 미친 척 밭 갈다 아들 앞에서 들통,https://namu.wiki/w/오디세우스
|
||||||
|
33,오디세우스,오디세우스,Y,여장한 아킬레우스를 무기 장사꾼 변장으로 색출,https://namu.wiki/w/오디세우스
|
||||||
|
34,오디세우스,오디세우스,Y,아킬레우스의 무구를 놓고 아이아스와 말싸움 승리,https://namu.wiki/w/오디세우스
|
||||||
|
35,오디세우스,오디세우스,Y,거지 변장으로 트로이 성내 잠입 정찰,https://namu.wiki/w/오디세우스
|
||||||
|
36,오디세우스,오디세우스,Y,트로이 수호 신상 팔라디온 훔치기 작전,https://namu.wiki/w/오디세우스
|
||||||
|
37,오디세우스,오디세이아,Y,귀향길 첫 정거장 키코네스족 약탈하다 역관광,https://namu.wiki/w/오디세이아
|
||||||
|
38,폴리페모스,폴리페모스,Y,다 도망쳐놓고 굳이 본명 외쳐서 저주 확정,https://namu.wiki/w/폴리페모스
|
||||||
|
39,폴리페모스,폴리페모스,Y,사람을 통째로 잡아먹는 식사 매너,https://namu.wiki/w/폴리페모스
|
||||||
|
40,아이올로스,아이올로스,Y,두 번째 찾아간 오디세우스를 문전박대,https://namu.wiki/w/아이올로스
|
||||||
|
41,라이스트리고네스,오디세이아,Y,귀향 함대 12척 중 11척을 전멸시킨 식인 거인족,https://namu.wiki/w/오디세이아
|
||||||
|
42,로토파고이,로토파고이,Y,울며 버티는 선원들을 배에 묶어 끌고 온 오디세우스,https://namu.wiki/w/로토파고이
|
||||||
|
43,키르케,키르케,Y,돼지가 된 부하들을 되돌리는 조건 협상,https://namu.wiki/w/키르케
|
||||||
|
44,키르케,키르케,Y,귀향하려면 저승부터 다녀오라는 미션 브리핑,https://namu.wiki/w/키르케
|
||||||
|
45,엘페노르,오디세이아,Y,지붕에서 자다 떨어져 죽고 저승에서 재회한 막내 선원,https://namu.wiki/w/오디세이아
|
||||||
|
46,오디세우스,오디세우스,Y,저승에서 어머니의 망령과 마주친 오디세우스,https://namu.wiki/w/오디세우스
|
||||||
|
47,오디세우스,오디세우스,Y,저승의 아가멤논이 해준 아내 조심하라는 조언,https://namu.wiki/w/오디세우스
|
||||||
|
48,오디세우스,오디세우스,Y,죽은 영웅보다 산 머슴이 낫다는 아킬레우스의 저승 명언,https://namu.wiki/w/오디세우스
|
||||||
|
49,오디세우스,오디세우스,Y,저승에서 목격한 시시포스와 탄탈로스의 형벌,https://namu.wiki/w/오디세우스
|
||||||
|
50,세이렌,세이렌,Y,노래 듣고 풀어달라 몸부림친 오디세우스,https://namu.wiki/w/세이렌
|
||||||
|
51,스킬라,스킬라,Y,카립디스 피하려다 부하 여섯 명을 상납한 선택,https://namu.wiki/w/스킬라
|
||||||
|
52,카립디스,카립디스,Y,난파 후 무화과나무에 매달려 소용돌이를 버틴 생존기,https://namu.wiki/w/카립디스
|
||||||
|
53,오디세우스,오디세우스,Y,굶주린 선원들이 금기를 깨고 소를 구운 항해 최악의 바비큐,https://namu.wiki/w/오디세우스
|
||||||
|
54,칼립소,칼립소,Y,뗏목 만들어 탈출했지만 포세이돈 폭풍으로 또 난파,https://namu.wiki/w/칼립소
|
||||||
|
55,오디세우스,오디세우스,Y,여신 이노의 마법 스카프 하나로 이틀 밤낮 헤엄친 표류,https://namu.wiki/w/오디세우스
|
||||||
|
56,나우시카,나우시카(그리스 로마 신화),Y,빨래하러 나왔다가 알몸 표류자를 주운 공주,https://namu.wiki/w/나우시카(그리스 로마 신화)
|
||||||
|
57,알키노오스,오디세이아,Y,모험담 들려준 값으로 보물 실어 귀향시켜준 왕,https://namu.wiki/w/오디세이아
|
||||||
|
58,데모도코스,오디세이아,Y,트로이 목마 노래에 몰래 울다 들킨 오디세우스,https://namu.wiki/w/오디세이아
|
||||||
|
59,오디세우스,오디세우스,Y,파이아케스 청년들 도발에 원반던지기 한 방으로 제압,https://namu.wiki/w/오디세우스
|
||||||
|
60,오디세우스,오디세이아,Y,오디세우스를 데려다준 배를 돌로 만든 포세이돈,https://namu.wiki/w/오디세이아
|
||||||
|
61,오디세우스,오디세우스,Y,잠든 채 이타카 해변에 짐짝처럼 내려진 귀향,https://namu.wiki/w/오디세우스
|
||||||
|
62,오디세우스,오디세우스,Y,안개 때문에 20년 만의 고향을 못 알아본 오디세우스,https://namu.wiki/w/오디세우스
|
||||||
|
63,오디세우스,오디세우스,Y,아테나의 분장술로 폭삭 늙은 거지로 변신,https://namu.wiki/w/오디세우스
|
||||||
|
64,에우마이오스,오디세이아,Y,거지 변장한 주인을 극진히 대접한 충직한 돼지치기,https://namu.wiki/w/오디세이아
|
||||||
|
65,아르고스,오디세이아,Y,20년을 기다려 변장한 오디세우스를 알아보고 숨을 거둔 개,https://namu.wiki/w/오디세이아
|
||||||
|
66,에우리클레이아,오디세이아,Y,발 씻기다 흉터를 만지고 오디세우스의 정체를 눈치챈 유모,https://namu.wiki/w/오디세이아
|
||||||
|
67,이로스,오디세이아,Y,거지 변장한 오디세우스에게 권투 한 방에 뻗은 진짜 거지,https://namu.wiki/w/오디세이아
|
||||||
|
68,텔레마코스,텔레마코스,Y,아버지 소식 찾아 스파르타의 메넬라오스와 헬레네 방문,https://namu.wiki/w/텔레마코스
|
||||||
|
69,텔레마코스,텔레마코스,Y,구혼자들이 짠 매복 암살을 피해 돌아온 귀환길,https://namu.wiki/w/텔레마코스
|
||||||
|
70,텔레마코스,텔레마코스,Y,거지가 아버지라는 사실에 오열한 부자 상봉,https://namu.wiki/w/텔레마코스
|
||||||
|
71,페넬로페,페넬로페,Y,시녀의 밀고로 들통난 수의 되풀기 3년 사기,https://namu.wiki/w/페넬로페
|
||||||
|
72,페넬로페,페넬로페,Y,남편을 앞에 두고도 활 시합을 제안한 촉,https://namu.wiki/w/페넬로페
|
||||||
|
73,페넬로페,페넬로페,Y,침대를 옮겨보라는 함정 문제로 남편 최종 검증,https://namu.wiki/w/페넬로페
|
||||||
|
74,오디세우스,오디세우스,Y,올리브나무를 통째로 깎아 만든 부부만 아는 침대의 비밀,https://namu.wiki/w/오디세우스
|
||||||
|
75,안티노오스,안티노오스,Y,술잔 들다 첫 화살에 목이 꿰뚫린 구혼자 대장,https://namu.wiki/w/안티노오스
|
||||||
|
76,안티노오스,안티노오스,Y,거지에게 의자를 집어던진 구혼자의 최후 복선,https://namu.wiki/w/안티노오스
|
||||||
|
77,안티노오스,안티노오스,Y,금품으로 목숨을 흥정하다 실패한 구혼자들,https://namu.wiki/w/안티노오스
|
||||||
|
78,오디세우스,오디세우스,Y,도끼 열두 자루 구멍을 화살 하나로 관통한 활 솜씨,https://namu.wiki/w/오디세우스
|
||||||
|
79,오디세우스,오디세우스,Y,활 시합 우승 직후 시작된 연회장 대숙청,https://namu.wiki/w/오디세우스
|
||||||
|
80,라에르테스,라에르테스,Y,과수원 나무 목록을 읊어 아버지에게 정체 증명한 오디세우스,https://namu.wiki/w/라에르테스
|
||||||
|
81,라에르테스,라에르테스,Y,아들이 트로이에서 죽은 줄 알고 농사만 짓던 전직 왕,https://namu.wiki/w/라에르테스
|
||||||
|
82,오디세우스,오디세우스,Y,구혼자 유족들과의 내전 직전 아테나의 강제 중재,https://namu.wiki/w/오디세우스
|
||||||
|
83,키르케,키르케,Y,세이렌과 스킬라 공략법을 알려준 항해 스포일러,https://namu.wiki/w/키르케
|
||||||
|
84,폴리페모스,폴리페모스,Y,눈 찔리고 아무도가 그랬다며 동료 도움을 못 받은 거인,https://namu.wiki/w/폴리페모스
|
||||||
|
85,오디세우스,오디세우스,Y,부하들은 다 죽고 혼자만 살아 돌아온 함대의 최후,https://namu.wiki/w/오디세우스
|
||||||
|
86,페넬로페,페넬로페,Y,재회하고도 밤새 모험담을 들은 부부의 회포,https://namu.wiki/w/페넬로페
|
||||||
|
87,오디세우스,오디세우스,Y,트로이 전쟁 10년에 귀향 10년 도합 20년 출장,https://namu.wiki/w/오디세우스
|
||||||
|
88,세이렌,세이렌,Y,오디세우스를 놓치고 체면을 구긴 세이렌 자매,https://namu.wiki/w/세이렌
|
||||||
|
89,칼립소,칼립소,Y,신들은 되면서 님프 연애는 안 되냐고 항의한 칼립소,https://namu.wiki/w/칼립소
|
||||||
|
90,오디세우스,오디세우스,Y,노를 어깨에 메고 바다 모르는 내륙까지 걸으라는 마지막 예언,https://namu.wiki/w/오디세우스
|
||||||
|
BIN
generator/animation_odyssey/fonts/brand_logo.png
Normal file
BIN
generator/animation_odyssey/fonts/brand_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
358
generator/animation_odyssey/gen.py
Normal file
358
generator/animation_odyssey/gen.py
Normal file
@ -0,0 +1,358 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
animation_odyssey 생성 단계 (나레이션 + 스토리보드) — 병합본
|
||||||
|
==================================================================
|
||||||
|
오디세이아 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
|
||||||
|
(animation_greekroman 폴더와 동일 구조 — 소재만 '그리스 로마 신화 신·영웅' → '오디세우스의 귀향 대모험'.)
|
||||||
|
scraper.py 가 만든 odyssey_keywords_*.csv 를 입력으로 한다.
|
||||||
|
|
||||||
|
· 나레이션: latest_csv / load_rows / pick_seed / generate
|
||||||
|
· 스토리보드: clamp_scenes / make_storyboard
|
||||||
|
|
||||||
|
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
DATA_DIR = HERE / "data" # 오디세이아 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
|
||||||
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
|
||||||
|
_TYPO = {"…": "...", "‘": "'", "’": "'", "“": '"',
|
||||||
|
"”": '"', "·": " ", "~": "~", "〜": "~"}
|
||||||
|
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
|
||||||
|
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
|
||||||
|
|
||||||
|
|
||||||
|
def _no_emoji(s):
|
||||||
|
s = s or ""
|
||||||
|
for _k, _v in _TYPO.items():
|
||||||
|
s = s.replace(_k, _v)
|
||||||
|
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ① 나레이션 (신화 썰 → 병맛 대본) ==============================
|
||||||
|
|
||||||
|
def latest_csv() -> Path:
|
||||||
|
files = sorted(DATA_DIR.glob("odyssey_keywords_*.csv"))
|
||||||
|
if not files:
|
||||||
|
sys.exit("[!] animation_odyssey/data/ 에 odyssey_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
|
||||||
|
return files[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(csv_path: Path):
|
||||||
|
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...] (king = 신화 인물 이름)"""
|
||||||
|
rows = []
|
||||||
|
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||||||
|
r = csv.reader(f)
|
||||||
|
next(r, None) # 헤더 스킵
|
||||||
|
for line in r:
|
||||||
|
if len(line) < 6:
|
||||||
|
continue
|
||||||
|
order, king, doc, anec, kw, url = line[:6]
|
||||||
|
rows.append({
|
||||||
|
"order": int(order) if order.isdigit() else 0,
|
||||||
|
"king": king, "doc": doc,
|
||||||
|
"is_anecdote": anec.strip().upper() == "Y",
|
||||||
|
"keyword": kw, "url": url,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# 오디세이아(귀향 대모험) 핵심 사건·인물 신호 — 이런 키워드를 '썰 씨앗'으로 최우선.
|
||||||
|
ODYSSEY_HINTS = [
|
||||||
|
"오디세이아", "오디세우스", "트로이", "목마", "섬", "동굴", "키클롭스", "폴리페모스",
|
||||||
|
"키르케", "칼립소", "세이렌", "스킬라", "카립디스", "아이올로스", "헬리오스",
|
||||||
|
"구혼자", "귀향", "파이에케스", "로토파고이", "수의", "표류", "항해", "저승",
|
||||||
|
"페넬로페", "텔레마코스", "안티노오스", "나우시카", "이타카",
|
||||||
|
"아무도", "난파", "변장", "활 시합",
|
||||||
|
]
|
||||||
|
# 오디세이아와 무관하거나 내용이 빈약한 위키 섹션 — 씨앗에서 제외(뽑히면 '그리스로마신화'로 샌다).
|
||||||
|
SEED_DROP = [
|
||||||
|
"개요", "어원", "특징", "총평", "평가", "긍정적", "부정적", "기타", "그 외",
|
||||||
|
"가계도", "가족", "대중매체", "창작물", "미디어", "만화", "숭배", "해석",
|
||||||
|
"신화 속 행적", "신화에서", "전승", "이름", "별명", "어록", "상징",
|
||||||
|
"비블리오테케", "텔레고네이아", # 오디세이아 외 전거(다른 신화 출처)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_pool(rows):
|
||||||
|
"""범용·무관 섹션을 걸러 '오디세이아에 실질적으로 쓸 만한' 씨앗만 남긴다."""
|
||||||
|
pool = []
|
||||||
|
for r in rows:
|
||||||
|
kw = r["keyword"].strip()
|
||||||
|
if not kw or kw == r["king"]: # 최상단 표제(= 인물명) 제외
|
||||||
|
continue
|
||||||
|
if any(bad in kw for bad in SEED_DROP): # 범용/무관 섹션 제외
|
||||||
|
continue
|
||||||
|
pool.append(r)
|
||||||
|
return pool
|
||||||
|
|
||||||
|
|
||||||
|
def pick_seed(rows, king=None, keyword=None):
|
||||||
|
"""대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택. ★원 소스는 항상 오디세이아.
|
||||||
|
※ CSV 의 is_anecdote(Y) 는 '평가/기타/대중매체' 같은 범용 섹션에 붙어 오히려 오디세이아를 벗어나므로 쓰지 않는다."""
|
||||||
|
cand = rows
|
||||||
|
if king:
|
||||||
|
cand = [r for r in cand if r["king"] == king]
|
||||||
|
if not cand:
|
||||||
|
kings = sorted({r["king"] for r in rows})
|
||||||
|
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
|
||||||
|
if keyword:
|
||||||
|
kw_match = [r for r in cand if keyword in r["keyword"]]
|
||||||
|
if not kw_match:
|
||||||
|
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
|
||||||
|
return random.choice(kw_match)
|
||||||
|
# ① 오디세이아 핵심 사건 키워드를 최우선(항해·괴물·귀향 등). 인물명도 함께 검사 —
|
||||||
|
# 키워드 문구에 힌트가 없어도 인물 자체가 오디세이아 전용(세이렌·키르케 등)이면 씨앗으로 유효.
|
||||||
|
odyssey = [r for r in cand
|
||||||
|
if any(h in r["keyword"] or h in r["king"] for h in ODYSSEY_HINTS)]
|
||||||
|
if odyssey:
|
||||||
|
return random.choice(odyssey)
|
||||||
|
# ② 없으면 범용/무관 섹션을 걸러낸 실질 씨앗 풀에서
|
||||||
|
pool = _seed_pool(cand)
|
||||||
|
if pool:
|
||||||
|
return random.choice(pool)
|
||||||
|
# ③ 최후 폴백: 빈 풀 방지
|
||||||
|
return random.choice(cand)
|
||||||
|
|
||||||
|
|
||||||
|
NARR_SYSTEM = """\
|
||||||
|
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
|
||||||
|
오디세이아 속 오디세우스와 그 일행·신들의 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
|
||||||
|
|
||||||
|
[소재 원칙] (가장 중요)
|
||||||
|
- 원 소스는 반드시 서사시 '오디세이아'(오디세우스의 10년 귀향 대모험)다. 모든 썰은 이 항해 이야기 안에서 풀어라.
|
||||||
|
- 인물이 신(포세이돈·아테나·제우스 등)이라도 '오디세이아 속 그 신의 역할'로만 다뤄라
|
||||||
|
(포세이돈=귀향을 방해한 바다신, 아테나=오디세우스를 편애해 도운 여신 식). 오디세이아 밖 일반 그리스·로마 신화
|
||||||
|
일화(제우스의 바람기, 신들의 탄생·계보, 헤라클레스 과업 등)로 새지 마라 — 그건 다른 장르다.
|
||||||
|
|
||||||
|
[톤 규칙]
|
||||||
|
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
|
||||||
|
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
|
||||||
|
- 오디세이아 내용(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
|
||||||
|
유명 장면(트로이 목마, 외눈박이 폴리페모스 "아무도 아니다" 사기, 세이렌의 노래, 키르케의 돼지 마법,
|
||||||
|
칼립소의 섬, 스킬라와 카립디스, 페넬로페의 수의 짜기, 활쏘기 시합과 구혼자 응징 등)을 드립 소재로 적극 활용해도 된다.
|
||||||
|
포세이돈의 분노, 아테나의 편애 같은 유명 밈은 써도 되지만 선정적으로 묘사하진 마라.
|
||||||
|
단, 시청자가 '진짜 이야기'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
|
||||||
|
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
|
||||||
|
|
||||||
|
[구조] (반드시 이 4단)
|
||||||
|
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
|
||||||
|
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
|
||||||
|
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
|
||||||
|
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
|
||||||
|
|
||||||
|
[분량]
|
||||||
|
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
|
||||||
|
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
|
||||||
|
|
||||||
|
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
|
||||||
|
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
|
||||||
|
- 형식:
|
||||||
|
1) 첫 줄: 후킹 카피 한 줄 (예: "7년 스토킹 당한 오디세우스 썰 풉니다.")
|
||||||
|
2) 본문 1~2줄: 오디세이아 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
|
||||||
|
3) 셀링 한 줄: 인물이 반할 식으로 가게 핵심 (예: "오디세우스도 반할 OO시장 숨은 맛집 'OOO' 🐟")
|
||||||
|
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
|
||||||
|
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
|
||||||
|
6) 👉 방문 팁 또는 행동유도 한 줄
|
||||||
|
7) 마지막 줄: 해시태그 나열. #오디세이 #오디세우스 를 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
|
||||||
|
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
|
||||||
|
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
|
||||||
|
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_USER = """\
|
||||||
|
[이번 쇼츠 소재]
|
||||||
|
- 인물: {king} ({doc})
|
||||||
|
- 썰 키워드: "{keyword}"
|
||||||
|
- 참고 출처: {url}
|
||||||
|
|
||||||
|
[마케팅 대상 (마지막에 이걸로 갈아타기)]
|
||||||
|
- 가게명: {store}
|
||||||
|
- 소개/키워드: {product}
|
||||||
|
|
||||||
|
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
|
||||||
|
{seconds}초 쇼츠 나레이션을 만들어라.
|
||||||
|
썰 키워드가 빈약하면, 그 인물이 '오디세이아(오디세우스의 귀향 대모험)'에서 맡은 역할·사건
|
||||||
|
(항해·괴물·표류·귀향·구혼자 응징 등)으로 살을 붙여라. 오디세이아와 무관한 다른 신화 일화로 새지 마라.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
|
||||||
|
"hook": {"type": "string"},
|
||||||
|
"story": {"type": "string"},
|
||||||
|
"pivot": {"type": "string"},
|
||||||
|
"cta": {"type": "string"},
|
||||||
|
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
|
||||||
|
"estimated_seconds": {"type": "integer"},
|
||||||
|
"hashtags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
|
||||||
|
},
|
||||||
|
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate(client, model, seed, product, seconds, store=""):
|
||||||
|
"""신화 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini). store = 가게명(SNS 캡션용)."""
|
||||||
|
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
|
||||||
|
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
|
||||||
|
user = NARR_USER.format(
|
||||||
|
king=seed["king"], doc=seed["doc"], keyword=seed["keyword"], url=seed["url"],
|
||||||
|
product=product, store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
|
||||||
|
)
|
||||||
|
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=user,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=sys_prompt,
|
||||||
|
temperature=1.1, # 병맛 = 약간 높은 창의성
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=NARR_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
data = json.loads(resp.text)
|
||||||
|
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
|
||||||
|
data["_product"] = product
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
|
||||||
|
|
||||||
|
SB_SYSTEM = """\
|
||||||
|
너는 숏폼 영상 디렉터다. 주어진 '병맛 오디세이아 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
|
||||||
|
|
||||||
|
[핵심 규칙]
|
||||||
|
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
|
||||||
|
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
|
||||||
|
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
|
||||||
|
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
|
||||||
|
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
|
||||||
|
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the muscular god dramatically throwing a lightning bolt from a cloud, sweating)
|
||||||
|
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
|
||||||
|
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
|
||||||
|
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
|
||||||
|
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
|
||||||
|
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
|
||||||
|
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||||
|
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||||
|
★ 잔혹 장면(눈 찌르기, 학살, 잡아먹힘 등)은 이미지 안전필터에 걸려 생성이 거부된다.
|
||||||
|
폭력을 직접 묘사하지 말고 만화적으로 순화해 적어라 — 유혈/찌르는 순간/시체 금지.
|
||||||
|
(예: 눈 찌르기 → the cyclops comically covering his eye with cartoon stars around his head,
|
||||||
|
학살 → suitors comically fleeing in panic with exaggerated cartoon expressions)
|
||||||
|
|
||||||
|
[전역 설정]
|
||||||
|
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의.
|
||||||
|
★ 한국의 '그리스 로마 신화 학습만화'처럼, 신을 '진짜 신의 모습'으로 그린다 — 의인화 동물 금지.
|
||||||
|
· 남신/남자 영웅: 조각상 같은 근육질의 멋진 체격(broad shoulders, heroic muscular greek-statue
|
||||||
|
physique), 잘생긴 얼굴. · 여신: 아름답고 늘씬한 몸매(beautiful, slender, graceful), 긴 머리.
|
||||||
|
· 반드시 '성인 8등신 비율'로: tall adult heroic proportions, 8-heads-tall elegant body,
|
||||||
|
NOT chibi, NOT super-deformed, NOT cute mascot — 치비/SD/애기얼굴 금지.
|
||||||
|
→ 단 과한 노출·선정적 묘사는 금지. 토가/키톤/갑옷 등 신화 복식은 제대로 입힌다.
|
||||||
|
복식/색/머리/상징물(투구·활·삼지창·베일 등 그 인물의 아이콘)을 못박아 컷마다 동일하게.
|
||||||
|
(예: Odysseus as a tall rugged hero with a heroic muscular greek-statue physique, short dark curly
|
||||||
|
hair and a trimmed beard, wearing a worn bronze breastplate and a red cloak, holding a bow and a
|
||||||
|
spear / Athena as a beautiful slender goddess with long dark hair, wearing a bronze helmet and
|
||||||
|
flowing white chiton with a golden owl emblem, holding a spear and round shield)
|
||||||
|
★ 눈 그림체: 순정만화/한국 신화만화풍의 크고 또렷한 눈(large expressive sparkling manhwa eyes,
|
||||||
|
detailed irises with light reflections)으로 통일. 이 '그림체'만 유지하고, 장면 감정에 따라
|
||||||
|
눈을 감거나(eyes closed) 실눈(squint)·윙크·부릅뜸(wide dramatic glare) 등 표정은 바꿔도 된다.
|
||||||
|
- style_guide: 그림 스타일을 영어로. 한국 그리스로마신화 학습만화풍(classic Korean mythology manhwa
|
||||||
|
style) — 깔끔하고 또렷한 외곽선, 화사하고 선명한 색, 반짝이는 하이라이트, 극적인 연출,
|
||||||
|
'성인 비율의 캐릭터(tall adult proportions, never chibi or super-deformed)',
|
||||||
|
단순 배경(에게해·범선·동굴·고대 그리스 신전 등 오디세이아 항해 분위기), 세로 9:16.
|
||||||
|
|
||||||
|
[출력] 지정된 JSON 스키마로만.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_USER = """\
|
||||||
|
[마케팅 대상] {product}
|
||||||
|
[소재 인물] {king}
|
||||||
|
[목표 장면 수] 약 {scenes}개
|
||||||
|
|
||||||
|
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
|
||||||
|
{narration}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_sheet": {"type": "string"},
|
||||||
|
"style_guide": {"type": "string"},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"narration": {"type": "string"},
|
||||||
|
"caption": {"type": "string"},
|
||||||
|
"image_prompt": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["narration", "caption", "image_prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["character_sheet", "style_guide", "scenes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_scenes(scene_list, target):
|
||||||
|
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||||
|
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||||
|
def clen(s):
|
||||||
|
return len(s.get("narration", "").strip())
|
||||||
|
|
||||||
|
while len(scene_list) > target:
|
||||||
|
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||||
|
i = min(range(len(scene_list) - 1),
|
||||||
|
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||||
|
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||||
|
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||||
|
base["narration"] = (a + " " + b).strip()
|
||||||
|
scene_list[i:i + 2] = [base]
|
||||||
|
return scene_list
|
||||||
|
|
||||||
|
|
||||||
|
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||||
|
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=SB_USER.format(product=product, king=king,
|
||||||
|
scenes=scenes, narration=narration),
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=SB_SYSTEM,
|
||||||
|
temperature=0.8,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=SB_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sb = json.loads(resp.text)
|
||||||
|
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||||
|
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||||
|
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||||
|
raw = sb.get("scenes", [])
|
||||||
|
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||||
|
if len(kept) < len(raw):
|
||||||
|
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||||
|
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||||
|
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||||
|
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||||
|
sb["_product"] = product
|
||||||
|
sb["_king"] = king
|
||||||
|
return sb
|
||||||
275
generator/animation_odyssey/main.py
Normal file
275
generator/animation_odyssey/main.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
키워드 → 병맛 오디세이 마케팅 쇼츠 (완전 자동, 원커맨드)
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
||||||
|
1) 랜덤으로 오디세우스 귀향 모험 '썰' 매칭
|
||||||
|
2) Gemini 로 병맛 마케팅 나레이션 생성
|
||||||
|
3) 장면 분할(스토리보드)
|
||||||
|
4) 장면별 이미지 + 음성 생성
|
||||||
|
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
|
||||||
|
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
||||||
|
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
||||||
|
|
||||||
|
선행: scraper.py 로 만든 data/odyssey_keywords_*.csv 가 있어야 함.
|
||||||
|
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
||||||
|
|
||||||
|
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
|
||||||
|
python animation_odyssey\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
||||||
|
python animation_odyssey\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
||||||
|
python animation_odyssey\\main.py https://naver.me/xxxx
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
|
||||||
|
--king 오디세우스 특정 인물 고정 (기본: 랜덤)
|
||||||
|
--scenes 12 장면 수=이미지 수 (기본 12)
|
||||||
|
--seconds 45 목표 길이 초 (기본 45)
|
||||||
|
--voice Puck TTS 목소리
|
||||||
|
--keep 중간 파일(assets) 삭제하지 않고 보존
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
|
||||||
|
|
||||||
|
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
|
||||||
|
import render as MS # animation 영상합성 엔진(이전 make_short.py)
|
||||||
|
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation_odyssey" # 통합 출력: shorts_all/output/animation_odyssey/
|
||||||
|
TEXT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(s: str) -> str:
|
||||||
|
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
|
||||||
|
return s[:30] or "short"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="키워드 또는 네이버 링크 → 병맛 오디세이 쇼츠 (완전 자동)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
|
||||||
|
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
|
||||||
|
)
|
||||||
|
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
|
||||||
|
ap.add_argument("keywords", nargs="*",
|
||||||
|
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
|
||||||
|
# 결과 폴더 라벨
|
||||||
|
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
|
||||||
|
# 생성 옵션
|
||||||
|
ap.add_argument("--king", help="특정 인물 고정 (예: 오디세우스, 기본: 랜덤)")
|
||||||
|
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
|
||||||
|
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
|
||||||
|
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
|
||||||
|
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
|
||||||
|
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
|
||||||
|
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
|
||||||
|
ap.add_argument("--images", default="load_image",
|
||||||
|
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
|
||||||
|
"(예: --images 연산골막국수). 비었으면 전부 AI")
|
||||||
|
ap.add_argument("--max-real", type=int, default=0,
|
||||||
|
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
|
||||||
|
ap.add_argument("--real-scenes",
|
||||||
|
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
|
||||||
|
ap.add_argument("--no-match", action="store_true",
|
||||||
|
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
|
||||||
|
ap.add_argument("--photos", type=int, default=4,
|
||||||
|
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
|
||||||
|
ap.add_argument("--no-photos", action="store_true",
|
||||||
|
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
|
||||||
|
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
|
||||||
|
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
|
||||||
|
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
|
||||||
|
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
|
||||||
|
ap.add_argument("--img-model", default=MS.IMG_MODEL,
|
||||||
|
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
|
||||||
|
ap.add_argument("--allow-text", action="store_true",
|
||||||
|
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
|
||||||
|
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
|
||||||
|
ap.add_argument("--speed", type=float, default=1.3,
|
||||||
|
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
|
||||||
|
ap.add_argument("--no-trim", action="store_true",
|
||||||
|
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
|
||||||
|
ap.add_argument("--tail", type=float, default=0.0,
|
||||||
|
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
|
||||||
|
ap.add_argument("--workers", type=int, default=4,
|
||||||
|
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
|
||||||
|
ap.add_argument("--bgm",
|
||||||
|
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
|
||||||
|
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
|
||||||
|
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
|
||||||
|
ap.add_argument("--bgm-volume", type=float, default=0.3,
|
||||||
|
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
|
||||||
|
ap.add_argument("--api-key")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
|
||||||
|
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
|
||||||
|
if not args.link:
|
||||||
|
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
|
||||||
|
if link_tok:
|
||||||
|
args.link = link_tok
|
||||||
|
args.keywords = [k for k in args.keywords if k != link_tok]
|
||||||
|
|
||||||
|
product = " ".join(args.keywords).strip()
|
||||||
|
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
|
||||||
|
brief_store = None
|
||||||
|
if not product and NV.looks_like_naver_place(args.link or ""):
|
||||||
|
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
|
||||||
|
try:
|
||||||
|
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if not bullets:
|
||||||
|
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||||
|
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||||
|
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not _key:
|
||||||
|
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
try:
|
||||||
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if rstore and not brief_store:
|
||||||
|
brief_store = rstore
|
||||||
|
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
|
if not bullets:
|
||||||
|
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||||
|
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||||
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
|
product = " ".join(bullets)
|
||||||
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
|
for b in bullets:
|
||||||
|
print(f" • {b}")
|
||||||
|
if not product:
|
||||||
|
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
|
||||||
|
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
|
||||||
|
|
||||||
|
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
|
||||||
|
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
|
||||||
|
rows = gen.load_rows(csv_path)
|
||||||
|
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
|
||||||
|
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
|
||||||
|
and args.images == "load_image"):
|
||||||
|
store_for_dir = (brief_store or args.store or args.label
|
||||||
|
or " ".join(args.keywords[:2]) or "naver_store")
|
||||||
|
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
|
||||||
|
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
|
||||||
|
if existing:
|
||||||
|
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
else:
|
||||||
|
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
|
||||||
|
try:
|
||||||
|
_, finals = NV.fetch_and_select(args.link, photo_dir,
|
||||||
|
n=args.photos, client=client)
|
||||||
|
if finals:
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
|
||||||
|
else:
|
||||||
|
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
|
||||||
|
|
||||||
|
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
|
||||||
|
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
|
||||||
|
stem = f"{safe_name(label)}_{ts}"
|
||||||
|
job_dir = OUT_DIR / stem
|
||||||
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) 랜덤(또는 지정) 인물 썰 매칭
|
||||||
|
seed = gen.pick_seed(rows, king=args.king)
|
||||||
|
print(f"■ 작업 폴더 : {job_dir}")
|
||||||
|
print(f"■ 마케팅 대상 : {product}")
|
||||||
|
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
|
||||||
|
|
||||||
|
# 2) 병맛 나레이션 (+ SNS 업로드용 캡션)
|
||||||
|
print("[1/4] 나레이션 생성...")
|
||||||
|
store_name = brief_store or getattr(args, "store", None) or label
|
||||||
|
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
|
||||||
|
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
|
||||||
|
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
|
||||||
|
(job_dir / "narration.json").write_text(
|
||||||
|
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
|
||||||
|
lyric = (f"[제목] {narr.get('title','')}\n"
|
||||||
|
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
|
||||||
|
f"[마케팅] {product}\n"
|
||||||
|
f"{'-'*40}\n"
|
||||||
|
f"{narr.get('full_narration','')}\n")
|
||||||
|
if narr.get("hashtags"):
|
||||||
|
lyric += f"\n{' '.join(narr['hashtags'])}\n"
|
||||||
|
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
|
||||||
|
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
|
||||||
|
caption = (narr.get("sns_caption") or "").strip()
|
||||||
|
if caption:
|
||||||
|
(job_dir / "caption.txt").write_text(caption, encoding="utf-8")
|
||||||
|
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
|
||||||
|
|
||||||
|
# 3) 스토리보드(장면 분할)
|
||||||
|
print("[2/4] 스토리보드 분할...")
|
||||||
|
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
|
||||||
|
product, seed["king"], scenes=args.scenes)
|
||||||
|
(job_dir / "storyboard.json").write_text(
|
||||||
|
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f" 장면 {len(sb['scenes'])}개")
|
||||||
|
|
||||||
|
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
|
||||||
|
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
|
||||||
|
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
|
||||||
|
if not real_images:
|
||||||
|
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
|
||||||
|
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
|
||||||
|
bgm = MS.resolve_bgm(args.bgm or "bgm")
|
||||||
|
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
|
||||||
|
if args.bgm and not _off and not bgm:
|
||||||
|
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
|
||||||
|
elif bgm:
|
||||||
|
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
|
||||||
|
print("[3/4] 이미지·음성 생성...")
|
||||||
|
store = args.store or brief_store or product.split(",")[0].strip() or None
|
||||||
|
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
|
||||||
|
real_images=real_images, real_scenes=real_scenes,
|
||||||
|
max_real=args.max_real, match_content=not args.no_match,
|
||||||
|
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
|
||||||
|
img_model=args.img_model, allow_text=args.allow_text,
|
||||||
|
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
|
||||||
|
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
|
||||||
|
|
||||||
|
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
|
||||||
|
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
|
||||||
|
shutil.rmtree(job_dir / "assets", ignore_errors=True)
|
||||||
|
for junk in ("narration.json", "storyboard.json"):
|
||||||
|
(job_dir / junk).unlink(missing_ok=True)
|
||||||
|
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
|
||||||
|
|
||||||
|
print(f"\n✅ 완성: {out_mp4}")
|
||||||
|
print(f" {seed['king']} 썰 · {product}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
1001
generator/animation_odyssey/render.py
Normal file
1001
generator/animation_odyssey/render.py
Normal file
File diff suppressed because it is too large
Load Diff
276
generator/animation_odyssey/scraper.py
Normal file
276
generator/animation_odyssey/scraper.py
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
나무위키 오디세이아 등장인물 '썰 키워드' 스크래퍼
|
||||||
|
====================================================
|
||||||
|
|
||||||
|
오디세이아(오디세우스의 귀향 대모험)에 등장하는 주요 인물·신 20여 명의 나무위키 문서에서
|
||||||
|
목차(섹션 제목)를 긁어와 숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
|
||||||
|
|
||||||
|
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
|
||||||
|
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
|
||||||
|
|
||||||
|
설치:
|
||||||
|
pip install playwright beautifulsoup4
|
||||||
|
playwright install chromium # (이미 설치돼 있으면 생략)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
python scraper.py
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
python scraper.py --headful # 브라우저 창 보이게
|
||||||
|
python scraper.py --only 오디세우스 폴리페모스 # 특정 인물만
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 설정
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
BASE = "https://namu.wiki/w/"
|
||||||
|
OUT_DIR = Path(__file__).parent / "data" # 오디세이아 등장인물 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
|
||||||
|
|
||||||
|
# 오디세이아 서사에 '반드시 출현'하는 인물·괴물만: (번호, 이름, 나무위키 문서 제목)
|
||||||
|
# ★ 제우스/포세이돈/아테나/헤르메스 같은 범용 신 문서는 넣지 않는다 —
|
||||||
|
# 문서 전체 목차가 크롤되므로 오디세이아와 무관한 일반 신화 키워드(티타노마키아 등)가
|
||||||
|
# 섞여 들어와 엉뚱한 인물이 주인공인 영상이 만들어진다(2026-07 제우스 썰 사고).
|
||||||
|
# 신들의 개입 에피소드는 나레이션 프롬프트가 알아서 살을 붙인다.
|
||||||
|
# 동음이의어 문서는 worker 가 "이름" ↔ "이름(그리스 로마 신화)" ↔ "이름(그리스 신화)" 를 자동 재시도한다.
|
||||||
|
ODYSSEY_FIGURES = [
|
||||||
|
# 주인공·가족
|
||||||
|
(1, "오디세우스", "오디세우스"),
|
||||||
|
(2, "페넬로페", "페넬로페"),
|
||||||
|
(3, "텔레마코스", "텔레마코스"),
|
||||||
|
(4, "라에르테스", "라에르테스"),
|
||||||
|
# 귀향길 사건의 등장인물·괴물 (오디세이아 서사 전용)
|
||||||
|
(5, "폴리페모스", "폴리페모스"),
|
||||||
|
(6, "키르케", "키르케"),
|
||||||
|
(7, "칼립소", "칼립소"),
|
||||||
|
(8, "세이렌", "세이렌"),
|
||||||
|
(9, "스킬라", "스킬라"),
|
||||||
|
(10, "카립디스", "카립디스"),
|
||||||
|
(11, "아이올로스", "아이올로스"),
|
||||||
|
(12, "나우시카", "나우시카(그리스 로마 신화)"),
|
||||||
|
(13, "로토파고이", "로토파고이"),
|
||||||
|
# 이타카의 구혼자
|
||||||
|
(14, "안티노오스", "안티노오스"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
|
||||||
|
ANECDOTE_HINTS = [
|
||||||
|
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
|
||||||
|
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
|
||||||
|
"업적", "가족", "이름", "별명",
|
||||||
|
"신화", "전승", "전설", "설화", "사랑", "연인", "상징", "숭배", "창작물", "최후",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_heading(text: str) -> str:
|
||||||
|
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
|
||||||
|
t = text.replace("[편집]", "").strip()
|
||||||
|
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
|
||||||
|
t = re.sub(r"^\s*[\d.]+\s*", "", t)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_anecdote(keyword: str) -> bool:
|
||||||
|
return any(h in keyword for h in ANECDOTE_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_king(page, title: str):
|
||||||
|
"""한 인물 문서를 열고 목차 키워드 목록을 반환."""
|
||||||
|
url = BASE + title
|
||||||
|
result = {"title": title, "url": url, "page_title": None,
|
||||||
|
"keywords": [], "anecdote_keywords": [], "error": None}
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
|
||||||
|
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
# 문서 실제 제목
|
||||||
|
try:
|
||||||
|
result["page_title"] = (await page.title()).split(" - ")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
result["error"] = f"HTTP {resp.status}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
|
||||||
|
keywords = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const clean = s => s.replace('[편집]', '').trim();
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
// 1순위: 목차(table of contents)
|
||||||
|
const tocLinks = document.querySelectorAll(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
|
||||||
|
);
|
||||||
|
if (tocLinks.length) {
|
||||||
|
items = Array.from(tocLinks).map(a => clean(a.textContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2순위(목차가 없으면): 본문 헤딩 태그
|
||||||
|
if (items.length === 0) {
|
||||||
|
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
||||||
|
items = Array.from(hs).map(h => clean(h.textContent));
|
||||||
|
}
|
||||||
|
return items.filter(Boolean);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 중복 제거 + 번호 정리
|
||||||
|
seen, cleaned = set(), []
|
||||||
|
for raw in keywords:
|
||||||
|
kw = clean_heading(raw)
|
||||||
|
if kw and kw not in seen and len(kw) <= 60:
|
||||||
|
seen.add(kw)
|
||||||
|
cleaned.append(kw)
|
||||||
|
|
||||||
|
result["keywords"] = cleaned
|
||||||
|
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
|
||||||
|
parser.add_argument("--only", nargs="*", help="특정 인물만 (이름으로 지정)")
|
||||||
|
parser.add_argument("--concurrency", type=int, default=4,
|
||||||
|
help="동시에 열 페이지 수 (기본 4)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
kings = ODYSSEY_FIGURES
|
||||||
|
if args.only:
|
||||||
|
wanted = set(args.only)
|
||||||
|
kings = [k for k in ODYSSEY_FIGURES if k[1] in wanted]
|
||||||
|
if not kings:
|
||||||
|
print(f"[!] 매칭되는 인물 없음: {args.only}")
|
||||||
|
print(" 가능한 값:", ", ".join(k[1] for k in ODYSSEY_FIGURES))
|
||||||
|
return
|
||||||
|
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
all_results = []
|
||||||
|
|
||||||
|
total = len(kings)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=not args.headful)
|
||||||
|
context = await browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
locale="ko-KR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
|
||||||
|
async def _block(route):
|
||||||
|
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
|
||||||
|
await route.abort()
|
||||||
|
else:
|
||||||
|
await route.continue_()
|
||||||
|
await context.route("**/*", _block)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(max(1, args.concurrency))
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
async def worker(idx, name, title):
|
||||||
|
nonlocal done
|
||||||
|
# 문서 제목 후보: 지정 제목 → "이름" → "이름(그리스 로마 신화)" → "이름(그리스 신화)" 순으로 자동 재시도
|
||||||
|
# (나무위키 동음이의어 처리 방식이 인물마다 달라 제목을 못 맞출 수 있어서)
|
||||||
|
titles = [title]
|
||||||
|
for alt in (name, f"{name}(그리스 로마 신화)", f"{name}(그리스 신화)"):
|
||||||
|
if alt not in titles:
|
||||||
|
titles.append(alt)
|
||||||
|
async with sem:
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
r = None
|
||||||
|
for t in titles:
|
||||||
|
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
|
||||||
|
# 실패하면 백오프하며 최대 3회까지 재시도
|
||||||
|
r = await scrape_king(page, t)
|
||||||
|
for attempt in range(3):
|
||||||
|
if not r["error"]:
|
||||||
|
break
|
||||||
|
await page.wait_for_timeout(1500 * (attempt + 1))
|
||||||
|
r = await scrape_king(page, t)
|
||||||
|
# 성공했고 목차도 나왔으면 이 제목으로 확정
|
||||||
|
if not r["error"] and r["keywords"]:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
r["order"] = idx
|
||||||
|
r["name"] = name
|
||||||
|
done += 1
|
||||||
|
if r["error"]:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
|
||||||
|
else:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) "
|
||||||
|
f"키워드 {len(r['keywords'])}개 "
|
||||||
|
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
|
||||||
|
return r
|
||||||
|
|
||||||
|
all_results = await asyncio.gather(
|
||||||
|
*(worker(idx, name, title) for idx, name, title in kings)
|
||||||
|
)
|
||||||
|
all_results = sorted(all_results, key=lambda r: r["order"])
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
# 저장 ---------------------------------------------------------------
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
json_path = OUT_DIR / f"odyssey_keywords_{ts}.json"
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_path = OUT_DIR / f"odyssey_keywords_{ts}.csv"
|
||||||
|
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
|
||||||
|
for r in all_results:
|
||||||
|
anec = set(r["anecdote_keywords"])
|
||||||
|
for kw in r["keywords"]:
|
||||||
|
w.writerow([r["order"], r["name"], r["title"],
|
||||||
|
"Y" if kw in anec else "", kw, r["url"]])
|
||||||
|
|
||||||
|
total_kw = sum(len(r["keywords"]) for r in all_results)
|
||||||
|
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
|
||||||
|
fails = [r["name"] for r in all_results if r["error"]]
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}개")
|
||||||
|
if fails:
|
||||||
|
print(f"수집 실패: {', '.join(fails)}")
|
||||||
|
print(f"JSON -> {json_path}")
|
||||||
|
print(f"CSV -> {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
asyncio.run(main())
|
||||||
15
generator/animation_samgukji/character/README.txt
Normal file
15
generator/animation_samgukji/character/README.txt
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
[캐릭터 공식 레퍼런스 폴더 — '눈만 고정']
|
||||||
|
|
||||||
|
이 폴더에 고양이 왕 기준 이미지를 1장 넣어두면(예: king_ref.png),
|
||||||
|
모든 쇼츠 영상의 고양이 왕이 '그 눈과 표정'을 그대로 따라갑니다.
|
||||||
|
|
||||||
|
- 고정되는 것 : 눈 모양 / 눈동자 / 표정 (귀여운 순한 눈으로 통일)
|
||||||
|
- 고정 안 됨 : 곤룡포 색, 관모, 포즈, 배경 → 왕별 character_sheet / 장면 프롬프트대로
|
||||||
|
(즉 광해군·순종 등 왕마다 복장은 다르게 나옴)
|
||||||
|
|
||||||
|
- 파일명은 자유 (폴더 안 '첫 번째' 이미지 파일을 사용. png/jpg/jpeg/webp)
|
||||||
|
- 한 장만 두는 걸 권장 (여러 장이면 정렬상 첫 파일만 사용)
|
||||||
|
- 빼고 싶으면 이미지를 폴더 밖으로 빼거나 폴더를 비우면 됨
|
||||||
|
→ 그럼 예전처럼 영상마다 캐릭터를 새로 그림(눈은 EYE_STYLE 텍스트 지시로 귀엽게 유지)
|
||||||
|
|
||||||
|
연결 코드: make_short.py 의 load_char_ref() / CHAR_REF_DIR / gen_image(ref_mode="eyes")
|
||||||
BIN
generator/animation_samgukji/character/image.png
Normal file
BIN
generator/animation_samgukji/character/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
@ -0,0 +1,408 @@
|
|||||||
|
번호,인물,문서제목,썰후보여부,키워드,URL
|
||||||
|
1,조조,조조,,조조,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,,개요,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,이름,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,,생애,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,가족 관계,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,,무덤,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,평가,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,업적과 능력,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,,vs 유비,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,어록,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,기타,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,Y,기타 창작물,https://namu.wiki/w/조조
|
||||||
|
1,조조,조조,,둘러보기,https://namu.wiki/w/조조
|
||||||
|
2,사마의,사마의,,사마의,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,,개요,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,Y,일생(정사),https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,Y,연의,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,Y,가족 관계,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,Y,평가,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,Y,기타,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,Y,미디어 믹스,https://namu.wiki/w/사마의
|
||||||
|
2,사마의,사마의,,둘러보기,https://namu.wiki/w/사마의
|
||||||
|
3,하후돈,하후돈,,하후돈,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,개요,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,정사 삼국지,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,초기 생애,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,동탁 토벌전,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,복양 전투,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,서주에서,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,조조 휘하,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,관도대전,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,박망파 전투,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,하북 평정,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,장로 정벌,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,216년~217년 유수구 전투,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,번성 공방전,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,죽음,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,사후 (위나라 건국),https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,삼국지연의,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,평가,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,친족 우대의 수혜자,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,긍정적 평가,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,연의에 기반한 오해 및 재평가,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,기타,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,Y,미디어 믹스,https://namu.wiki/w/하후돈
|
||||||
|
3,하후돈,하후돈,,관련 문서,https://namu.wiki/w/하후돈
|
||||||
|
4,장료,장료,,장료,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,개요,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,정사 삼국지,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,초기 생애,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,여포 휘하,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,관도대전,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,창희 토벌,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,하북 평정,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,진란 토벌,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,형주 정벌,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,환성 전투,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,2차 합비 공방전[7],https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,조조 휘하,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,유수구 전투,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,번성 공방전,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,조비 휘하,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,동구 전투,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,죽음,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,삼국지연의,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,평가,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,군사적 능력,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,인품과 성품,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,기타,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,Y,미디어 믹스,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,둘러보기,https://namu.wiki/w/장료
|
||||||
|
4,장료,장료,,관련 문서,https://namu.wiki/w/장료
|
||||||
|
5,허저,허저,,허저,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,개요,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,정사 삼국지,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,초기 생애,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,양 전투,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,관도대전,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,하북 평정,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,관중 평정,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,장로 정벌,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,,조조 휘하,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,죽음,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,삼국지연의,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,평가,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,여담,https://namu.wiki/w/허저
|
||||||
|
5,허저,허저,Y,대중매체에서,https://namu.wiki/w/허저
|
||||||
|
6,곽가,곽가,,곽가,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,개요,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,생애,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,초기 생애,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,조조에게 임관,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,서주에서,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,관도대전,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,하북 평정,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,Y,죽음,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,사후,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,Y,평가,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,Y,긍정적 평가,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,Y,부정적 평가: 연의의 수혜자,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,별 볼일 없는 전공,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,적벽대전에서 곽가만 있었다면 이겼다?,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,,사생활,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,Y,여담,https://namu.wiki/w/곽가
|
||||||
|
6,곽가,곽가,Y,미디어 믹스,https://namu.wiki/w/곽가
|
||||||
|
7,순욱,순욱,,순욱,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,,개요,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,Y,생애(정사),https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,Y,연의,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,Y,평가,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,Y,가족 관계,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,Y,미디어 믹스,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,Y,기타,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,,순욱/빈 찬합 밈,https://namu.wiki/w/순욱
|
||||||
|
7,순욱,순욱,,둘러보기,https://namu.wiki/w/순욱
|
||||||
|
8,가후,가후,,가후,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,개요,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,정사 삼국지,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,초기 생애,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,동탁 휘하,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,이각 휘하,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,단외 휘하,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,장수 휘하,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,완 전투,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,양 전투,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,관도대전,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,적벽대전,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,관중 평정,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,조비에게 조언,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,조비 휘하 (위나라 건국),https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,죽음,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,삼국지연의,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,평가,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,긍정적 평가,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,부정적 평가,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,기타,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,Y,미디어 믹스,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,관련 문서,https://namu.wiki/w/가후
|
||||||
|
8,가후,가후,,둘러보기,https://namu.wiki/w/가후
|
||||||
|
9,유비,유비,,유비,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,,개요,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,Y,이름과 칭호,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,,생애,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,Y,평가,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,,vs 조조,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,,혈통,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,Y,가족 관계,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,,무덤,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,Y,기타,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,Y,대중매체에서,https://namu.wiki/w/유비
|
||||||
|
9,유비,유비,,둘러보기,https://namu.wiki/w/유비
|
||||||
|
10,관우,관우,,관우,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,개요,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,Y,생애(정사),https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,Y,연의,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,역대 봉호,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,중화 왕조,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,조선,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,Y,가족 관계,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,Y,평가,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,Y,시호 논란,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,악시설,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,선시설,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,신격화,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,말말말,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,Y,대중매체에서,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,관련 문서,https://namu.wiki/w/관우
|
||||||
|
10,관우,관우,,둘러보기,https://namu.wiki/w/관우
|
||||||
|
11,장비,장비,,장비,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,,개요,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,생애(정사),https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,연의,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,연의의 피해자?,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,가족 관계,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,평가,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,기타,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,Y,기타 창작물,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,,관련 항목,https://namu.wiki/w/장비
|
||||||
|
11,장비,장비,,둘러보기,https://namu.wiki/w/장비
|
||||||
|
12,제갈량,제갈량,,제갈량,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,,개요,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,생애(정사),https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,연의,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,,인간관계,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,업적,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,평가,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,논란,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,관련 일화들,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,,사상,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,여담,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,Y,미디어 믹스,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,,관련 문서,https://namu.wiki/w/제갈량
|
||||||
|
12,제갈량,제갈량,,둘러보기,https://namu.wiki/w/제갈량
|
||||||
|
13,조운,조운,,조운,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,,개요,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,,소개,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,Y,정사,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,Y,평가,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,Y,연의,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,Y,가족 관계,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,Y,여담,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,Y,미디어 믹스,https://namu.wiki/w/조운
|
||||||
|
13,조운,조운,,관련 문서,https://namu.wiki/w/조운
|
||||||
|
14,마초,마초,,마초,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,,개요,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,Y,정사,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,Y,평가,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,Y,연의,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,Y,가족 관계,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,Y,여담,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,Y,대중매체에서,https://namu.wiki/w/마초
|
||||||
|
14,마초,마초,,둘러보기,https://namu.wiki/w/마초
|
||||||
|
15,황충,황충,,황충,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,개요,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,Y,생애(정사),https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,Y,연의,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,유비군 합류,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,한중 공방전,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,Y,사망,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,Y,평가,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,노장이었는가?,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,노장이 아니다.,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,노장이 맞다.,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,Y,여담,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,Y,대중매체에서,https://namu.wiki/w/황충
|
||||||
|
15,황충,황충,,둘러보기,https://namu.wiki/w/황충
|
||||||
|
16,강유,강유,,강유,https://namu.wiki/w/강유
|
||||||
|
16,강유,강유,,개요,https://namu.wiki/w/강유
|
||||||
|
16,강유,강유,Y,생애(정사),https://namu.wiki/w/강유
|
||||||
|
16,강유,강유,Y,연의,https://namu.wiki/w/강유
|
||||||
|
16,강유,강유,Y,평가,https://namu.wiki/w/강유
|
||||||
|
16,강유,강유,Y,여담,https://namu.wiki/w/강유
|
||||||
|
16,강유,강유,Y,대중매체에서,https://namu.wiki/w/강유
|
||||||
|
17,방통,방통,,방통,https://namu.wiki/w/방통
|
||||||
|
17,방통,방통,,개요,https://namu.wiki/w/방통
|
||||||
|
17,방통,방통,Y,생애(정사),https://namu.wiki/w/방통
|
||||||
|
17,방통,방통,Y,연의,https://namu.wiki/w/방통
|
||||||
|
17,방통,방통,Y,평가,https://namu.wiki/w/방통
|
||||||
|
17,방통,방통,Y,여담,https://namu.wiki/w/방통
|
||||||
|
17,방통,방통,Y,대중매체에서,https://namu.wiki/w/방통
|
||||||
|
18,손권,손권,,손권,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,,개요,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,,시호,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,,생애,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,Y,평가,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,Y,가족 관계,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,Y,기타,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,Y,대중매체에서,https://namu.wiki/w/손권
|
||||||
|
18,손권,손권,,둘러보기,https://namu.wiki/w/손권
|
||||||
|
19,손책,손책,,손책,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,개요,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,생애,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,초기 생애,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,원술 휘하에서,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,유요를 격파하다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,왕랑을 격파하다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,엄백호를 격파하다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,원술로부터 독립,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,유훈을 격파하다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,조조와의 관계,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,고대를 죽이다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,허공을 죽이다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,피습을 당하다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,우길을 죽이다,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,Y,죽음,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,사후,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,Y,평가,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,Y,가족 관계,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,Y,기타,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,Y,대중매체에서,https://namu.wiki/w/손책
|
||||||
|
19,손책,손책,,둘러보기,https://namu.wiki/w/손책
|
||||||
|
20,주유,주유(삼국지),,주유(삼국지),https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,개요,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,정사 삼국지,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,초기 생애,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,원술 휘하에서,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,손책 휘하에서,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,손권 휘하에서,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,적벽대전,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),,남군 공방전,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,죽음,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,삼국지연의,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,평가,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,가족 및 후손,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,기타,https://namu.wiki/w/주유(삼국지)
|
||||||
|
20,주유,주유(삼국지),Y,대중매체에서,https://namu.wiki/w/주유(삼국지)
|
||||||
|
21,육손,육손,,육손,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,개요,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,정사 삼국지,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,초기 생애,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,손가와의 화친,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,번성 공방전,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,"700리 촉영을 불사르다, 이릉대전",https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,외교와 내정,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,석정 전투,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,건업에서(오나라 건국),https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,4차 합비 공방전,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,형주에서 보여준 지략,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,손권과의 말년,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,이궁지쟁 사건과 최후,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,사후,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,삼국지연의,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,평가,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,기타,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,Y,미디어 믹스,https://namu.wiki/w/육손
|
||||||
|
21,육손,육손,,둘러보기,https://namu.wiki/w/육손
|
||||||
|
22,노숙,노숙(삼국지),,노숙(삼국지),https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),,개요,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),Y,정사 삼국지,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),,초기 생애,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),,손권에게 임관,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),,적벽대전,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),,익양대치,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),Y,죽음,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),,사후,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),Y,삼국지연의,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),Y,평가,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),Y,기타,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
22,노숙,노숙(삼국지),Y,기타 창작물,https://namu.wiki/w/노숙(삼국지)
|
||||||
|
23,감녕,감녕,,감녕,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,개요,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,정사 삼국지,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,익주의 토호,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,익주를 떠나다,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,황조의 푸대접,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,손권에게 투항,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,남군 공방전,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,환성 전투,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,익양대치,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,2차 합비 공방전,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,유수구 전투,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,그의 성격을 보여주는 일화,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,죽음,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,삼국지연의,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,나이,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,,감녕전의 순서 문제,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,기타,https://namu.wiki/w/감녕
|
||||||
|
23,감녕,감녕,Y,미디어 믹스,https://namu.wiki/w/감녕
|
||||||
|
24,태사자,태사자,,태사자,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,개요,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,생애,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,요동으로 피신하다,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,공융을 구원하다,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,손책을 따르다,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,예장을 정찰하다,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,,동오의 명장,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,Y,죽음,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,Y,기타,https://namu.wiki/w/태사자
|
||||||
|
24,태사자,태사자,Y,기타 창작물,https://namu.wiki/w/태사자
|
||||||
|
25,여포,여포,,여포,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,개요,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,Y,정사 삼국지,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,정원 휘하,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,동탁 휘하,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,왕윤과의 갈등,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,방랑,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,Y,여포의 최후,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,Y,삼국지연의,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,Y,평가,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,무력,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,통솔,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,지력,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,정치,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,인격,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,배신,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,색욕,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,번외: 항우와의 비교,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,유비와의 관계,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,,이민족 떡밥,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,Y,여담,https://namu.wiki/w/여포
|
||||||
|
25,여포,여포,Y,대중매체에서,https://namu.wiki/w/여포
|
||||||
|
26,동탁,동탁,,동탁,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,,개요,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,,생애,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,Y,평가,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,Y,재평가?,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,,반론,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,Y,가족 관계,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,Y,기타 창작물,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,Y,여담,https://namu.wiki/w/동탁
|
||||||
|
26,동탁,동탁,,둘러 보기,https://namu.wiki/w/동탁
|
||||||
|
27,원소,원소(삼국지),,원소(삼국지),https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,개요,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,생애,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),Y,평가,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,정치적 능력,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,행정적 능력,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,군사적 능력,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,용인술,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,도덕성,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,외모와 쇼맨십,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),Y,여담,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,조조에게 열등감을 가졌었다?,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,후계자 선정에서 잘못을 했나?,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,우유부단했는가?,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,투구 대신에 모자와 정장 차림,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),Y,가족 관계,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),Y,대중매체에서,https://namu.wiki/w/원소(삼국지)
|
||||||
|
27,원소,원소(삼국지),,둘러보기,https://namu.wiki/w/원소(삼국지)
|
||||||
|
28,원술,원술,,원술,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,,개요,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,,생애,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,Y,평가,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,Y,가족 관계,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,Y,여담,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,Y,대중매체에서,https://namu.wiki/w/원술
|
||||||
|
28,원술,원술,,둘러보기(계보),https://namu.wiki/w/원술
|
||||||
|
@ -0,0 +1,920 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "조조",
|
||||||
|
"url": "https://namu.wiki/w/조조",
|
||||||
|
"page_title": "조조",
|
||||||
|
"keywords": [
|
||||||
|
"조조",
|
||||||
|
"개요",
|
||||||
|
"이름",
|
||||||
|
"생애",
|
||||||
|
"가족 관계",
|
||||||
|
"무덤",
|
||||||
|
"평가",
|
||||||
|
"업적과 능력",
|
||||||
|
"vs 유비",
|
||||||
|
"어록",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"이름",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"업적과 능력",
|
||||||
|
"어록",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 1,
|
||||||
|
"name": "조조"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "사마의",
|
||||||
|
"url": "https://namu.wiki/w/사마의",
|
||||||
|
"page_title": "사마의",
|
||||||
|
"keywords": [
|
||||||
|
"사마의",
|
||||||
|
"개요",
|
||||||
|
"일생(정사)",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"일생(정사)",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 2,
|
||||||
|
"name": "사마의"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "하후돈",
|
||||||
|
"url": "https://namu.wiki/w/하후돈",
|
||||||
|
"page_title": "하후돈",
|
||||||
|
"keywords": [
|
||||||
|
"하후돈",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"동탁 토벌전",
|
||||||
|
"복양 전투",
|
||||||
|
"서주에서",
|
||||||
|
"조조 휘하",
|
||||||
|
"관도대전",
|
||||||
|
"박망파 전투",
|
||||||
|
"하북 평정",
|
||||||
|
"장로 정벌",
|
||||||
|
"216년~217년 유수구 전투",
|
||||||
|
"번성 공방전",
|
||||||
|
"죽음",
|
||||||
|
"사후 (위나라 건국)",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"친족 우대의 수혜자",
|
||||||
|
"긍정적 평가",
|
||||||
|
"연의에 기반한 오해 및 재평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"복양 전투",
|
||||||
|
"박망파 전투",
|
||||||
|
"216년~217년 유수구 전투",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"연의에 기반한 오해 및 재평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 3,
|
||||||
|
"name": "하후돈"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "장료",
|
||||||
|
"url": "https://namu.wiki/w/장료",
|
||||||
|
"page_title": "장료",
|
||||||
|
"keywords": [
|
||||||
|
"장료",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"여포 휘하",
|
||||||
|
"관도대전",
|
||||||
|
"창희 토벌",
|
||||||
|
"하북 평정",
|
||||||
|
"진란 토벌",
|
||||||
|
"형주 정벌",
|
||||||
|
"환성 전투",
|
||||||
|
"2차 합비 공방전[7]",
|
||||||
|
"조조 휘하",
|
||||||
|
"유수구 전투",
|
||||||
|
"번성 공방전",
|
||||||
|
"조비 휘하",
|
||||||
|
"동구 전투",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"군사적 능력",
|
||||||
|
"인품과 성품",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스",
|
||||||
|
"둘러보기",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"환성 전투",
|
||||||
|
"유수구 전투",
|
||||||
|
"동구 전투",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 4,
|
||||||
|
"name": "장료"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "허저",
|
||||||
|
"url": "https://namu.wiki/w/허저",
|
||||||
|
"page_title": "허저",
|
||||||
|
"keywords": [
|
||||||
|
"허저",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"양 전투",
|
||||||
|
"관도대전",
|
||||||
|
"하북 평정",
|
||||||
|
"관중 평정",
|
||||||
|
"장로 정벌",
|
||||||
|
"조조 휘하",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"양 전투",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 5,
|
||||||
|
"name": "허저"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "곽가",
|
||||||
|
"url": "https://namu.wiki/w/곽가",
|
||||||
|
"page_title": "곽가",
|
||||||
|
"keywords": [
|
||||||
|
"곽가",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"초기 생애",
|
||||||
|
"조조에게 임관",
|
||||||
|
"서주에서",
|
||||||
|
"관도대전",
|
||||||
|
"하북 평정",
|
||||||
|
"죽음",
|
||||||
|
"사후",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가: 연의의 수혜자",
|
||||||
|
"별 볼일 없는 전공",
|
||||||
|
"적벽대전에서 곽가만 있었다면 이겼다?",
|
||||||
|
"사생활",
|
||||||
|
"여담",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"죽음",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가: 연의의 수혜자",
|
||||||
|
"여담",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 6,
|
||||||
|
"name": "곽가"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "순욱",
|
||||||
|
"url": "https://namu.wiki/w/순욱",
|
||||||
|
"page_title": "순욱",
|
||||||
|
"keywords": [
|
||||||
|
"순욱",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"미디어 믹스",
|
||||||
|
"기타",
|
||||||
|
"순욱/빈 찬합 밈",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"미디어 믹스",
|
||||||
|
"기타"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 7,
|
||||||
|
"name": "순욱"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "가후",
|
||||||
|
"url": "https://namu.wiki/w/가후",
|
||||||
|
"page_title": "가후",
|
||||||
|
"keywords": [
|
||||||
|
"가후",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"동탁 휘하",
|
||||||
|
"이각 휘하",
|
||||||
|
"단외 휘하",
|
||||||
|
"장수 휘하",
|
||||||
|
"완 전투",
|
||||||
|
"양 전투",
|
||||||
|
"관도대전",
|
||||||
|
"적벽대전",
|
||||||
|
"관중 평정",
|
||||||
|
"조비에게 조언",
|
||||||
|
"조비 휘하 (위나라 건국)",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스",
|
||||||
|
"관련 문서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"완 전투",
|
||||||
|
"양 전투",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"긍정적 평가",
|
||||||
|
"부정적 평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 8,
|
||||||
|
"name": "가후"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "유비",
|
||||||
|
"url": "https://namu.wiki/w/유비",
|
||||||
|
"page_title": "유비",
|
||||||
|
"keywords": [
|
||||||
|
"유비",
|
||||||
|
"개요",
|
||||||
|
"이름과 칭호",
|
||||||
|
"생애",
|
||||||
|
"평가",
|
||||||
|
"vs 조조",
|
||||||
|
"혈통",
|
||||||
|
"가족 관계",
|
||||||
|
"무덤",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"이름과 칭호",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 9,
|
||||||
|
"name": "유비"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "관우",
|
||||||
|
"url": "https://namu.wiki/w/관우",
|
||||||
|
"page_title": "관우",
|
||||||
|
"keywords": [
|
||||||
|
"관우",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"역대 봉호",
|
||||||
|
"중화 왕조",
|
||||||
|
"조선",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"시호 논란",
|
||||||
|
"악시설",
|
||||||
|
"선시설",
|
||||||
|
"신격화",
|
||||||
|
"말말말",
|
||||||
|
"대중매체에서",
|
||||||
|
"관련 문서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"시호 논란",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 10,
|
||||||
|
"name": "관우"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "장비",
|
||||||
|
"url": "https://namu.wiki/w/장비",
|
||||||
|
"page_title": "장비",
|
||||||
|
"keywords": [
|
||||||
|
"장비",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"연의의 피해자?",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물",
|
||||||
|
"관련 항목",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"연의의 피해자?",
|
||||||
|
"가족 관계",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 11,
|
||||||
|
"name": "장비"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "제갈량",
|
||||||
|
"url": "https://namu.wiki/w/제갈량",
|
||||||
|
"page_title": "제갈량",
|
||||||
|
"keywords": [
|
||||||
|
"제갈량",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"인간관계",
|
||||||
|
"업적",
|
||||||
|
"평가",
|
||||||
|
"논란",
|
||||||
|
"관련 일화들",
|
||||||
|
"사상",
|
||||||
|
"여담",
|
||||||
|
"미디어 믹스",
|
||||||
|
"관련 문서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"업적",
|
||||||
|
"평가",
|
||||||
|
"논란",
|
||||||
|
"관련 일화들",
|
||||||
|
"여담",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 12,
|
||||||
|
"name": "제갈량"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "조운",
|
||||||
|
"url": "https://namu.wiki/w/조운",
|
||||||
|
"page_title": "조운",
|
||||||
|
"keywords": [
|
||||||
|
"조운",
|
||||||
|
"개요",
|
||||||
|
"소개",
|
||||||
|
"정사",
|
||||||
|
"평가",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"여담",
|
||||||
|
"미디어 믹스",
|
||||||
|
"관련 문서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사",
|
||||||
|
"평가",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"여담",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 13,
|
||||||
|
"name": "조운"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "마초",
|
||||||
|
"url": "https://namu.wiki/w/마초",
|
||||||
|
"page_title": "마초",
|
||||||
|
"keywords": [
|
||||||
|
"마초",
|
||||||
|
"개요",
|
||||||
|
"정사",
|
||||||
|
"평가",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사",
|
||||||
|
"평가",
|
||||||
|
"연의",
|
||||||
|
"가족 관계",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 14,
|
||||||
|
"name": "마초"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "황충",
|
||||||
|
"url": "https://namu.wiki/w/황충",
|
||||||
|
"page_title": "황충",
|
||||||
|
"keywords": [
|
||||||
|
"황충",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"유비군 합류",
|
||||||
|
"한중 공방전",
|
||||||
|
"사망",
|
||||||
|
"평가",
|
||||||
|
"노장이었는가?",
|
||||||
|
"노장이 아니다.",
|
||||||
|
"노장이 맞다.",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"사망",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 15,
|
||||||
|
"name": "황충"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "강유",
|
||||||
|
"url": "https://namu.wiki/w/강유",
|
||||||
|
"page_title": "강유",
|
||||||
|
"keywords": [
|
||||||
|
"강유",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 16,
|
||||||
|
"name": "강유"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "방통",
|
||||||
|
"url": "https://namu.wiki/w/방통",
|
||||||
|
"page_title": "방통",
|
||||||
|
"keywords": [
|
||||||
|
"방통",
|
||||||
|
"개요",
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"생애(정사)",
|
||||||
|
"연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 17,
|
||||||
|
"name": "방통"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "손권",
|
||||||
|
"url": "https://namu.wiki/w/손권",
|
||||||
|
"page_title": "손권",
|
||||||
|
"keywords": [
|
||||||
|
"손권",
|
||||||
|
"개요",
|
||||||
|
"시호",
|
||||||
|
"생애",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 18,
|
||||||
|
"name": "손권"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "손책",
|
||||||
|
"url": "https://namu.wiki/w/손책",
|
||||||
|
"page_title": "손책",
|
||||||
|
"keywords": [
|
||||||
|
"손책",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"초기 생애",
|
||||||
|
"원술 휘하에서",
|
||||||
|
"유요를 격파하다",
|
||||||
|
"왕랑을 격파하다",
|
||||||
|
"엄백호를 격파하다",
|
||||||
|
"원술로부터 독립",
|
||||||
|
"유훈을 격파하다",
|
||||||
|
"조조와의 관계",
|
||||||
|
"고대를 죽이다",
|
||||||
|
"허공을 죽이다",
|
||||||
|
"피습을 당하다",
|
||||||
|
"우길을 죽이다",
|
||||||
|
"죽음",
|
||||||
|
"사후",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"죽음",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 19,
|
||||||
|
"name": "손책"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "주유(삼국지)",
|
||||||
|
"url": "https://namu.wiki/w/주유(삼국지)",
|
||||||
|
"page_title": "주유(삼국지)",
|
||||||
|
"keywords": [
|
||||||
|
"주유(삼국지)",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"원술 휘하에서",
|
||||||
|
"손책 휘하에서",
|
||||||
|
"손권 휘하에서",
|
||||||
|
"적벽대전",
|
||||||
|
"남군 공방전",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"가족 및 후손",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"가족 및 후손",
|
||||||
|
"기타",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 20,
|
||||||
|
"name": "주유"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "육손",
|
||||||
|
"url": "https://namu.wiki/w/육손",
|
||||||
|
"page_title": "육손",
|
||||||
|
"keywords": [
|
||||||
|
"육손",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"손가와의 화친",
|
||||||
|
"번성 공방전",
|
||||||
|
"700리 촉영을 불사르다, 이릉대전",
|
||||||
|
"외교와 내정",
|
||||||
|
"석정 전투",
|
||||||
|
"건업에서(오나라 건국)",
|
||||||
|
"4차 합비 공방전",
|
||||||
|
"형주에서 보여준 지략",
|
||||||
|
"손권과의 말년",
|
||||||
|
"이궁지쟁 사건과 최후",
|
||||||
|
"사후",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"석정 전투",
|
||||||
|
"이궁지쟁 사건과 최후",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 21,
|
||||||
|
"name": "육손"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "노숙(삼국지)",
|
||||||
|
"url": "https://namu.wiki/w/노숙(삼국지)",
|
||||||
|
"page_title": "노숙(삼국지)",
|
||||||
|
"keywords": [
|
||||||
|
"노숙(삼국지)",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"초기 생애",
|
||||||
|
"손권에게 임관",
|
||||||
|
"적벽대전",
|
||||||
|
"익양대치",
|
||||||
|
"죽음",
|
||||||
|
"사후",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 22,
|
||||||
|
"name": "노숙"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "감녕",
|
||||||
|
"url": "https://namu.wiki/w/감녕",
|
||||||
|
"page_title": "감녕",
|
||||||
|
"keywords": [
|
||||||
|
"감녕",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"익주의 토호",
|
||||||
|
"익주를 떠나다",
|
||||||
|
"황조의 푸대접",
|
||||||
|
"손권에게 투항",
|
||||||
|
"남군 공방전",
|
||||||
|
"환성 전투",
|
||||||
|
"익양대치",
|
||||||
|
"2차 합비 공방전",
|
||||||
|
"유수구 전투",
|
||||||
|
"그의 성격을 보여주는 일화",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"나이",
|
||||||
|
"감녕전의 순서 문제",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"환성 전투",
|
||||||
|
"유수구 전투",
|
||||||
|
"그의 성격을 보여주는 일화",
|
||||||
|
"죽음",
|
||||||
|
"삼국지연의",
|
||||||
|
"기타",
|
||||||
|
"미디어 믹스"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 23,
|
||||||
|
"name": "감녕"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "태사자",
|
||||||
|
"url": "https://namu.wiki/w/태사자",
|
||||||
|
"page_title": "태사자",
|
||||||
|
"keywords": [
|
||||||
|
"태사자",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"요동으로 피신하다",
|
||||||
|
"공융을 구원하다",
|
||||||
|
"손책을 따르다",
|
||||||
|
"예장을 정찰하다",
|
||||||
|
"동오의 명장",
|
||||||
|
"죽음",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"죽음",
|
||||||
|
"기타",
|
||||||
|
"기타 창작물"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 24,
|
||||||
|
"name": "태사자"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "여포",
|
||||||
|
"url": "https://namu.wiki/w/여포",
|
||||||
|
"page_title": "여포",
|
||||||
|
"keywords": [
|
||||||
|
"여포",
|
||||||
|
"개요",
|
||||||
|
"정사 삼국지",
|
||||||
|
"정원 휘하",
|
||||||
|
"동탁 휘하",
|
||||||
|
"왕윤과의 갈등",
|
||||||
|
"방랑",
|
||||||
|
"여포의 최후",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"무력",
|
||||||
|
"통솔",
|
||||||
|
"지력",
|
||||||
|
"정치",
|
||||||
|
"인격",
|
||||||
|
"배신",
|
||||||
|
"색욕",
|
||||||
|
"번외: 항우와의 비교",
|
||||||
|
"유비와의 관계",
|
||||||
|
"이민족 떡밥",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"정사 삼국지",
|
||||||
|
"여포의 최후",
|
||||||
|
"삼국지연의",
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 25,
|
||||||
|
"name": "여포"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "동탁",
|
||||||
|
"url": "https://namu.wiki/w/동탁",
|
||||||
|
"page_title": "동탁",
|
||||||
|
"keywords": [
|
||||||
|
"동탁",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"평가",
|
||||||
|
"재평가?",
|
||||||
|
"반론",
|
||||||
|
"가족 관계",
|
||||||
|
"기타 창작물",
|
||||||
|
"여담",
|
||||||
|
"둘러 보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"평가",
|
||||||
|
"재평가?",
|
||||||
|
"가족 관계",
|
||||||
|
"기타 창작물",
|
||||||
|
"여담"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 26,
|
||||||
|
"name": "동탁"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "원소(삼국지)",
|
||||||
|
"url": "https://namu.wiki/w/원소(삼국지)",
|
||||||
|
"page_title": "원소(삼국지)",
|
||||||
|
"keywords": [
|
||||||
|
"원소(삼국지)",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"평가",
|
||||||
|
"정치적 능력",
|
||||||
|
"행정적 능력",
|
||||||
|
"군사적 능력",
|
||||||
|
"용인술",
|
||||||
|
"도덕성",
|
||||||
|
"외모와 쇼맨십",
|
||||||
|
"여담",
|
||||||
|
"조조에게 열등감을 가졌었다?",
|
||||||
|
"후계자 선정에서 잘못을 했나?",
|
||||||
|
"우유부단했는가?",
|
||||||
|
"투구 대신에 모자와 정장 차림",
|
||||||
|
"가족 관계",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"평가",
|
||||||
|
"여담",
|
||||||
|
"가족 관계",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 27,
|
||||||
|
"name": "원소"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "원술",
|
||||||
|
"url": "https://namu.wiki/w/원술",
|
||||||
|
"page_title": "원술",
|
||||||
|
"keywords": [
|
||||||
|
"원술",
|
||||||
|
"개요",
|
||||||
|
"생애",
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서",
|
||||||
|
"둘러보기(계보)"
|
||||||
|
],
|
||||||
|
"anecdote_keywords": [
|
||||||
|
"평가",
|
||||||
|
"가족 관계",
|
||||||
|
"여담",
|
||||||
|
"대중매체에서"
|
||||||
|
],
|
||||||
|
"error": null,
|
||||||
|
"order": 28,
|
||||||
|
"name": "원술"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
generator/animation_samgukji/fonts/brand_logo.png
Normal file
BIN
generator/animation_samgukji/fonts/brand_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
298
generator/animation_samgukji/gen.py
Normal file
298
generator/animation_samgukji/gen.py
Normal file
@ -0,0 +1,298 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
animation_samgukji 생성 단계 (나레이션 + 스토리보드) — 병합본
|
||||||
|
==================================================================
|
||||||
|
삼국지 인물 '썰' CSV → ① 병맛 마케팅 나레이션 ② 장면 분할 스토리보드 를 한 파일에 모음.
|
||||||
|
(animation(조선왕) 폴더와 동일 구조 — 소재만 '조선왕' → '삼국지 인물'.)
|
||||||
|
scraper.py 가 만든 samguk_keywords_*.csv 를 입력으로 한다.
|
||||||
|
|
||||||
|
· 나레이션: latest_csv / load_rows / pick_seed / generate
|
||||||
|
· 스토리보드: clamp_scenes / make_storyboard
|
||||||
|
|
||||||
|
구조: 훅 → 썰 전개(역사 + 병맛 과장) → 어거지 전환 → 마케팅 + CTA → 장면별 분할(자막+그림 프롬프트).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
DATA_DIR = HERE / "data" # 삼국지 인물 일화 CSV(선행 데이터). ★ output/ 이 아님 — 거긴 출력물(mp4)만.
|
||||||
|
DEFAULT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
# 자막 폰트(주아/송명/맑은고딕)에 없는 이모지·기호(😊▼★♥ 등)는 화면에서 □ 로 깨진다 → 제거.
|
||||||
|
_TYPO = {"…": "...", "‘": "'", "’": "'", "“": '"',
|
||||||
|
"”": '"', "·": " ", "~": "~", "〜": "~"}
|
||||||
|
# 폰트(주아/송명/맑은고딕)에 확실히 있는 것 = 한글 음절·자모(ㅋㅋ ㅠㅠ) + ASCII 인쇄문자. 그 외(이모지·…·특수기호)는 제거.
|
||||||
|
_KEEP_RE = re.compile(r"[^가-힣ㄱ-ㅣ\x20-\x7E]")
|
||||||
|
|
||||||
|
|
||||||
|
def _no_emoji(s):
|
||||||
|
s = s or ""
|
||||||
|
for _k, _v in _TYPO.items():
|
||||||
|
s = s.replace(_k, _v)
|
||||||
|
return re.sub(r" +", " ", _KEEP_RE.sub("", s)).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ① 나레이션 (삼국지 썰 → 병맛 대본) ==============================
|
||||||
|
|
||||||
|
def latest_csv() -> Path:
|
||||||
|
files = sorted(DATA_DIR.glob("samguk_keywords_*.csv"))
|
||||||
|
if not files:
|
||||||
|
sys.exit("[!] animation_samgukji/data/ 에 samguk_keywords_*.csv 가 없음. 먼저 'python scraper.py' 실행.")
|
||||||
|
return files[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(csv_path: Path):
|
||||||
|
"""CSV → [{order, king, doc, is_anecdote, keyword, url}, ...] (king = 삼국지 인물 이름)"""
|
||||||
|
rows = []
|
||||||
|
with csv_path.open(encoding="utf-8-sig", newline="") as f:
|
||||||
|
r = csv.reader(f)
|
||||||
|
next(r, None) # 헤더 스킵
|
||||||
|
for line in r:
|
||||||
|
if len(line) < 6:
|
||||||
|
continue
|
||||||
|
order, king, doc, anec, kw, url = line[:6]
|
||||||
|
rows.append({
|
||||||
|
"order": int(order) if order.isdigit() else 0,
|
||||||
|
"king": king, "doc": doc,
|
||||||
|
"is_anecdote": anec.strip().upper() == "Y",
|
||||||
|
"keyword": kw, "url": url,
|
||||||
|
})
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def pick_seed(rows, king=None, keyword=None, prefer_anecdote=True):
|
||||||
|
"""대본의 씨앗이 될 (인물, 썰키워드) 한 건 선택."""
|
||||||
|
cand = rows
|
||||||
|
if king:
|
||||||
|
cand = [r for r in cand if r["king"] == king]
|
||||||
|
if not cand:
|
||||||
|
kings = sorted({r["king"] for r in rows})
|
||||||
|
sys.exit(f"[!] '{king}' 없음. 가능: {', '.join(kings)}")
|
||||||
|
if keyword:
|
||||||
|
kw_match = [r for r in cand if keyword in r["keyword"]]
|
||||||
|
if not kw_match:
|
||||||
|
sys.exit(f"[!] '{keyword}' 키워드가 {king or '전체'}에 없음.")
|
||||||
|
return random.choice(kw_match)
|
||||||
|
if prefer_anecdote:
|
||||||
|
anec = [r for r in cand if r["is_anecdote"]]
|
||||||
|
if anec:
|
||||||
|
cand = anec
|
||||||
|
return random.choice(cand)
|
||||||
|
|
||||||
|
|
||||||
|
NARR_SYSTEM = """\
|
||||||
|
너는 한국 숏폼(쇼츠/릴스) 전문 카피라이터다. '병맛(어이없고 황당하지만 묘하게 중독성 있는)' 유머 톤으로,
|
||||||
|
삼국지(중국 후한 말~삼국시대) 인물들의 '썰'을 풀다가 마지막에 자연스러운 척 억지로 광고로 갈아타는 {seconds}초 나레이션 대본을 쓴다.
|
||||||
|
|
||||||
|
[톤 규칙]
|
||||||
|
- 구어체 반말 나레이션. TTS(AI 음성)가 읽을 것을 전제로, 문장은 짧고 리듬감 있게.
|
||||||
|
- 진지하게 시작했다가 갑자기 헛소리로 빠지는 낙차가 핵심. "근데", "그래서", "참고로" 같은 접속사로 능청스럽게 전환.
|
||||||
|
- 역사적 사실(인물 이름, 사건 키워드)은 최소한의 뼈대로만 쓰고, 디테일은 과장·왜곡해서 웃기게 채운다.
|
||||||
|
삼국지연의(소설) 속 유명 장면(도원결의, 삼고초려, 적벽대전, 장판파 등)을 드립 소재로 적극 활용해도 된다.
|
||||||
|
단, 시청자가 '진짜 역사'와 '드립'을 구분 못 할 정도로 거짓 정보를 사실처럼 단정하진 마라(가벼운 드립 신호 유지).
|
||||||
|
- 욕설·혐오·정치 비방 금지. 누구나 웃을 수 있는 선.
|
||||||
|
|
||||||
|
[구조] (반드시 이 4단)
|
||||||
|
1) hook : 3초 안에 스크롤 멈추게 하는 한 방. 질문/충격/궁금증.
|
||||||
|
2) story : 썰 본문. 역사로 시작해 점점 병맛으로 과열. 광고로 넘어갈 '연결고리'를 은근히 심어둔다.
|
||||||
|
3) pivot : "근데 알고 보니/그래서/요즘 같으면…" 식으로 광고로 억지 전환. 이 억지스러움 자체가 개그.
|
||||||
|
4) cta : 광고 대상의 핵심 셀링포인트 + 행동유도 한 줄.
|
||||||
|
|
||||||
|
[분량]
|
||||||
|
- 한국어 나레이션 기준 약 {seconds}초 분량 = {lo}~{hi}자. full_narration 은 반드시 이 글자수 범위로.
|
||||||
|
- 이 범위를 넘기지 마라. 너무 길면 TTS가 빨라져 병맛이 죽고 영상도 길어진다. 간결하게.
|
||||||
|
|
||||||
|
[SNS 캡션] (sns_caption 필드 — 유튜브/인스타/틱톡에 그대로 붙여넣을 업로드 설명)
|
||||||
|
- 아래 '형식'을 지켜라. 이모지를 적극 사용(나레이션과 달리 캡션은 이모지 OK). 줄바꿈으로 구분.
|
||||||
|
- 형식:
|
||||||
|
1) 첫 줄: 후킹 카피 한 줄 (예: "제갈량이 사실 OO 단골이었다는 썰 풉니다.")
|
||||||
|
2) 본문 1~2줄: 삼국지 썰 ↔ 가게를 잇는 재밌는 소개 (이모지 곁들여)
|
||||||
|
3) 셀링 한 줄: 명장이 탐낼 식으로 가게 핵심 (예: "조조도 탐낼 OO시장 숨은 맛집 'OOO' 🍲")
|
||||||
|
4) 📍 가게명 (위치) ← 가게명/위치 모르면 이 줄 생략
|
||||||
|
5) 🔥 대표 메뉴·포인트 나열 ← 모르면 생략
|
||||||
|
6) 👉 방문 팁 또는 행동유도 한 줄
|
||||||
|
7) 마지막 줄: 해시태그 나열. #삼국지 #역사썰 을 앞에 두고, 가게·지역·메뉴·병맛 태그 + #shorts #AIO2O 로 끝.
|
||||||
|
- 가게 정보(이름·위치·메뉴)는 '마케팅 대상' 텍스트에서 뽑아라. 없으면 그 줄은 자연스럽게 빼라(억지로 지어내지 마라).
|
||||||
|
|
||||||
|
[출력]
|
||||||
|
- 반드시 지정된 JSON 스키마로만 응답. 설명/사족 금지.
|
||||||
|
- full_narration 은 hook+story+pivot+cta 를 자연스럽게 이어붙인 '읽는 그대로의' 최종 나레이션.
|
||||||
|
- sns_caption 은 위 [SNS 캡션] 형식대로. title 에는 해시태그를 넣지 말 것(태그는 hashtags·sns_caption 에만).
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_USER = """\
|
||||||
|
[이번 쇼츠 소재]
|
||||||
|
- 인물: {king} ({doc})
|
||||||
|
- 썰 키워드: "{keyword}"
|
||||||
|
- 참고 출처: {url}
|
||||||
|
|
||||||
|
[마케팅 대상 (마지막에 이걸로 갈아타기)]
|
||||||
|
- 가게명: {store}
|
||||||
|
- 소개/키워드: {product}
|
||||||
|
|
||||||
|
위 '썰 키워드'에서 출발해, 병맛으로 풀다가 마지막에 '마케팅 대상'으로 억지 전환하는
|
||||||
|
{seconds}초 쇼츠 나레이션을 만들어라. 썰 키워드가 빈약하면 그 인물의 유명한 일화(연의 속 명장면 포함)로 살을 붙여도 된다.
|
||||||
|
"""
|
||||||
|
|
||||||
|
NARR_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "쇼츠 제목/썸네일 문구 (짧고 자극적). 해시태그(#…)를 넣지 말 것 — 태그는 hashtags 필드에만."},
|
||||||
|
"hook": {"type": "string"},
|
||||||
|
"story": {"type": "string"},
|
||||||
|
"pivot": {"type": "string"},
|
||||||
|
"cta": {"type": "string"},
|
||||||
|
"full_narration": {"type": "string", "description": "TTS가 읽을 최종 나레이션 전문"},
|
||||||
|
"estimated_seconds": {"type": "integer"},
|
||||||
|
"hashtags": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"sns_caption": {"type": "string", "description": "SNS 업로드용 설명 전문([SNS 캡션] 형식, 이모지·해시태그 포함)"},
|
||||||
|
},
|
||||||
|
"required": ["title", "hook", "story", "pivot", "cta", "full_narration", "sns_caption"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def generate(client, model, seed, product, seconds, store=""):
|
||||||
|
"""삼국지 인물 썰 + 마케팅 대상 → 병맛 나레이션 dict (Gemini)."""
|
||||||
|
# 글자수를 목표 길이에 비례시킨다(한국어 나레이션 ≒ 초당 5.4~6.4자). 60초면 ≒324~384자.
|
||||||
|
lo, hi = round(seconds * 5.4), round(seconds * 6.4)
|
||||||
|
user = NARR_USER.format(
|
||||||
|
king=seed["king"], doc=seed["doc"],
|
||||||
|
keyword=seed["keyword"], url=seed["url"], product=product,
|
||||||
|
store=(store or "(가게명 미상 — 소개 텍스트에서 유추)"), seconds=seconds,
|
||||||
|
)
|
||||||
|
sys_prompt = NARR_SYSTEM.format(seconds=seconds, lo=lo, hi=hi)
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=user,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=sys_prompt,
|
||||||
|
temperature=1.1, # 병맛 = 약간 높은 창의성
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=NARR_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
data = json.loads(resp.text)
|
||||||
|
data["full_narration"] = _no_emoji(data.get("full_narration", "")) # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
data["_seed"] = {"king": seed["king"], "keyword": seed["keyword"], "url": seed["url"]}
|
||||||
|
data["_product"] = product
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== ② 스토리보드 (나레이션 → 장면 분할) ==============================
|
||||||
|
|
||||||
|
SB_SYSTEM = """\
|
||||||
|
너는 숏폼 영상 디렉터다. 주어진 '병맛 삼국지 마케팅 나레이션'을 세로 쇼츠용 스토리보드로 쪼갠다.
|
||||||
|
|
||||||
|
[핵심 규칙]
|
||||||
|
- narration 필드에는 받은 나레이션 '원문을 그대로' 잘라 담아라. 절대 다시 쓰거나 요약하지 마라.
|
||||||
|
(이 텍스트가 그대로 TTS 음성이 되므로, 모든 장면 narration 을 이으면 원본과 100% 같아야 한다.)
|
||||||
|
- 장면 수는 아래 '목표 장면 수'에 맞춰라. 한 장면 narration 이 너무 길어지지 않게(자막 한두 줄) 적당히 끊어 담아라.
|
||||||
|
- caption: 화면에 띄울 굵은 자막. 짧고 임팩트 있게(최대 18자). narration 의 핵심을 뽑되 드립 살려서.
|
||||||
|
- image_prompt: 그 장면 그림을 그릴 영어 프롬프트. character_sheet 와 style_guide 를 항상 전제로,
|
||||||
|
'이 장면에서 캐릭터가 뭘 하는지'를 구체적으로. (예: the cat general sweating while defending a castle gate alone)
|
||||||
|
★ 절대 그림 안에 '글자/간판 글씨/메뉴판 글자/브랜드명 텍스트'를 그리도록 요구하지 마라.
|
||||||
|
(AI가 한글을 깨진 글자로 그린다.) 간판·현수막·메뉴판이 등장해도 'blank sign with no text'처럼
|
||||||
|
글자 없는 상태로 묘사하라. 가게 이름 등 텍스트는 영상 자막에서 따로 넣는다.
|
||||||
|
★ 감정이 강한 장면(웃음/울음/졸림/한숨/장난/좌절 등)에서는 눈 표정도 영어로 적어라
|
||||||
|
(예: eyes squeezed shut while laughing, sleepy half-closed eyes, a playful wink,
|
||||||
|
teary closed eyes). 평범한 장면은 굳이 눈을 적지 말고 뜬 눈(기본)으로 두면 된다.
|
||||||
|
→ 그래야 컷마다 눈 표정이 다양해진다.
|
||||||
|
|
||||||
|
[전역 설정]
|
||||||
|
- character_sheet: 이 영상에 일관되게 등장할 주인공 캐릭터를 영어로 정의. 삼국지 인물을 '귀여운 통통한 고양이'로 의인화.
|
||||||
|
복식/색/특징(갑옷·한푸·무기·수염 등 그 인물의 상징)을 못박아 컷마다 동일하게.
|
||||||
|
(예: a chubby cute cat as Guan Yu from Three Kingdoms, wearing green hanfu robe and golden armor,
|
||||||
|
long flowing black beard, holding a tiny green dragon crescent blade, round face /
|
||||||
|
a chubby cute cat as Zhuge Liang, wearing white-and-teal hanfu scholar robe, holding a white feather fan)
|
||||||
|
★ 눈 그림체: 눈을 떴을 때는 large round eyes with big round solid black pupils and a small
|
||||||
|
soft white highlight, gentle innocent look (귀엽고 둥근 눈)으로 통일. 단 이 '그림체'만 유지하고,
|
||||||
|
장면 감정에 따라 눈을 감거나(eyes closed) 실눈(squint)·윙크·졸린 눈 등 표정은 바꿔도 된다.
|
||||||
|
절대 날카롭거나 찢어진/화난/매서운/사실적인 맹수 눈은 금지.
|
||||||
|
- style_guide: 그림 스타일을 영어로. 한국 웹툰풍 플랫 일러스트, 굵은 외곽선, 따뜻한 색, 단순 배경(고대 중국 삼국시대 분위기), 세로 9:16.
|
||||||
|
|
||||||
|
[출력] 지정된 JSON 스키마로만.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_USER = """\
|
||||||
|
[마케팅 대상] {product}
|
||||||
|
[소재 인물] {king}
|
||||||
|
[목표 장면 수] 약 {scenes}개
|
||||||
|
|
||||||
|
[나레이션 원문 — 이걸 그대로 잘라서 narration 필드에 담아라]
|
||||||
|
{narration}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SB_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_sheet": {"type": "string"},
|
||||||
|
"style_guide": {"type": "string"},
|
||||||
|
"scenes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"narration": {"type": "string"},
|
||||||
|
"caption": {"type": "string"},
|
||||||
|
"image_prompt": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["narration", "caption", "image_prompt"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["character_sheet", "style_guide", "scenes"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def clamp_scenes(scene_list, target):
|
||||||
|
"""장면 수가 target 보다 많으면, 가장 짧은 인접쌍을 병합해 target 개까지 줄인다.
|
||||||
|
narration 은 순서대로 보존된다(분할은 하지 않는다)."""
|
||||||
|
def clen(s):
|
||||||
|
return len(s.get("narration", "").strip())
|
||||||
|
|
||||||
|
while len(scene_list) > target:
|
||||||
|
# 합쳤을 때 가장 짧아지는 인접쌍을 골라 병합
|
||||||
|
i = min(range(len(scene_list) - 1),
|
||||||
|
key=lambda k: clen(scene_list[k]) + clen(scene_list[k + 1]))
|
||||||
|
base = dict(scene_list[i + 1]) # 이웃(뒤 컷)의 caption/image_prompt 유지
|
||||||
|
a, b = scene_list[i].get("narration", "").strip(), scene_list[i + 1].get("narration", "").strip()
|
||||||
|
base["narration"] = (a + " " + b).strip()
|
||||||
|
scene_list[i:i + 2] = [base]
|
||||||
|
return scene_list
|
||||||
|
|
||||||
|
|
||||||
|
def make_storyboard(client, model, narration, product, king, scenes=8) -> dict:
|
||||||
|
"""나레이션 → 스토리보드 dict (Gemini)."""
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model,
|
||||||
|
contents=SB_USER.format(product=product, king=king,
|
||||||
|
scenes=scenes, narration=narration),
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=SB_SYSTEM,
|
||||||
|
temperature=0.8,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=SB_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sb = json.loads(resp.text)
|
||||||
|
# 모델이 '목표 장면 수'를 채우려고 narration 이 빈 장면(닫는 CTA 컷 등)을 끼워넣을 때가 있다.
|
||||||
|
# 이 파이프라인은 narration 을 TTS·자막으로 쓰므로 빈 장면은 의미가 없고,
|
||||||
|
# 빈 텍스트를 TTS 에 넣으면 빈 응답을 반복하다 죽는다 → 생성 단계에서 미리 제거.
|
||||||
|
raw = sb.get("scenes", [])
|
||||||
|
kept = [s for s in raw if s.get("narration", "").strip()]
|
||||||
|
if len(kept) < len(raw):
|
||||||
|
print(f"[i] 빈 나레이션 장면 {len(raw) - len(kept)}개 제거")
|
||||||
|
sb["scenes"] = clamp_scenes(kept, scenes) # 장면이 목표보다 많으면 병합해 맞춤
|
||||||
|
for sc in sb["scenes"]: # 이모지 제거(자막·TTS 깨짐 방지)
|
||||||
|
sc["narration"] = _no_emoji(sc.get("narration", ""))
|
||||||
|
sc["caption"] = _no_emoji(sc.get("caption", ""))
|
||||||
|
sb["_product"] = product
|
||||||
|
sb["_king"] = king
|
||||||
|
return sb
|
||||||
275
generator/animation_samgukji/main.py
Normal file
275
generator/animation_samgukji/main.py
Normal file
@ -0,0 +1,275 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
키워드 → 병맛 삼국지 마케팅 쇼츠 (완전 자동, 원커맨드)
|
||||||
|
==========================================================
|
||||||
|
|
||||||
|
마케팅하고 싶은 대상을 '자유로운 키워드/문장'으로 주면:
|
||||||
|
1) 랜덤으로 삼국지 인물 '썰' 매칭
|
||||||
|
2) Gemini 로 병맛 마케팅 나레이션 생성
|
||||||
|
3) 장면 분할(스토리보드)
|
||||||
|
4) 장면별 이미지 + 음성 생성
|
||||||
|
5) Ken Burns + 자막(맨 위) + 음성 합성 → 세로 mp4
|
||||||
|
6) 한 작업의 결과(나레이션·스토리보드·이미지·음성·mp4)는
|
||||||
|
모두 output/<작업폴더>/ 안에만 저장. 완성 후 중간 파일(assets)은 자동 삭제.
|
||||||
|
|
||||||
|
선행: scraper.py 로 만든 data/samguk_keywords_*.csv 가 있어야 함.
|
||||||
|
API 키: GEMINI_API_KEY 환경변수 (또는 --api-key). 이미지 생성은 결제 활성화 필요.
|
||||||
|
|
||||||
|
★ 실행 (shorts_all 폴더 안에서, 키워드는 자유롭게):
|
||||||
|
python animation_samgukji\\main.py 감성 펜션 왕의휴식 객실 노천탕 평창 숲속 1박 12만원
|
||||||
|
python animation_samgukji\\main.py "강원도 평창 '왕의휴식' 감성 펜션, 객실 노천탕, 1박 12만원"
|
||||||
|
python animation_samgukji\\main.py https://naver.me/xxxx
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
--label 왕의휴식 결과 폴더 이름에 쓸 라벨 (기본: 키워드 앞부분 자동)
|
||||||
|
--king 조조 특정 인물 고정 (기본: 랜덤)
|
||||||
|
--scenes 12 장면 수=이미지 수 (기본 12)
|
||||||
|
--seconds 45 목표 길이 초 (기본 45)
|
||||||
|
--voice Puck TTS 목소리
|
||||||
|
--keep 중간 파일(assets) 삭제하지 않고 보존
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if hasattr(sys.stdout, "reconfigure"):
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8")
|
||||||
|
sys.stderr.reconfigure(encoding="utf-8")
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) # 공유 모듈(naver.py, media.py)
|
||||||
|
|
||||||
|
import gen # 나레이션 + 스토리보드 생성(병합: 이전 gen_narration + gen_storyboard)
|
||||||
|
import render as MS # animation 영상합성 엔진(이전 make_short.py)
|
||||||
|
import naver as NV # 공유 네이버 크롤러/브리핑/사진 선별
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
|
||||||
|
TEXT_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def safe_name(s: str) -> str:
|
||||||
|
s = re.sub(r"[^\w가-힣]+", "_", s).strip("_")
|
||||||
|
return s[:30] or "short"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="키워드 또는 네이버 링크 → 병맛 삼국지 쇼츠 (완전 자동)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="예) python main.py 감성 펜션 왕의휴식 객실 노천탕 1박 12만원\n"
|
||||||
|
" python main.py https://naver.me/xxxx (링크만 — AI브리핑 자동 키워드화)",
|
||||||
|
)
|
||||||
|
# 마케팅 대상: 자유 키워드(여러 단어/문장) 또는 네이버 링크(섞어 써도 됨)
|
||||||
|
ap.add_argument("keywords", nargs="*",
|
||||||
|
help="마케팅 대상 키워드/문장, 또는 네이버 링크 (--link 없이 링크만 줘도 됨)")
|
||||||
|
# 결과 폴더 라벨
|
||||||
|
ap.add_argument("--label", help="결과 폴더 라벨 (기본: 키워드 앞부분 자동)")
|
||||||
|
# 생성 옵션
|
||||||
|
ap.add_argument("--king", help="특정 인물 고정 (예: 조조, 기본: 랜덤)")
|
||||||
|
ap.add_argument("--scenes", type=int, default=12, help="장면 수=이미지 수 (기본 12)")
|
||||||
|
ap.add_argument("--seconds", type=int, default=45, help="목표 길이(초) (기본 45)")
|
||||||
|
ap.add_argument("--voice", default=MS.TTS_VOICE, help=f"TTS 목소리 (기본 {MS.TTS_VOICE})")
|
||||||
|
ap.add_argument("--keep", action="store_true", help="중간 파일(assets) 보존")
|
||||||
|
ap.add_argument("--csv", help="썰 CSV 경로 (기본: output/ 최신)")
|
||||||
|
# 실제 사진(가게/메뉴판) 섞기 + 마지막 링크 카드
|
||||||
|
ap.add_argument("--images", default="load_image",
|
||||||
|
help="실제 사진 폴더. 가게 이름만 줘도 load_image/<가게>/ 를 찾음 "
|
||||||
|
"(예: --images 연산골막국수). 비었으면 전부 AI")
|
||||||
|
ap.add_argument("--max-real", type=int, default=0,
|
||||||
|
help="실제 사진으로 채울 최대 장면 수 (기본 0=폴더 사진 전부, 장면 수 한도 내)")
|
||||||
|
ap.add_argument("--real-scenes",
|
||||||
|
help="실제 사진 깔 장면 번호 1-based, 콤마구분 (예: 5,6,7). 주면 내용매칭 대신 수동")
|
||||||
|
ap.add_argument("--no-match", action="store_true",
|
||||||
|
help="실제 사진 내용 자동매칭(Gemini 비전) 끄고 위치 기반(뒤쪽 장면)으로")
|
||||||
|
ap.add_argument("--photos", type=int, default=4,
|
||||||
|
help="네이버 사진탭에서 자동 선별할 공식사진 수 (기본 4, --link 줄 때)")
|
||||||
|
ap.add_argument("--no-photos", action="store_true",
|
||||||
|
help="네이버 사진 자동 수집 끄기 (전부 AI 그림으로)")
|
||||||
|
ap.add_argument("--link", help="맨 마지막 화면에 띄울 링크/문구 (예: naver.me/xxxx)")
|
||||||
|
ap.add_argument("--link-seconds", type=float, default=3.0, help="링크 카드 길이(초)")
|
||||||
|
ap.add_argument("--store", help="링크 카드에 함께 띄울 가게 이름 (기본: 키워드 첫 부분 자동)")
|
||||||
|
ap.add_argument("--cta", default=MS.LINK_CTA, help=f"링크 카드 안내문구 (기본: '{MS.LINK_CTA}')")
|
||||||
|
ap.add_argument("--img-model", default=MS.IMG_MODEL,
|
||||||
|
help=f"이미지 생성 모델 (기본 {MS.IMG_MODEL}). 글자 또렷하게: gemini-3.1-flash-image / gemini-3-pro-image")
|
||||||
|
ap.add_argument("--allow-text", action="store_true",
|
||||||
|
help="그림 안 글자 금지 해제 (신형 모델로 간판에 진짜 이름 넣고 싶을 때)")
|
||||||
|
# 컷편집 느낌 (말 빠르게 + 컷 사이 침묵 제거 → 분량 ↓)
|
||||||
|
ap.add_argument("--speed", type=float, default=1.3,
|
||||||
|
help="말 속도 배율 (기본 1.3=30%% 빠르게, 음정 유지). 1.0=원본")
|
||||||
|
ap.add_argument("--no-trim", action="store_true",
|
||||||
|
help="컷 앞뒤 침묵 제거 끄기 (기본은 제거해 컷을 타이트하게)")
|
||||||
|
ap.add_argument("--tail", type=float, default=0.0,
|
||||||
|
help="각 컷 말 끝난 뒤 여운(초). 기본 0=말 끝나자마자 다음 컷. 여유 주려면 0.1~0.2")
|
||||||
|
ap.add_argument("--workers", type=int, default=4,
|
||||||
|
help="이미지·음성 동시 생성 수 (기본 4). 높이면 빠르지만 API 속도제한 주의")
|
||||||
|
ap.add_argument("--bgm",
|
||||||
|
help="배경음악(병맛 BGM) 파일 또는 폴더. 폴더면 무작위 1곡. "
|
||||||
|
"기본: bgm/ 폴더에 곡이 있으면 자동으로 무작위 사용. "
|
||||||
|
"특정 곡 고정하려면 --bgm 파일명. BGM 끄려면 --bgm none")
|
||||||
|
ap.add_argument("--bgm-volume", type=float, default=0.3,
|
||||||
|
help="BGM 볼륨 0~1 (기본 0.3). 나레이션 안 묻히게 조절")
|
||||||
|
ap.add_argument("--api-key")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
# --link 안 써도 됨: 위치 인자에 네이버 링크가 섞여 있으면 그걸 링크로 빼낸다
|
||||||
|
# (narration/kakao 처럼 `python main.py https://naver.me/xxxx` 만으로 실행 가능)
|
||||||
|
if not args.link:
|
||||||
|
link_tok = next((k for k in args.keywords if NV.looks_like_naver_place(k)), None)
|
||||||
|
if link_tok:
|
||||||
|
args.link = link_tok
|
||||||
|
args.keywords = [k for k in args.keywords if k != link_tok]
|
||||||
|
|
||||||
|
product = " ".join(args.keywords).strip()
|
||||||
|
# 키워드를 안 줬는데 --link 가 네이버 플레이스면 → AI 브리핑을 긁어와 마케팅 키워드로 사용
|
||||||
|
brief_store = None
|
||||||
|
if not product and NV.looks_like_naver_place(args.link or ""):
|
||||||
|
print("■ 키워드 없음 → 네이버 AI 브리핑 자동 수집...")
|
||||||
|
try:
|
||||||
|
brief_store, bullets, _cat = NV.fetch_briefing(args.link) # 업종(cat)은 animation 미사용
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] AI 브리핑 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if not bullets:
|
||||||
|
# AI 브리핑이 없으면 → 방문자 리뷰를 '더보기'까지 펼쳐 긁은 뒤 Gemini 로 요약(브리핑 대체)
|
||||||
|
print(f"■ '{brief_store or '이 가게'}' AI 브리핑 없음 → 방문자 리뷰 크롤링 후 요약...")
|
||||||
|
_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not _key:
|
||||||
|
sys.exit("[!] 리뷰 요약에 API 키 필요. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
try:
|
||||||
|
rstore, reviews = NV.fetch_reviews(args.link, max_reviews=30)
|
||||||
|
except Exception as e:
|
||||||
|
sys.exit(f"[!] 리뷰 수집 실패: {type(e).__name__}: {e}")
|
||||||
|
if rstore and not brief_store:
|
||||||
|
brief_store = rstore
|
||||||
|
bullets = NV.summarize_reviews(genai.Client(api_key=_key), brief_store, reviews)
|
||||||
|
if not bullets:
|
||||||
|
sys.exit(f"[!] '{brief_store or '이 가게'}'는 AI 브리핑도 리뷰도 못 얻었어요. "
|
||||||
|
"키워드를 직접 주거나 다른 링크를 쓰세요.")
|
||||||
|
print(f"■ 리뷰 {len(reviews)}개 수집 → 요약 {len(bullets)}줄 (브리핑 대체)")
|
||||||
|
product = " ".join(bullets)
|
||||||
|
print(f"■ 가게: {brief_store or '?'} / 소재 {len(bullets)}줄 → 키워드로 사용")
|
||||||
|
for b in bullets:
|
||||||
|
print(f" • {b}")
|
||||||
|
if not product:
|
||||||
|
sys.exit("[!] 마케팅 키워드가 필요해. 예: python auto_short.py 감성 펜션 ... "
|
||||||
|
"또는 --link 에 네이버 가게 링크(AI 브리핑 자동 수집).")
|
||||||
|
|
||||||
|
api_key = args.api_key or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
sys.exit("[!] API 키 없음. setx GEMINI_API_KEY \"키\" 후 새 터미널, 또는 --api-key.")
|
||||||
|
|
||||||
|
csv_path = Path(args.csv) if args.csv else gen.latest_csv()
|
||||||
|
rows = gen.load_rows(csv_path)
|
||||||
|
|
||||||
|
client = genai.Client(api_key=api_key)
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
# 네이버 링크 주고 사진 폴더를 직접 안 줬으면 → 사진탭 공식사진을 자동 수집·선별
|
||||||
|
if (not args.no_photos and NV.looks_like_naver_place(args.link or "")
|
||||||
|
and args.images == "load_image"):
|
||||||
|
store_for_dir = (brief_store or args.store or args.label
|
||||||
|
or " ".join(args.keywords[:2]) or "naver_store")
|
||||||
|
photo_dir = HERE / "load_image" / safe_name(store_for_dir)
|
||||||
|
existing = [p for p in photo_dir.glob("*.jpg")] if photo_dir.exists() else []
|
||||||
|
if existing:
|
||||||
|
print(f"■ 사진 캐시 재사용: {photo_dir} ({len(existing)}장)")
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
else:
|
||||||
|
print(f"■ 네이버 사진탭 → 공식사진 {args.photos}장 자동 수집·선별...")
|
||||||
|
try:
|
||||||
|
_, finals = NV.fetch_and_select(args.link, photo_dir,
|
||||||
|
n=args.photos, client=client)
|
||||||
|
if finals:
|
||||||
|
args.images = safe_name(store_for_dir)
|
||||||
|
print(f"■ 사진 {len(finals)}장 준비 → {photo_dir}")
|
||||||
|
else:
|
||||||
|
print("■ 공식사진을 못 받았어요 → 전부 AI 그림으로 진행")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"■ 사진 수집 실패({type(e).__name__}) → 전부 AI 그림으로 진행")
|
||||||
|
|
||||||
|
# 이 작업 전용 폴더 (모든 산출물은 여기 안에만)
|
||||||
|
label = args.label or " ".join(args.keywords[:3]) or brief_store or product[:20]
|
||||||
|
stem = f"{safe_name(label)}_{ts}"
|
||||||
|
job_dir = OUT_DIR / stem
|
||||||
|
job_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) 랜덤(또는 지정) 인물 썰 매칭
|
||||||
|
seed = gen.pick_seed(rows, king=args.king)
|
||||||
|
print(f"■ 작업 폴더 : {job_dir}")
|
||||||
|
print(f"■ 마케팅 대상 : {product}")
|
||||||
|
print(f"■ 매칭된 썰 : {seed['king']} / \"{seed['keyword']}\"\n")
|
||||||
|
|
||||||
|
# 2) 병맛 나레이션
|
||||||
|
print("[1/4] 나레이션 생성...")
|
||||||
|
store_name = brief_store or getattr(args, "store", None) or label
|
||||||
|
narr = gen.generate(client, TEXT_MODEL, seed, product, args.seconds, store=store_name)
|
||||||
|
# 제목엔 해시태그가 들어갈 이유가 없다 — LLM 이 끼워넣은 '#태그' 토큰 제거(태그는 hashtags 필드에만).
|
||||||
|
narr["title"] = re.sub(r"\s*#\S+", "", narr.get("title", "")).strip()
|
||||||
|
(job_dir / "narration.json").write_text(
|
||||||
|
json.dumps(narr, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
# 사람이 바로 읽는 나레이션 '가사' 텍스트 (제목 + 본문 + 해시태그)
|
||||||
|
lyric = (f"[제목] {narr.get('title','')}\n"
|
||||||
|
f"[소재] {seed['king']} / \"{seed['keyword']}\"\n"
|
||||||
|
f"[마케팅] {product}\n"
|
||||||
|
f"{'-'*40}\n"
|
||||||
|
f"{narr.get('full_narration','')}\n")
|
||||||
|
if narr.get("hashtags"):
|
||||||
|
lyric += f"\n{' '.join(narr['hashtags'])}\n"
|
||||||
|
(job_dir / "narration.txt").write_text(lyric, encoding="utf-8")
|
||||||
|
# SNS 업로드용 캡션(이모지·해시태그 포함) — content_sync/finalize 가 읽어 Content.caption 에 저장.
|
||||||
|
caption = (narr.get("sns_caption") or "").strip()
|
||||||
|
if caption:
|
||||||
|
(job_dir / "caption.txt").write_text(caption, encoding="utf-8")
|
||||||
|
print(f" \"{narr['title']}\" ({len(narr['full_narration'])}자)")
|
||||||
|
|
||||||
|
# 3) 스토리보드(장면 분할)
|
||||||
|
print("[2/4] 스토리보드 분할...")
|
||||||
|
sb = gen.make_storyboard(client, TEXT_MODEL, narr["full_narration"],
|
||||||
|
product, seed["king"], scenes=args.scenes)
|
||||||
|
(job_dir / "storyboard.json").write_text(
|
||||||
|
json.dumps(sb, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
print(f" 장면 {len(sb['scenes'])}개")
|
||||||
|
|
||||||
|
# 4) 이미지 + 음성 + 영상 합성 (assets·mp4 모두 job_dir 안)
|
||||||
|
# 실제 사진(가게/메뉴판)을 내용 맞는 장면에 AI 배경과 합성, 마지막에 링크 카드 추가
|
||||||
|
real_images, real_scenes = MS.resolve_real_images(args.images, args.real_scenes)
|
||||||
|
if not real_images:
|
||||||
|
print(f"■ 실제 사진 : 없음 ('{args.images}' 폴더 비었거나 없음) → 전부 AI")
|
||||||
|
# --bgm 안 줘도 bgm/ 폴더에 곡이 있으면 자동으로 무작위 1곡 사용
|
||||||
|
bgm = MS.resolve_bgm(args.bgm or "bgm")
|
||||||
|
_off = str(args.bgm or "").strip().lower() in ("none", "off", "no")
|
||||||
|
if args.bgm and not _off and not bgm:
|
||||||
|
print(f"■ BGM '{args.bgm}' 를 못 찾음 → BGM 없이 진행")
|
||||||
|
elif bgm:
|
||||||
|
print(f"■ BGM : {bgm.name} (bgm/ 무작위)")
|
||||||
|
print("[3/4] 이미지·음성 생성...")
|
||||||
|
store = args.store or brief_store or product.split(",")[0].strip() or None
|
||||||
|
out_mp4 = MS.produce(client, sb, stem, workdir=job_dir, voice=args.voice, force=False,
|
||||||
|
real_images=real_images, real_scenes=real_scenes,
|
||||||
|
max_real=args.max_real, match_content=not args.no_match,
|
||||||
|
link=args.link, link_seconds=args.link_seconds, store=store, cta=args.cta,
|
||||||
|
img_model=args.img_model, allow_text=args.allow_text,
|
||||||
|
speed=args.speed, trim_silence=not args.no_trim, tail=args.tail,
|
||||||
|
workers=args.workers, bgm=bgm, bgm_volume=args.bgm_volume)
|
||||||
|
|
||||||
|
# 5) 중간 파일 정리: assets/ + 기계용 json 삭제. 결과 mp4 와 narration.txt(제목/해시태그)만 남김.
|
||||||
|
if not args.keep and out_mp4.exists() and out_mp4.stat().st_size > 0:
|
||||||
|
shutil.rmtree(job_dir / "assets", ignore_errors=True)
|
||||||
|
for junk in ("narration.json", "storyboard.json"):
|
||||||
|
(job_dir / junk).unlink(missing_ok=True)
|
||||||
|
print(" 중간 파일(assets·json) 삭제 완료 — mp4 + narration.txt 만 보존")
|
||||||
|
|
||||||
|
print(f"\n✅ 완성: {out_mp4}")
|
||||||
|
print(f" {seed['king']} 썰 · {product}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
970
generator/animation_samgukji/render.py
Normal file
970
generator/animation_samgukji/render.py
Normal file
@ -0,0 +1,970 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
스토리보드 → 완성 쇼츠 영상(mp4) 조립 [A방식: 정지 일러스트 + 모션]
|
||||||
|
=====================================================================
|
||||||
|
|
||||||
|
gen_storyboard.py 가 만든 스토리보드를 받아 장면별로:
|
||||||
|
1) Gemini 이미지로 일러스트 생성 (캐릭터 일관성 = 1번 컷을 레퍼런스로 물림)
|
||||||
|
2) Gemini TTS 로 그 장면 나레이션 음성 생성 (장면 길이 = 음성 길이)
|
||||||
|
3) ffmpeg 로 Ken Burns(zoompan 줌) + 자막(PNG 오버레이) + 음성 합성 → 세로 1080x1920 mp4
|
||||||
|
(Intel QSV 하드웨어 인코딩 자동 사용, 없으면 libx264 ultrafast)
|
||||||
|
|
||||||
|
이미지/음성은 output/ 에 장면별로 캐싱한다. 재실행 시 이미 있으면 재사용(재과금 X).
|
||||||
|
다시 그리고 싶으면 --force.
|
||||||
|
|
||||||
|
설치: pip install google-genai pillow imageio-ffmpeg (moviepy 불필요 — ffmpeg 직접 렌더)
|
||||||
|
실행:
|
||||||
|
python make_short.py # 최신 *_storyboard.json
|
||||||
|
python make_short.py --storyboard output/xxx_storyboard.json --voice Puck
|
||||||
|
python make_short.py --force # 이미지/음성 새로 생성
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
import media as MU
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
OUT_DIR = HERE.parent / "output" / "animation_samgukji" # 통합 출력: shorts_all/output/animation_samgukji/
|
||||||
|
W, H = 1080, 1920 # 세로 쇼츠
|
||||||
|
BAND_RATIO = 0.18 # 상단 흰 자막 밴드 높이 비율(그림은 그 아래, 안 가림)
|
||||||
|
NARR_SIZE = 48 # 나레이션 자막 기본 글자 크기(밴드에 안 들어가면 자동 축소)
|
||||||
|
NARR_MIN_SIZE = 30 # 자동 축소 하한(이보다 작아지진 않음)
|
||||||
|
BRAND_TITLE = "썰박스" # 상단 밴드 제목(옆에 '×' + AI O2O 로고 결합)
|
||||||
|
BRAND_LOGO = str(Path(__file__).parent / "fonts" / "brand_logo.png") # 제목 옆 AI O2O 로고(흰배경·검정)
|
||||||
|
IMG_MODEL = "gemini-2.5-flash-image"
|
||||||
|
VISION_MODEL = "gemini-2.5-flash" # 실제 사진 내용 분석 + 장면 매칭용
|
||||||
|
TTS_MODEL = "gemini-2.5-pro-preview-tts" # 최상위 TTS(발음 품질 우선). 저렴하게: gemini-3.1-flash-tts-preview
|
||||||
|
TTS_VOICE = "Puck"
|
||||||
|
TTS_STYLE = ("다음 한 문장을 한국 숏폼 병맛 나레이션처럼 신나고 빠르게, "
|
||||||
|
"능청스럽게 약간 오버해서 읽어줘:")
|
||||||
|
# 한글 자막용 폰트 — 둥글둥글 B급 느낌의 '주아체'. per-engine fonts/ 우선, 없으면 상위 공유(generator/fonts).
|
||||||
|
def _find_font(name):
|
||||||
|
for c in (HERE / "fonts" / name, HERE.parent / "fonts" / name):
|
||||||
|
if c.exists():
|
||||||
|
return str(c)
|
||||||
|
return str(HERE / "fonts" / name)
|
||||||
|
FONT = _find_font("Jua-Regular.ttf")
|
||||||
|
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||||
|
# 상단 제목('썰록') 전용 폰트 — 조선 느낌 명조(송명체). 자막과 일부러 다르게.
|
||||||
|
TITLE_FONT = _find_font("SongMyung-Regular.ttf")
|
||||||
|
TITLE_SIZE = 58
|
||||||
|
|
||||||
|
# AI 이미지 모델은 글자(특히 한글)를 제대로 못 그려 간판/메뉴 글씨가 깨진다.
|
||||||
|
# → 모든 이미지 생성에서 '글자 자체를 그리지 말라'고 강하게 지시. (가게 이름은 자막/카드로 표기)
|
||||||
|
NO_TEXT = (" CRITICAL: Render NO text of any kind in the image — no letters, words, numbers, "
|
||||||
|
"Korean hangul, logos, or signage text. Keep every sign, banner, board and menu "
|
||||||
|
"completely BLANK with no writing on them.")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 데이터
|
||||||
|
IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_real_images(images_dir, real_scenes_arg):
|
||||||
|
"""--images 값 → 정렬된 실제 사진 경로 리스트, --real-scenes → 정수 리스트.
|
||||||
|
|
||||||
|
images_dir 해석 순서 (가게마다 load_image/<가게>/ 로 나눠 담는 걸 지원):
|
||||||
|
1) 절대경로면 그대로
|
||||||
|
2) 상대경로/이름이면 HERE/<값> (예: load_image/연산골막국수)
|
||||||
|
3) 그래도 없으면 HERE/load_image/<값> (예: --images 연산골막국수)
|
||||||
|
가장 먼저 '이미지가 들어있는' 폴더를 채택. 없으면 빈 리스트.
|
||||||
|
"""
|
||||||
|
real_images, used = [], None
|
||||||
|
if images_dir:
|
||||||
|
d = Path(images_dir)
|
||||||
|
candidates = [d] if d.is_absolute() else [HERE / d, HERE / "load_image" / d]
|
||||||
|
for c in candidates:
|
||||||
|
if c.exists() and c.is_dir():
|
||||||
|
imgs = sorted(p for p in c.iterdir() if p.suffix.lower() in IMG_EXTS)
|
||||||
|
if imgs:
|
||||||
|
real_images, used = imgs, c
|
||||||
|
break
|
||||||
|
if used:
|
||||||
|
print(f"■ 실제 사진 폴더: {used} ({len(real_images)}장)")
|
||||||
|
real_scenes = None
|
||||||
|
if real_scenes_arg:
|
||||||
|
real_scenes = [int(x) for x in re.split(r"[,\s]+", str(real_scenes_arg).strip()) if x]
|
||||||
|
return real_images, real_scenes
|
||||||
|
|
||||||
|
|
||||||
|
# 실제 사진 카드를 합성하는 장면에서, AI 배경이 카드 자리를 비워두도록 주는 포즈 힌트
|
||||||
|
PRESENT_POSES = [
|
||||||
|
"Place the cat king in the UPPER part of the frame, smiling and pointing down toward the "
|
||||||
|
"lower-center, leaving the lower-center area visually simple (a photo will be placed there).",
|
||||||
|
"Put the cat king on the LEFT side presenting with one paw to the right-center, keeping the "
|
||||||
|
"right-center area simple and uncluttered (a photo will be placed there).",
|
||||||
|
"Show the cat king at the BOTTOM looking up with sparkling eyes toward the upper-center, "
|
||||||
|
"keeping the upper-center area simple (a photo will be placed there).",
|
||||||
|
]
|
||||||
|
# PRESENT_POSES 순서에 맞춰 실제 사진 카드를 놓을 (가로,세로) 중심 비율
|
||||||
|
PRESENT_SPOTS = [(0.50, 0.62), (0.62, 0.50), (0.50, 0.40)]
|
||||||
|
|
||||||
|
# 고양이 왕의 '눈' 그림체를 귀엽게 유지하되, 눈을 '떴을 때'의 모양만 고정한다.
|
||||||
|
# (모든 장면을 동그란 뜬눈으로 강제하면 감김/실눈/윙크 같은 표정 다양성이 사라짐)
|
||||||
|
EYE_STYLE = (
|
||||||
|
"When the cat's eyes are open, they are the same cute friendly eyes: large round eyes "
|
||||||
|
"with big round solid black pupils and a small soft white highlight, gentle innocent "
|
||||||
|
"look. Keep this cute rounded eye style; never sharp, angry, fierce, glaring or "
|
||||||
|
"realistic predatory eyes."
|
||||||
|
)
|
||||||
|
|
||||||
|
# 눈을 감거나(감김/실눈/윙크/졸림/웃음·울음으로 눈이 변하는) 장면용: 표정은 장면대로 두되 화풍만 유지.
|
||||||
|
EYE_FREE = (
|
||||||
|
"In this scene the cat's eye EXPRESSION follows the moment — the eyes may be gently "
|
||||||
|
"closed, squinting, blinking, winking, teary or sleepy half-closed as appropriate. "
|
||||||
|
"Keep the same cute, soft, rounded drawing style (never sharp, angry or realistic), "
|
||||||
|
"but do NOT force wide-open round eyes here."
|
||||||
|
)
|
||||||
|
|
||||||
|
# image_prompt(영어)에 아래 표현이 있으면 '눈 표정 장면'으로 보고 뜬눈 고정/레퍼런스를 푼다.
|
||||||
|
_EYE_EXPR_KW = (
|
||||||
|
"closed eye", "eyes closed", "close its eye", "close his eye", "shut eye",
|
||||||
|
"shut its eye", "squint", "narrowed eye", "wink", "half-closed", "half closed",
|
||||||
|
"sleepy", "sleeping", "asleep", "dozing", "doze", "napping", "yawn", "drowsy",
|
||||||
|
"crying", "sob", "weeping", "tears", "teary", "laughing", "giggl", "chuckl",
|
||||||
|
"wince", "grimac", "blink",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def scene_needs_eye_expression(sc) -> bool:
|
||||||
|
"""장면이 눈 감김/실눈/윙크 등 '뜬눈 아닌' 표정을 필요로 하면 True."""
|
||||||
|
text = ((sc.get("image_prompt") or "") + " " + (sc.get("caption") or "")).lower()
|
||||||
|
return any(k in text for k in _EYE_EXPR_KW)
|
||||||
|
|
||||||
|
# 캐릭터 공식 레퍼런스: 이 폴더에 이미지를 넣어두면(예: king_ref.png),
|
||||||
|
# 모든 영상의 모든 컷이 그 고양이 왕의 얼굴/눈/복장/화풍을 그대로 따라간다.
|
||||||
|
CHAR_REF_DIR = HERE / "character"
|
||||||
|
|
||||||
|
|
||||||
|
def load_char_ref():
|
||||||
|
"""Shorts/character/ 안의 첫 이미지 바이트 반환(없으면 None). 모든 영상의 캐릭터 앵커로 쓰인다."""
|
||||||
|
if not CHAR_REF_DIR.is_dir():
|
||||||
|
return None
|
||||||
|
for p in sorted(CHAR_REF_DIR.iterdir()):
|
||||||
|
if p.suffix.lower() in IMG_EXTS:
|
||||||
|
return p.read_bytes()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 이미지
|
||||||
|
def gen_image(client, prompt, ref_bytes=None, model=None, no_text=True,
|
||||||
|
ref_mode="full") -> bytes:
|
||||||
|
"""Gemini 이미지 생성. ref_bytes 주면 캐릭터 일관성 위해 레퍼런스로 사용.
|
||||||
|
|
||||||
|
model: 사용할 이미지 모델 (없으면 IMG_MODEL). 신형일수록 글자 렌더링 우수.
|
||||||
|
no_text: True 면 '그림에 글자 금지' 지시를 붙인다(글자 깨짐 방지).
|
||||||
|
ref_mode: 'full' = 레퍼런스의 캐릭터/복장/화풍을 통째로 유지.
|
||||||
|
'eyes' = 레퍼런스에선 '눈/표정만' 복제하고 복장·장면은 프롬프트대로.
|
||||||
|
"""
|
||||||
|
parts = []
|
||||||
|
if ref_bytes:
|
||||||
|
parts.append(types.Part.from_bytes(data=ref_bytes, mime_type="image/png"))
|
||||||
|
if ref_mode == "eyes":
|
||||||
|
prompt = ("Use the reference image ONLY to copy the cat character's EYES and "
|
||||||
|
"facial expression exactly: the same large round eyes with big round "
|
||||||
|
"solid black pupils and a small soft white highlight, gentle innocent "
|
||||||
|
"look. Do NOT copy the outfit, hat, robe color, pose, framing or "
|
||||||
|
"background from the reference — those follow the scene description. "
|
||||||
|
"New scene: " + prompt)
|
||||||
|
else:
|
||||||
|
prompt = ("Keep the EXACT same character design, outfit and art style as the "
|
||||||
|
"reference image. New scene: " + prompt)
|
||||||
|
parts.append(types.Part.from_text(text=prompt + (NO_TEXT if no_text else "")))
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model or IMG_MODEL,
|
||||||
|
contents=parts,
|
||||||
|
config=types.GenerateContentConfig(response_modalities=["IMAGE"]),
|
||||||
|
)
|
||||||
|
for p in resp.candidates[0].content.parts:
|
||||||
|
if getattr(p, "inline_data", None):
|
||||||
|
return p.inline_data.data
|
||||||
|
raise RuntimeError("이미지 파트 없음")
|
||||||
|
|
||||||
|
|
||||||
|
def composite_card(base_path, photo_path, out_path,
|
||||||
|
rel_w=0.60, center=(0.5, 0.60), angle=4.0, border=22):
|
||||||
|
"""AI 배경(base) 위에 '실제 사진'을 작은 폴라로이드 카드로 얹어 합성 저장.
|
||||||
|
|
||||||
|
사진은 원본 그대로(왜곡 X) 축소만 하고, 흰 테두리+그림자+살짝 기울기로 자연스럽게 올린다.
|
||||||
|
rel_w: 카드 사진 가로 = 화면폭 * rel_w. center: 카드 중심 (가로,세로) 비율. angle: 기울기(도).
|
||||||
|
"""
|
||||||
|
from PIL import Image, ImageFilter
|
||||||
|
|
||||||
|
base = Image.open(base_path).convert("RGBA")
|
||||||
|
photo = Image.open(photo_path).convert("RGB")
|
||||||
|
|
||||||
|
cw = int(W * rel_w)
|
||||||
|
ph = int(photo.height * (cw / photo.width))
|
||||||
|
max_h = int(H * 0.40) # 너무 길면 높이 기준으로 제한
|
||||||
|
if ph > max_h:
|
||||||
|
ph, cw = max_h, int(photo.width * (max_h / photo.height))
|
||||||
|
photo = photo.resize((cw, ph), Image.LANCZOS)
|
||||||
|
|
||||||
|
# 폴라로이드 흰 카드 (아래쪽 여백 좀 더)
|
||||||
|
card = Image.new("RGBA", (cw + 2 * border, ph + 2 * border + int(border * 1.4)),
|
||||||
|
(255, 255, 255, 255))
|
||||||
|
card.paste(photo, (border, border))
|
||||||
|
card = card.rotate(angle, expand=True, resample=Image.BICUBIC)
|
||||||
|
|
||||||
|
cx, cy = int(W * center[0]), int(H * center[1])
|
||||||
|
x, y = cx - card.width // 2, cy - card.height // 2
|
||||||
|
|
||||||
|
# 그림자
|
||||||
|
alpha = card.split()[3]
|
||||||
|
shadow = Image.new("RGBA", card.size, (0, 0, 0, 0))
|
||||||
|
shadow.paste(Image.new("RGBA", card.size, (0, 0, 0, 150)), (0, 0), alpha)
|
||||||
|
shadow = shadow.filter(ImageFilter.GaussianBlur(18))
|
||||||
|
base.alpha_composite(shadow, (x + 10, y + 16))
|
||||||
|
base.alpha_composite(card, (x, y))
|
||||||
|
base.convert("RGB").save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
def cover_crop(src: Path, dst: Path):
|
||||||
|
"""이미지를 1080x1920 꽉 차게 cover-crop 해서 저장."""
|
||||||
|
im = Image.open(src).convert("RGB")
|
||||||
|
scale = max(W / im.width, H / im.height)
|
||||||
|
nw, nh = int(im.width * scale + 0.5), int(im.height * scale + 0.5)
|
||||||
|
im = im.resize((nw, nh), Image.LANCZOS)
|
||||||
|
left, top = (nw - W) // 2, (nh - H) // 2
|
||||||
|
im.crop((left, top, left + W, top + H)).save(dst)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 음성
|
||||||
|
def _extract_audio(resp):
|
||||||
|
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
|
||||||
|
cands = getattr(resp, "candidates", None) or []
|
||||||
|
for c in cands:
|
||||||
|
content = getattr(c, "content", None)
|
||||||
|
parts = getattr(content, "parts", None) or [] if content else []
|
||||||
|
for p in parts:
|
||||||
|
inline = getattr(p, "inline_data", None)
|
||||||
|
if inline and inline.data:
|
||||||
|
m = re.search(r"rate=(\d+)", inline.mime_type or "")
|
||||||
|
return inline.data, (int(m.group(1)) if m else 24000)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gen_tts(client, text, voice, retries=4) -> bytes:
|
||||||
|
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
|
||||||
|
last = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=TTS_MODEL,
|
||||||
|
contents=f"{TTS_STYLE}\n\n{text}",
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["AUDIO"],
|
||||||
|
speech_config=types.SpeechConfig(
|
||||||
|
voice_config=types.VoiceConfig(
|
||||||
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
got = _extract_audio(resp)
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
|
||||||
|
fr = ""
|
||||||
|
try:
|
||||||
|
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
|
||||||
|
except Exception:
|
||||||
|
fr = "no-candidates"
|
||||||
|
last = f"빈 응답(finish_reason={fr or '?'})"
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{type(e).__name__}: {e}"
|
||||||
|
if attempt < retries:
|
||||||
|
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
|
||||||
|
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
|
||||||
|
end="", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}…")
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_to_wav(pcm, path, rate):
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
|
||||||
|
w.writeframes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def wav_seconds(path: Path) -> float:
|
||||||
|
with wave.open(str(path), "rb") as w:
|
||||||
|
return w.getnframes() / w.getframerate()
|
||||||
|
|
||||||
|
|
||||||
|
def _atempo_chain(speed: float) -> list:
|
||||||
|
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
|
||||||
|
chain, s = [], speed
|
||||||
|
while s > 2.0:
|
||||||
|
chain.append("atempo=2.0"); s /= 2.0
|
||||||
|
while s < 0.5:
|
||||||
|
chain.append("atempo=0.5"); s *= 2.0
|
||||||
|
chain.append(f"atempo={s:.4f}")
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
|
||||||
|
threshold="-38dB", keep=0.06):
|
||||||
|
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
|
||||||
|
|
||||||
|
- trim_silence: 앞/뒤 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
|
||||||
|
silenceremove 로 앞을 자르고, areverse 로 뒤집어 다시 앞(=원래 뒤)을 자른 뒤 복원.
|
||||||
|
keep: 잘라낸 끝에 남길 여유(초). 0 이면 너무 칼같이 잘려 어색할 수 있어 약간 남긴다.
|
||||||
|
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
|
||||||
|
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
|
||||||
|
"""
|
||||||
|
af = []
|
||||||
|
if trim_silence:
|
||||||
|
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
|
||||||
|
f"start_threshold={threshold}:detection=peak")
|
||||||
|
af += [one, "areverse", one, "areverse"]
|
||||||
|
if speed and abs(speed - 1.0) > 1e-3:
|
||||||
|
af += _atempo_chain(speed)
|
||||||
|
if not af:
|
||||||
|
shutil.copyfile(src_wav, dst_wav); return
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
|
||||||
|
"-ar", "24000", "-ac", "1", str(dst_wav)])
|
||||||
|
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
|
||||||
|
raise RuntimeError("가공 후 음성이 너무 짧음")
|
||||||
|
except (RuntimeError, OSError):
|
||||||
|
shutil.copyfile(src_wav, dst_wav)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 조립 (ffmpeg 직접 렌더)
|
||||||
|
FPS = 30
|
||||||
|
|
||||||
|
|
||||||
|
def _ffmpeg() -> str:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd):
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def pick_encoder(exe) -> list:
|
||||||
|
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
|
||||||
|
test = subprocess.run(
|
||||||
|
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
|
||||||
|
"-c:v", "h264_qsv", "-f", "null", "-"],
|
||||||
|
capture_output=True, text=True, errors="replace")
|
||||||
|
if test.returncode == 0:
|
||||||
|
print(" 인코더: h264_qsv (Intel 하드웨어)")
|
||||||
|
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
|
||||||
|
print(" 인코더: libx264 (superfast)")
|
||||||
|
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
|
||||||
|
|
||||||
|
|
||||||
|
def _wrap_lines(draw, text, font, max_w):
|
||||||
|
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
|
||||||
|
lines, cur = [], ""
|
||||||
|
for ch in text:
|
||||||
|
if ch == "\n":
|
||||||
|
lines.append(cur); cur = ""; continue
|
||||||
|
if draw.textlength(cur + ch, font=font) <= max_w:
|
||||||
|
cur += ch
|
||||||
|
else:
|
||||||
|
lines.append(cur); cur = ch
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
return lines or [""]
|
||||||
|
|
||||||
|
|
||||||
|
def _blossom(d, cx, cy, r, color):
|
||||||
|
"""간단한 5장 꽃잎 벚꽃 장식(제목 양옆 무늬)."""
|
||||||
|
import math
|
||||||
|
pr = r * 0.62 # 꽃잎 반지름
|
||||||
|
for k in range(5):
|
||||||
|
a = math.radians(-90 + k * 72)
|
||||||
|
px, py = cx + r * 0.55 * math.cos(a), cy + r * 0.55 * math.sin(a)
|
||||||
|
d.ellipse([px - pr, py - pr, px + pr, py + pr], fill=color)
|
||||||
|
d.ellipse([cx - r * 0.32, cy - r * 0.32, cx + r * 0.32, cy + r * 0.32],
|
||||||
|
fill=(255, 238, 175, 255)) # 꽃심
|
||||||
|
|
||||||
|
|
||||||
|
_LOGO_RGBA = None
|
||||||
|
|
||||||
|
|
||||||
|
def _brand_logo():
|
||||||
|
"""제목 옆 AI O2O 로고를 캐시 로드(흰 배경→투명, 검정 톤 통일). 파일 없으면 None."""
|
||||||
|
global _LOGO_RGBA
|
||||||
|
if _LOGO_RGBA is not None:
|
||||||
|
return _LOGO_RGBA
|
||||||
|
if not Path(BRAND_LOGO).exists():
|
||||||
|
return None
|
||||||
|
# 흰 배경 PNG → 밝기를 알파로 반전(검은 획=불투명, 흰 배경=투명). 안티에일리어싱도 자연 반투명.
|
||||||
|
g = Image.open(BRAND_LOGO).convert("L")
|
||||||
|
alpha = g.point(lambda p: 255 - p)
|
||||||
|
logo = Image.new("RGBA", g.size, (30, 30, 30, 255))
|
||||||
|
logo.putalpha(alpha)
|
||||||
|
_LOGO_RGBA = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
|
||||||
|
def render_band_png(text, out_path, font_path, title=BRAND_TITLE):
|
||||||
|
"""상단 흰 밴드에 제목('썰박스' + '×' + AI O2O 로고) + 나레이션(검정 글씨)을 그린 1080x1920 PNG.
|
||||||
|
밴드 아래는 투명 → 그 자리에 그림이 들어가 '그림을 가리지 않는' 만화컷 레이아웃."""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
band_h = int(H * BAND_RATIO)
|
||||||
|
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
|
||||||
|
d = ImageDraw.Draw(img)
|
||||||
|
d.rectangle([0, 0, W, band_h], fill=(255, 255, 255, 255)) # 흰 밴드
|
||||||
|
d.line([(0, band_h - 2), (W, band_h - 2)], fill=(0, 0, 0, 38), width=2) # 옅은 구분선
|
||||||
|
|
||||||
|
# ── 제목: "썰박스" + "×" + AI O2O 로고 를 가로 중앙에 나란히 ──
|
||||||
|
title_y = int(H * 0.012)
|
||||||
|
title_fp = TITLE_FONT if Path(TITLE_FONT).exists() else font_path
|
||||||
|
tf = ImageFont.truetype(title_fp, TITLE_SIZE)
|
||||||
|
cy = title_y + TITLE_SIZE / 2 # 제목 요소 세로 중심
|
||||||
|
ink = (30, 30, 30, 255)
|
||||||
|
sep = "×" # 곱셈기호(×) — 콜라보 표기
|
||||||
|
gap = 26
|
||||||
|
|
||||||
|
w_title = d.textlength(title, font=tf)
|
||||||
|
w_sep = d.textlength(sep, font=tf)
|
||||||
|
|
||||||
|
logo = _brand_logo()
|
||||||
|
logo_h = 0
|
||||||
|
if logo is not None:
|
||||||
|
logo_h = int(TITLE_SIZE * 1.05)
|
||||||
|
logo_w = max(1, int(logo.width * logo_h / logo.height))
|
||||||
|
logo = logo.resize((logo_w, logo_h), Image.LANCZOS)
|
||||||
|
else:
|
||||||
|
logo_w = 0
|
||||||
|
|
||||||
|
total = w_title + gap + w_sep + (gap + logo_w if logo is not None else 0)
|
||||||
|
group_left = (W - total) / 2
|
||||||
|
x = group_left
|
||||||
|
d.text((x, cy), title, font=tf, fill=ink, anchor="lm")
|
||||||
|
x += w_title + gap
|
||||||
|
d.text((x, cy), sep, font=tf, fill=ink, anchor="lm")
|
||||||
|
x += w_sep + gap
|
||||||
|
if logo is not None:
|
||||||
|
img.alpha_composite(logo, (int(x), int(cy - logo_h / 2)))
|
||||||
|
|
||||||
|
# 제목 그룹 양옆 벚꽃(원래 디자인 유지)
|
||||||
|
rose = (228, 150, 168, 255)
|
||||||
|
bgap = 46
|
||||||
|
_blossom(d, group_left - bgap, cy, 17, rose)
|
||||||
|
_blossom(d, group_left + total + bgap, cy, 17, rose)
|
||||||
|
|
||||||
|
rule_y = title_y + TITLE_SIZE + 26 # 제목 아래 구분선: 화면 끝까지, 불투명
|
||||||
|
d.line([(0, rule_y), (W, rule_y)], fill=(60, 60, 60, 255), width=4)
|
||||||
|
|
||||||
|
# 나레이션: 기본은 NARR_SIZE, 밴드 높이를 넘칠 만큼 길면 폰트를 자동 축소해
|
||||||
|
# 항상 흰 밴드 '안'에만 그린다(넘쳐서 아래 그림을 침범하지 않도록). 세로 가운데 정렬.
|
||||||
|
top = rule_y + 22
|
||||||
|
avail = band_h - top - 24
|
||||||
|
size = NARR_SIZE
|
||||||
|
while size > NARR_MIN_SIZE:
|
||||||
|
f = ImageFont.truetype(font_path, size)
|
||||||
|
lines = _wrap_lines(d, text, f, W - 150)
|
||||||
|
lh = int(size * 1.4)
|
||||||
|
if len(lines) * lh <= avail:
|
||||||
|
break
|
||||||
|
size -= 2
|
||||||
|
else:
|
||||||
|
f = ImageFont.truetype(font_path, NARR_MIN_SIZE)
|
||||||
|
lines = _wrap_lines(d, text, f, W - 150)
|
||||||
|
lh = int(NARR_MIN_SIZE * 1.4)
|
||||||
|
y = top + max(0, (avail - len(lines) * lh) // 2)
|
||||||
|
for ln in lines:
|
||||||
|
d.text((W / 2, y), ln, font=f, fill=(20, 20, 20, 255), anchor="ma")
|
||||||
|
y += lh
|
||||||
|
img.save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
LINK_CTA = "링크는 고정댓글 확인" # ▼ 등 특수문자는 자막 폰트(주아체)에 없어 □로 깨짐 → 한글만
|
||||||
|
|
||||||
|
|
||||||
|
def render_link_card_png(base_img_path, link, out_path, font_path, store=None, cta=None):
|
||||||
|
"""배경 사진을 어둡게 깔고 가게 이름 + 안내문구 + 링크를 큼직하게 그린 1080x1920 PNG."""
|
||||||
|
from PIL import ImageDraw, ImageFont
|
||||||
|
base = Image.open(base_img_path).convert("RGB")
|
||||||
|
base = Image.blend(base, Image.new("RGB", base.size, (0, 0, 0)), 0.55)
|
||||||
|
d = ImageDraw.Draw(base)
|
||||||
|
|
||||||
|
def centered(text, y, size, fill, fit_one_line=False):
|
||||||
|
if fit_one_line: # 한 줄에 들어갈 때까지 폰트 축소
|
||||||
|
while size > 40 and d.textlength(text, font=ImageFont.truetype(font_path, size)) > W - 140:
|
||||||
|
size -= 4
|
||||||
|
f = ImageFont.truetype(font_path, size)
|
||||||
|
for ln in _wrap_lines(d, text, f, W - 140):
|
||||||
|
w = d.textlength(ln, font=f)
|
||||||
|
d.text(((W - w) / 2, y), ln, font=f, fill=fill,
|
||||||
|
stroke_width=5, stroke_fill="black")
|
||||||
|
y += int(size * 1.25)
|
||||||
|
|
||||||
|
if store:
|
||||||
|
centered(store, int(H * 0.33), 88, "white", fit_one_line=True)
|
||||||
|
centered(cta or LINK_CTA, int(H * 0.43), 58, "#FFFFFF", fit_one_line=True)
|
||||||
|
centered(link, int(H * 0.51), 72, "#FFE94A", fit_one_line=True)
|
||||||
|
base.save(out_path)
|
||||||
|
|
||||||
|
|
||||||
|
# 모든 세그먼트를 같은 규격으로 만들어 concat copy 가 되게 한다.
|
||||||
|
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- BGM
|
||||||
|
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgm(bgm_arg):
|
||||||
|
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
|
||||||
|
|
||||||
|
해석 순서(가게/분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||||
|
1) 파일이면 그대로
|
||||||
|
2) 폴더면 그 안 음악 중 무작위 1곡 (병맛 BGM 여러 개 넣어두고 매번 다르게)
|
||||||
|
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||||
|
"""
|
||||||
|
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||||
|
return None
|
||||||
|
import random
|
||||||
|
p = Path(bgm_arg)
|
||||||
|
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||||
|
for c in cands:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
if c.is_dir():
|
||||||
|
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
|
||||||
|
if files:
|
||||||
|
return random.choice(files)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
|
||||||
|
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
|
||||||
|
|
||||||
|
- BGM 이 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
|
||||||
|
- 나레이션[0:a] + BGM[1:a]*volume 를 amix(duration=first)로 섞어 영상 길이에 맞춘다.
|
||||||
|
- 영상은 재인코딩하지 않는다(-c:v copy) → 빠름. 시작에 짧은 페이드인.
|
||||||
|
"""
|
||||||
|
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
|
||||||
|
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
||||||
|
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
|
||||||
|
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
||||||
|
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
|
||||||
|
str(out_path)])
|
||||||
|
|
||||||
|
|
||||||
|
# Shared low-level media utilities. Keep local render.py for per-feature
|
||||||
|
# layout, prompts, local fonts, BGM folder resolution, and produce().
|
||||||
|
_ffmpeg = MU._ffmpeg
|
||||||
|
_run = MU._run
|
||||||
|
pick_encoder = MU.pick_encoder
|
||||||
|
pcm_to_wav = MU.pcm_to_wav
|
||||||
|
wav_seconds = MU.wav_seconds
|
||||||
|
process_audio = MU.process_audio
|
||||||
|
_wrap_lines = MU._wrap_lines
|
||||||
|
mix_bgm = MU.mix_bgm
|
||||||
|
|
||||||
|
|
||||||
|
def _scene_motion(idx: int, n: int, frames: int) -> str:
|
||||||
|
"""장면별 zoompan 카메라 모션(z/x/y 표현식만 반환 — d/s/fps 는 호출부에서 붙임).
|
||||||
|
- 1번 컷(훅): 펀치인 — 1.30 에서 확 들어와 시선 고정 후 서서히 줌
|
||||||
|
- 뒤에서 3번째 컷(피벗 추정 — 억지 광고 전환): 셰이크. 장면 역할 태그가
|
||||||
|
스토리보드에 없어 위치 휴리스틱(hook→story→pivot→cta 순서상 cta 직전)
|
||||||
|
- 나머지: 줌인 / 줌아웃 / 좌→우 팬 / 우→좌 팬 순환
|
||||||
|
"""
|
||||||
|
center = "x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'"
|
||||||
|
f = max(frames, 1)
|
||||||
|
if idx == 1: # 훅: 리빌(살짝 당김→전체)
|
||||||
|
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
|
||||||
|
if n >= 6 and idx == n - 2: # 피벗: 아주 약한 흔들림(줌 1.06 유지)
|
||||||
|
return ("zoompan=z='1.06':"
|
||||||
|
"x='iw/2-(iw/zoom/2)+8*sin(on*1.6)':"
|
||||||
|
"y='ih/2-(ih/zoom/2)+6*cos(on*2.2)'")
|
||||||
|
kind = idx % 4
|
||||||
|
if kind == 0: # 줌아웃(끝에 전체)
|
||||||
|
return f"zoompan=z='max(1.10-0.10*on/{f},1.0)':{center}"
|
||||||
|
if kind == 1: # 줌인(시작에 전체)
|
||||||
|
return f"zoompan=z='min(1.0+0.10*on/{f},1.10)':{center}"
|
||||||
|
if kind == 2: # 약한 좌→우 팬(줌 1.06, ~6%만 이동)
|
||||||
|
return f"zoompan=z='1.06':x='(iw-iw/zoom)*on/{f}':y='ih/2-(ih/zoom/2)'"
|
||||||
|
return f"zoompan=z='1.06':x='(iw-iw/zoom)*(1-on/{f})':y='ih/2-(ih/zoom/2)'"
|
||||||
|
|
||||||
|
|
||||||
|
def build_video(scenes, audio_paths, img_paths, out_path, link=None, link_seconds=3.0,
|
||||||
|
store=None, cta=None, speed=1.0, trim_silence=True, tail=0.15,
|
||||||
|
bgm=None, bgm_volume=0.3):
|
||||||
|
"""장면별 ffmpeg 세그먼트(Ken Burns 줌 + 자막 오버레이 + 음성) → concat 으로 최종 mp4.
|
||||||
|
|
||||||
|
speed/trim_silence: 컷편집 느낌 — 음성을 빠르게 + 앞뒤 침묵 제거(→ 컷이 타이트).
|
||||||
|
tail: 각 컷 음성 끝에 붙일 정적(초). 0 이면 말 끝나자마자 바로 다음 컷.
|
||||||
|
bgm: 배경음악 파일 경로. 주면 concat 후 BGM 을 작은 볼륨으로 깔아 한 번 더 믹스.
|
||||||
|
bgm_volume: BGM 볼륨(0~1). 기본 0.12 = 나레이션 안 묻히게 12%.
|
||||||
|
"""
|
||||||
|
print("[4/4] 영상 합성...", flush=True)
|
||||||
|
exe = _ffmpeg()
|
||||||
|
enc = pick_encoder(exe)
|
||||||
|
font = FONT if Path(FONT).exists() else FONT_FALLBACK
|
||||||
|
out_path = Path(out_path)
|
||||||
|
tmp = out_path.parent / "_render"
|
||||||
|
tmp.mkdir(exist_ok=True)
|
||||||
|
segs = []
|
||||||
|
total = 0.0
|
||||||
|
|
||||||
|
for idx, (sc, ap, ip) in enumerate(zip(scenes, audio_paths, img_paths), 1):
|
||||||
|
# 컷편집용으로 음성 가공(빠르게 + 침묵 제거) 후 그 길이를 컷 길이로 사용
|
||||||
|
proc_ap = tmp / f"aud{idx:02d}.wav"
|
||||||
|
process_audio(exe, ap, proc_ap, speed=speed, trim_silence=trim_silence)
|
||||||
|
speech = wav_seconds(proc_ap)
|
||||||
|
dur = speech + max(0.0, tail) # 말 끝나고 약간의 여운만
|
||||||
|
frames = max(1, round(dur * FPS))
|
||||||
|
sub_png = tmp / f"sub{idx:02d}.png"
|
||||||
|
# 화면 자막 = 그 장면에서 '말하는 문장'(나레이션) 그대로 → 무음 시청자도 내용 전달
|
||||||
|
render_band_png(sc.get("narration") or sc.get("caption", ""), sub_png, font)
|
||||||
|
seg = tmp / f"seg{idx:02d}.mp4"
|
||||||
|
# 레이아웃: 흰 배경 위 → 그림은 상단 밴드 '아래' 영역에만(안 가림) → 밴드 PNG 오버레이
|
||||||
|
band_h = int(H * BAND_RATIO)
|
||||||
|
img_h = H - band_h
|
||||||
|
bw, bh = int(W * 3), int(img_h * 3) # zoompan 정수픽셀 반올림 떨림 방지용 업스케일(2배는 떨림 재발, 3배로 메모리·화질 절충)
|
||||||
|
motion = _scene_motion(idx, len(scenes), frames)
|
||||||
|
fd = min(0.18, dur / 4) # 컷 전환 화이트 페이드(짧은 컷은 비례 축소)
|
||||||
|
# 페이드는 '그림'에만 → 캡션(자막)은 그 위에 나중에 얹어 항상 선명하게 유지.
|
||||||
|
# 첫 컷(hook)은 흰 화면 없이 바로 이미지로 시작 → 페이드인은 2번째 컷부터.
|
||||||
|
fades = "" if idx == 1 else f"fade=t=in:st=0:d={fd:.2f}:color=white,"
|
||||||
|
fades += f"fade=t=out:st={max(dur - fd, 0):.2f}:d={fd:.2f}:color=white"
|
||||||
|
fc = (f"color=c=white:s={W}x{H}:r={FPS}[bg];"
|
||||||
|
f"[0:v]scale={bw}:{bh}:force_original_aspect_ratio=increase:flags=lanczos,"
|
||||||
|
f"crop={bw}:{bh},"
|
||||||
|
f"{motion}:d={frames}:s={W}x{img_h}:fps={FPS}[img];"
|
||||||
|
f"[bg][img]overlay=0:{band_h},{fades}[base];"
|
||||||
|
f"[base][1:v]overlay=0:0[v]")
|
||||||
|
# 음성 뒤에 tail 만큼 무음을 붙여 영상 길이와 맞춤(apad + -t 로 컷)
|
||||||
|
_run([exe, "-y", "-loop", "1", "-i", str(ip), "-i", str(sub_png), "-i", str(proc_ap),
|
||||||
|
"-filter_complex", fc, "-map", "[v]", "-map", "2:a",
|
||||||
|
"-af", "apad", "-t", f"{dur:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
|
||||||
|
"-pix_fmt", "yuv420p", str(seg)])
|
||||||
|
segs.append(seg)
|
||||||
|
total += dur
|
||||||
|
print(f"[{idx:2d}] 세그먼트 (말 {speech:.1f}초 → 컷 {dur:.1f}초)")
|
||||||
|
print(f" ▶ 본편 합계 {total:.1f}초" + (f" + 링크 {link_seconds:.1f}초" if link else ""))
|
||||||
|
|
||||||
|
# 링크 카드 (무음)
|
||||||
|
if link and img_paths:
|
||||||
|
card_png = tmp / "linkcard.png"
|
||||||
|
render_link_card_png(img_paths[-1], link, card_png, font, store=store, cta=cta)
|
||||||
|
seg = tmp / "seg_link.mp4"
|
||||||
|
_run([exe, "-y", "-loop", "1", "-i", str(card_png),
|
||||||
|
"-f", "lavfi", "-i", "anullsrc=r=44100:cl=stereo",
|
||||||
|
"-t", f"{link_seconds:.3f}", "-r", str(FPS), *enc, *_AUDIO_ARGS,
|
||||||
|
"-pix_fmt", "yuv420p", str(seg)])
|
||||||
|
segs.append(seg)
|
||||||
|
print(f"[링크] 카드 세그먼트 ({link_seconds:.1f}초)")
|
||||||
|
|
||||||
|
# concat (재인코딩 없이 복사 → 즉시). 실패하면 재인코딩으로 폴백.
|
||||||
|
# BGM 이 있으면 일단 임시 본편으로 이어붙이고, 그 위에 BGM 을 믹스해 최종본을 만든다.
|
||||||
|
listf = tmp / "concat.txt"
|
||||||
|
# concat 목록은 절대경로로 (ffmpeg 가 목록파일 위치 기준으로 상대경로를 또 해석하는 문제 방지)
|
||||||
|
listf.write_text("".join(f"file '{s.resolve().as_posix()}'\n" for s in segs), encoding="utf-8")
|
||||||
|
concat_out = (tmp / "_body.mp4") if bgm else out_path
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
|
||||||
|
"-c", "copy",
|
||||||
|
# AAC priming(1024샘플) 탓에 concat 이 비디오 시작 pts 를 +0.023s 밀어버리면
|
||||||
|
# 페북/인스타(Buffer) 썸네일 추출(t=0)이 빈 구간=검은 화면을 잡는다 → pts 0 으로 재작성.
|
||||||
|
"-bsf:v", "setts=ts=TS-STARTDTS:pts=PTS-STARTPTS",
|
||||||
|
"-movflags", "+faststart", str(concat_out)])
|
||||||
|
except RuntimeError:
|
||||||
|
_run([exe, "-y", "-f", "concat", "-safe", "0", "-i", str(listf),
|
||||||
|
*enc, *_AUDIO_ARGS, "-pix_fmt", "yuv420p", "-movflags", "+faststart",
|
||||||
|
str(concat_out)])
|
||||||
|
|
||||||
|
if bgm:
|
||||||
|
mix_bgm(exe, concat_out, bgm, out_path, volume=bgm_volume)
|
||||||
|
print(f" ♪ BGM 믹스: {Path(bgm).name} (볼륨 {bgm_volume})")
|
||||||
|
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _mime_of(path) -> str:
|
||||||
|
return "image/jpeg" if Path(path).suffix.lower() in (".jpg", ".jpeg") else "image/png"
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_photo(client, photo_path) -> str:
|
||||||
|
"""실제 사진 1장을 Gemini 비전으로 보고 짧은 한국어 라벨 반환 (예: '수육 한 접시')."""
|
||||||
|
data = Path(photo_path).read_bytes()
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL,
|
||||||
|
contents=[
|
||||||
|
types.Part.from_bytes(data=data, mime_type=_mime_of(photo_path)),
|
||||||
|
types.Part.from_text(text=(
|
||||||
|
"이 사진의 핵심 대상을 한국어 명사구 12자 이내로만 답해. "
|
||||||
|
"음식이면 메뉴명(예: '수육', '메밀국수'), 가게면 '가게 외관'/'매장 내부', "
|
||||||
|
"메뉴판이면 '메뉴판'. 설명·문장 금지, 라벨만.")),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (resp.text or "").strip().splitlines()[0][:20] if resp.text else ""
|
||||||
|
|
||||||
|
|
||||||
|
_MATCH_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"assignments": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"photo_index": {"type": "integer"},
|
||||||
|
"scene_number": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["photo_index", "scene_number"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["assignments"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def match_real_scenes(client, scenes, real_images, max_real=3) -> dict:
|
||||||
|
"""각 실제 사진을 비전 분석 → 내용이 가장 맞는 장면에 배치한 {장면번호: 사진경로} 반환.
|
||||||
|
|
||||||
|
실패하면 위치 기반(plan_real_scenes)으로 폴백한다.
|
||||||
|
"""
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
if not real_images:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
# 1) 사진별 내용 라벨
|
||||||
|
labels = []
|
||||||
|
for p in real_images:
|
||||||
|
try:
|
||||||
|
lab = analyze_photo(client, p)
|
||||||
|
except Exception:
|
||||||
|
lab = ""
|
||||||
|
labels.append(lab)
|
||||||
|
print(f" · {Path(p).name} → '{lab or '?'}'")
|
||||||
|
|
||||||
|
# 2) 라벨 ↔ 장면 매칭 (구조화 출력 1회)
|
||||||
|
scene_lines = "\n".join(
|
||||||
|
f"{i+1}. [{s.get('caption','')}] {s.get('narration','')}"
|
||||||
|
for i, s in enumerate(scenes))
|
||||||
|
photo_lines = "\n".join(f"{i}. {labels[i] or '내용불명'}" for i in range(len(real_images)))
|
||||||
|
prompt = (
|
||||||
|
"아래는 영상의 '장면 목록'과, 영상에 넣을 '실제 사진'들의 내용 라벨이다.\n"
|
||||||
|
"각 사진을 그 내용이 가장 잘 어울리는 장면에 배치하라.\n"
|
||||||
|
"[규칙]\n"
|
||||||
|
"- 한 장면엔 사진 하나, 한 사진은 한 장면에만.\n"
|
||||||
|
"- 그 음식/메뉴/가게를 직접 언급·소개하는 장면에 우선 배치.\n"
|
||||||
|
"- 비슷하면 마케팅/소개가 나오는 뒤쪽 장면을 선호.\n"
|
||||||
|
f"- 최대 {max_real}장까지만 배치(나머지는 제외).\n\n"
|
||||||
|
f"[장면 목록]\n{scene_lines}\n\n[사진 라벨]\n{photo_lines}\n")
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL,
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.2,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=_MATCH_SCHEMA,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assigns = json.loads(resp.text).get("assignments", [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (매칭 실패 → 위치 기반으로 폴백: {type(e).__name__})")
|
||||||
|
return plan_real_scenes(scenes, real_images, None, max_real)
|
||||||
|
|
||||||
|
real_map, used_photo = {}, set()
|
||||||
|
for a in assigns:
|
||||||
|
pi, sn = a.get("photo_index"), a.get("scene_number")
|
||||||
|
if pi is None or sn is None:
|
||||||
|
continue
|
||||||
|
if 0 <= pi < len(real_images) and 1 <= sn <= len(scenes) \
|
||||||
|
and pi not in used_photo and sn not in real_map:
|
||||||
|
real_map[sn] = Path(real_images[pi])
|
||||||
|
used_photo.add(pi)
|
||||||
|
if len(real_map) >= max_real:
|
||||||
|
break
|
||||||
|
return real_map or plan_real_scenes(scenes, real_images, None, max_real)
|
||||||
|
|
||||||
|
|
||||||
|
def plan_real_scenes(scenes, real_images, real_scenes=None, max_real=3) -> dict:
|
||||||
|
"""어느 장면 번호(1-based)에 어떤 실제 사진을 깔지 매핑 dict 로 반환.
|
||||||
|
|
||||||
|
- real_scenes 가 주어지면 그 장면 번호들에 사진을 순서대로 매핑.
|
||||||
|
- 없으면 '뒤쪽(마케팅 전환부) 장면들'에 사진 개수만큼(최대 max_real) 자동 배치.
|
||||||
|
"""
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
real_map = {}
|
||||||
|
if not real_images:
|
||||||
|
return real_map
|
||||||
|
if real_scenes:
|
||||||
|
for no, img in zip(real_scenes, real_images):
|
||||||
|
if 1 <= no <= len(scenes):
|
||||||
|
real_map[no] = Path(img)
|
||||||
|
else:
|
||||||
|
n = min(len(real_images), max_real, len(scenes))
|
||||||
|
start = len(scenes) - n + 1 # 뒤에서 n개
|
||||||
|
for k in range(n):
|
||||||
|
real_map[start + k] = Path(real_images[k])
|
||||||
|
return real_map
|
||||||
|
|
||||||
|
|
||||||
|
def produce(client, sb, stem, workdir=None, voice=TTS_VOICE, force=False,
|
||||||
|
real_images=None, real_scenes=None, max_real=3, match_content=True,
|
||||||
|
link=None, link_seconds=3.0, store=None, cta=None,
|
||||||
|
img_model=None, allow_text=False,
|
||||||
|
speed=1.0, trim_silence=True, tail=0.15, workers=4,
|
||||||
|
bgm=None, bgm_volume=0.3) -> Path:
|
||||||
|
"""스토리보드 dict → 장면별 이미지/음성 생성 후 mp4 합성. 완성 경로 반환.
|
||||||
|
|
||||||
|
workdir: 이 작업의 결과/중간파일을 담을 폴더. 이미지·음성은 workdir/assets/ 에,
|
||||||
|
완성 mp4 는 workdir/{stem}.mp4 로 저장한다. (기본: output/{stem})
|
||||||
|
real_images: 실제 사진(가게/메뉴판 등) 경로 리스트. 뒤쪽 장면에 깔아 AI 컷과 섞는다.
|
||||||
|
real_scenes: 실제 사진을 깔 장면 번호(1-based) 리스트. 없으면 뒤쪽 자동.
|
||||||
|
max_real: 자동 배치 시 실제 사진으로 채울 최대 장면 수.
|
||||||
|
link: 주면 맨 마지막에 링크 카드 1장을 붙인다.
|
||||||
|
"""
|
||||||
|
scenes = sb["scenes"]
|
||||||
|
style = sb.get("style_guide", "")
|
||||||
|
char = sb.get("character_sheet", "")
|
||||||
|
workdir = Path(workdir) if workdir else (OUT_DIR / stem)
|
||||||
|
workdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
assets = workdir / "assets"
|
||||||
|
assets.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
# 실제 사진 → 장면 배치 결정
|
||||||
|
# · real_scenes 수동지정이 최우선
|
||||||
|
# · 아니면 내용 매칭(비전). 단, 새로 생성할 장면이 있을 때만(전부 캐시면 비전 호출 낭비라 생략)
|
||||||
|
real_images = list(real_images or [])
|
||||||
|
# max_real 이 None/0/음수면 '폴더 사진 전부 사용'(장면 수 한도 내)
|
||||||
|
eff_max = max_real if (max_real and max_real > 0) else len(real_images)
|
||||||
|
need_gen = force or any(not (assets / f"scene{i:02d}.png").exists()
|
||||||
|
for i in range(1, len(scenes) + 1))
|
||||||
|
if not real_images:
|
||||||
|
real_map = {}
|
||||||
|
elif real_scenes:
|
||||||
|
real_map = plan_real_scenes(scenes, real_images, real_scenes, eff_max)
|
||||||
|
elif match_content and client is not None and need_gen:
|
||||||
|
print(f"■ 실제 사진 내용 분석(Gemini 비전) → 장면 매칭 (최대 {eff_max}장)")
|
||||||
|
real_map = match_real_scenes(client, scenes, real_images, eff_max)
|
||||||
|
else:
|
||||||
|
real_map = plan_real_scenes(scenes, real_images, None, eff_max)
|
||||||
|
real_order = {no: k for k, no in enumerate(sorted(real_map))} # 포즈/위치 번갈아 쓰기용
|
||||||
|
if real_map:
|
||||||
|
picks = ", ".join(f"{no}←{real_map[no].name}" for no in sorted(real_map))
|
||||||
|
print(f"■ 실제 사진 합성 장면: {picks}")
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
img_paths = [assets / f"scene{i:02d}.png" for i in range(1, len(scenes) + 1)]
|
||||||
|
audio_paths = [assets / f"scene{i:02d}.wav" for i in range(1, len(scenes) + 1)]
|
||||||
|
|
||||||
|
def make_image(i, ref, ref_mode="full"):
|
||||||
|
"""장면 i 이미지 1장 생성(필요시 실사진 합성). ref=캐릭터 앵커 바이트."""
|
||||||
|
sc = scenes[i - 1]
|
||||||
|
raw_png = assets / f"scene{i:02d}_raw.png"
|
||||||
|
img_png = assets / f"scene{i:02d}.png"
|
||||||
|
# 눈 표정 장면(감김/실눈/윙크/졸림/웃음·울음 등)은 뜬눈 고정을 풀고 표정대로 그린다.
|
||||||
|
# · '눈만 고정(eyes)' 레퍼런스도 이런 장면에선 빼야 눈이 안 떠짐.
|
||||||
|
free_eyes = scene_needs_eye_expression(sc)
|
||||||
|
eye = EYE_FREE if free_eyes else EYE_STYLE
|
||||||
|
use_ref = None if (free_eyes and ref_mode == "eyes") else ref
|
||||||
|
if i in real_map:
|
||||||
|
k = real_order[i]
|
||||||
|
pose = PRESENT_POSES[k % len(PRESENT_POSES)]
|
||||||
|
spot = PRESENT_SPOTS[k % len(PRESENT_SPOTS)]
|
||||||
|
prompt = (f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. {pose} "
|
||||||
|
f"Vertical 9:16 composition.")
|
||||||
|
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
|
||||||
|
no_text=not allow_text, ref_mode=ref_mode)
|
||||||
|
raw_png.write_bytes(data)
|
||||||
|
cover_crop(raw_png, img_png) # 1) AI 배경
|
||||||
|
composite_card(img_png, real_map[i], img_png, center=spot) # 2) 실사진 카드
|
||||||
|
else:
|
||||||
|
prompt = f"{style}. {char}. {eye} Scene: {sc['image_prompt']}. Vertical 9:16 composition."
|
||||||
|
data = gen_image(client, prompt, ref_bytes=use_ref, model=img_model,
|
||||||
|
no_text=not allow_text, ref_mode=ref_mode)
|
||||||
|
raw_png.write_bytes(data)
|
||||||
|
cover_crop(raw_png, img_png)
|
||||||
|
|
||||||
|
import time as _tm; _t_img = _tm.monotonic()
|
||||||
|
# --- 이미지: 캐릭터 앵커(1컷) 먼저, 나머지는 병렬 ---
|
||||||
|
# 앵커 의존성: 모든 컷이 1번 컷(캐릭터)을 ref 로 참조해야 일관성이 유지된다.
|
||||||
|
# → 앵커 1장만 순차로 만든 뒤, 나머지는 동시에 생성(서로 독립).
|
||||||
|
need_img = [i for i in range(1, len(scenes) + 1)
|
||||||
|
if force or not img_paths[i - 1].exists()]
|
||||||
|
cached_img = [i for i in range(1, len(scenes) + 1) if i not in need_img]
|
||||||
|
for i in cached_img:
|
||||||
|
print(f"[{i:2d}] 이미지 캐시 사용")
|
||||||
|
|
||||||
|
# 공식 캐릭터 레퍼런스가 있으면 '눈만 고정' 모드: 모든 컷이 레퍼런스에서 눈/표정만 복제.
|
||||||
|
# 갑옷·한푸 등 복장은 복제하지 않고 인물별 character_sheet/장면 프롬프트대로 그려진다.
|
||||||
|
char_ref = load_char_ref()
|
||||||
|
if char_ref is not None:
|
||||||
|
print("■ 캐릭터 레퍼런스(눈만 고정) 사용: character/ "
|
||||||
|
"→ 눈/표정은 통일, 복장은 인물별로 다르게")
|
||||||
|
ref_bytes, ref_mode, anchor = char_ref, "eyes", None
|
||||||
|
else:
|
||||||
|
ref_mode = "full"
|
||||||
|
ref_bytes = None
|
||||||
|
for i in range(1, len(scenes) + 1): # 캐시된 raw 가 있으면 앵커로 재사용
|
||||||
|
raw = assets / f"scene{i:02d}_raw.png"
|
||||||
|
if raw.exists() and not force:
|
||||||
|
ref_bytes = raw.read_bytes(); break
|
||||||
|
anchor = None
|
||||||
|
if ref_bytes is None:
|
||||||
|
anchor = next((i for i in need_img if i not in real_map), None)
|
||||||
|
if anchor is not None:
|
||||||
|
print(f"[{anchor:2d}] 캐릭터 앵커 이미지 생성...", flush=True)
|
||||||
|
make_image(anchor, None)
|
||||||
|
ref_bytes = (assets / f"scene{anchor:02d}_raw.png").read_bytes()
|
||||||
|
|
||||||
|
rest = [i for i in need_img if i != anchor]
|
||||||
|
if rest:
|
||||||
|
print(f"■ 이미지 {len(rest)}장 병렬 생성 (워커 {workers})...", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(make_image, i, ref_bytes, ref_mode): i for i in rest}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
i = futs[fut]; fut.result()
|
||||||
|
tag = f"실사합성({real_map[i].name})" if i in real_map else "이미지"
|
||||||
|
print(f"[{i:2d}] {tag} done", flush=True)
|
||||||
|
|
||||||
|
print(f"[⏱] 이미지 {_tm.monotonic() - _t_img:.1f}초", flush=True); _t_tts = _tm.monotonic()
|
||||||
|
# --- 음성: 전부 독립 → 병렬 ---
|
||||||
|
def make_tts(i):
|
||||||
|
text = (scenes[i - 1].get("narration") or "").strip()
|
||||||
|
if not text:
|
||||||
|
# 빈 나레이션 방어: TTS 가 빈 텍스트에 빈 응답을 반복하다 죽으므로 0.3초 무음으로 대체.
|
||||||
|
# (정상 경로에선 gen_storyboard 가 빈 장면을 미리 거르지만, 만약을 대비한 이중 안전장치)
|
||||||
|
pcm_to_wav(b"\x00\x00" * int(24000 * 0.3), audio_paths[i - 1], 24000)
|
||||||
|
return
|
||||||
|
pcm, rate = gen_tts(client, text, voice)
|
||||||
|
pcm_to_wav(pcm, audio_paths[i - 1], rate)
|
||||||
|
|
||||||
|
need_wav = [i for i in range(1, len(scenes) + 1)
|
||||||
|
if force or not audio_paths[i - 1].exists()]
|
||||||
|
for i in (i for i in range(1, len(scenes) + 1) if i not in need_wav):
|
||||||
|
print(f" [{i:2d}] 음성 캐시 사용 ({wav_seconds(audio_paths[i - 1]):.1f}초)")
|
||||||
|
if need_wav:
|
||||||
|
print(f"■ 음성 {len(need_wav)}개 병렬 생성 (워커 {workers})...", flush=True)
|
||||||
|
with ThreadPoolExecutor(max_workers=workers) as ex:
|
||||||
|
futs = {ex.submit(make_tts, i): i for i in need_wav}
|
||||||
|
for fut in as_completed(futs):
|
||||||
|
i = futs[fut]; fut.result()
|
||||||
|
print(f" [{i:2d}] 음성 {wav_seconds(audio_paths[i - 1]):.1f}초", flush=True)
|
||||||
|
|
||||||
|
print(f"[⏱] 음성 {_tm.monotonic() - _t_tts:.1f}초", flush=True)
|
||||||
|
out = workdir / f"{stem}.mp4"
|
||||||
|
print(f"\n영상 합성 → {out.name}")
|
||||||
|
build_video(scenes, audio_paths, img_paths, out, link=link, link_seconds=link_seconds,
|
||||||
|
store=store, cta=cta, speed=speed, trim_silence=trim_silence, tail=tail,
|
||||||
|
bgm=bgm, bgm_volume=bgm_volume)
|
||||||
|
print(f"완성: {out}")
|
||||||
|
return out
|
||||||
288
generator/animation_samgukji/scraper.py
Normal file
288
generator/animation_samgukji/scraper.py
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
나무위키 삼국지 인물 '썰 키워드' 스크래퍼
|
||||||
|
========================================
|
||||||
|
|
||||||
|
삼국지(후한 말~삼국시대) 주요 인물 28명의 나무위키 문서에서 목차(섹션 제목)를 긁어와
|
||||||
|
숏폼 소재가 될 만한 '썰/일화 키워드'를 추출해 JSON / CSV 로 저장한다.
|
||||||
|
|
||||||
|
나무위키는 Cloudflare 보호 + JS 렌더링이라 requests 로는 막히므로
|
||||||
|
Playwright(Chromium)로 실제 브라우저를 띄워 렌더링된 목차를 읽는다.
|
||||||
|
|
||||||
|
설치:
|
||||||
|
pip install playwright beautifulsoup4
|
||||||
|
playwright install chromium # (이미 설치돼 있으면 생략)
|
||||||
|
|
||||||
|
실행:
|
||||||
|
python scraper.py
|
||||||
|
|
||||||
|
옵션:
|
||||||
|
python scraper.py --headful # 브라우저 창 보이게
|
||||||
|
python scraper.py --only 조조 여포 # 특정 인물만
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
# 설정
|
||||||
|
# ----------------------------------------------------------------------------
|
||||||
|
BASE = "https://namu.wiki/w/"
|
||||||
|
OUT_DIR = Path(__file__).parent / "data" # 삼국지 인물 일화 CSV/JSON(선행 데이터). ★ output/ 아님 — 거긴 mp4만.
|
||||||
|
|
||||||
|
# 삼국지 주요 인물 28명: (번호, 이름, 나무위키 문서 제목)
|
||||||
|
# 동음이의어가 있는 인물(장비/마초/원소 등)은 "(삼국지)" 를 붙인 문서 제목을 쓴다.
|
||||||
|
# 제목이 틀려도 worker 가 "이름" ↔ "이름(삼국지)" 를 자동으로 재시도한다.
|
||||||
|
SAMGUK_HEROES = [
|
||||||
|
# 위(魏)
|
||||||
|
(1, "조조", "조조"),
|
||||||
|
(2, "사마의", "사마의"),
|
||||||
|
(3, "하후돈", "하후돈"),
|
||||||
|
(4, "장료", "장료"),
|
||||||
|
(5, "허저", "허저"),
|
||||||
|
(6, "곽가", "곽가"),
|
||||||
|
(7, "순욱", "순욱"),
|
||||||
|
(8, "가후", "가후"),
|
||||||
|
# 촉(蜀)
|
||||||
|
(9, "유비", "유비"),
|
||||||
|
(10, "관우", "관우"),
|
||||||
|
(11, "장비", "장비(삼국지)"),
|
||||||
|
(12, "제갈량", "제갈량"),
|
||||||
|
(13, "조운", "조운(삼국지)"),
|
||||||
|
(14, "마초", "마초(삼국지)"),
|
||||||
|
(15, "황충", "황충(삼국지)"),
|
||||||
|
(16, "강유", "강유(삼국지)"),
|
||||||
|
(17, "방통", "방통(삼국지)"),
|
||||||
|
# 오(吳)
|
||||||
|
(18, "손권", "손권"),
|
||||||
|
(19, "손책", "손책"),
|
||||||
|
(20, "주유", "주유(삼국지)"),
|
||||||
|
(21, "육손", "육손(삼국지)"),
|
||||||
|
(22, "노숙", "노숙(삼국지)"),
|
||||||
|
(23, "감녕", "감녕"),
|
||||||
|
(24, "태사자", "태사자"),
|
||||||
|
# 군웅(群雄)
|
||||||
|
(25, "여포", "여포"),
|
||||||
|
(26, "동탁", "동탁"),
|
||||||
|
(27, "원소", "원소(삼국지)"),
|
||||||
|
(28, "원술", "원술"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 썰/일화/논란성 키워드가 모이는 섹션 (목차 제목에 이 단어가 들어가면 '썰' 우선순위↑)
|
||||||
|
ANECDOTE_HINTS = [
|
||||||
|
"여담", "일화", "야사", "평가", "논란", "비판", "사건", "기타",
|
||||||
|
"이야기", "에피소드", "어록", "대중매체", "미디어", "기록", "사망", "죽음",
|
||||||
|
"독살", "암살", "치세", "업적", "가족", "이름", "별명",
|
||||||
|
"연의", "정사", "전투", "최후", "민간", "창작물",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def clean_heading(text: str) -> str:
|
||||||
|
"""목차 항목 텍스트 정리: 앞 번호와 [편집] 등 제거."""
|
||||||
|
t = text.replace("[편집]", "").strip()
|
||||||
|
# 앞쪽 "1.", "2.3.", "10." 같은 목차 번호 제거
|
||||||
|
t = re.sub(r"^\s*[\d.]+\s*", "", t)
|
||||||
|
return t.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def is_anecdote(keyword: str) -> bool:
|
||||||
|
return any(h in keyword for h in ANECDOTE_HINTS)
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_king(page, title: str):
|
||||||
|
"""한 인물 문서를 열고 목차 키워드 목록을 반환."""
|
||||||
|
url = BASE + title
|
||||||
|
result = {"title": title, "url": url, "page_title": None,
|
||||||
|
"keywords": [], "anecdote_keywords": [], "error": None}
|
||||||
|
try:
|
||||||
|
resp = await page.goto(url, wait_until="domcontentloaded", timeout=45000)
|
||||||
|
|
||||||
|
# 고정 sleep 대신 목차 요소가 뜰 때까지만 대기 (없으면 짧게 폴백)
|
||||||
|
try:
|
||||||
|
await page.wait_for_selector(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a',
|
||||||
|
timeout=8000,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# 목차가 없는 문서일 수 있으니 헤딩 렌더만 잠깐 기다림
|
||||||
|
await page.wait_for_timeout(800)
|
||||||
|
|
||||||
|
# 문서 실제 제목
|
||||||
|
try:
|
||||||
|
result["page_title"] = (await page.title()).split(" - ")[0].strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
result["error"] = f"HTTP {resp.status}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 렌더링된 목차/제목에서 키워드 추출 (클래스명이 자주 바뀌므로 여러 선택자 시도)
|
||||||
|
keywords = await page.evaluate(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const clean = s => s.replace('[편집]', '').trim();
|
||||||
|
let items = [];
|
||||||
|
|
||||||
|
// 1순위: 목차(table of contents)
|
||||||
|
const tocLinks = document.querySelectorAll(
|
||||||
|
'.wiki-macro-toc a, [class*="toc"] a, .toc-item a'
|
||||||
|
);
|
||||||
|
if (tocLinks.length) {
|
||||||
|
items = Array.from(tocLinks).map(a => clean(a.textContent));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2순위(목차가 없으면): 본문 헤딩 태그
|
||||||
|
if (items.length === 0) {
|
||||||
|
const hs = document.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
||||||
|
items = Array.from(hs).map(h => clean(h.textContent));
|
||||||
|
}
|
||||||
|
return items.filter(Boolean);
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 중복 제거 + 번호 정리
|
||||||
|
seen, cleaned = set(), []
|
||||||
|
for raw in keywords:
|
||||||
|
kw = clean_heading(raw)
|
||||||
|
if kw and kw not in seen and len(kw) <= 60:
|
||||||
|
seen.add(kw)
|
||||||
|
cleaned.append(kw)
|
||||||
|
|
||||||
|
result["keywords"] = cleaned
|
||||||
|
result["anecdote_keywords"] = [k for k in cleaned if is_anecdote(k)]
|
||||||
|
except Exception as e:
|
||||||
|
result["error"] = f"{type(e).__name__}: {e}"
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("--headful", action="store_true", help="브라우저 창 표시")
|
||||||
|
parser.add_argument("--only", nargs="*", help="특정 인물만 (이름으로 지정)")
|
||||||
|
parser.add_argument("--concurrency", type=int, default=4,
|
||||||
|
help="동시에 열 페이지 수 (기본 4)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
kings = SAMGUK_HEROES
|
||||||
|
if args.only:
|
||||||
|
wanted = set(args.only)
|
||||||
|
kings = [k for k in SAMGUK_HEROES if k[1] in wanted]
|
||||||
|
if not kings:
|
||||||
|
print(f"[!] 매칭되는 인물 없음: {args.only}")
|
||||||
|
print(" 가능한 값:", ", ".join(k[1] for k in SAMGUK_HEROES))
|
||||||
|
return
|
||||||
|
|
||||||
|
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
all_results = []
|
||||||
|
|
||||||
|
total = len(kings)
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser = await p.chromium.launch(headless=not args.headful)
|
||||||
|
context = await browser.new_context(
|
||||||
|
user_agent=(
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||||
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/124.0.0.0 Safari/537.36"
|
||||||
|
),
|
||||||
|
locale="ko-KR",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 목차 텍스트만 필요하므로 이미지/CSS/폰트/미디어는 받지 않음 (로딩 대폭 단축)
|
||||||
|
async def _block(route):
|
||||||
|
if route.request.resource_type in {"image", "stylesheet", "font", "media"}:
|
||||||
|
await route.abort()
|
||||||
|
else:
|
||||||
|
await route.continue_()
|
||||||
|
await context.route("**/*", _block)
|
||||||
|
|
||||||
|
sem = asyncio.Semaphore(max(1, args.concurrency))
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
async def worker(idx, name, title):
|
||||||
|
nonlocal done
|
||||||
|
# 문서 제목 후보: 지정 제목 → "이름" → "이름(삼국지)" 순으로 자동 재시도
|
||||||
|
# (나무위키 동음이의어 처리 방식이 인물마다 달라 제목을 못 맞출 수 있어서)
|
||||||
|
titles = [title]
|
||||||
|
for alt in (name, f"{name}(삼국지)"):
|
||||||
|
if alt not in titles:
|
||||||
|
titles.append(alt)
|
||||||
|
async with sem:
|
||||||
|
page = await context.new_page()
|
||||||
|
try:
|
||||||
|
r = None
|
||||||
|
for t in titles:
|
||||||
|
# 나무위키는 동시 요청 시 간헐적으로 404/차단을 주므로
|
||||||
|
# 실패하면 백오프하며 최대 3회까지 재시도
|
||||||
|
r = await scrape_king(page, t)
|
||||||
|
for attempt in range(3):
|
||||||
|
if not r["error"]:
|
||||||
|
break
|
||||||
|
await page.wait_for_timeout(1500 * (attempt + 1))
|
||||||
|
r = await scrape_king(page, t)
|
||||||
|
# 성공했고 목차도 나왔으면 이 제목으로 확정
|
||||||
|
if not r["error"] and r["keywords"]:
|
||||||
|
break
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
r["order"] = idx
|
||||||
|
r["name"] = name
|
||||||
|
done += 1
|
||||||
|
if r["error"]:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) 실패: {r['error']}", flush=True)
|
||||||
|
else:
|
||||||
|
print(f"[{done:2d}/{total}] {name} ({title}) "
|
||||||
|
f"키워드 {len(r['keywords'])}개 "
|
||||||
|
f"(썰후보 {len(r['anecdote_keywords'])}개)", flush=True)
|
||||||
|
return r
|
||||||
|
|
||||||
|
all_results = await asyncio.gather(
|
||||||
|
*(worker(idx, name, title) for idx, name, title in kings)
|
||||||
|
)
|
||||||
|
all_results = sorted(all_results, key=lambda r: r["order"])
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
# 저장 ---------------------------------------------------------------
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
json_path = OUT_DIR / f"samguk_keywords_{ts}.json"
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(all_results, ensure_ascii=False, indent=2), encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
csv_path = OUT_DIR / f"samguk_keywords_{ts}.csv"
|
||||||
|
with csv_path.open("w", encoding="utf-8-sig", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["번호", "인물", "문서제목", "썰후보여부", "키워드", "URL"])
|
||||||
|
for r in all_results:
|
||||||
|
anec = set(r["anecdote_keywords"])
|
||||||
|
for kw in r["keywords"]:
|
||||||
|
w.writerow([r["order"], r["name"], r["title"],
|
||||||
|
"Y" if kw in anec else "", kw, r["url"]])
|
||||||
|
|
||||||
|
total_kw = sum(len(r["keywords"]) for r in all_results)
|
||||||
|
total_anec = sum(len(r["anecdote_keywords"]) for r in all_results)
|
||||||
|
fails = [r["name"] for r in all_results if r["error"]]
|
||||||
|
|
||||||
|
print("\n" + "=" * 50)
|
||||||
|
print(f"완료: 인물 {len(all_results)}명 / 키워드 {total_kw}개 / 썰후보 {total_anec}개")
|
||||||
|
if fails:
|
||||||
|
print(f"수집 실패: {', '.join(fails)}")
|
||||||
|
print(f"JSON -> {json_path}")
|
||||||
|
print(f"CSV -> {csv_path}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if sys.platform == "win32":
|
||||||
|
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
||||||
|
asyncio.run(main())
|
||||||
BIN
generator/fonts/Jua-Regular.ttf
Normal file
BIN
generator/fonts/Jua-Regular.ttf
Normal file
Binary file not shown.
BIN
generator/fonts/SongMyung-Regular.ttf
Normal file
BIN
generator/fonts/SongMyung-Regular.ttf
Normal file
Binary file not shown.
285
generator/media.py
Normal file
285
generator/media.py
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
Best3 저수준 도구함 (TTS · 비전 · ffmpeg · BGM · 폰트)
|
||||||
|
======================================================
|
||||||
|
|
||||||
|
one_short / render 가 공통으로 쓰는 유틸 모음. 단독 실행용 아님.
|
||||||
|
· 비전: analyze_photos(사진/클립프레임 라벨·점수), extract_frame
|
||||||
|
· 음성: gen_tts(Gemini TTS), pcm_to_wav, wav_seconds, process_audio(무음제거·속도)
|
||||||
|
· ffmpeg: _ffmpeg, _run, pick_encoder(QSV/libx264), _AUDIO_ARGS
|
||||||
|
· 자막: _wrap_lines(픽셀폭 줄바꿈)
|
||||||
|
· BGM: resolve_bgm(폴더 랜덤 1곡), mix_bgm
|
||||||
|
|
||||||
|
설치: pip install google-genai pillow imageio-ffmpeg
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from google.genai import types
|
||||||
|
|
||||||
|
HERE = Path(__file__).parent
|
||||||
|
W, H = 1080, 1920 # 세로 쇼츠
|
||||||
|
FPS = 30
|
||||||
|
|
||||||
|
VISION_MODEL = "gemini-2.5-flash" # 사진/클립 비전 분석용
|
||||||
|
TTS_MODEL = "gemini-2.5-flash-preview-tts"
|
||||||
|
TTS_VOICE = "Aoede" # 산뜻하고 가벼운 보이스. 대안: Charon(차분) / Sulafat(따뜻) / Laomedeia(경쾌)
|
||||||
|
TTS_STYLE = ("다음 문장을 밝고 경쾌하게, 적당히 빠른 속도로 활기차고 신나는 톤으로 읽어줘. "
|
||||||
|
"지루하지 않게 텐션을 올리되, 과한 병맛·오버는 아니고 세련되게:")
|
||||||
|
LINK_CTA = "소개한 곳 링크는 고정댓글에서 확인하세요" # info 카드 링크 안내
|
||||||
|
|
||||||
|
# 한글 자막용 폰트 — 주아체(fonts/ 동봉), 없으면 맑은고딕 볼드. 제목/정보용은 송명체.
|
||||||
|
FONT = str(HERE / "fonts" / "Jua-Regular.ttf")
|
||||||
|
FONT_FALLBACK = r"C:\Windows\Fonts\malgunbd.ttf"
|
||||||
|
TITLE_FONT = str(HERE / "fonts" / "SongMyung-Regular.ttf")
|
||||||
|
|
||||||
|
_AUDIO_ARGS = ["-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-ac", "2"]
|
||||||
|
|
||||||
|
# 수동 입력(링크 없이 직접 업로드)용 — 로컬 폴더의 이미지/영상 파일 목록
|
||||||
|
_LOCAL_IMG_EXTS = (".jpg", ".jpeg", ".png", ".webp", ".bmp")
|
||||||
|
_LOCAL_VID_EXTS = (".mp4", ".mov", ".webm", ".m4v")
|
||||||
|
|
||||||
|
|
||||||
|
def list_media(folder, kind="image"):
|
||||||
|
"""로컬 폴더의 이미지(kind='image') 또는 영상(kind='video') 파일을 정렬해 [Path...] 반환.
|
||||||
|
링크 없이 직접 사진/클립을 올릴 때 사용. 폴더가 없으면 빈 리스트."""
|
||||||
|
exts = _LOCAL_VID_EXTS if kind == "video" else _LOCAL_IMG_EXTS
|
||||||
|
d = Path(folder) if folder else None
|
||||||
|
if not d or not d.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(p for p in d.iterdir() if p.suffix.lower() in exts)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ffmpeg
|
||||||
|
def _ffmpeg() -> str:
|
||||||
|
import imageio_ffmpeg
|
||||||
|
return imageio_ffmpeg.get_ffmpeg_exe()
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd):
|
||||||
|
r = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
||||||
|
if r.returncode != 0:
|
||||||
|
raise RuntimeError("ffmpeg 실패:\n" + (r.stderr or "")[-1500:])
|
||||||
|
return r
|
||||||
|
|
||||||
|
|
||||||
|
def pick_encoder(exe) -> list:
|
||||||
|
"""Intel QSV 하드웨어 인코딩이 실제로 동작하면 그걸, 아니면 libx264 ultrafast."""
|
||||||
|
test = subprocess.run(
|
||||||
|
[exe, "-hide_banner", "-f", "lavfi", "-i", "color=c=black:s=128x128:d=1",
|
||||||
|
"-c:v", "h264_qsv", "-f", "null", "-"],
|
||||||
|
capture_output=True, text=True, errors="replace")
|
||||||
|
if test.returncode == 0:
|
||||||
|
print(" 인코더: h264_qsv (Intel 하드웨어)")
|
||||||
|
return ["-c:v", "h264_qsv", "-global_quality", "24", "-preset", "fast"]
|
||||||
|
print(" 인코더: libx264 (superfast)")
|
||||||
|
return ["-c:v", "libx264", "-preset", "superfast", "-crf", "26"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 비전
|
||||||
|
def analyze_photos(client, photo_paths, model=None):
|
||||||
|
"""사진/클립프레임을 비전으로 한 번에 분석.
|
||||||
|
반환 rows: 입력 순서대로 [{label, char_fit, has_text}].
|
||||||
|
label = 보이는 핵심 한국어 한 줄(나레이션 매칭용)
|
||||||
|
char_fit = 컷이 매력적인 정도 1~10(클립·사진 순위용)
|
||||||
|
has_text = 글자/자막/캡션이 박혀 보이면 True
|
||||||
|
실패 시 빈 라벨/0/False."""
|
||||||
|
n = len(photo_paths)
|
||||||
|
parts = [types.Part.from_text(text=(
|
||||||
|
"여러 이미지(가게 사진 또는 영상 캡처)다. 각 이미지를 분석해라.\n"
|
||||||
|
"- label: 보이는 핵심을 짧은 한국어 한 줄로(예: '대게 한 상', '바다 노을 뷰', '야외 수영장', "
|
||||||
|
"'아늑한 객실', '외관/간판'). 메뉴판이면 '메뉴판'.\n"
|
||||||
|
"- char_fit: 광고 컷으로 매력적인 정도 1~10 "
|
||||||
|
"(음식·음료·수영장·아늑한 실내=높게 / 메뉴판·로고·밋밋한 외관·사람 위주=낮게).\n"
|
||||||
|
"- has_text: 이미지에 '글자/자막/캡션/큰 로고 텍스트'가 박혀 보이면 true, 깨끗하면 false.\n"
|
||||||
|
"각 이미지 앞 [이미지 N] 의 N 을 index 로 써라."))]
|
||||||
|
for i, p in enumerate(photo_paths):
|
||||||
|
parts.append(types.Part.from_text(text=f"[이미지 {i}]"))
|
||||||
|
parts.append(types.Part.from_bytes(data=Path(p).read_bytes(), mime_type="image/jpeg"))
|
||||||
|
schema = {"type": "object", "properties": {"photos": {"type": "array", "items": {
|
||||||
|
"type": "object", "properties": {
|
||||||
|
"index": {"type": "integer"}, "label": {"type": "string"},
|
||||||
|
"char_fit": {"type": "integer"}, "has_text": {"type": "boolean"}},
|
||||||
|
"required": ["index", "label", "char_fit", "has_text"]}}}, "required": ["photos"]}
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model or VISION_MODEL, contents=parts,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.2, response_mime_type="application/json", response_schema=schema))
|
||||||
|
by = {r["index"]: r for r in json.loads(resp.text).get("photos", [])
|
||||||
|
if 0 <= r.get("index", -1) < n}
|
||||||
|
return [{"label": (by.get(i, {}).get("label") or "").strip(),
|
||||||
|
"char_fit": by.get(i, {}).get("char_fit", 0),
|
||||||
|
"has_text": bool(by.get(i, {}).get("has_text", False))} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
|
def extract_frame(video_path, out_jpg, at=1.0):
|
||||||
|
"""영상에서 프레임 1장 추출(비전 분석/썸네일용)."""
|
||||||
|
_run([_ffmpeg(), "-y", "-ss", str(at), "-i", str(video_path),
|
||||||
|
"-frames:v", "1", str(out_jpg)])
|
||||||
|
return out_jpg
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 음성
|
||||||
|
def _extract_audio(resp):
|
||||||
|
"""TTS 응답에서 (pcm, rate) 추출. 비었으면 None 반환(재시도 신호)."""
|
||||||
|
cands = getattr(resp, "candidates", None) or []
|
||||||
|
for c in cands:
|
||||||
|
content = getattr(c, "content", None)
|
||||||
|
parts = getattr(content, "parts", None) or [] if content else []
|
||||||
|
for p in parts:
|
||||||
|
inline = getattr(p, "inline_data", None)
|
||||||
|
if inline and inline.data:
|
||||||
|
m = re.search(r"rate=(\d+)", inline.mime_type or "")
|
||||||
|
return inline.data, (int(m.group(1)) if m else 24000)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def gen_tts(client, text, voice, retries=4) -> bytes:
|
||||||
|
"""Gemini TTS. 프리뷰 모델이 가끔 빈 응답(content=None)을 주므로 재시도한다."""
|
||||||
|
last = ""
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=TTS_MODEL,
|
||||||
|
contents=f"{TTS_STYLE}\n\n{text}",
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
response_modalities=["AUDIO"],
|
||||||
|
speech_config=types.SpeechConfig(
|
||||||
|
voice_config=types.VoiceConfig(
|
||||||
|
prebuilt_voice_config=types.PrebuiltVoiceConfig(voice_name=voice)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
got = _extract_audio(resp)
|
||||||
|
if got:
|
||||||
|
return got
|
||||||
|
# 비었으면 왜 비었는지(finish_reason 등) 기록하고 재시도
|
||||||
|
fr = ""
|
||||||
|
try:
|
||||||
|
fr = str(getattr(resp.candidates[0], "finish_reason", "") or "")
|
||||||
|
except Exception:
|
||||||
|
fr = "no-candidates"
|
||||||
|
last = f"빈 응답(finish_reason={fr or '?'})"
|
||||||
|
except Exception as e:
|
||||||
|
last = f"{type(e).__name__}: {e}"
|
||||||
|
if attempt < retries:
|
||||||
|
wait = 2 * attempt # 2s, 4s, 6s … 점증 백오프
|
||||||
|
print(f"\n ↻ TTS 재시도 {attempt}/{retries-1} ({last}) — {wait}s 후",
|
||||||
|
end="", flush=True)
|
||||||
|
time.sleep(wait)
|
||||||
|
raise RuntimeError(f"TTS 실패(재시도 {retries}회): {last}\n 문장: {text[:40]}…")
|
||||||
|
|
||||||
|
|
||||||
|
def pcm_to_wav(pcm, path, rate):
|
||||||
|
with wave.open(str(path), "wb") as w:
|
||||||
|
w.setnchannels(1); w.setsampwidth(2); w.setframerate(rate)
|
||||||
|
w.writeframes(pcm)
|
||||||
|
|
||||||
|
|
||||||
|
def wav_seconds(path: Path) -> float:
|
||||||
|
with wave.open(str(path), "rb") as w:
|
||||||
|
return w.getnframes() / w.getframerate()
|
||||||
|
|
||||||
|
|
||||||
|
def _atempo_chain(speed: float) -> list:
|
||||||
|
"""atempo 는 0.5~2.0 범위만 받으므로 큰 배율은 곱으로 쪼개 체이닝."""
|
||||||
|
chain, s = [], speed
|
||||||
|
while s > 2.0:
|
||||||
|
chain.append("atempo=2.0"); s /= 2.0
|
||||||
|
while s < 0.5:
|
||||||
|
chain.append("atempo=0.5"); s *= 2.0
|
||||||
|
chain.append(f"atempo={s:.4f}")
|
||||||
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def process_audio(exe, src_wav, dst_wav, speed=1.0, trim_silence=True,
|
||||||
|
threshold="-38dB", keep=0.06):
|
||||||
|
"""컷편집용 음성 가공: 앞뒤 침묵 제거 + 말 속도 올리기.
|
||||||
|
|
||||||
|
- trim_silence: 앞/뒤 침묵을 잘라 컷이 탁탁 넘어가게(가운데 호흡은 유지).
|
||||||
|
silenceremove 로 앞을 자르고, areverse 로 뒤집어 다시 앞(=원래 뒤)을 자른 뒤 복원.
|
||||||
|
keep: 잘라낸 끝에 남길 여유(초). 작을수록 마디가 바짝 붙는다.
|
||||||
|
- speed: 1.0=원본, 1.3 이면 30% 빠르게(음정 유지). 길이도 그만큼 줄어든다.
|
||||||
|
가공 결과가 비거나 실패하면 원본을 그대로 복사해 안전하게 폴백.
|
||||||
|
"""
|
||||||
|
af = []
|
||||||
|
if trim_silence:
|
||||||
|
one = (f"silenceremove=start_periods=1:start_silence={keep}:"
|
||||||
|
f"start_threshold={threshold}:detection=peak")
|
||||||
|
af += [one, "areverse", one, "areverse"]
|
||||||
|
if speed and abs(speed - 1.0) > 1e-3:
|
||||||
|
af += _atempo_chain(speed)
|
||||||
|
if not af:
|
||||||
|
shutil.copyfile(src_wav, dst_wav); return
|
||||||
|
try:
|
||||||
|
_run([exe, "-y", "-i", str(src_wav), "-af", ",".join(af),
|
||||||
|
"-ar", "24000", "-ac", "1", str(dst_wav)])
|
||||||
|
if wav_seconds(dst_wav) < 0.15: # 과하게 잘렸으면 폴백
|
||||||
|
raise RuntimeError("가공 후 음성이 너무 짧음")
|
||||||
|
except (RuntimeError, OSError):
|
||||||
|
shutil.copyfile(src_wav, dst_wav)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 자막 유틸
|
||||||
|
def _wrap_lines(draw, text, font, max_w):
|
||||||
|
"""글자 단위로 픽셀 폭(max_w)에 맞춰 줄바꿈."""
|
||||||
|
lines, cur = [], ""
|
||||||
|
for ch in text:
|
||||||
|
if ch == "\n":
|
||||||
|
lines.append(cur); cur = ""; continue
|
||||||
|
if draw.textlength(cur + ch, font=font) <= max_w:
|
||||||
|
cur += ch
|
||||||
|
else:
|
||||||
|
lines.append(cur); cur = ch
|
||||||
|
if cur:
|
||||||
|
lines.append(cur)
|
||||||
|
return lines or [""]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- BGM
|
||||||
|
AUDIO_EXTS = (".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac", ".opus")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_bgm(bgm_arg):
|
||||||
|
"""--bgm 값 → 실제 음악 파일 경로(없으면 None).
|
||||||
|
|
||||||
|
해석 순서(분위기별로 bgm/ 폴더에 나눠 담는 걸 지원):
|
||||||
|
1) 파일이면 그대로
|
||||||
|
2) 폴더면 그 안 음악 중 무작위 1곡
|
||||||
|
3) 이름만 주면 HERE/<값> → HERE/bgm/<값> 순으로 파일/폴더 탐색
|
||||||
|
"""
|
||||||
|
if not bgm_arg or str(bgm_arg).strip().lower() in ("none", "off", "no"):
|
||||||
|
return None
|
||||||
|
import random
|
||||||
|
p = Path(bgm_arg)
|
||||||
|
cands = [p] if p.is_absolute() else [HERE / p, HERE / "bgm" / p]
|
||||||
|
for c in cands:
|
||||||
|
if c.is_file():
|
||||||
|
return c
|
||||||
|
if c.is_dir():
|
||||||
|
files = [f for f in sorted(c.iterdir()) if f.suffix.lower() in AUDIO_EXTS]
|
||||||
|
if files:
|
||||||
|
return random.choice(files)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def mix_bgm(exe, video_path, bgm_path, out_path, volume=0.3, fade=1.0):
|
||||||
|
"""완성 영상(나레이션 음성 포함) 위에 BGM 한 트랙을 작은 볼륨으로 깔아 최종본 저장.
|
||||||
|
|
||||||
|
- BGM 이 영상보다 짧으면 무한 루프(-stream_loop -1), 길면 영상 길이에 맞춰 잘림.
|
||||||
|
- 나레이션[0:a] + BGM[1:a]*volume 를 amix(duration=first)로 섞어 영상 길이에 맞춘다.
|
||||||
|
- 영상은 재인코딩하지 않는다(-c:v copy) → 빠름. 시작에 짧은 페이드인.
|
||||||
|
"""
|
||||||
|
fc = (f"[1:a]volume={volume},afade=t=in:st=0:d={fade}[bg];"
|
||||||
|
f"[0:a][bg]amix=inputs=2:duration=first:dropout_transition=0[a]")
|
||||||
|
_run([exe, "-y", "-i", str(video_path), "-stream_loop", "-1", "-i", str(bgm_path),
|
||||||
|
"-filter_complex", fc, "-map", "0:v", "-map", "[a]",
|
||||||
|
"-c:v", "copy", *_AUDIO_ARGS, "-shortest", "-movflags", "+faststart",
|
||||||
|
str(out_path)])
|
||||||
962
generator/naver.py
Normal file
962
generator/naver.py
Normal file
@ -0,0 +1,962 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
네이버 플레이스 크롤링 레이어 (AI브리핑 · 사진) — animation 3종(조선/삼국지/그리스로마) 공유본
|
||||||
|
====================================================================================
|
||||||
|
animation 계열이 공유하는 '링크 1개' 네이버 크롤러. ★ 이 파일 하나로 자립 동작.
|
||||||
|
· fetch_briefing 가게이름 + 업종타입(cat) + AI 브리핑(불릿)
|
||||||
|
· fetch_and_select 공식 사진 크롤 + Gemini 비전 선별(★인물 항상 제외)
|
||||||
|
공유 헬퍼(MOBILE_UA / STEALTH / extract_place)는 한 번만 정의.
|
||||||
|
※ 클립(fetch_clips)·지도(fetch_map)는 narration/kakao 폴더 삭제와 함께 제거됨(animation 미사용).
|
||||||
|
|
||||||
|
[정리한 것]
|
||||||
|
- 방문자 후기(fetch_reviews) 제거: AI요약(브리핑)만 나레이션 근거로 쓰므로 불필요.
|
||||||
|
- fetch_briefing 은 항상 3-tuple (store, bullets, cat) 반환. cat 안 쓰는 형식은 그냥 무시.
|
||||||
|
- 사진 선별은 항상 '인물 사진 제외'(초상권). 끄고 싶으면 fetch_and_select(..., exclude_person=False).
|
||||||
|
|
||||||
|
[봇 차단 회피] 자동화 흔적 줄인 스텔스 컨텍스트 + 모바일 UA 로 접근.
|
||||||
|
설치: pip install playwright google-genai pillow httpx ; playwright install chromium
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import random
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import unquote, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from google.genai import types
|
||||||
|
from PIL import Image
|
||||||
|
from playwright.async_api import async_playwright
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== 공유 헬퍼 (UA · 스텔스 · place id) ==============================
|
||||||
|
|
||||||
|
# ── 안티봇 핑거프린트 회피용 브라우저 프로필 후보군 ────────────────────────────────
|
||||||
|
# 네이버가 '같은 지문의 자동화 접근'을 반복 감지하면 캡차 벽을 내린다(_check_blocked 참고).
|
||||||
|
# 요청마다 프로필을 섞어 지문을 분산시킨다. ★ 핵심: 한 프로필 안의 UA·client-hint(sec-ch-ua)·
|
||||||
|
# platform·viewport 는 서로 '모순 없이' 맞물려야 한다 — 실제 브라우저면 절대 어긋나지 않는 값들이라
|
||||||
|
# 하나라도 어긋나면 그 자체가 봇 신호가 된다.
|
||||||
|
# · iOS Safari 는 sec-ch-ua 계열을 아예 보내지 않음 → 힌트 비움(client_hints=None).
|
||||||
|
# · Android Chrome 은 sec-ch-ua / -mobile / -platform 3종을 UA 버전과 일치시켜 보냄.
|
||||||
|
BROWSER_PROFILES = [
|
||||||
|
{
|
||||||
|
"name": "ios-safari-16",
|
||||||
|
"ua": ("Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) "
|
||||||
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/604.1"),
|
||||||
|
"platform": "iPhone",
|
||||||
|
"viewport": {"width": 414, "height": 896},
|
||||||
|
"dsf": 3,
|
||||||
|
"client_hints": None, # Safari 는 client-hint 미전송
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ios-safari-17",
|
||||||
|
"ua": ("Mozilla/5.0 (iPhone; CPU iPhone OS 17_5_1 like Mac OS X) "
|
||||||
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1"),
|
||||||
|
"platform": "iPhone",
|
||||||
|
"viewport": {"width": 390, "height": 844},
|
||||||
|
"dsf": 3,
|
||||||
|
"client_hints": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "android-chrome-126",
|
||||||
|
"ua": ("Mozilla/5.0 (Linux; Android 14; SM-S918N) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/126.0.0.0 Mobile Safari/537.36"),
|
||||||
|
"platform": "Linux armv81",
|
||||||
|
"viewport": {"width": 412, "height": 915},
|
||||||
|
"dsf": 2.625,
|
||||||
|
"client_hints": {
|
||||||
|
"sec-ch-ua": '"Not/A)Brand";v="8", "Chromium";v="126", "Google Chrome";v="126"',
|
||||||
|
"sec-ch-ua-mobile": "?1",
|
||||||
|
"sec-ch-ua-platform": '"Android"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "android-chrome-125-pixel",
|
||||||
|
"ua": ("Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||||
|
"Chrome/125.0.0.0 Mobile Safari/537.36"),
|
||||||
|
"platform": "Linux armv81",
|
||||||
|
"viewport": {"width": 412, "height": 883},
|
||||||
|
"dsf": 2.625,
|
||||||
|
"client_hints": {
|
||||||
|
"sec-ch-ua": '"Google Chrome";v="125", "Chromium";v="125", "Not.A/Brand";v="24"',
|
||||||
|
"sec-ch-ua-mobile": "?1",
|
||||||
|
"sec-ch-ua-platform": '"Android"',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def pick_profile(profile=None):
|
||||||
|
"""브라우저 프로필 선택. 문자열이면 name 매칭, dict 면 그대로, None 이면 랜덤."""
|
||||||
|
if isinstance(profile, dict):
|
||||||
|
return profile
|
||||||
|
if isinstance(profile, str):
|
||||||
|
for pr in BROWSER_PROFILES:
|
||||||
|
if pr["name"] == profile:
|
||||||
|
return pr
|
||||||
|
return random.choice(BROWSER_PROFILES)
|
||||||
|
|
||||||
|
|
||||||
|
def _stealth_script(platform: str) -> str:
|
||||||
|
"""프로필 platform 과 일관된 자동화 탐지 우회 init 스크립트.
|
||||||
|
Safari(iPhone) 프로필에선 Chromium 이 노출하는 navigator.userAgentData(실제 Safari 엔 없음)를
|
||||||
|
제거해, UA 는 Safari 인데 JS 지문은 Chromium 인 모순을 없앤다."""
|
||||||
|
script = (
|
||||||
|
"Object.defineProperty(navigator,'webdriver',{get:()=>undefined});"
|
||||||
|
"Object.defineProperty(navigator,'languages',{get:()=>['ko-KR','ko']});"
|
||||||
|
f"Object.defineProperty(navigator,'platform',{{get:()=>{json.dumps(platform)}}});"
|
||||||
|
"window.chrome={runtime:{}};"
|
||||||
|
)
|
||||||
|
if platform == "iPhone":
|
||||||
|
script += "try{Object.defineProperty(navigator,'userAgentData',{get:()=>undefined});}catch(e){}"
|
||||||
|
return script
|
||||||
|
|
||||||
|
|
||||||
|
# 하위호환: 기존에 MOBILE_UA/STEALTH 를 직접 참조하던 코드용(기본 프로필 값).
|
||||||
|
MOBILE_UA = BROWSER_PROFILES[0]["ua"]
|
||||||
|
STEALTH = _stealth_script(BROWSER_PROFILES[0]["platform"])
|
||||||
|
|
||||||
|
VISION_MODEL = "gemini-2.5-flash"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_place(final_url: str):
|
||||||
|
"""리다이렉트된 최종 URL → (업종타입, place id). 예: ('restaurant', '1234567')."""
|
||||||
|
m = re.search(r"place\.naver\.com/([a-z]+)/(\d+)", final_url)
|
||||||
|
if m:
|
||||||
|
return m.group(1), m.group(2)
|
||||||
|
m2 = re.search(r"(\d{7,})", final_url)
|
||||||
|
return "restaurant", (m2.group(1) if m2 else None)
|
||||||
|
|
||||||
|
|
||||||
|
def looks_like_naver_place(s: str) -> bool:
|
||||||
|
return bool(s) and ("naver.me/" in s or "place.naver.com" in s or "map.naver.com" in s)
|
||||||
|
|
||||||
|
|
||||||
|
async def _goto_tolerant(page, url, timeout=30000):
|
||||||
|
"""naver.me 단축링크는 즉시 리다이렉트하며 최초 내비게이션을 Chromium 이 ERR_ABORTED 로 끊을
|
||||||
|
때가 있다(원문서가 커밋되기 전에 리다이렉트됨). goto 예외가 ABORTED/net::ERR 계열이면 리다이렉트는
|
||||||
|
실제로 착지하므로 예외를 삼키고 잠깐 기다렸다 진행한다(그 외 예외는 그대로 전파)."""
|
||||||
|
try:
|
||||||
|
await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
||||||
|
except Exception as e:
|
||||||
|
if "ERR_ABORTED" not in str(e) and "net::ERR" not in str(e):
|
||||||
|
raise
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_place_home(page, url):
|
||||||
|
"""링크 → 실제 업종 홈으로 진입하고 (cat, pid) 반환.
|
||||||
|
naver.me 는 UA(프로필)에 따라 앱브릿지(m.map.naver.com/appLink)로 새어 업종을 못 읽을 때가 있다.
|
||||||
|
→ place id 만 먼저 뽑은 뒤, 업종 비의존 제네릭 진입(m.place.naver.com/place/{id}/home)으로
|
||||||
|
재진입해 네이버가 실제 업종(accommodation/restaurant/…)으로 리다이렉트하게 한다. pid 없으면 예외.
|
||||||
|
※ naver.me goto 는 ERR_ABORTED 로 끊기거나 리다이렉트 실패로 about:blank 에 머물 수 있어 최대 3회 재시도."""
|
||||||
|
pid = None
|
||||||
|
for _ in range(3):
|
||||||
|
await _goto_tolerant(page, url)
|
||||||
|
await page.wait_for_timeout(2500)
|
||||||
|
_, pid = extract_place(page.url)
|
||||||
|
if pid:
|
||||||
|
break
|
||||||
|
await page.wait_for_timeout(1200) # 미착지(about:blank) → 잠깐 쉬고 goto 재시도
|
||||||
|
if not pid:
|
||||||
|
raise RuntimeError("place id 를 못 찾음. URL 확인 또는 headful=True 로 화면 확인.")
|
||||||
|
await _goto_tolerant(page, f"https://m.place.naver.com/place/{pid}/home")
|
||||||
|
# 제네릭 '/place/' → 실제 업종('/accommodation/' 등)으로 리다이렉트될 때까지 폴링(최대 ~6s)
|
||||||
|
cat = "place"
|
||||||
|
for _ in range(12):
|
||||||
|
await page.wait_for_timeout(500)
|
||||||
|
c, _pid = extract_place(page.url)
|
||||||
|
if c and c != "place":
|
||||||
|
cat = c
|
||||||
|
break
|
||||||
|
return cat, pid
|
||||||
|
|
||||||
|
|
||||||
|
class NaverBlockedError(RuntimeError):
|
||||||
|
"""네이버 봇 탐지(캡차/보안 확인)로 접근이 막힌 상태. 프로필 전환 재시도의 트리거."""
|
||||||
|
|
||||||
|
|
||||||
|
# 봇벽 차단 시 재시도 한도 = 최대 3회(최초 1회 + 재시도 3회 = 총 4회). 프로필 개수로도 상한.
|
||||||
|
MAX_BLOCK_RETRIES = 3
|
||||||
|
# 프로필 전환 재시도 사이 점진적 대기(초). 같은 IP 를 연달아 두드려 차단이 굳는 걸 늦춘다.
|
||||||
|
BLOCK_RETRY_DELAYS = [3, 6, 12]
|
||||||
|
|
||||||
|
|
||||||
|
def _shuffled_profiles():
|
||||||
|
"""중복 없이 섞은 프로필 순서 — 재시도마다 서로 다른 지문을 쓰도록 보장."""
|
||||||
|
order = BROWSER_PROFILES[:]
|
||||||
|
random.shuffle(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_blocked(page):
|
||||||
|
"""네이버 봇 탐지(보안 확인/캡차) 페이지인지 확인. 걸리면 뒤 폴링/파싱은 항상 빈 결과만
|
||||||
|
내놓아 'AI 브리핑/리뷰가 없다'로 오인되므로, 원인이 드러나도록 즉시 예외로 구분한다."""
|
||||||
|
try:
|
||||||
|
text = await page.evaluate("document.body.innerText")
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
if "보안 확인을 완료해" in text:
|
||||||
|
raise NaverBlockedError(
|
||||||
|
"네이버가 자동화 접근으로 판단해 보안 인증(캡차)을 요구했어요. "
|
||||||
|
"브리핑/리뷰가 없는 게 아니라 접근이 막힌 상태 — 잠시 후 다시 시도하세요."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_profile_retry(attempt, label=""):
|
||||||
|
"""attempt(profile) 코루틴을 프로필을 바꿔가며 시도한다. NaverBlockedError(봇벽) 에만
|
||||||
|
다른 프로필로 재시도하고, 그 외 예외/정상 반환은 즉시 전파한다(잘못된 URL·브리핑 부재 등은
|
||||||
|
프로필을 바꿔도 결과가 같으므로 재시도하지 않는다). 마지막 시도 실패 시 마지막 예외를 던진다.
|
||||||
|
시도 횟수 = min(프로필 수, MAX_BLOCK_RETRIES + 1)."""
|
||||||
|
profiles = _shuffled_profiles()[: MAX_BLOCK_RETRIES + 1]
|
||||||
|
last_exc = None
|
||||||
|
for i, pr in enumerate(profiles):
|
||||||
|
try:
|
||||||
|
return await attempt(pr)
|
||||||
|
except NaverBlockedError as e:
|
||||||
|
last_exc = e
|
||||||
|
if i < len(profiles) - 1:
|
||||||
|
delay = BLOCK_RETRY_DELAYS[min(i, len(BLOCK_RETRY_DELAYS) - 1)]
|
||||||
|
print(f"■ {label}프로필 '{pr['name']}' 차단 → {delay}s 후 다른 프로필로 재시도...")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_store(raw: str) -> str:
|
||||||
|
"""og:title/title 값에서 실제 가게명만 정리. 예: '골목냉면 : 네이버\\x1c' → '골목냉면'."""
|
||||||
|
if not raw:
|
||||||
|
return ""
|
||||||
|
s = re.sub(r"[\x00-\x1f]", "", raw) # 제어문자 제거
|
||||||
|
s = re.split(r"\s*[:\-|]\s*네이버", s)[0] # ' : 네이버', ' - 네이버 지도' 접미사 제거
|
||||||
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
|
return s.splitlines()[0].strip() if s else ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _store_name(page) -> str:
|
||||||
|
"""가게명 추출. og:title 메타(가장 안정적) 우선 → title → 화면 요소.
|
||||||
|
섹션 헤딩('AI 브리핑' 등)이나 '네이버' 포함 값은 배제한다.
|
||||||
|
(네이버가 span.GHAhO 같은 난독 클래스를 자주 바꿔 화면 셀렉터만으론 불안정하므로 메타 우선.)"""
|
||||||
|
async def _meta():
|
||||||
|
return await page.locator("meta[property='og:title']").first.get_attribute("content", timeout=1500)
|
||||||
|
for getter in (_meta, page.title):
|
||||||
|
try:
|
||||||
|
name = _clean_store(await getter())
|
||||||
|
if name and name != "AI 브리핑" and "네이버" not in name:
|
||||||
|
return name
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for sel in ("span.GHAhO", "#_title span", "h2"): # 최후 폴백(섹션명 배제)
|
||||||
|
try:
|
||||||
|
t = (await page.locator(sel).first.inner_text(timeout=1200)).strip().splitlines()[0]
|
||||||
|
if t and t != "AI 브리핑" and not t.startswith("AI "):
|
||||||
|
return t
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _new_page(p, headful=False, viewport=None, device_scale_factor=None, profile=None):
|
||||||
|
"""공통 브라우저 컨텍스트(스텔스 + 프로필 지문) → (browser, ctx, page).
|
||||||
|
profile: name 문자열 / dict / None(랜덤). UA·client-hint·platform·viewport 를 한 세트로 적용.
|
||||||
|
viewport·device_scale_factor 인자를 명시하면 프로필 값보다 우선(사진 크롤 등 해상도 고정용)."""
|
||||||
|
pr = pick_profile(profile)
|
||||||
|
browser = await p.chromium.launch(
|
||||||
|
headless=not headful,
|
||||||
|
args=["--disable-blink-features=AutomationControlled", "--no-sandbox"])
|
||||||
|
headers = {"Accept-Language": "ko-KR,ko;q=0.9"}
|
||||||
|
if pr.get("client_hints"):
|
||||||
|
headers.update(pr["client_hints"])
|
||||||
|
else:
|
||||||
|
# Safari 프로필: Chromium 이 기본으로 흘리는 sec-ch-ua(…HeadlessChrome…) 를 빈 값으로 덮어
|
||||||
|
# 'Safari UA + Chromium 힌트' 모순 지문을 차단(실제 Safari 는 이 헤더들을 안 보냄).
|
||||||
|
headers.update({"sec-ch-ua": "", "sec-ch-ua-mobile": "", "sec-ch-ua-platform": ""})
|
||||||
|
kwargs = dict(user_agent=pr["ua"], locale="ko-KR",
|
||||||
|
viewport=viewport or pr.get("viewport") or {"width": 414, "height": 896},
|
||||||
|
extra_http_headers=headers)
|
||||||
|
dsf = device_scale_factor or pr.get("dsf")
|
||||||
|
if dsf:
|
||||||
|
kwargs["device_scale_factor"] = dsf
|
||||||
|
ctx = await browser.new_context(**kwargs)
|
||||||
|
await ctx.add_init_script(_stealth_script(pr.get("platform", "iPhone")))
|
||||||
|
page = await ctx.new_page()
|
||||||
|
return browser, ctx, page
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== 브리핑 (이름 · 업종타입 · AI요약) ==============================
|
||||||
|
|
||||||
|
# AI 브리핑은 DOM 스크래핑이 아니라 네이버 플레이스가 진입 직후 쏘는 GraphQL(getAiBriefing) 응답에서
|
||||||
|
# 구조화된 형태로 온다: data.aiBriefing.textSummaries[].sentence. DOM 렌더/스크롤 타이밍에 의존하는
|
||||||
|
# 기존 방식(_EXTRACT_JS)은 (a) '상단 1줄 요약'과 '하단 상세 브리핑'을 혼동하고, (b) 브리핑이 있어도
|
||||||
|
# 상단 '요약 티저'가 없는 업체를 놓치는 취약점이 있어, 응답 자체를 가로채는 방식으로 대체했다.
|
||||||
|
|
||||||
|
# getAiBriefing 배치 GraphQL 응답의 인트로(preamble) 문장 — 마케팅 소재론 불필요하므로 버린다.
|
||||||
|
_BRIEFING_INTRO_RE = re.compile(
|
||||||
|
r"(리뷰[를에]?\s*(바탕|기반|활용))|요약(했|해|합|하면)|정리한\s*(정보|내용)|다음과\s*같습니다")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ai_briefing_response(resp):
|
||||||
|
"""이 응답이 getAiBriefing 오퍼레이션을 포함한 GraphQL 배치 응답인가."""
|
||||||
|
if "graphql" not in resp.url:
|
||||||
|
return False
|
||||||
|
req = resp.request
|
||||||
|
if req.method != "POST":
|
||||||
|
return False
|
||||||
|
return "getAiBriefing" in (req.post_data or "")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ai_briefing(txt):
|
||||||
|
"""getAiBriefing 배치 응답(JSON 문자열) 파싱.
|
||||||
|
반환:
|
||||||
|
[불릿...] — aiBriefing.textSummaries 에 내용 있음(브리핑 존재). 인트로 문장·'객실 정보:' 류
|
||||||
|
라벨 제거 + <b> 태그 제거 + 중복 제거까지 마친 마케팅용 불릿.
|
||||||
|
[] — aiBriefing 필드는 있으나 null/빈 내용(브리핑 없는 업체) → 확정적 '없음'.
|
||||||
|
None — 배치에 aiBriefing 필드 자체가 없음(무관한 배치/파싱 실패) → 판정 불가(대기 계속)."""
|
||||||
|
try:
|
||||||
|
batch = json.loads(txt)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
if not isinstance(batch, list):
|
||||||
|
batch = [batch]
|
||||||
|
for item in batch:
|
||||||
|
data = (item or {}).get("data") or {}
|
||||||
|
if "aiBriefing" not in data:
|
||||||
|
continue
|
||||||
|
ai = data.get("aiBriefing") or {}
|
||||||
|
summaries = ai.get("textSummaries") or []
|
||||||
|
out, seen = [], set()
|
||||||
|
for ts in summaries:
|
||||||
|
s = re.sub(r"</?b>", "", (ts.get("sentence") or "")).strip()
|
||||||
|
if not s or _BRIEFING_INTRO_RE.search(s):
|
||||||
|
continue # 빈 문장·인트로(preamble) 제외
|
||||||
|
s = re.sub(r"^[가-힣A-Za-z ]{1,12}:\s*", "", s) # '객실 정보: ' 류 라벨 제거
|
||||||
|
s = re.sub(r"\s+", " ", s).strip()
|
||||||
|
if s and s not in seen:
|
||||||
|
seen.add(s); out.append(s)
|
||||||
|
return out # 내용 있으면 [불릿...], 없으면 []
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
AI_BRIEFING_TIMEOUT = 18 # getAiBriefing 응답 대기(초). 정상은 ~6s, 이 안에 안 오면 스로틀/지연으로 본다.
|
||||||
|
AI_BRIEFING_RETRIES = 2 # 응답이 끝내 안 올 때(kind='timeout') 새 세션으로 재시도할 횟수.
|
||||||
|
AI_BRIEFING_RETRY_DELAY = 4 # 재시도 전 대기(초) — 연속 타격으로 스로틀이 굳는 걸 늦춘다.
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch(url, headful=False):
|
||||||
|
"""봇벽 차단 시 프로필을 바꿔가며 재시도(_with_profile_retry). 추가로, getAiBriefing 응답이
|
||||||
|
끝내 안 오면(kind='timeout', 스로틀/지연 추정) 새 세션으로 최대 AI_BRIEFING_RETRIES 회 재시도한다.
|
||||||
|
반환은 공개 API 형식인 3-튜플 (store, bullets, cat) — 내부 kind 는 여기서 소비.
|
||||||
|
kind='briefing' → 브리핑 확보(bullets 채움)
|
||||||
|
kind='none' → 브리핑 없는 업체 확정(bullets=None → 호출부가 리뷰 폴백)
|
||||||
|
kind='timeout' → 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
|
||||||
|
last_store, last_cat = None, None
|
||||||
|
for i in range(AI_BRIEFING_RETRIES + 1):
|
||||||
|
store, bullets, cat, kind = await _with_profile_retry(
|
||||||
|
lambda pr: _fetch_once(url, pr, headful=headful), label="브리핑 ")
|
||||||
|
last_store, last_cat = store, cat
|
||||||
|
if kind in ("briefing", "none"):
|
||||||
|
return store, bullets, cat # 있음/없음 확정 → 재시도 무의미
|
||||||
|
# kind == 'timeout': getAiBriefing 응답이 안 옴 → 새 세션 재시도
|
||||||
|
if i < AI_BRIEFING_RETRIES:
|
||||||
|
print(f"■ '{store or '이 가게'}' AI 브리핑 응답 지연(스로틀 추정) → "
|
||||||
|
f"{AI_BRIEFING_RETRY_DELAY}s 후 새 세션 재시도 ({i + 1}/{AI_BRIEFING_RETRIES})...")
|
||||||
|
await asyncio.sleep(AI_BRIEFING_RETRY_DELAY)
|
||||||
|
return last_store, None, last_cat # 재시도 소진 → 리뷰 폴백
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_once(url, profile, headful=False):
|
||||||
|
"""네이버 플레이스 진입 → getAiBriefing GraphQL 응답을 가로채 브리핑을 파싱.
|
||||||
|
반환 4-튜플 (store, bullets, cat, kind):
|
||||||
|
kind='briefing' → bullets=[불릿...] (브리핑 존재)
|
||||||
|
kind='none' → bullets=None (aiBriefing null/빈 = 브리핑 없는 업체)
|
||||||
|
kind='timeout' → bullets=None (응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
|
||||||
|
|
||||||
|
# getAiBriefing 응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
|
||||||
|
# parsed: [불릿...] 또는 [](없음 확정) 이면 done, None(무관/파싱실패)이면 계속 대기.
|
||||||
|
captured = {"bullets": None}
|
||||||
|
done = asyncio.Event()
|
||||||
|
|
||||||
|
async def on_response(resp):
|
||||||
|
if done.is_set() or not _is_ai_briefing_response(resp):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
txt = await resp.text()
|
||||||
|
except Exception:
|
||||||
|
return # 응답 바디를 못 읽음(경합 등) → 무시
|
||||||
|
parsed = _parse_ai_briefing(txt)
|
||||||
|
if parsed is None:
|
||||||
|
return # aiBriefing 필드 없는 배치 → 계속 대기
|
||||||
|
captured["bullets"] = parsed # [불릿...] 또는 [](없음) → 확정
|
||||||
|
done.set()
|
||||||
|
|
||||||
|
page.on("response", lambda r: asyncio.ensure_future(on_response(r)))
|
||||||
|
|
||||||
|
try:
|
||||||
|
cat, _pid = await _resolve_place_home(page, url)
|
||||||
|
except NaverBlockedError:
|
||||||
|
await browser.close()
|
||||||
|
raise # 봇벽 → 프로필 전환 재시도(_with_profile_retry)
|
||||||
|
except Exception:
|
||||||
|
# 단축링크가 끝내 안 풀림(about:blank 등, 대개 일시적 스로틀) → 예외로 죽이지 말고
|
||||||
|
# timeout 으로 돌려 새 세션 재시도 대상이 되게 한다(소진 시 None → 호출부 리뷰 폴백).
|
||||||
|
await browser.close()
|
||||||
|
return None, None, None, "timeout"
|
||||||
|
|
||||||
|
store = await _store_name(page)
|
||||||
|
try:
|
||||||
|
await _check_blocked(page)
|
||||||
|
except Exception:
|
||||||
|
await browser.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# getAiBriefing 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
|
||||||
|
got = True
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(done.wait(), timeout=AI_BRIEFING_TIMEOUT)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
got = False
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
|
||||||
|
if not got:
|
||||||
|
return store, None, cat, "timeout" # 응답 보류 → 재시도 대상
|
||||||
|
bullets = captured["bullets"]
|
||||||
|
if not bullets: # [] = aiBriefing null/빈 → 브리핑 없음
|
||||||
|
return store, None, cat, "none"
|
||||||
|
return store, bullets, cat, "briefing"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_briefing(url, headful=False):
|
||||||
|
"""동기 진입점. (가게이름, [불릿...], 업종타입) 반환.
|
||||||
|
업종타입 = 링크의 플레이스 타입(restaurant/accommodation/hairshop…). 브리핑 없으면 불릿이 None.
|
||||||
|
※ animation 처럼 업종이 필요 없는 형식은 3번째 값을 그냥 무시하면 된다."""
|
||||||
|
return asyncio.run(_fetch(url, headful=headful))
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== 리뷰 크롤링 (브리핑 없을 때 폴백) ==============================
|
||||||
|
|
||||||
|
# 방문자 리뷰 '본문' 셀렉터. 네이버가 난독 클래스(pui__…)를 자주 바꾸므로 우선순위대로 시도해
|
||||||
|
# '충분히(3개+) 잡히는 첫 셀렉터'를 쓴다. ★ 최다매칭이 아님 — 작성자/메타 링크(a.pui__GStJHb 등)가
|
||||||
|
# 더 많이 잡혀서 그걸 고르면 후기가 아니라 '작성자 이름·리뷰수'가 수집되는 문제가 있었다.
|
||||||
|
# 리뷰 본문 전용 클래스(.pui__vn15t2)를 최우선으로 둔다.
|
||||||
|
_REVIEW_JS = r"""() => {
|
||||||
|
const SELS = ['.pui__vn15t2', 'a.pui__xtsQN-', 'div.pui__vn15t2', 'span.pui__gfuUIT'];
|
||||||
|
const clean = s => (s || '').replace(/\s*더보기\s*$/,'').replace(/\s+/g,' ').trim();
|
||||||
|
for (const sel of SELS) {
|
||||||
|
let els;
|
||||||
|
try { els = [...document.querySelectorAll(sel)]; } catch (e) { continue; }
|
||||||
|
const texts = els.map(e => clean(e.innerText))
|
||||||
|
.filter(t => t.length >= 8 && /[가-힣]/.test(t));
|
||||||
|
if (texts.length >= 3) return texts;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_reviews(url, max_reviews=30, headful=False):
|
||||||
|
"""봇벽 차단 시 프로필을 바꿔가며 재시도(최대 MAX_BLOCK_RETRIES 회)."""
|
||||||
|
return await _with_profile_retry(
|
||||||
|
lambda pr: _fetch_reviews_once(url, pr, max_reviews=max_reviews, headful=headful),
|
||||||
|
label="리뷰 ")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_reviews_once(url, profile, max_reviews=30, headful=False):
|
||||||
|
"""방문자 리뷰 탭에서 '더보기'를 모두 펼쳐 리뷰 본문을 수집. (가게이름, [리뷰문장…]) 반환."""
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
|
||||||
|
|
||||||
|
try:
|
||||||
|
cat, pid = await _resolve_place_home(page, url)
|
||||||
|
except Exception:
|
||||||
|
await browser.close()
|
||||||
|
raise
|
||||||
|
store = await _store_name(page)
|
||||||
|
try:
|
||||||
|
await _check_blocked(page)
|
||||||
|
except Exception:
|
||||||
|
await browser.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# 방문자 리뷰 탭으로 이동(구조에 따라 /review/visitor 또는 /review)
|
||||||
|
reviews, seen = [], set()
|
||||||
|
for tab in ("review/visitor", "review"):
|
||||||
|
try:
|
||||||
|
resp = await page.goto(f"https://m.place.naver.com/{cat}/{pid}/{tab}",
|
||||||
|
wait_until="domcontentloaded", timeout=25000)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if resp and resp.status >= 400:
|
||||||
|
continue
|
||||||
|
await page.wait_for_timeout(2200)
|
||||||
|
|
||||||
|
# 스크롤로 리뷰를 더 로드하면서, 매 라운드 '더보기'를 눌러 본문을 전부 펼친 뒤 수집.
|
||||||
|
stagnant = 0
|
||||||
|
for _ in range(16):
|
||||||
|
# ★ 리뷰 '본문(.pui__vn15t2)' 안의 더보기만 클릭한다.
|
||||||
|
# 페이지 전체의 '더보기'를 무차별 클릭하면 사진/방문정보 더보기가 눌려
|
||||||
|
# 리뷰 목록이 통째로 사라지는(0개 수집) 문제가 있었다 → 본문 내부로 한정.
|
||||||
|
try:
|
||||||
|
bodies = page.locator(".pui__vn15t2")
|
||||||
|
bn = await bodies.count()
|
||||||
|
for i in range(bn):
|
||||||
|
mb = bodies.nth(i).locator(
|
||||||
|
"xpath=.//*[normalize-space(text())='더보기']")
|
||||||
|
if await mb.count():
|
||||||
|
try:
|
||||||
|
await mb.first.click(timeout=400)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await page.wait_for_timeout(400)
|
||||||
|
|
||||||
|
for t in await page.evaluate(_REVIEW_JS):
|
||||||
|
if t not in seen:
|
||||||
|
seen.add(t); reviews.append(t)
|
||||||
|
|
||||||
|
if len(reviews) >= max_reviews:
|
||||||
|
break
|
||||||
|
before = len(reviews)
|
||||||
|
# 무한스크롤 트리거: 문서 맨 아래로 점프 + 휠 + 대기(리뷰 추가 로드 유발)
|
||||||
|
try:
|
||||||
|
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await page.mouse.wheel(0, 3200)
|
||||||
|
await page.wait_for_timeout(1100)
|
||||||
|
stagnant = stagnant + 1 if len(reviews) == before else 0
|
||||||
|
if stagnant >= 4: # 4라운드 연속 새 리뷰 없음 → 끝까지 봤다고 판단
|
||||||
|
break
|
||||||
|
|
||||||
|
if reviews: # 이 탭에서 뭔가 건졌으면 다른 탭은 시도 안 함
|
||||||
|
break
|
||||||
|
|
||||||
|
await browser.close()
|
||||||
|
return store, reviews[:max_reviews]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_reviews(url, max_reviews=30, headful=False):
|
||||||
|
"""동기 진입점. 방문자 리뷰 본문을 '더보기'까지 펼쳐 최대 max_reviews 개 수집. (가게이름, [리뷰…])."""
|
||||||
|
return asyncio.run(_fetch_reviews(url, max_reviews=max_reviews, headful=headful))
|
||||||
|
|
||||||
|
|
||||||
|
_REVIEW_SUMMARY_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"bullets": {"type": "array", "items": {"type": "string"},
|
||||||
|
"description": "가게 홍보 소재로 쓸 핵심 특징 불릿(각 한 문장)"},
|
||||||
|
},
|
||||||
|
"required": ["bullets"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize_reviews(client, store, reviews, model="gemini-2.5-flash", n=4):
|
||||||
|
"""방문자 리뷰들을 Gemini 로 요약해 '브리핑 대체' 불릿 리스트로 반환(실패 시 빈 리스트).
|
||||||
|
AI 브리핑이 없는 가게에서, 브리핑과 같은 형식(짧은 특징 문장들)을 만들어 나레이션 근거로 쓴다."""
|
||||||
|
reviews = [r for r in reviews if r][:40]
|
||||||
|
if not reviews:
|
||||||
|
return []
|
||||||
|
joined = "\n".join(f"- {r}" for r in reviews)
|
||||||
|
sname = store or "이 가게"
|
||||||
|
prompt = (
|
||||||
|
f"다음은 '{sname}'를 방문한 손님들이 남긴 실제 리뷰 모음이다.\n"
|
||||||
|
f"이 리뷰들을 종합해서, 가게를 홍보 영상 소재로 쓸 수 있도록 핵심 특징을 "
|
||||||
|
f"{n}개의 짧은 불릿으로 요약하라.\n"
|
||||||
|
f"- ★ 첫 번째 불릿은 반드시 가게 이름 '{sname}'(으)로 시작해, 이 가게가 어떤 곳/무슨 메뉴인지 "
|
||||||
|
f"한 문장으로 소개하라. (예: '{sname}은(는) …가 유명한 …맛집입니다.')\n"
|
||||||
|
f"- 나머지 불릿에도 맥락이 필요하면 '{sname}'을(를) 자연스럽게 써도 된다. 절대 'OO식당' 같은 "
|
||||||
|
"익명 표현으로 바꾸지 마라 — 반드시 실제 상호를 쓴다.\n"
|
||||||
|
"- 각 불릿은 한 문장. 메뉴/맛/분위기/서비스/특징 위주로.\n"
|
||||||
|
"- 여러 리뷰에서 반복되는 공통점을 우선. 한두 명만 말한 지엽적 내용은 제외.\n"
|
||||||
|
"- 리뷰에 없는 사실을 지어내지 마라(과장·거짓 금지).\n\n"
|
||||||
|
f"[리뷰]\n{joined}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=model, contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.4, response_mime_type="application/json",
|
||||||
|
response_schema=_REVIEW_SUMMARY_SCHEMA),
|
||||||
|
)
|
||||||
|
out, seen = [], set()
|
||||||
|
for b in json.loads(resp.text).get("bullets", []):
|
||||||
|
b = re.sub(r"\s+", " ", str(b)).strip()
|
||||||
|
if b and b not in seen:
|
||||||
|
seen.add(b); out.append(b)
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================== 사진 (크롤 + Gemini 비전 선별, 인물 제외) ==============================
|
||||||
|
|
||||||
|
# 공식 대표사진 CDN(업체 등록)만 후보로 사용.
|
||||||
|
# 손님 리뷰 사진(pup-review-*)은 저작권/초상권이 '리뷰 작성자 개인'에게 있어
|
||||||
|
# 가게 동의만으론 광고 영상에 쓸 수 없으므로 제외한다.
|
||||||
|
# 공식 대표사진이 오는 호스트(둘 다 '업체가 직접 등록'한 사진이라 광고 사용 가능):
|
||||||
|
# · ldb-phinf : 음식점/일반 업체가 등록한 공식 대표사진
|
||||||
|
# · naverbooking-phinf : 네이버 '예약'형 숙소(펜션·게하·글램핑)의 공식 객실 사진
|
||||||
|
# → 예약 숙소는 ldb 대신 이쪽으로 온다 (이게 'accommodation 사진 0장'의 원인이었음)
|
||||||
|
# 손님 리뷰 사진(pup-review-*)은 초상권 문제로 의도적 제외.
|
||||||
|
OFFICIAL_HOST = "ldb-phinf.pstatic.net"
|
||||||
|
BOOKING_HOST = "naverbooking-phinf.pstatic.net"
|
||||||
|
REVIEW_HOST = "pup-review-phinf.pstatic.net" # (참고: 의도적으로 후보에서 제외)
|
||||||
|
CAND_HOSTS = (OFFICIAL_HOST, BOOKING_HOST)
|
||||||
|
|
||||||
|
|
||||||
|
def _underlying(url: str) -> str:
|
||||||
|
"""search.pstatic.net/common?...&src=<진짜URL> 프록시면 src 를 풀어서 반환."""
|
||||||
|
m = re.search(r"[?&]src=([^&]+)", url)
|
||||||
|
if m:
|
||||||
|
return unquote(m.group(1))
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
async def crawl_and_download(url, dst_dir, max_candidates=14, headful=False):
|
||||||
|
"""사진 탭 → 공식 대표사진을 dst_dir 에 내려받고 (가게이름, [경로]) 반환."""
|
||||||
|
dst_dir = Path(dst_dir)
|
||||||
|
dst_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
seen, seen_tab, store = [], [], ""
|
||||||
|
cur = {"label": "업체"} # 현재 보고 있는 사진탭 카테고리(응답에 태깅)
|
||||||
|
|
||||||
|
async with async_playwright() as p:
|
||||||
|
browser, ctx, page = await _new_page(p, headful=headful)
|
||||||
|
|
||||||
|
def on_resp(resp):
|
||||||
|
u = resp.url
|
||||||
|
if "phinf.pstatic.net" in u or "search.pstatic.net/common" in u:
|
||||||
|
seen.append(u); seen_tab.append(cur["label"])
|
||||||
|
page.on("response", on_resp)
|
||||||
|
|
||||||
|
# 링크 → place id → 사진 탭
|
||||||
|
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
cat, pid = extract_place(page.url)
|
||||||
|
if not pid:
|
||||||
|
await browser.close()
|
||||||
|
raise RuntimeError("place id 를 못 찾음.")
|
||||||
|
store = await _store_name(page)
|
||||||
|
|
||||||
|
await page.goto(f"https://m.place.naver.com/{cat}/{pid}/photo",
|
||||||
|
wait_until="domcontentloaded", timeout=30000)
|
||||||
|
await page.wait_for_timeout(2000)
|
||||||
|
|
||||||
|
# [핵심] 사진탭 상단 카테고리 탭(업체/내부/음식·음료/외부/풍경...)을 돌며 로드.
|
||||||
|
# 기본 '업체' 탭엔 대표사진 몇 장뿐 → 다른 탭을 눌러야 음식·음료(와인 등)·내부가 나온다.
|
||||||
|
# (세션이 길면 헤드리스 브라우저가 불안정해지므로 탭 수·스크롤을 적당히 제한)
|
||||||
|
try:
|
||||||
|
chips = [c.strip() for c in await page.locator("a.sbkBy").all_inner_texts()]
|
||||||
|
except Exception:
|
||||||
|
chips = []
|
||||||
|
targets = [c for c in chips if c and c != "클립"][:6] or [None] # 클립(영상)은 제외
|
||||||
|
for label in targets:
|
||||||
|
cur["label"] = label or "업체" # 이 탭에서 로드되는 사진을 이 라벨로 태깅
|
||||||
|
try:
|
||||||
|
if label is not None:
|
||||||
|
await page.locator("a.sbkBy", has_text=label).first.click(timeout=3000)
|
||||||
|
await page.wait_for_timeout(900)
|
||||||
|
for _ in range(5): # 각 탭에서 스크롤하며 lazy 로드 유발
|
||||||
|
await page.mouse.wheel(0, 2500)
|
||||||
|
await page.wait_for_timeout(300)
|
||||||
|
except Exception:
|
||||||
|
break # 연결 끊기면 모은 만큼으로 진행
|
||||||
|
await page.wait_for_timeout(300)
|
||||||
|
|
||||||
|
# 후보 = 탭(업체/내부/음식·음료/외부…)을 '고루 섞어' 뽑는다.
|
||||||
|
# 앞에서 N장만 자르면 먼저 로드된 업체(food) 탭이 다 차지해 내부/외부가 잘린다
|
||||||
|
# → 탭별로 모은 뒤 라운드로빈으로 채워 다양성을 강제한다. 각 탭 안에선 공식 먼저.
|
||||||
|
tab_imgs, bases = {}, set() # tab -> [(host, base)…]
|
||||||
|
official_n = 0
|
||||||
|
for u, tab in zip(seen, seen_tab):
|
||||||
|
real = _underlying(u)
|
||||||
|
host = urlparse(real).netloc
|
||||||
|
if host not in CAND_HOSTS: # 공식 대표사진(ldb-phinf)만 통과
|
||||||
|
continue
|
||||||
|
if "icon" in real or "profile" in real:
|
||||||
|
continue
|
||||||
|
base = real.split("?")[0]
|
||||||
|
if base in bases:
|
||||||
|
continue
|
||||||
|
bases.add(base)
|
||||||
|
tab_imgs.setdefault(tab, []).append((host, base))
|
||||||
|
official_n += 1
|
||||||
|
# 메뉴판·동영상 탭은 광고컷으로 약하므로 뒤로 미룬다
|
||||||
|
def _tab_rank(t):
|
||||||
|
return 1 if (t and ("메뉴" in t or "동영상" in t)) else 0
|
||||||
|
order = sorted(tab_imgs.keys(), key=_tab_rank)
|
||||||
|
candidates, ptr = [], {t: 0 for t in order}
|
||||||
|
while len(candidates) < max_candidates and any(ptr[t] < len(tab_imgs[t]) for t in order):
|
||||||
|
for t in order:
|
||||||
|
if ptr[t] < len(tab_imgs[t]):
|
||||||
|
candidates.append(tab_imgs[t][ptr[t]][1]); ptr[t] += 1
|
||||||
|
if len(candidates) >= max_candidates:
|
||||||
|
break
|
||||||
|
tabsum = ", ".join(f"{t}:{len(v)}" for t, v in tab_imgs.items())
|
||||||
|
print(f"■ 사진 후보 {len(candidates)}장 (공식 {official_n}, "
|
||||||
|
f"전체 응답 {len(seen)}) / 탭별 [{tabsum}]")
|
||||||
|
# [진단] 후보 0인데 응답은 많을 때, 실제로 어떤 CDN 호스트에서 왔는지 확인용.
|
||||||
|
# 여기에 CAND_HOSTS 가 아닌 호스트가 잡히면 (리뷰/블로그 아닌 한) 추가하면 된다.
|
||||||
|
if official_n == 0 and seen:
|
||||||
|
from collections import Counter
|
||||||
|
hosts = Counter(urlparse(_underlying(u)).netloc for u in seen)
|
||||||
|
print(" [진단] 응답 호스트별: "
|
||||||
|
+ ", ".join(f"{h}:{c}" for h, c in hosts.most_common()))
|
||||||
|
|
||||||
|
# 1차: 브라우저 컨텍스트로 다운로드(쿠키/헤더 유지). 연결 끊기면 남은 건 httpx 폴백.
|
||||||
|
paths, got = [], set()
|
||||||
|
for i, u in enumerate(candidates, 1):
|
||||||
|
try:
|
||||||
|
r = await ctx.request.get(u, timeout=15000)
|
||||||
|
if not r.ok:
|
||||||
|
continue
|
||||||
|
data = await r.body()
|
||||||
|
if len(data) < 3000: # 너무 작으면 깨진/아이콘
|
||||||
|
continue
|
||||||
|
fp = dst_dir / f"cand{i:02d}.jpg"
|
||||||
|
fp.write_bytes(data)
|
||||||
|
paths.append(fp); got.add(i)
|
||||||
|
except Exception:
|
||||||
|
break # 연결이 죽었을 가능성 → 폴백으로
|
||||||
|
try:
|
||||||
|
await browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2차(폴백): 1차에서 못 받은 후보를 httpx 로 직접 다운로드 (공개 CDN, Referer 헤더로 차단 회피)
|
||||||
|
missing = [(i, u) for i, u in enumerate(candidates, 1) if i not in got]
|
||||||
|
if missing:
|
||||||
|
hdr = {"User-Agent": MOBILE_UA, "Referer": "https://m.place.naver.com/"}
|
||||||
|
with httpx.Client(headers=hdr, timeout=15, follow_redirects=True) as hc:
|
||||||
|
for i, u in missing:
|
||||||
|
try:
|
||||||
|
rr = hc.get(u)
|
||||||
|
if rr.status_code == 200 and len(rr.content) >= 3000:
|
||||||
|
fp = dst_dir / f"cand{i:02d}.jpg"
|
||||||
|
fp.write_bytes(rr.content); paths.append(fp)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
paths.sort()
|
||||||
|
print(f"■ 다운로드 {len(paths)}장 → {dst_dir}")
|
||||||
|
return store, paths
|
||||||
|
|
||||||
|
|
||||||
|
# 비전 채점 스키마 — has_person 포함(인물 제외 판정용).
|
||||||
|
_SCORE_SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"scores": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"index": {"type": "integer"},
|
||||||
|
"category": {"type": "string",
|
||||||
|
"enum": ["food", "interior", "exterior", "menu", "drink", "other"]},
|
||||||
|
"subject": {"type": "string"}, # 핵심 피사체 한두 단어(다양성 판단용)
|
||||||
|
"quality": {"type": "integer"}, # 1~10
|
||||||
|
"good_for_ad": {"type": "boolean"},
|
||||||
|
"has_person": {"type": "boolean"}, # 사람(전신/일부/뒷모습 포함)이 보이면 true
|
||||||
|
},
|
||||||
|
"required": ["index", "category", "subject", "quality", "good_for_ad", "has_person"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["scores"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def vision_score(client, paths, cap=16):
|
||||||
|
"""각 사진을 Gemini 비전으로 채점. [{index,category,subject,quality,good_for_ad,has_person}] 반환."""
|
||||||
|
paths = paths[:cap]
|
||||||
|
parts = [types.Part.from_text(text=(
|
||||||
|
"다음 사진들은 한 음식점의 사진이다. 각 사진을 평가하라.\n"
|
||||||
|
"- category: food(음식)/drink(음료)/interior(내부)/exterior(외부/간판)/menu(메뉴판)/other\n"
|
||||||
|
"- subject: 핵심 피사체를 한두 단어로(예: 구운고기, 랍스터, 스테이크, 와인, 파스타, 수영장야경, 실내홀, 외관).\n"
|
||||||
|
" ★ 거의 같은 피사체면 같은 단어로 통일하라(예: 그릴에 구운 고기 사진 여러 장은 전부 '구운고기').\n"
|
||||||
|
" 이 값으로 비슷한 사진을 묶어 다양하게 고른다.\n"
|
||||||
|
"- quality: 광고 영상에 쓸 만큼 잘 나왔는지 1~10 (선명·밝기·구도·먹음직스러움). 흐리거나 어둡거나 잘리면 낮게.\n"
|
||||||
|
"- has_person: 사진 안에 사람이 조금이라도 보이면 true (얼굴·전신·일부·뒷모습·손 등 무엇이든).\n"
|
||||||
|
" 사람이 전혀 없는 빈 공간/음식/사물 사진만 false.\n"
|
||||||
|
"- good_for_ad: 가게 홍보 컷으로 바로 써도 좋은가(true/false).\n"
|
||||||
|
" ★ 다음은 무조건 category='other', good_for_ad=false, quality 1~3 으로:\n"
|
||||||
|
" · 로고/브랜드 글자/간판 텍스트만 큰 이미지\n"
|
||||||
|
" · 메뉴판·가격표·영수증 등 글자 위주 이미지\n"
|
||||||
|
" · 사람이 보이는 사진(초상권 문제 — 사람 없는 컷이 많으니 사람 있으면 제외), 텍스트 그래픽/포스터만 있는 이미지\n"
|
||||||
|
" 즉 '음식·음료·매장 공간'이 또렷하게 보이고 사람이 없는 사진만 good_for_ad=true.\n"
|
||||||
|
"각 사진 바로 앞에 [사진 N] 표시가 있다. 그 N 을 index 로.")), ]
|
||||||
|
for i, p in enumerate(paths):
|
||||||
|
parts.append(types.Part.from_text(text=f"[사진 {i}]"))
|
||||||
|
parts.append(types.Part.from_bytes(data=Path(p).read_bytes(), mime_type="image/jpeg"))
|
||||||
|
resp = client.models.generate_content(
|
||||||
|
model=VISION_MODEL, contents=parts,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
temperature=0.2, response_mime_type="application/json",
|
||||||
|
response_schema=_SCORE_SCHEMA),
|
||||||
|
)
|
||||||
|
return json.loads(resp.text).get("scores", [])
|
||||||
|
|
||||||
|
|
||||||
|
BAD_CATS = {"menu", "other"}
|
||||||
|
|
||||||
|
|
||||||
|
def _dhash(path, size=8):
|
||||||
|
"""차이 해시(dHash, 64bit). 시각적으로 비슷한 사진끼리는 해밍거리가 작다.
|
||||||
|
(같은 그릴/같은 수영장뷰처럼 '거의 같은 그림'을 잡아내 중복 선택을 막는 용도.)"""
|
||||||
|
try:
|
||||||
|
img = Image.open(path).convert("L").resize((size + 1, size), Image.LANCZOS)
|
||||||
|
px = list(img.getdata())
|
||||||
|
bits = 0
|
||||||
|
for r in range(size):
|
||||||
|
row = px[r * (size + 1):(r + 1) * (size + 1)]
|
||||||
|
for c in range(size):
|
||||||
|
bits = (bits << 1) | (1 if row[c] < row[c + 1] else 0)
|
||||||
|
return bits
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ham(a, b):
|
||||||
|
if a is None or b is None:
|
||||||
|
return 999
|
||||||
|
return bin(a ^ b).count("1")
|
||||||
|
|
||||||
|
|
||||||
|
def too_similar(path, others, thresh=8):
|
||||||
|
"""path 의 차이해시(dHash)가 others(이미지 경로 리스트) 중 어느 것과든 해밍거리 <= thresh 면 True.
|
||||||
|
= 시각적으로 '거의 같은 그림'. 사진/클립이 서로 너무 비슷하게 뽑히는 걸 막는 데 쓴다(중복 방지)."""
|
||||||
|
h = _dhash(path)
|
||||||
|
if h is None:
|
||||||
|
return False
|
||||||
|
return any(_ham(h, _dhash(o)) <= thresh for o in others)
|
||||||
|
|
||||||
|
|
||||||
|
def _subj(s):
|
||||||
|
"""다양성 판단 키: 피사체(subject) 우선, 없으면 종류(category)."""
|
||||||
|
return (s.get("subject") or s.get("category") or "other").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _ngrams(s, k=2):
|
||||||
|
s = s.replace(" ", "")
|
||||||
|
return {s[i:i + k] for i in range(len(s) - k + 1)} or {s}
|
||||||
|
|
||||||
|
|
||||||
|
def _sim_subj(a, b):
|
||||||
|
"""두 피사체가 사실상 같은 종류인가(예: '구운고기' vs '구운고기새우' → True)."""
|
||||||
|
if not a or not b:
|
||||||
|
return False
|
||||||
|
if a in b or b in a:
|
||||||
|
return True
|
||||||
|
return bool(_ngrams(a) & _ngrams(b)) # 2글자 조각이 하나라도 겹치면 비슷
|
||||||
|
|
||||||
|
|
||||||
|
def pick_best(paths, scores, n=4, per_cat=2, dup_thresh=8, exclude_person=True):
|
||||||
|
"""채점 결과로 '종류·피사체 안 겹치게 잘 나온' N장 선택 → 경로 리스트.
|
||||||
|
|
||||||
|
비전 모델이 피사체를 너무 잘게 쪼개는 경우가 있어(구운고기/구운고기새우/문어새우 …
|
||||||
|
전부 사실상 '그릴 음식') 단순 피사체 분산만으론 비슷한 그림이 몰린다. 그래서:
|
||||||
|
1차) 종류(category) 우선 분산 + 피사체 비유사 — 최대한 다른 종류·다른 피사체로.
|
||||||
|
2차) 같은 종류 2번째 허용하되 '비유사 피사체'만(그릴 다음엔 디저트 같은 식).
|
||||||
|
3차) 그래도 모자라면 피사체 유사 허용(동일사진 hash 만 제외).
|
||||||
|
4차) 최후: 동일사진만 피해 채움.
|
||||||
|
로고/메뉴판/텍스트(other·menu, good_for_ad=false)는 후보에서 제외.
|
||||||
|
★ exclude_person=True(기본): 사람이 보이는 사진은 초상권 문제로 끝까지 제외.
|
||||||
|
"""
|
||||||
|
by_idx = {s["index"]: s for s in scores if 0 <= s.get("index", -1) < len(paths)}
|
||||||
|
ranked = sorted(by_idx.values(),
|
||||||
|
key=lambda s: (s.get("good_for_ad", False), s.get("quality", 0)),
|
||||||
|
reverse=True)
|
||||||
|
usable = [s for s in ranked
|
||||||
|
if s.get("good_for_ad") and s.get("category") not in BAD_CATS
|
||||||
|
and not (exclude_person and s.get("has_person"))] # 인물 제외(초상권)
|
||||||
|
|
||||||
|
hashes = {paths[s["index"]]: _dhash(paths[s["index"]]) for s in ranked}
|
||||||
|
chosen, chosen_h, chosen_subj, cat_cnt = [], [], [], {}
|
||||||
|
|
||||||
|
def is_dup(p):
|
||||||
|
h = hashes.get(p)
|
||||||
|
return any(_ham(h, ch) <= dup_thresh for ch in chosen_h)
|
||||||
|
|
||||||
|
def take(s):
|
||||||
|
p = paths[s["index"]]
|
||||||
|
chosen.append(p); chosen_h.append(hashes.get(p)); chosen_subj.append(_subj(s))
|
||||||
|
c = s.get("category", "other"); cat_cnt[c] = cat_cnt.get(c, 0) + 1
|
||||||
|
|
||||||
|
def fits(s, new_cat, distinct_subj, respect_cap):
|
||||||
|
p = paths[s["index"]]; c = s.get("category", "other")
|
||||||
|
if p in chosen or is_dup(p):
|
||||||
|
return False
|
||||||
|
if respect_cap and cat_cnt.get(c, 0) >= per_cat:
|
||||||
|
return False
|
||||||
|
if new_cat and cat_cnt.get(c, 0) >= 1:
|
||||||
|
return False
|
||||||
|
if distinct_subj and any(_sim_subj(_subj(s), cs) for cs in chosen_subj):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
# (카테고리상한, 새카테고리우선, 피사체비유사) 단계적 완화.
|
||||||
|
# 3단계: 한 카테고리로 쏠린 가게(전부 food 등)는 상한을 풀되 '피사체 다양성'은 끝까지 지킨다.
|
||||||
|
# → 한정식집처럼 다 음식이어도 돌솥밥/불고기/생선구이/전… 서로 다른 걸 고른다.
|
||||||
|
for new_cat, distinct, cap in [
|
||||||
|
(True, True, True), (False, True, True),
|
||||||
|
(False, True, False), (False, False, False)]:
|
||||||
|
for s in usable:
|
||||||
|
if len(chosen) >= n:
|
||||||
|
break
|
||||||
|
if fits(s, new_cat, distinct, cap):
|
||||||
|
take(s)
|
||||||
|
if len(chosen) >= n:
|
||||||
|
break
|
||||||
|
# 최후: 동일사진만 피해 나머지로 채움 (단, exclude_person 이면 사람 있는 사진은 끝까지 제외)
|
||||||
|
if len(chosen) < n:
|
||||||
|
for s in ranked:
|
||||||
|
if len(chosen) >= n:
|
||||||
|
break
|
||||||
|
if exclude_person and s.get("has_person"):
|
||||||
|
continue
|
||||||
|
p = paths[s["index"]]
|
||||||
|
if p not in chosen and not is_dup(p):
|
||||||
|
chosen.append(p); chosen_h.append(hashes.get(p)); chosen_subj.append(_subj(s))
|
||||||
|
if chosen_subj:
|
||||||
|
print(f" 선별 피사체: {', '.join(chosen_subj)}")
|
||||||
|
return chosen
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_and_select(url, out_dir, n=4, client=None, max_candidates=16,
|
||||||
|
headful=False, exclude_person=True):
|
||||||
|
"""전체 파이프라인: 크롤링 → (있으면)비전 선별 → out_dir 에 photoNN.jpg 저장.
|
||||||
|
|
||||||
|
반환 (가게이름, [최종 사진 경로]). client 없으면 비전 없이 앞에서 N장.
|
||||||
|
exclude_person=True(기본): 인물 사진 제외(초상권). 셋(animation/narration/kakao) 다 기본 적용.
|
||||||
|
"""
|
||||||
|
out_dir = Path(out_dir)
|
||||||
|
tmp = out_dir / "_cand"
|
||||||
|
if tmp.exists():
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
store, cand = asyncio.run(crawl_and_download(url, tmp, max_candidates, headful))
|
||||||
|
if not cand:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
return store, []
|
||||||
|
|
||||||
|
if client is not None and len(cand) > n:
|
||||||
|
try:
|
||||||
|
scores = vision_score(client, cand)
|
||||||
|
chosen = pick_best(cand, scores, n=n, exclude_person=exclude_person)
|
||||||
|
print(f"■ 비전 선별: {len(cand)}장 → {len(chosen)}장")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" (비전 선별 실패 → 앞에서 {n}장: {type(e).__name__})")
|
||||||
|
chosen = cand[:n]
|
||||||
|
else:
|
||||||
|
chosen = cand[:n]
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
finals = []
|
||||||
|
for i, src in enumerate(chosen, 1):
|
||||||
|
dst = out_dir / f"photo{i:02d}.jpg"
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
finals.append(dst)
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
return store, finals
|
||||||
|
|
||||||
32
main.py
32
main.py
@ -28,8 +28,9 @@ 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.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.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.ssulbox.api.routers.v1.content import router as ssulbox_router
|
||||||
from app.utils.cors import CustomCORSMiddleware
|
from app.utils.cors import CustomCORSMiddleware
|
||||||
from config import prj_settings
|
from config import prj_settings, ssulbox_settings
|
||||||
|
|
||||||
tags_metadata = [
|
tags_metadata = [
|
||||||
{
|
{
|
||||||
@ -290,6 +291,31 @@ tags_metadata = [
|
|||||||
1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조)
|
1. 사용자의 Instagram 계정이 연동되어 있어야 합니다 (Social Account API 참조)
|
||||||
2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다
|
2. task_id에 해당하는 비디오가 생성 완료 상태(result_movie_url 존재)여야 합니다
|
||||||
3. 업로드 성공 시 Instagram media_id와 permalink 반환
|
3. 업로드 성공 시 Instagram media_id와 permalink 반환
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Ssulbox",
|
||||||
|
"description": """
|
||||||
|
**썰박스** - 병맛 역사 썰툰 마케팅 쇼츠 생성
|
||||||
|
|
||||||
|
네이버 지도 링크나 업장명만 넣으면 4개 시나리오(조선왕·삼국지·그리스로마신화·오디세이)
|
||||||
|
중 하나로 대본 → 그림 → 목소리 → 영상을 자동 생성합니다.
|
||||||
|
|
||||||
|
**인증: 필요** - `Authorization: Bearer {access_token}` 헤더 필수
|
||||||
|
|
||||||
|
## 생성 흐름
|
||||||
|
|
||||||
|
1. `GET /ssul/search/place` - 업장 검색 (place_url 획득)
|
||||||
|
2. `POST /ssul/create` - 생성 요청. **이 시점에 크레딧 1이 선차감됩니다**
|
||||||
|
3. `GET /ssul/tasks/{id}` - 3초 간격 폴링 (step 0~4)
|
||||||
|
4. 완료 시 `video_url` 반환. 실패 시 크레딧 자동 환불
|
||||||
|
|
||||||
|
## ADO2 영상과의 차이
|
||||||
|
|
||||||
|
- ADO2: 크롤링 → 가사 → Suno 음악 → Creatomate 영상
|
||||||
|
- 썰박스: 크롤링 → 대본 → 그림·목소리(Gemini) → 로컬 렌더
|
||||||
|
|
||||||
|
크레딧 지갑과 계정은 **동일**합니다.
|
||||||
""",
|
""",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -423,6 +449,10 @@ 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 라우터 추가
|
||||||
|
|
||||||
|
# 썰박스 — 설정으로 끌 수 있게 한다(생성 엔진 없이 배포하는 환경 대비)
|
||||||
|
if ssulbox_settings.SSULBOX_ENABLED:
|
||||||
|
app.include_router(ssulbox_router) # /ssul/* 라우터
|
||||||
|
|
||||||
# DEBUG 모드에서만 테스트 라우터 등록
|
# DEBUG 모드에서만 테스트 라우터 등록
|
||||||
if prj_settings.DEBUG:
|
if prj_settings.DEBUG:
|
||||||
app.include_router(auth_test_router, prefix="/user") # Test Auth API 라우터
|
app.include_router(auth_test_router, prefix="/user") # Test Auth API 라우터
|
||||||
|
|||||||
@ -13,7 +13,11 @@ dependencies = [
|
|||||||
"fastapi-cli>=0.0.16",
|
"fastapi-cli>=0.0.16",
|
||||||
"fastapi[standard]>=0.125.0",
|
"fastapi[standard]>=0.125.0",
|
||||||
"gspread>=6.2.1",
|
"gspread>=6.2.1",
|
||||||
|
# 썰박스 생성 엔진(generator/) 전용 — subprocess 로 실행되지만 같은 venv 를 쓴다
|
||||||
|
"google-genai>=1.0", # 대본·이미지·음성 생성
|
||||||
|
"imageio-ffmpeg>=0.5", # ffmpeg 바이너리 (영상 렌더)
|
||||||
"openai>=2.13.0",
|
"openai>=2.13.0",
|
||||||
|
"pillow>=10.0", # 자막·이미지 합성
|
||||||
"playwright>=1.57.0",
|
"playwright>=1.57.0",
|
||||||
"pydantic-settings>=2.12.0",
|
"pydantic-settings>=2.12.0",
|
||||||
"python-dotenv>=1.0.0",
|
"python-dotenv>=1.0.0",
|
||||||
@ -31,6 +35,11 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pytest>=9.0.2",
|
"pytest>=9.0.2",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
|
# Windows 로컬 개발 전용. 리눅스 배포 컨테이너는 /usr/share/zoneinfo 를 쓰므로
|
||||||
|
# 불필요하다(compose 에서 TZ=Asia/Seoul 설정). Windows 에는 시스템 tz DB 가 없어
|
||||||
|
# config.py 의 ZoneInfo("Asia/Seoul") 이 실패한다.
|
||||||
|
# 배포는 `uv pip install .` 라 dev 그룹을 설치하지 않는다.
|
||||||
|
"tzdata>=2025.1; sys_platform == 'win32'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
92
uv.lock
generated
92
uv.lock
generated
@ -440,15 +440,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "google-auth"
|
name = "google-auth"
|
||||||
version = "2.49.1"
|
version = "2.56.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cryptography" },
|
{ name = "cryptography" },
|
||||||
{ name = "pyasn1-modules" },
|
{ name = "pyasn1-modules" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ea/80/6a696a07d3d3b0a92488933532f03dbefa4a24ab80fb231395b9a2a1be77/google_auth-2.49.1.tar.gz", hash = "sha256:16d40da1c3c5a0533f57d268fe72e0ebb0ae1cc3b567024122651c045d879b64", size = 333825, upload-time = "2026-03-12T19:30:58.135Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/c7/33/dbc946a407401b975f0719658f18e664ece2109f79ffd1ff3bf226c205f4/google_auth-2.56.2.tar.gz", hash = "sha256:e28f103ca8091fb7012b99c44243d7366c29863713b8e34a220c3322b7a07051", size = 365820, upload-time = "2026-07-21T21:53:28.188Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/eb/c6c2478d8a8d633460be40e2a8a6f8f429171997a35a96f81d3b680dec83/google_auth-2.49.1-py3-none-any.whl", hash = "sha256:195ebe3dca18eddd1b3db5edc5189b76c13e96f29e73043b923ebcf3f1a860f7", size = 240737, upload-time = "2026-03-12T19:30:53.159Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/63/50636aae68c9bf17c891c7eb18b49baa9bd6b31d2a97b8de4813a9fc8d1c/google_auth-2.56.2-py3-none-any.whl", hash = "sha256:c8270ea95b2697b74e3d8438ae9c5b898e38b623b915c7b5c5635921e7de68a6", size = 258588, upload-time = "2026-07-21T21:53:26.399Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
requests = [
|
||||||
|
{ name = "requests" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -464,6 +469,27 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" },
|
{ url = "https://files.pythonhosted.org/packages/2a/e0/cb454a95f460903e39f101e950038ec24a072ca69d0a294a6df625cc1627/google_auth_oauthlib-1.3.1-py3-none-any.whl", hash = "sha256:1a139ef23f1318756805b0e95f655c238bffd29655329a2978218248da4ee7f8", size = 19247, upload-time = "2026-03-30T20:02:23.894Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "google-genai"
|
||||||
|
version = "2.14.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "distro" },
|
||||||
|
{ name = "google-auth", extra = ["requests"] },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "pydantic" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
{ name = "tenacity" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "websockets" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/dc/df/4f820054c99f29f2fe3de4a8a7c9534dd795302e4a07483a0cb07c3a29b6/google_genai-2.14.0.tar.gz", hash = "sha256:a9d1f4f362d76280f1be1340fcb3c86e63dbca128f6a4ae09d86ab47ff7148e8", size = 641055, upload-time = "2026-07-22T21:35:44.717Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/86/5ac5fb53e44cca4a6607fb917eb331fa237c65a103b9ec2e8e8acc8a42db/google_genai-2.14.0-py3-none-any.whl", hash = "sha256:ae7172cdd35695189b516b33a878e4132e5daa2dbc03a5b44cddfa8a82fad664", size = 1030738, upload-time = "2026-07-22T21:35:42.785Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "greenlet"
|
name = "greenlet"
|
||||||
version = "3.3.0"
|
version = "3.3.0"
|
||||||
@ -554,6 +580,20 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "imageio-ffmpeg"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/44/bd/c3343c721f2a1b0c9fc71c1aebf1966a3b7f08c2eea8ed5437a2865611d6/imageio_ffmpeg-0.6.0.tar.gz", hash = "sha256:e2556bed8e005564a9f925bb7afa4002d82770d6b08825078b7697ab88ba1755", size = 25210, upload-time = "2025-01-16T21:34:32.747Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/58/87ef68ac83f4c7690961bce288fd8e382bc5f1513860fc7f90a9c1c1c6bf/imageio_ffmpeg-0.6.0-py3-none-macosx_10_9_intel.macosx_10_9_x86_64.whl", hash = "sha256:9d2baaf867088508d4a3458e61eeb30e945c4ad8016025545f66c4b5aaef0a61", size = 24932969, upload-time = "2025-01-16T21:34:20.464Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/5c/f3d8a657d362cc93b81aab8feda487317da5b5d31c0e1fdfd5e986e55d17/imageio_ffmpeg-0.6.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b1ae3173414b5fc5f538a726c4e48ea97edc0d2cdc11f103afee655c463fa742", size = 21113891, upload-time = "2025-01-16T21:34:00.277Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/e7/1925bfbc563c39c1d2e82501d8372734a5c725e53ac3b31b4c2d081e895b/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:1d47bebd83d2c5fc770720d211855f208af8a596c82d17730aa51e815cdee6dc", size = 25632706, upload-time = "2025-01-16T21:33:53.475Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/2d/43c8522a2038e9d0e7dbdf3a61195ecc31ca576fb1527a528c877e87d973/imageio_ffmpeg-0.6.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:c7e46fcec401dd990405049d2e2f475e2b397779df2519b544b8aab515195282", size = 29498237, upload-time = "2025-01-16T21:34:13.726Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a0/13/59da54728351883c3c1d9fca1710ab8eee82c7beba585df8f25ca925f08f/imageio_ffmpeg-0.6.0-py3-none-win32.whl", hash = "sha256:196faa79366b4a82f95c0f4053191d2013f4714a715780f0ad2a68ff37483cc2", size = 19652251, upload-time = "2025-01-16T21:34:06.812Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/c6/fa760e12a2483469e2bf5058c5faff664acf66cadb4df2ad6205b016a73d/imageio_ffmpeg-0.6.0-py3-none-win_amd64.whl", hash = "sha256:02fa47c83703c37df6bfe4896aab339013f62bf02c5ebf2dce6da56af04ffc0a", size = 31246824, upload-time = "2025-01-16T21:34:28.6Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.3.0"
|
version = "2.3.0"
|
||||||
@ -718,8 +758,11 @@ dependencies = [
|
|||||||
{ name = "beautifulsoup4" },
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "fastapi-cli" },
|
{ name = "fastapi-cli" },
|
||||||
|
{ name = "google-genai" },
|
||||||
{ name = "gspread" },
|
{ name = "gspread" },
|
||||||
|
{ name = "imageio-ffmpeg" },
|
||||||
{ name = "openai" },
|
{ name = "openai" },
|
||||||
|
{ name = "pillow" },
|
||||||
{ name = "playwright" },
|
{ name = "playwright" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
@ -737,6 +780,7 @@ dependencies = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "pytest-asyncio" },
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
@ -748,8 +792,11 @@ requires-dist = [
|
|||||||
{ 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" },
|
||||||
|
{ name = "google-genai", specifier = ">=1.0" },
|
||||||
{ name = "gspread", specifier = ">=6.2.1" },
|
{ name = "gspread", specifier = ">=6.2.1" },
|
||||||
|
{ name = "imageio-ffmpeg", specifier = ">=0.5" },
|
||||||
{ name = "openai", specifier = ">=2.13.0" },
|
{ name = "openai", specifier = ">=2.13.0" },
|
||||||
|
{ name = "pillow", specifier = ">=10.0" },
|
||||||
{ name = "playwright", specifier = ">=1.57.0" },
|
{ name = "playwright", specifier = ">=1.57.0" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
{ name = "pydantic-settings", specifier = ">=2.12.0" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||||
@ -767,6 +814,7 @@ requires-dist = [
|
|||||||
dev = [
|
dev = [
|
||||||
{ name = "pytest", specifier = ">=9.0.2" },
|
{ name = "pytest", specifier = ">=9.0.2" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
|
{ name = "tzdata", marker = "sys_platform == 'win32'", specifier = ">=2025.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -806,6 +854,26 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pillow"
|
||||||
|
version = "12.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "playwright"
|
name = "playwright"
|
||||||
version = "1.57.0"
|
version = "1.57.0"
|
||||||
@ -1329,6 +1397,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tenacity"
|
||||||
|
version = "9.1.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tqdm"
|
name = "tqdm"
|
||||||
version = "4.67.1"
|
version = "4.67.1"
|
||||||
@ -1377,6 +1454,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tzdata"
|
||||||
|
version = "2026.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.6.3"
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user