Compare commits

...

13 Commits

Author SHA1 Message Date
63e9e18719 fix 로그인 시 즉시 반영 안되는 버그 2026-09-16 14:15:06 +09:00
487a8ade11 logout 행동 변경 2026-09-16 14:11:15 +09:00
9fc8bdbbbd 최근 작업 제한 2026-09-16 14:08:24 +09:00
78cf43d7ff add icon for user 2026-09-16 13:04:49 +09:00
ef08b9f433 화면 버튼만 비활성화 2026-09-16 11:37:37 +09:00
49b4178453 show credits 2026-09-16 10:46:33 +09:00
d59304f833 add login on button 2026-09-16 09:30:05 +09:00
o2oadmin
e6c839dd08 change docker compose for port 2026-09-15 17:01:27 +09:00
ffaa512aee 프론트엔드 변경 2026-09-15 17:00:17 +09:00
c6c84a10ff fix dockerfile 2026-09-15 16:44:21 +09:00
c4efd6406d add login 2026-09-15 16:00:54 +09:00
b27a7b528a remove credits 2026-09-10 17:25:35 +09:00
364616ec0f fix 0 pixel gate, fallback cursur halutination, comment halutination 2026-09-09 15:43:52 +09:00
43 changed files with 1359 additions and 133 deletions

View File

@ -13,6 +13,11 @@ COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app WORKDIR /app
# 가상환경을 만들지 않고 이미지의 python에 바로 깐다.
# compose가 소스를 /app에 물리면 그 안의 .venv가 가려지고, 가려진 자리를 볼륨으로 되살리면
# 컨테이너를 다시 만들어도 옛 볼륨이 따라붙어 이미지를 새로 구워도 패키지가 안 바뀐다
ENV UV_PROJECT_ENVIRONMENT=/usr/local
# 의존성만 먼저 깔아 레이어를 캐시한다 # 의존성만 먼저 깔아 레이어를 캐시한다
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project RUN uv sync --frozen --no-install-project
@ -20,8 +25,7 @@ RUN uv sync --frozen --no-install-project
COPY . . COPY . .
RUN uv sync --frozen RUN uv sync --frozen
ENV PATH="/app/.venv/bin:$PATH" \ ENV PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1
# CLI 토큰은 컨테이너가 켜질 때 호스트의 것을 참조한다: # CLI 토큰은 컨테이너가 켜질 때 호스트의 것을 참조한다:
# -v ~/.config/higgsfield:/root/.config/higgsfield # -v ~/.config/higgsfield:/root/.config/higgsfield

View File

@ -26,8 +26,29 @@ uv sync
| `TYPECAST_API_KEY` | 롱컷 tts (축제는 OpenAI를 써서 필요 없다) | | `TYPECAST_API_KEY` | 롱컷 tts (축제는 OpenAI를 써서 필요 없다) |
| `SUNO_API_KEY` · `SUNO_CALLBACK_URL` | bgm | | `SUNO_API_KEY` · `SUNO_CALLBACK_URL` | bgm |
| `HIGGSFIELD_ACCOUNT` | upscale · i2v 계정 가드 | | `HIGGSFIELD_ACCOUNT` | upscale · i2v 계정 가드 |
| `MYSQL_URL` | DB 세션 | | `MYSQL_HOST` · `MYSQL_PORT` · `MYSQL_USER` · `MYSQL_PASSWORD` · `MYSQL_DB` | DB 세션 |
| `AZURE_BLOB_BASE_URL` · `AZURE_BLOB_SAS_TOKEN` | blob 업로드 | | `AZURE_BLOB_BASE_URL` · `AZURE_BLOB_SAS_TOKEN` | blob 업로드 |
| `GOOGLE_CLIENT_ID` | 구글 로그인. 비면 로그인만 꺼지고 서버는 뜬다 |
| `JWT_SECRET` | 세션 쿠키 서명. 바뀌면 로그인된 사람이 전부 풀린다 |
### 구글 로그인
구글 클라우드 콘솔에서 **웹 애플리케이션** OAuth 클라이언트를 만들고 승인된 자바스크립트
원본에 서비스 주소를 넣는다. 발급된 client id를 `GOOGLE_CLIENT_ID`에 넣는다.
`client_secret`은 쓰지 않는다 — 코드 교환 없이 ID 토큰만 검증한다.
`JWT_SECRET`은 아무 긴 임의 문자열이면 된다.
```bash
python3 -c "import secrets; print(secrets.token_urlsafe(48))"
```
사용자는 처음 로그인할 때 만들어지고 잡을 `job_limit`(기본 3)개까지 만들 수 있다.
무제한으로 둘 계정은 `user` 테이블의 그 값을 올린다.
```sql
UPDATE user SET job_limit = 100000 WHERE email = 'jhyeu@o2o.kr';
```
### Docker ### Docker

View File

@ -3,7 +3,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from pipelines import worker from pipelines import worker
from routers import archive, f1, playreel from routers import archive, auth, f1, playreel
from utils import blob from utils import blob
from utils.database import close_engine, create_missing_tables from utils.database import close_engine, create_missing_tables
@ -21,6 +21,7 @@ async def lifespan(app: FastAPI):
app = FastAPI(title="poster-alive", lifespan=lifespan) app = FastAPI(title="poster-alive", lifespan=lifespan)
app.include_router(auth.router)
app.include_router(f1.router) app.include_router(f1.router)
app.include_router(playreel.router) app.include_router(playreel.router)
app.include_router(archive.router) app.include_router(archive.router)

View File

@ -11,7 +11,8 @@ class DetailSection:
index: int # 그 원본 안에서의 순번 index: int # 그 원본 안에서의 순번
y0: int y0: int
y1: int y1: int
image: Image.Image # compose는 좌표만 쓴다 — 조각 그림 없이 만들어도 된다
image: Image.Image | None = None
tag: str = UNTAGGED tag: str = UNTAGGED
@property @property

View File

@ -11,8 +11,12 @@ class Scene(BaseModel):
"""나레이션 큐 하나에 대응하는 화면.""" """나레이션 큐 하나에 대응하는 화면."""
cue: int cue: int
kind: SceneKind kind: SceneKind
section: str | None = None # scroll일 때 쓸 섹션 이름 section: str | None = None # scroll 시작점이 된 섹션 이름
tag: str | None = None # 그 섹션의 태그. 왜 골랐는지 남긴다 tag: str | None = None # 그 섹션의 태그. 왜 골랐는지 남긴다
# scroll은 조각이 아니라 롱이미지 위에서 일어난다. 아래 셋이 그 좌표다
source: str | None = None # 훑을 롱이미지 (detail_01 …)
y0: float | None = None # 시작 위치. 롱이미지 높이 대비 0~1
y1: float | None = None # 끝 위치
class LongcutPlan(BaseModel): class LongcutPlan(BaseModel):

View File

@ -141,12 +141,15 @@ def final_checks(task: PlayreelTask) -> list[dict]:
playreel에는 그 단계가 없어 검사 자체가 돌지 않는다. playreel에는 그 단계가 없어 검사 자체가 돌지 않는다.
""" """
plan = LongcutPlan.model_validate(task.scene_plan or {"scenes": []}) plan = LongcutPlan.model_validate(task.scene_plan or {"scenes": []})
scroll_tags = {scene.tag for scene in plan.scenes if scene.kind == "scroll"} scrolls = [scene for scene in plan.scenes if scene.kind == "scroll"]
scroll_tags = {scene.tag for scene in scrolls}
return [ return [
{"key": "hybrid", "label": "원본 글자 합성", {"key": "hybrid", "label": "원본 글자 합성",
"ok": bool(task.hybrid_clip_url)}, "ok": bool(task.hybrid_clip_url)},
# 씬 종류가 scroll이라고 적힌 것만으로는 부족하다 — 실제로 훑은 거리를 본다.
# 조각이 화면보다 짧아 한 픽셀도 안 움직인 채 통과한 적이 있다
{"key": "scroll", "label": "상세페이지 스크롤", {"key": "scroll", "label": "상세페이지 스크롤",
"ok": any(scene.kind == "scroll" for scene in plan.scenes)}, "ok": any((scene.y1 or 0.0) > (scene.y0 or 0.0) for scene in scrolls)},
{"key": "schedule", "label": "캐스팅 스케줄 포함", {"key": "schedule", "label": "캐스팅 스케줄 포함",
"ok": REQUIRED_SECTION_TAG in scroll_tags}, "ok": REQUIRED_SECTION_TAG in scroll_tags},
{"key": "bgm", "label": "배경음악", {"key": "bgm", "label": "배경음악",

View File

@ -33,6 +33,7 @@ from services.split_detail import split_details
from services.tts import synthesize from services.tts import synthesize
from services.upscale_poster import as_png_path, upscale_poster from services.upscale_poster import as_png_path, upscale_poster
from tables.task import PlayreelTask from tables.task import PlayreelTask
from tables.user import UNASSIGNED_USER_ID
from utils import blob from utils import blob
from utils.higgsfield import KLING_3_0 from utils.higgsfield import KLING_3_0
from utils.video import frames_at from utils.video import frames_at
@ -55,6 +56,20 @@ async def load_sections(entries: list[dict]) -> list[DetailSection]:
for entry in entries] for entry in entries]
def section_specs(entries: list[dict]) -> list[DetailSection]:
"""compose용 — 조각 그림은 안 받는다. 스크롤은 롱이미지 위에서 일어나고
섹션은 어디부터 훑을지를 정하는 좌표로만 쓰인다"""
return [DetailSection(source=entry["source"], index=entry["index"],
y0=entry["y0"], y1=entry["y1"], tag=entry["tag"])
for entry in entries]
async def load_details(task: PlayreelTask) -> dict[str, Image.Image]:
"""상세페이지 롱이미지. 이름은 split이 붙인 source와 같아야 한다"""
return {f"detail_{order:02d}": Image.open(io.BytesIO(await blob.download_bytes(url)))
for order, url in enumerate(task.detail_image_urls or [], 1)}
def working_poster_url(task: PlayreelTask) -> str: def working_poster_url(task: PlayreelTask) -> str:
"""업스케일이 있으면 그것이 이후 단계의 원본이 됨""" """업스케일이 있으면 그것이 이후 단계의 원본이 됨"""
return task.upscaled_poster_url or task.poster_url return task.upscaled_poster_url or task.poster_url
@ -65,10 +80,10 @@ def thumbnail_url(task: PlayreelTask) -> str | None:
return artifact_url(PIPELINE, task.id, THUMBNAIL) if task.video_url else None return artifact_url(PIPELINE, task.id, THUMBNAIL) if task.video_url else None
async def create_task(session: AsyncSession, url: str, goods_id: str, async def create_task(session: AsyncSession, url: str, goods_id: str, slug: str, *,
slug: str) -> PlayreelTask: user_id: str = UNASSIGNED_USER_ID) -> PlayreelTask:
task = PlayreelTask(source_url=url, goods_id=goods_id, slug=slug, task = PlayreelTask(source_url=url, goods_id=goods_id, slug=slug,
name=f"공연 {goods_id}", name=f"공연 {goods_id}", user_id=user_id,
stage_timings=initial_timings(PlayreelState)) stage_timings=initial_timings(PlayreelState))
session.add(task) session.add(task)
await session.commit() await session.commit()
@ -234,7 +249,8 @@ async def run_compose(session: AsyncSession, task: PlayreelTask) -> None:
meta = NolMeta.model_validate(task.nol_meta) meta = NolMeta.model_validate(task.nol_meta)
timeline = NarrationTimeline.model_validate(task.narration_timeline) timeline = NarrationTimeline.model_validate(task.narration_timeline)
selected = [entry for entry in task.detail_sections or [] if entry["selected"]] selected = [entry for entry in task.detail_sections or [] if entry["selected"]]
sections = await load_sections(selected) sections = section_specs(selected)
details = await load_details(task)
# 하이브리드 합성을 못 거친 클립은 제목이 흔들림 # 하이브리드 합성을 못 거친 클립은 제목이 흔들림
clip = await blob.download_bytes(task.hybrid_clip_url or task.clip_url) clip = await blob.download_bytes(task.hybrid_clip_url or task.clip_url)
@ -245,7 +261,8 @@ async def run_compose(session: AsyncSession, task: PlayreelTask) -> None:
result = await asyncio.to_thread( result = await asyncio.to_thread(
compose_longcut, poster, clip, timeline, narration, sections, band_info(meta), compose_longcut, poster, clip, timeline, narration, sections, band_info(meta),
bgm_mp3=bgm, qr=Image.open(qr_path) if qr_path.exists() else None, preview=True) details=details, bgm_mp3=bgm,
qr=Image.open(qr_path) if qr_path.exists() else None, preview=True)
filename = f"{task.slug}_30_v{task.version + 1}.mp4" filename = f"{task.slug}_30_v{task.version + 1}.mp4"
task.video_url = await store_bytes(PIPELINE, task.id, filename, result.video, task.video_url = await store_bytes(PIPELINE, task.id, filename, result.video,

View File

@ -24,6 +24,7 @@ from services.render import render
from services.tts import synthesize from services.tts import synthesize
from services.upscale_poster import as_png_path from services.upscale_poster import as_png_path
from tables.task import PosterAliveTask, new_task_id from tables.task import PosterAliveTask, new_task_id
from tables.user import UNASSIGNED_USER_ID
from utils import blob from utils import blob
from utils.image import sniff_extension from utils.image import sniff_extension
@ -31,10 +32,12 @@ PIPELINE = "poster_alive"
async def create_task(session: AsyncSession, name: str, poster: bytes, *, async def create_task(session: AsyncSession, name: str, poster: bytes, *,
skip_review: bool = False) -> PosterAliveTask: skip_review: bool = False,
user_id: str = UNASSIGNED_USER_ID) -> PosterAliveTask:
# poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다. # poster_url이 NOT NULL이라 blob에 올린 뒤에야 행을 넣을 수 있다.
# 그 경로에 id가 필요하므로 여기서 미리 만든다 # 그 경로에 id가 필요하므로 여기서 미리 만든다
task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review, task = PosterAliveTask(id=new_task_id(), name=name, skip_review=skip_review,
user_id=user_id,
stage_timings=initial_timings(PosterAliveState)) stage_timings=initial_timings(PosterAliveState))
with Image.open(io.BytesIO(poster)) as image: with Image.open(io.BytesIO(poster)) as image:
task.poster_width, task.poster_height = image.size task.poster_width, task.poster_height = image.size

View File

@ -11,6 +11,7 @@ dependencies = [
"openai>=3.6.0", "openai>=3.6.0",
"pillow>=12.3.0", "pillow>=12.3.0",
"pydantic-settings>=2.15.0", "pydantic-settings>=2.15.0",
"python-jose[cryptography]>=3.5.0",
"python-multipart>=0.0.32", "python-multipart>=0.0.32",
"scipy>=1.18.1", "scipy>=1.18.1",
"sqlalchemy>=2.0.52", "sqlalchemy>=2.0.52",

View File

@ -15,6 +15,7 @@ from routers.view import UNNAMED
from tables.task import PlayreelTask, PosterAliveTask from tables.task import PlayreelTask, PosterAliveTask
from utils.database import get_session from utils.database import get_session
# 완성본 구경은 로그인 없이 연다
router = APIRouter(prefix="/api/archive", tags=["archive"]) router = APIRouter(prefix="/api/archive", tags=["archive"])

113
backend/routers/auth.py Normal file
View File

@ -0,0 +1,113 @@
"""/api/auth — 구글 로그인과 세션
브라우저가 구글에서 받아온 credential을 한 번 검증하고, 그 뒤로는 자체 쿠키를 쓴다.
"""
from fastapi import APIRouter, Depends, HTTPException, Request, Response
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from tables.user import User
from utils import session_token
from utils.database import get_session
from utils.google_identity import (GoogleLoginDisabled, GoogleTokenInvalid, is_enabled,
verify_id_token)
router = APIRouter(prefix="/api/auth", tags=["auth"])
class GoogleLoginBody(BaseModel):
credential: str
def user_view(user: User) -> dict:
"""구글 sub는 내보내지 않는다 — 화면이 쓸 일이 없다"""
return {
"email": user.email,
"name": user.name,
"picture": user.picture_url,
"jobs_created": user.jobs_created,
"job_limit": user.job_limit,
"jobs_left": user.jobs_left,
}
async def find_or_create(session: AsyncSession, account) -> User:
user = await session.get(User, account.sub)
if user is None:
user = User(id=account.sub, email=account.email, name=account.name,
picture_url=account.picture)
session.add(user)
else:
# 이메일·이름·사진은 구글 쪽에서 바뀔 수 있어 로그인할 때마다 맞춘다
user.email, user.name, user.picture_url = (account.email, account.name,
account.picture)
await session.commit()
return user
@router.get("/config")
async def config():
"""프론트가 버튼을 그릴지 정하는 데 쓴다. client_id는 비밀이 아니다"""
from settings import settings
return {"enabled": is_enabled(), "client_id": settings.google_client_id}
@router.post("/google")
async def google_login(body: GoogleLoginBody, response: Response,
session: AsyncSession = Depends(get_session)):
try:
account = await verify_id_token(body.credential)
except GoogleLoginDisabled:
raise HTTPException(503, "구글 로그인이 설정되지 않았습니다")
except GoogleTokenInvalid as failure:
raise HTTPException(401, str(failure))
user = await find_or_create(session, account)
response.set_cookie(
session_token.COOKIE_NAME, session_token.issue(user.id),
max_age=session_token.cookie_max_age(),
httponly=True, samesite="lax", path="/",
)
return user_view(user)
@router.get("/me")
async def me(request: Request, session: AsyncSession = Depends(get_session)):
user = await optional_user(request, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다")
return user_view(user)
@router.post("/logout")
async def logout(response: Response):
response.delete_cookie(session_token.COOKIE_NAME, path="/")
return {"ok": True}
async def optional_user(request: Request, session: AsyncSession) -> User | None:
token = request.cookies.get(session_token.COOKIE_NAME)
if not token:
return None
try:
user_id = session_token.read(token)
except session_token.SessionInvalid:
return None
return await session.get(User, user_id)
async def current_user(request: Request,
session: AsyncSession = Depends(get_session)) -> User:
"""로그인이 필요한 엔드포인트가 의존성으로 받는다"""
user = await optional_user(request, session)
if user is None:
raise HTTPException(401, "로그인이 필요합니다")
return user
async def creating_user(user: User = Depends(current_user)) -> User:
"""잡을 만드는 엔드포인트용 — 한도를 여기서 막는다"""
if not user.can_create_job:
raise HTTPException(
403, f"만들 수 있는 개수를 다 썼습니다 ({user.jobs_created}/{user.job_limit})")
return user

View File

@ -11,13 +11,17 @@ from models.pipeline_state import PosterAliveState
from pipelines import poster_alive from pipelines import poster_alive
from pipelines.artifact import load_image from pipelines.artifact import load_image
from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from from pipelines.runner import AWAITING_REVIEW, FAILED, QUEUED, RUNNING, reset_from
from routers.auth import creating_user, current_user
from routers.view import motion_elements, poster_alive_view from routers.view import motion_elements, poster_alive_view
from tables.user import User
from services.i2v import MIN_LONG_EDGE_PX from services.i2v import MIN_LONG_EDGE_PX
from services.motion import plan_motion from services.motion import plan_motion
from tables.task import PosterAliveTask from tables.task import PosterAliveTask
from utils.database import get_session from utils.database import get_session
router = APIRouter(prefix="/api/f1", tags=["f1"]) # 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
router = APIRouter(prefix="/api/f1", tags=["f1"],
dependencies=[Depends(current_user)])
ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} ACCEPTED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
MAX_UPLOAD_BYTES = 30 * 1024 * 1024 MAX_UPLOAD_BYTES = 30 * 1024 * 1024
@ -60,6 +64,7 @@ async def rewrite_motion_plan(task: PosterAliveTask, motions: list[str]) -> None
@router.post("/jobs") @router.post("/jobs")
async def create(poster: UploadFile = File(...), name: str = Form(""), async def create(poster: UploadFile = File(...), name: str = Form(""),
auto: bool = Form(False), auto: bool = Form(False),
user: User = Depends(creating_user),
session: AsyncSession = Depends(get_session)): session: AsyncSession = Depends(get_session)):
if poster.content_type not in ACCEPTED_TYPES: if poster.content_type not in ACCEPTED_TYPES:
raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})") raise HTTPException(415, f"지원 형식: JPG/PNG/WEBP/GIF (받은 것: {poster.content_type})")
@ -81,14 +86,21 @@ async def create(poster: UploadFile = File(...), name: str = Form(""),
f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.") f"(받은 것: {width}x{height}). 고해상 원본으로 올려주세요.")
# 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다 # 이름을 비워 두면 narration_text 가 포스터에서 읽은 행사명으로 채운다
task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto) task = await poster_alive.create_task(session, name.strip(), raw, skip_review=auto,
user_id=user.id)
# 잡을 지워도 회복되지 않게 누적값을 올린다
user.jobs_created += 1
await session.commit()
return {"id": task.id, "ahead": 0} return {"id": task.id, "ahead": 0}
@router.get("/jobs") @router.get("/jobs")
async def list_jobs(session: AsyncSession = Depends(get_session)): async def list_jobs(user: User = Depends(current_user),
session: AsyncSession = Depends(get_session)):
"""자기가 만든 것만 돌려준다"""
found = await session.scalars( found = await session.scalars(
select(PosterAliveTask).order_by(PosterAliveTask.created_at.desc())) select(PosterAliveTask).where(PosterAliveTask.user_id == user.id)
.order_by(PosterAliveTask.created_at.desc()))
return [poster_alive_view(task) for task in found] return [poster_alive_view(task) for task in found]

View File

@ -11,11 +11,15 @@ from pipelines import playreel
from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits, from pipelines.gate import (REVERSIBLE_GATES, PlayreelGate, apply_playreel_edits,
previous_gate, resume_stage) previous_gate, resume_stage)
from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from from pipelines.runner import AWAITING_REVIEW, DONE, FAILED, QUEUED, RUNNING, reset_from
from routers.auth import creating_user, current_user
from routers.view import playreel_view from routers.view import playreel_view
from tables.task import PlayreelTask from tables.task import PlayreelTask
from tables.user import User
from utils.database import get_session from utils.database import get_session
router = APIRouter(prefix="/api/playreel", tags=["playreel"]) # 이 라우터의 모든 경로가 로그인을 요구한다. 잡 생성만 한도 검사를 더 받는다
router = APIRouter(prefix="/api/playreel", tags=["playreel"],
dependencies=[Depends(current_user)])
# 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다 # 프론트 parseGoodsId와 같은 규칙. 프론트 검사는 입력 도중의 안내이고 판정은 여기서 한다
GOODS_ID_PATTERNS = ( GOODS_ID_PATTERNS = (
@ -56,7 +60,8 @@ async def find_task(session: AsyncSession, task_id: str) -> PlayreelTask:
@router.post("/jobs") @router.post("/jobs")
async def create(body: CreateBody, session: AsyncSession = Depends(get_session)): async def create(body: CreateBody, user: User = Depends(creating_user),
session: AsyncSession = Depends(get_session)):
goods_id = parse_goods_id(body.url) goods_id = parse_goods_id(body.url)
if not goods_id: if not goods_id:
raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.") raise HTTPException(400, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.")
@ -64,14 +69,21 @@ async def create(body: CreateBody, session: AsyncSession = Depends(get_session))
if not SLUG_PATTERN.fullmatch(slug): if not SLUG_PATTERN.fullmatch(slug):
raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다") raise HTTPException(400, "slug는 영문 소문자·숫자·밑줄만 씁니다")
task = await playreel.create_task(session, body.url.strip(), goods_id, slug) task = await playreel.create_task(session, body.url.strip(), goods_id, slug,
user_id=user.id)
# 잡을 지워도 회복되지 않게 누적값을 올린다
user.jobs_created += 1
await session.commit()
return {"id": task.id} return {"id": task.id}
@router.get("/jobs") @router.get("/jobs")
async def list_jobs(session: AsyncSession = Depends(get_session)): async def list_jobs(user: User = Depends(current_user),
session: AsyncSession = Depends(get_session)):
"""자기가 만든 것만 돌려준다"""
found = await session.scalars( found = await session.scalars(
select(PlayreelTask).order_by(PlayreelTask.created_at.desc())) select(PlayreelTask).where(PlayreelTask.user_id == user.id)
.order_by(PlayreelTask.created_at.desc()))
return [playreel_view(task) for task in found] return [playreel_view(task) for task in found]

View File

@ -1,8 +1,9 @@
"""⑪ compose — 무빙포스터 훅 + 상세페이지 스크롤로 30초 롱컷을 조립한다. """⑪ compose — 무빙포스터 훅 + 상세페이지 스크롤로 30초 롱컷을 조립한다.
씬 경계는 나레이션 큐다. 0초는 포스터 원본이라 첫 프레임이 곧 썸네일이 된다. 씬 경계는 나레이션 큐다. 0초는 포스터 원본이라 첫 프레임이 곧 썸네일이 된다.
상세페이지는 잘라 붙이지 않고 롱이미지를 실제로 스크롤한다. 상세페이지는 잘라 붙이지 않고 롱이미지를 실제로 스크롤한다 — 조각은 "어디부터 읽을지"를
포스터·롱이미지는 세로 롱이라 9:16을 못 채워서 포스터 블러판 위에 얹는다. 정하는 시작점일 뿐이고, 화면에 흐르는 것은 언제나 원본 롱이미지다.
포스터는 세로 롱이라 9:16을 못 채워서 포스터 블러판 위에 얹는다.
텍스트 오버레이는 넣지 않는다. 남는 글자는 엔드밴드뿐이다. 텍스트 오버레이는 넣지 않는다. 남는 글자는 엔드밴드뿐이다.
""" """
@ -12,7 +13,7 @@ from collections.abc import Iterator, Sequence
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from PIL import Image, ImageDraw, ImageFilter from PIL import Image, ImageDraw, ImageFilter, ImageOps
from models.detail_section import DetailSection from models.detail_section import DetailSection
from models.longcut import EndBandInfo, LongcutPlan, LongcutResult, Scene from models.longcut import EndBandInfo, LongcutPlan, LongcutResult, Scene
@ -30,9 +31,15 @@ DUCK_THRESHOLD = 0.03
DUCK_ATTACK_MS, DUCK_RELEASE_MS = 60, 500 DUCK_ATTACK_MS, DUCK_RELEASE_MS = 60, 500
BGM_FADE_OUT = 1.2 BGM_FADE_OUT = 1.2
# 큐 1초당 화면 높이의 몇 배를 훑을지. 원본은 사람이 구간을 골랐다(known_issue 참조). # 큐 1초당 화면 높이의 몇 배를 훑을지. 5초 씬에 이미지 높이의 15~30%가 읽기 좋은 속도다
SCROLL_SCREENS_PER_SECOND = 0.35 SCROLL_SCREENS_PER_SECOND = 0.35
# 프레임보다 짧은 롱이미지를 늘릴 때 쓰는 값. 글자 없는 배경 띠를 거울 타일링한다
INK_DEVIATION = 32 # 배경색에서 이만큼 벗어난 픽셀을 잉크로 본다
INK_ROW_RATIO = 0.003 # 행의 잉크 비율이 이 미만이면 글자 없는 행
EXTEND_FEATHER = 40 # 이음매를 이만큼 겹쳐 섞는다
MIN_BLANK_BAND = 24 # 늘리기에 쓸 배경 띠의 최소 높이
PROOF_COUNT = 8 # 완성본 전체에서 고르게 뽑는 시점 수 PROOF_COUNT = 8 # 완성본 전체에서 고르게 뽑는 시점 수
PROOF_SIZE = (180, 320) PROOF_SIZE = (180, 320)
PROOF_GAP = 10 PROOF_GAP = 10
@ -46,7 +53,9 @@ NOL_WORDMARK = ASSET_DIR / "nol_wordmark_white.png"
# 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다. # 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다.
CLIP_CUES = 2 CLIP_CUES = 2
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "notice") # 여기 없는 태그는 시작점이 안 된다 — 유의사항·제작진·타 공연 배너·조각은 읽힐 내용이
# 아니고, 키비주얼은 앞 큐의 포스터와 겹친다. 관람 정보는 러닝타임·관람등급이라 남긴다
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "info")
REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다 REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다
@ -61,20 +70,78 @@ def blurred_bg(poster: Image.Image) -> Image.Image:
return Image.eval(blurred, lambda v: int(v * 0.45)) return Image.eval(blurred, lambda v: int(v * 0.45))
def trim_blank(image: Image.Image, threshold: int = 235) -> Image.Image: def ink_rows(image: Image.Image) -> np.ndarray:
"""상하단 흰·균일 여백 제거. split이 남긴 12px 여백이 화면에 흰 줄로 보인다.""" """행마다 글자·그림이 있는지. 배경 중앙값에서 벗어난 픽셀의 비율로 본다."""
pixels = np.asarray(image.convert("RGB")).astype(int) pixels = np.asarray(image.convert("L")).astype(np.int16)
white = (pixels.min(axis=2) > threshold).mean(axis=1) > 0.98 background = np.median(pixels)
uniform = pixels.std(axis=1).max(axis=1) < 4 return (np.abs(pixels - background) > INK_DEVIATION).mean(axis=1) > INK_ROW_RATIO
content = np.where(~(white | uniform))[0]
if len(content) == 0:
def blank_band(image: Image.Image) -> tuple[int, int]:
"""늘리기에 쓸, 글자 없는 가장 긴 행 구간. 없으면 맨 아래 띠를 쓴다."""
blank = ~ink_rows(image)
best = (0, 0)
start = None
for y, empty in enumerate(np.append(blank, False)):
if empty and start is None:
start = y
elif not empty and start is not None:
if y - start > best[1] - best[0]:
best = (start, y)
start = None
if best[1] - best[0] >= MIN_BLANK_BAND:
return best
return max(0, image.height - MIN_BLANK_BAND), image.height
def extend_to_height(image: Image.Image, height: int = H) -> Image.Image:
"""프레임보다 짧은 롱이미지를 배경 띠의 거울 타일링으로 늘린다.
짧다고 블러 배경 위에 얹으면 화면 절반이 포스터로 남는다. 배경만 이어 붙여
풀프레임을 채우고, 이음매는 페더로 지운다.
"""
if image.height >= height:
return image return image
return image.crop((0, int(content[0]), image.width, int(content[-1]) + 1))
top, bottom = blank_band(image)
band = image.crop((0, top, image.width, bottom))
canvas = Image.new("RGB", (image.width, height))
canvas.paste(image, (0, 0))
y, flipped = image.height, True
while y < height:
tile = ImageOps.flip(band) if flipped else band
tile = tile.crop((0, 0, tile.width, min(tile.height, height - y)))
canvas.paste(tile, (0, y))
_feather_seam(canvas, y, image.width)
y += tile.height
flipped = not flipped
return canvas
def fit_width(image: Image.Image) -> Image.Image: def _feather_seam(canvas: Image.Image, seam: int, width: int,
image = trim_blank(image) feather: int = EXTEND_FEATHER) -> None:
return image.resize((W, max(1, round(image.height * W / image.width))), Image.LANCZOS) """이음매 위아래를 선형 혼합해 가로줄이 보이지 않게 한다."""
top, bottom = max(0, seam - feather), min(canvas.height, seam + feather)
if bottom - top < 2:
return
patch = np.asarray(canvas.crop((0, top, width, bottom))).astype(np.float32)
blurred = np.asarray(
canvas.crop((0, top, width, bottom)).filter(ImageFilter.GaussianBlur(6))
).astype(np.float32)
weight = 1.0 - np.abs(np.linspace(-1.0, 1.0, bottom - top))
mixed = patch + (blurred - patch) * weight[:, None, None]
canvas.paste(Image.fromarray(mixed.clip(0, 255).astype(np.uint8)), (0, top))
def to_surface(image: Image.Image) -> Image.Image:
"""롱이미지를 폭 1080에 맞추고 프레임 높이까지 늘린다.
자르지 않는다 — 섹션 y좌표가 이 이미지 기준이라 위를 깎으면 시작점이 어긋난다.
"""
scaled = image.convert("RGB").resize(
(W, max(1, round(image.height * W / image.width))), Image.LANCZOS)
return extend_to_height(scaled, H)
def still_scene(still: Image.Image, progress: float, index: int) -> Image.Image: def still_scene(still: Image.Image, progress: float, index: int) -> Image.Image:
@ -95,15 +162,17 @@ def still_scene(still: Image.Image, progress: float, index: int) -> Image.Image:
return frame return frame
def scroll_scene(image: Image.Image, background: Image.Image, progress: float, def scroll_top(surface: Image.Image, fraction: float) -> int:
duration: float) -> Image.Image: """이미지 높이 대비 비율을 실제 crop 시작 y로. 끝을 넘지 않게 묶는다."""
"""읽는 속도로 훑는다. 이미지가 길면 위쪽만 보여준다.""" span = max(0, surface.height - H)
span = max(0, image.height - H) return min(max(0, round(surface.height * fraction)), span)
reach = min(span, round(duration * SCROLL_SCREENS_PER_SECOND * H))
top = int(reach * ease_sine(progress))
frame = background.copy() def scroll_scene(surface: Image.Image, progress: float, y0: float, y1: float) -> Image.Image:
frame.paste(image.crop((0, top, W, min(image.height, top + H))), (0, 0)) """롱이미지를 y0~y1 구간까지 훑는다. 프레임을 가득 채운다 — 배경이 비지 않는다."""
return frame start, end = scroll_top(surface, y0), scroll_top(surface, y1)
top = start + round((end - start) * ease_sine(progress))
return surface.crop((0, top, W, top + H))
def poster_scene(poster: Image.Image, background: Image.Image, progress: float) -> Image.Image: def poster_scene(poster: Image.Image, background: Image.Image, progress: float) -> Image.Image:
@ -149,10 +218,19 @@ def end_band(frame: Image.Image, info: EndBandInfo, seconds: float,
draw.text((60, top + offset + slide), text, font=font(weight, size), fill=fill) draw.text((60, top + offset + slide), text, font=font(weight, size), fill=fill)
def build_scene_plan(sections: Sequence[DetailSection], cue_count: int) -> LongcutPlan: def cue_spans(timeline: NarrationTimeline) -> list[tuple[float, float]]:
"""큐 앞 둘은 클립, 마지막은 포스터, 사이는 태그 우선순위로 채운다.""" """큐마다 (시작, 끝). 0번은 0초에서 시작해야 첫 프레임이 포스터가 된다."""
starts = [0.0] + [cue.start for cue in timeline.cues[1:]]
ends = [cue.start for cue in timeline.cues[1:]] + [timeline.total]
return list(zip(starts, ends, strict=True))
def scroll_starts(sections: Sequence[DetailSection],
surfaces: dict[str, Image.Image]) -> list[DetailSection]:
"""훑기 시작점이 될 섹션을 태그 우선순위대로. 롱이미지가 없는 섹션은 뺀다."""
usable = [section for section in sections if section.source in surfaces]
by_tag: dict[str, list[DetailSection]] = {} by_tag: dict[str, list[DetailSection]] = {}
for section in sections: for section in usable:
by_tag.setdefault(section.tag, []).append(section) by_tag.setdefault(section.tag, []).append(section)
ordered: list[DetailSection] = [] ordered: list[DetailSection] = []
@ -162,18 +240,60 @@ def build_scene_plan(sections: Sequence[DetailSection], cue_count: int) -> Longc
for section in by_tag.get(tag, []): for section in by_tag.get(tag, []):
if section not in ordered: if section not in ordered:
ordered.append(section) ordered.append(section)
return ordered
scenes = []
scroll_index = 0 def scroll_window(surface: Image.Image, start_px: int, seconds: float) -> tuple[float, float]:
for cue in range(cue_count): """시작 픽셀과 큐 길이로 훑을 구간을 정한다. 롱이미지 높이 대비 비율로 돌려준다.
여기서 crop 가능한 범위로 묶는다 — 플랜에 남는 값이 곧 실제 이동량이어야
게이트가 0픽셀 스크롤을 걸러낼 수 있다.
"""
span = max(0, surface.height - H)
start = min(max(0, start_px), span)
end = min(start + round(seconds * SCROLL_SCREENS_PER_SECOND * H), span)
return start / surface.height, end / surface.height
def section_start_px(section: DetailSection, source: Image.Image) -> int:
"""섹션 y좌표는 원본 롱이미지 기준이라 폭 1080 환산 배율을 곱한다."""
return round(section.y0 * W / source.width)
def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[float, float]],
sources: dict[str, Image.Image],
surfaces: dict[str, Image.Image]) -> LongcutPlan:
"""큐 앞 둘은 클립, 마지막은 포스터, 사이는 롱이미지를 훑는다."""
ordered = scroll_starts(sections, surfaces)
fallback = max(surfaces, key=lambda name: surfaces[name].height, default=None)
scenes: list[Scene] = []
index = 0
# 롱이미지마다 어디까지 읽었는지 픽셀로 따로 센다. 비율은 이미지 높이가 다르면
# 서로 다른 자리를 가리키므로 이어 읽기 지점으로 쓸 수 없다
read_to: dict[str, int] = {}
for cue, (start, end) in enumerate(spans):
if cue < CLIP_CUES: if cue < CLIP_CUES:
scenes.append(Scene(cue=cue, kind="clip")) scenes.append(Scene(cue=cue, kind="clip"))
elif cue == cue_count - 1: elif cue == len(spans) - 1:
scenes.append(Scene(cue=cue, kind="poster")) scenes.append(Scene(cue=cue, kind="poster"))
elif scroll_index < len(ordered): elif index < len(ordered):
section = ordered[scroll_index] section = ordered[index]
scenes.append(Scene(cue=cue, kind="scroll", section=section.name, tag=section.tag)) surface = surfaces[section.source]
scroll_index += 1 y0, y1 = scroll_window(surface,
section_start_px(section, sources[section.source]),
end - start)
scenes.append(Scene(cue=cue, kind="scroll", section=section.name,
tag=section.tag, source=section.source, y0=y0, y1=y1))
index = index + 1
read_to[section.source] = round(y1 * surface.height)
elif fallback is not None:
# 쓸 섹션이 떨어져도 포스터로 때우지 않는다. 그 롱이미지를 읽던 자리에서
# 이어 읽어야 같은 화면이 두 번 나오지 않는다
surface = surfaces[fallback]
y0, y1 = scroll_window(surface, read_to.get(fallback, 0), end - start)
scenes.append(Scene(cue=cue, kind="scroll", source=fallback, y0=y0, y1=y1))
read_to[fallback] = round(y1 * surface.height)
else: else:
scenes.append(Scene(cue=cue, kind="poster")) scenes.append(Scene(cue=cue, kind="poster"))
return LongcutPlan(scenes=scenes) return LongcutPlan(scenes=scenes)
@ -214,13 +334,11 @@ def mix_audio(narration_mp3: bytes, bgm_mp3: bytes | None, duration: float) -> n
def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image.Image, def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image.Image,
clip: bytes, sections: dict[str, Image.Image], clip: bytes, surfaces: dict[str, Image.Image],
info: EndBandInfo, qr: Image.Image | None) -> Iterator[Image.Image]: info: EndBandInfo, qr: Image.Image | None) -> Iterator[Image.Image]:
background = blurred_bg(poster) background = blurred_bg(poster)
starts = [0.0] + [cue.start for cue in timeline.cues[1:]] spans = cue_spans(timeline)
bounds = [cue.start for cue in timeline.cues[1:]] + [timeline.total]
clip_frames = list(decode_frames(clip)) clip_frames = list(decode_frames(clip))
scroll_images = {name: fit_width(image) for name, image in sections.items()}
scenes = {scene.cue: scene for scene in plan.scenes} scenes = {scene.cue: scene for scene in plan.scenes}
def frame_at(cue: int, at: float) -> Image.Image: def frame_at(cue: int, at: float) -> Image.Image:
@ -231,23 +349,24 @@ def compose_frames(timeline: NarrationTimeline, plan: LongcutPlan, poster: Image
frame = background.copy() frame = background.copy()
frame.paste(fitted, (0, (H - fitted.height) // 2)) frame.paste(fitted, (0, (H - fitted.height) // 2))
return frame return frame
start, end = starts[cue], bounds[cue] start, end = spans[cue]
progress = (at - start) / max(end - start, 0.01) progress = (at - start) / max(end - start, 0.01)
if scene.kind == "scroll" and scene.section in scroll_images: if scene.kind == "scroll" and scene.source in surfaces:
return scroll_scene(scroll_images[scene.section], background, progress, end - start) return scroll_scene(surfaces[scene.source], progress,
scene.y0 or 0.0, scene.y1 or 0.0)
return poster_scene(poster, background, progress) return poster_scene(poster, background, progress)
total_frames = round(timeline.total * FPS) total_frames = round(timeline.total * FPS)
for index in range(total_frames): for index in range(total_frames):
at = index / FPS at = index / FPS
cue = max(order for order in range(len(timeline.cues)) if at >= starts[order]) cue = max(order for order, (start, _) in enumerate(spans) if at >= start)
image = frame_at(cue, at) image = frame_at(cue, at)
remaining = (bounds[cue] - at) * FPS remaining = (spans[cue][1] - at) * FPS
if cue < len(timeline.cues) - 1 and remaining < CROSSFADE_FRAMES \ if cue < len(spans) - 1 and remaining < CROSSFADE_FRAMES \
and scenes[cue + 1].kind != "clip": and scenes[cue + 1].kind != "clip":
image = Image.blend(image, frame_at(cue + 1, at), 1 - remaining / CROSSFADE_FRAMES) image = Image.blend(image, frame_at(cue + 1, at), 1 - remaining / CROSSFADE_FRAMES)
if scenes[cue].kind == "poster": if scenes[cue].kind == "poster":
end_band(image, info, at - starts[cue], qr) end_band(image, info, at - spans[cue][0], qr)
yield image yield image
@ -262,18 +381,21 @@ def proof_sheet(tiles: list[Image.Image]) -> Image.Image:
def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes, def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes,
timeline: NarrationTimeline, narration_mp3: bytes, timeline: NarrationTimeline, narration_mp3: bytes,
sections: Sequence[DetailSection], info: EndBandInfo, *, sections: Sequence[DetailSection], info: EndBandInfo, *,
details: dict[str, Image.Image] | None = None,
bgm_mp3: bytes | None = None, qr: Image.Image | None = None, bgm_mp3: bytes | None = None, qr: Image.Image | None = None,
plan: LongcutPlan | None = None, plan: LongcutPlan | None = None,
preview: bool = False) -> LongcutResult: preview: bool = False) -> LongcutResult:
"""details는 source 이름(detail_01…) → 상세페이지 롱이미지. 스크롤은 이 위에서 일어난다."""
if isinstance(poster, Image.Image): if isinstance(poster, Image.Image):
source = poster.convert("RGB") source = poster.convert("RGB")
else: else:
source = Image.open(poster if isinstance(poster, Path) source = Image.open(poster if isinstance(poster, Path)
else io.BytesIO(poster)).convert("RGB") else io.BytesIO(poster)).convert("RGB")
plan = plan or build_scene_plan(sections, len(timeline.cues)) sources = dict(details or {})
used = {scene.section for scene in plan.scenes if scene.section} surfaces = {name: to_surface(image) for name, image in sources.items()}
images = {section.name: section.image for section in sections if section.name in used} spans = cue_spans(timeline)
plan = plan or build_scene_plan(sections, spans, sources, surfaces)
total_frames = round(timeline.total * FPS) total_frames = round(timeline.total * FPS)
# 훅 구간만 보면 뒤쪽 스크롤과 엔드밴드를 검수할 수 없어 전체에서 고르게 뽑는다 # 훅 구간만 보면 뒤쪽 스크롤과 엔드밴드를 검수할 수 없어 전체에서 고르게 뽑는다
@ -283,7 +405,7 @@ def compose_longcut(poster: bytes | Path | Image.Image, clip: bytes,
def frames() -> Iterator[Image.Image]: def frames() -> Iterator[Image.Image]:
for index, frame in enumerate( for index, frame in enumerate(
compose_frames(timeline, plan, source, clip, images, info, qr)): compose_frames(timeline, plan, source, clip, surfaces, info, qr)):
if index == 0: if index == 0:
frame.save(thumbnail, "JPEG", quality=THUMBNAIL_QUALITY) frame.save(thumbnail, "JPEG", quality=THUMBNAIL_QUALITY)
if preview and index in proof_at: if preview and index in proof_at:

View File

@ -30,6 +30,8 @@ POSTER_W = 1000 # 9:16 안에서 포스터가 차지할 폭 (좌우
TOP_PAD = 86 TOP_PAD = 86
LOOP_OVERLAP = 16 # 겹침 프레임. 소재가 잔잔할수록 키워야 이음매가 죽는다 LOOP_OVERLAP = 16 # 겹침 프레임. 소재가 잔잔할수록 키워야 이음매가 죽는다
MIN_LOOP_SECONDS = 3 MIN_LOOP_SECONDS = 3
# 한 프레임 이내로 넘치는 것은 잘려도 들리지 않는다. 이게 없으면 같은 초에서 실패한다
NARRATION_OVERFLOW_TOLERANCE = 1 / FPS
NARRATION_VOL, SFX_VOL, BGM_VOL = 1.45, 0.35, 0.20 NARRATION_VOL, SFX_VOL, BGM_VOL = 1.45, 0.35, 0.20
BGM_FADE_IN = 1.0 BGM_FADE_IN = 1.0
@ -260,8 +262,8 @@ def render(clip: bytes, poster: Path | Image.Image, *,
raise RuntimeError(f"루프 길이가 너무 짧다 ({loop_length}프레임) — overlap을 줄일 것") raise RuntimeError(f"루프 길이가 너무 짧다 ({loop_length}프레임) — overlap을 줄일 것")
duration = loop_length / FPS duration = loop_length / FPS
if timeline and timeline.cues[-1].end > duration: if timeline and timeline.cues[-1].end > duration + NARRATION_OVERFLOW_TOLERANCE:
raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.2f}초로 영상 {duration:.2f}초를 " raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.3f}초로 영상 {duration:.3f}초를 "
"넘는다 — overlap을 줄이거나 나레이션을 짧게") "넘는다 — overlap을 줄이거나 나레이션을 짧게")
# 2회차 — 순서대로 합성해 인코더에 바로 밀어넣는다 # 2회차 — 순서대로 합성해 인코더에 바로 밀어넣는다

View File

@ -21,6 +21,12 @@ class Settings(BaseSettings):
azure_blob_base_url: str = "" azure_blob_base_url: str = ""
azure_blob_sas_token: str = "" azure_blob_sas_token: str = ""
# 비어 있으면 구글 로그인이 꺼진다. 값은 비밀이 아니라 프론트 번들에도 들어간다
google_client_id: str = ""
# 자체 세션 토큰 서명 키. 바뀌면 로그인된 사람이 전부 풀린다
jwt_secret: str = ""
jwt_days: int = 14
@property @property
def mysql_url(self) -> str: def mysql_url(self) -> str:
password = quote_plus(self.mysql_password) password = quote_plus(self.mysql_password)

View File

@ -9,6 +9,7 @@ from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, Integer, String, Te
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from models.pipeline_state import PlayreelState, PosterAliveState from models.pipeline_state import PlayreelState, PosterAliveState
from tables.user import UNASSIGNED_USER_ID, USER_ID_LENGTH
from utils.database import Base from utils.database import Base
URL_LENGTH = 512 URL_LENGTH = 512
@ -31,6 +32,9 @@ class TaskBase(Base):
id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True, id: Mapped[str] = mapped_column(String(TASK_ID_LENGTH), primary_key=True,
default=new_task_id) default=new_task_id)
# 만든 사람의 구글 sub. 모르면 UNASSIGNED_USER_ID가 들어간다
user_id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), index=True,
default=UNASSIGNED_USER_ID)
name: Mapped[str] = mapped_column(String(255), default="") name: Mapped[str] = mapped_column(String(255), default="")
status: Mapped[str] = mapped_column(String(32), default="queued", index=True) status: Mapped[str] = mapped_column(String(32), default="queued", index=True)

45
backend/tables/user.py Normal file
View File

@ -0,0 +1,45 @@
"""구글 계정으로 로그인한 사람
id가 곧 구글 sub다. 이메일이 바뀌어도 유지되는 값이라 따로 발급하지 않는다.
잡 생성 횟수는 행을 세지 않고 누적값을 올린다 — 잡을 지워도 회복되지 않는다.
"""
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from utils.database import Base
USER_ID_LENGTH = 64
EMAIL_LENGTH = 320
PICTURE_URL_LENGTH = 512
DEFAULT_JOB_LIMIT = 3
# 소유자를 모르는 잡의 자리. 나중에 admin의 sub로 덮어쓴다
UNASSIGNED_USER_ID = "0"
class User(Base):
__tablename__ = "user"
id: Mapped[str] = mapped_column(String(USER_ID_LENGTH), primary_key=True)
email: Mapped[str] = mapped_column(String(EMAIL_LENGTH), index=True)
name: Mapped[str] = mapped_column(String(255), default="")
# 구글 프로필 사진. 토큰에만 들어오는 값이라 로그인할 때 받아 둔다
picture_url: Mapped[str] = mapped_column(String(PICTURE_URL_LENGTH), default="")
# 만들 수 있는 총 잡 수. 무제한으로 둘 계정은 이 값을 크게 올린다
job_limit: Mapped[int] = mapped_column(Integer, default=DEFAULT_JOB_LIMIT)
jobs_created: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
last_login_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(),
onupdate=func.now())
@property
def can_create_job(self) -> bool:
return self.jobs_created < self.job_limit
@property
def jobs_left(self) -> int:
return max(0, self.job_limit - self.jobs_created)

View File

@ -31,6 +31,16 @@ def load_sections(sections_dir: Path) -> list[DetailSection]:
for item in json.loads(index_path.read_text(encoding="utf-8"))] for item in json.loads(index_path.read_text(encoding="utf-8"))]
def load_details(product_dir: Path) -> dict[str, Image.Image]:
"""상세페이지 롱이미지. 스크롤은 조각이 아니라 이 위에서 일어난다."""
details = {}
for path in sorted((product_dir / "details").glob("detail_*.*")):
details[path.stem] = Image.open(path)
if not details:
raise SystemExit(f"{product_dir / 'details'} 비었음 — test_fetch_nol 을 먼저 돌릴 것")
return details
def band_info(meta: NolMeta) -> EndBandInfo: def band_info(meta: NolMeta) -> EndBandInfo:
period = f"{meta.play_start_date or ''} ~ {meta.play_end_date or ''}".strip(" ~") period = f"{meta.play_start_date or ''} ~ {meta.play_end_date or ''}".strip(" ~")
detail = " · ".join(part for part in (period.replace("-", "."), meta.place_name) if part) detail = " · ".join(part for part in (period.replace("-", "."), meta.place_name) if part)
@ -66,6 +76,7 @@ def main() -> None:
product_dir / "upscaled.png", clip_path.read_bytes(), timeline, product_dir / "upscaled.png", clip_path.read_bytes(), timeline,
(product_dir / "narration.mp3").read_bytes(), (product_dir / "narration.mp3").read_bytes(),
load_sections(product_dir / "sections"), band_info(meta), load_sections(product_dir / "sections"), band_info(meta),
details=load_details(product_dir),
bgm_mp3=bgm_path.read_bytes() if bgm_path.exists() else None, bgm_mp3=bgm_path.read_bytes() if bgm_path.exists() else None,
qr=Image.open(qr_path) if qr_path.exists() else None) qr=Image.open(qr_path) if qr_path.exists() else None)
@ -75,7 +86,9 @@ def main() -> None:
for scene in result.plan.scenes: for scene in result.plan.scenes:
label = f"{scene.section} [{scene.tag}]" if scene.section else "" label = f"{scene.section} [{scene.tag}]" if scene.section else ""
print(f" 큐{scene.cue} {scene.kind:7} {label}") span = (f" {scene.source} {scene.y0:.3f}→{scene.y1:.3f}"
if scene.kind == "scroll" else "")
print(f" 큐{scene.cue} {scene.kind:7} {label}{span}")
if not qr_path.exists(): if not qr_path.exists():
print(f"※ {qr_path.name} 없음 — 엔드밴드에 QR을 안 넣는다") print(f"※ {qr_path.name} 없음 — 엔드밴드에 QR을 안 넣는다")
print(f"\n{result.frames}프레임 · {result.duration:.2f}초 · {len(result.video) / 1e6:.1f}MB") print(f"\n{result.frames}프레임 · {result.duration:.2f}초 · {len(result.video) / 1e6:.1f}MB")

View File

@ -1,6 +1,5 @@
"""MySQL 비동기 세션. """MySQL 비동기 세션
FastAPI 의존성으로 받아 씀
FastAPI 의존성으로 쓴다:
async def handler(session: AsyncSession = Depends(get_session)): ... async def handler(session: AsyncSession = Depends(get_session)): ...
""" """
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator

View File

@ -0,0 +1,118 @@
"""구글 ID 토큰 검증
브라우저가 받아온 토큰이 구글이 우리 앱 앞으로 발급한 것인지만 본다
코드 교환을 하지 않아 client_secret이 없다 — 검증에 필요한 것은 공개키와 client_id뿐이다.
검증을 통과하면 그 뒤로는 우리 토큰을 쓴다. 구글 토큰을 세션으로 들고 다니지 않는다.
빠지면 안 되는 검사 셋
서명 — 구글 JWKS 공개키. 없으면 아무나 만든 JSON이 통과한다
aud — 우리 client_id. 없으면 다른 앱 앞으로 발급된 진짜 구글 토큰이 통과한다
iss — accounts.google.com
"""
import asyncio
import time
import httpx
from jose import JWTError, jwt
from settings import settings
JWKS_URL = "https://www.googleapis.com/oauth2/v3/certs"
# 구글이 두 표기를 다 쓴다. 한쪽만 받으면 어느 날 전원 로그인 실패가 된다
ISSUERS = ("accounts.google.com", "https://accounts.google.com")
JWKS_TTL_SECONDS = 3600
HTTP_TIMEOUT = 5.0
_jwks: dict | None = None
_jwks_fetched_at = 0.0
# 토큰이 동시에 여러 개 들어와도 JWKS는 한 번만 받는다
_jwks_lock = asyncio.Lock()
class GoogleLoginDisabled(RuntimeError):
"""GOOGLE_CLIENT_ID 없음 — 구글 로그인만 꺼지고 서버는 뜬다"""
class GoogleTokenInvalid(RuntimeError):
"""서명·aud·iss·만료 중 하나가 어긋남"""
class GoogleAccount:
def __init__(self, sub: str, email: str, name: str, picture: str):
self.sub = sub
self.email = email
self.name = name
self.picture = picture
def is_enabled() -> bool:
return bool(settings.google_client_id)
async def fetch_jwks() -> dict:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
response = await client.get(JWKS_URL)
response.raise_for_status()
return response.json()
async def get_jwks(*, force: bool = False) -> dict:
global _jwks, _jwks_fetched_at
async with _jwks_lock:
fresh = _jwks is not None and (time.monotonic() - _jwks_fetched_at) < JWKS_TTL_SECONDS
if fresh and not force:
return _jwks
try:
_jwks = await fetch_jwks()
_jwks_fetched_at = time.monotonic()
except Exception as failure:
print(f"[google] JWKS 조회 실패: {failure}", flush=True)
# 낡은 캐시라도 있으면 그걸로 간다. 구글이 잠깐 안 될 때 로그인이 통째로 죽는 것보다 낫다
if _jwks is None:
raise GoogleTokenInvalid("구글 공개키를 받지 못했습니다") from failure
return _jwks
def has_key(jwks: dict, kid: str | None) -> bool:
return any(key.get("kid") == kid for key in jwks.get("keys") or [])
async def verify_id_token(credential: str) -> GoogleAccount:
if not is_enabled():
raise GoogleLoginDisabled("GOOGLE_CLIENT_ID 없음")
if not credential:
raise GoogleTokenInvalid("빈 토큰")
try:
kid = jwt.get_unverified_header(credential).get("kid")
except JWTError as failure:
raise GoogleTokenInvalid("토큰 형식이 아님") from failure
jwks = await get_jwks()
# 공개키는 주기적으로 바뀐다. 캐시에 없는 kid면 한 번만 다시 받는다
if not has_key(jwks, kid):
jwks = await get_jwks(force=True)
try:
claims = jwt.decode(
credential, jwks, algorithms=["RS256"],
audience=settings.google_client_id, issuer=ISSUERS,
# at_hash는 access_token과 짝일 때만 의미가 있다. 브라우저가 주는
# 크리덴셜에는 access_token이 없어 켜 두면 정상 토큰이 거부된다
options={"verify_at_hash": False},
)
except JWTError as failure:
# 사유는 로그에만 남긴다
print(f"[google] ID 토큰 거부: {failure}", flush=True)
raise GoogleTokenInvalid("구글 토큰이 유효하지 않습니다") from failure
sub = str(claims.get("sub") or "")
if not sub:
raise GoogleTokenInvalid("sub 없음")
# 미인증 이메일은 신원으로 쓸 수 없다
if not claims.get("email_verified"):
raise GoogleTokenInvalid("이메일이 인증되지 않은 계정입니다")
return GoogleAccount(sub=sub, email=str(claims.get("email") or ""),
name=str(claims.get("name") or ""),
picture=str(claims.get("picture") or ""))

View File

@ -0,0 +1,41 @@
"""자체 세션 토큰
구글 검증을 통과한 뒤로는 이걸 HttpOnly 쿠키로 들고 다닌다
담는 것은 user.id 하나다. 이메일·이름은 바뀔 수 있어 토큰에 넣지 않고 매번 DB에서 읽는다.
"""
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from settings import settings
ALGORITHM = "HS256"
COOKIE_NAME = "poster_alive_session"
class SessionInvalid(RuntimeError):
"""서명이 안 맞거나 만료됨"""
def issue(user_id: str) -> str:
if not settings.jwt_secret:
raise RuntimeError("JWT_SECRET 없음 — 세션을 발급할 수 없습니다")
expires = datetime.now(timezone.utc) + timedelta(days=settings.jwt_days)
return jwt.encode({"sub": user_id, "exp": expires}, settings.jwt_secret,
algorithm=ALGORITHM)
def read(token: str) -> str:
"""토큰에서 user.id를 꺼낸다"""
try:
claims = jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])
except JWTError as failure:
raise SessionInvalid("세션이 유효하지 않습니다") from failure
user_id = str(claims.get("sub") or "")
if not user_id:
raise SessionInvalid("sub 없음")
return user_id
def cookie_max_age() -> int:
return settings.jwt_days * 24 * 3600

181
backend/uv.lock generated
View File

@ -95,6 +95,7 @@ dependencies = [
{ name = "openai" }, { name = "openai" },
{ name = "pillow" }, { name = "pillow" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "python-jose", extra = ["cryptography"] },
{ name = "python-multipart" }, { name = "python-multipart" },
{ name = "scipy" }, { name = "scipy" },
{ name = "sqlalchemy" }, { name = "sqlalchemy" },
@ -111,6 +112,7 @@ requires-dist = [
{ name = "openai", specifier = ">=3.6.0" }, { name = "openai", specifier = ">=3.6.0" },
{ name = "pillow", specifier = ">=12.3.0" }, { name = "pillow", specifier = ">=12.3.0" },
{ name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" },
{ name = "python-jose", extras = ["cryptography"], specifier = ">=3.5.0" },
{ name = "python-multipart", specifier = ">=0.0.32" }, { name = "python-multipart", specifier = ">=0.0.32" },
{ name = "scipy", specifier = ">=1.18.1" }, { name = "scipy", specifier = ">=1.18.1" },
{ name = "sqlalchemy", specifier = ">=2.0.52" }, { name = "sqlalchemy", specifier = ">=2.0.52" },
@ -126,6 +128,65 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
] ]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.5.0" version = "8.5.0"
@ -135,6 +196,68 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
] ]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/25/ca/8de7744cb3bc966c85430ca2d0fcaeea872507c6a4cf6e007f7fe269ed9d/ecdsa-0.19.2.tar.gz", hash = "sha256:62635b0ac1ca2e027f82122b5b81cb706edc38cd91c63dda28e4f3455a2bf930", size = 202432, upload-time = "2026-03-26T09:58:17.675Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/79/119091c98e2bf49e24ed9f3ae69f816d715d2904aefa6a2baa039a2ba0b0/ecdsa-0.19.2-py2.py3-none-any.whl", hash = "sha256:840f5dc5e375c68f36c1a7a5b9caad28f95daa65185c9253c0c08dd952bb7399", size = 150818, upload-time = "2026-03-26T09:58:15.808Z" },
]
[[package]] [[package]]
name = "fastapi" name = "fastapi"
version = "0.141.1" version = "0.141.1"
@ -450,6 +573,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
] ]
[[package]]
name = "pyasn1"
version = "0.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a4/9a/23310166d960def5897e91fe20e5b724601b02a22e84ba1f94232c0b7f67/pyasn1-0.6.4.tar.gz", hash = "sha256:9c447d8431c947fe4c8febc4ed9e760bc29011a5b01e5c74b67025bd9fb8ce81", size = 151262, upload-time = "2026-07-09T01:12:33.988Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3b/6163796d69c3977d1e4287bea4a6979161cbbdd170ebb430511e8e1999ce/pyasn1-0.6.4-py3-none-any.whl", hash = "sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b", size = 84410, upload-time = "2026-07-09T01:12:32.92Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.4" version = "2.13.4"
@ -529,6 +670,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" },
] ]
[[package]]
name = "python-jose"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "ecdsa" },
{ name = "pyasn1" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/77/3a1c9039db7124eb039772b935f2244fbb73fc8ee65b9acf2375da1c07bf/python_jose-3.5.0.tar.gz", hash = "sha256:fb4eaa44dbeb1c26dcc69e4bd7ec54a1cb8dd64d3b4d81ef08d90ff453f2b01b", size = 92726, upload-time = "2025-05-28T17:31:54.288Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d9/c3/0bd11992072e6a1c513b16500a5d07f91a24017c5909b02c72c62d7ad024/python_jose-3.5.0-py2.py3-none-any.whl", hash = "sha256:abd1202f23d34dfad2c3d28cb8617b90acf34132c7afd60abd0b0b7d3cb55771", size = 34624, upload-time = "2025-05-28T17:31:52.802Z" },
]
[package.optional-dependencies]
cryptography = [
{ name = "cryptography" },
]
[[package]] [[package]]
name = "python-multipart" name = "python-multipart"
version = "0.0.32" version = "0.0.32"
@ -564,6 +724,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
] ]
[[package]]
name = "rsa"
version = "4.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" },
]
[[package]] [[package]]
name = "scipy" name = "scipy"
version = "1.18.1" version = "1.18.1"
@ -615,6 +787,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" }, { url = "https://files.pythonhosted.org/packages/63/ad/741c19fcb66755ff953daf9243af8480e4bf3d7fbe57583c178c7d2b6b51/scipy-1.18.1-cp315-cp315t-win_arm64.whl", hash = "sha256:eda632a7981f69730d6281f451db9c1c370993a2c0d7ddb43e2a809a2862b83a", size = 25319710, upload-time = "2026-08-21T23:28:45.713Z" },
] ]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]] [[package]]
name = "sniffio" name = "sniffio"
version = "1.3.1" version = "1.3.1"

View File

@ -4,13 +4,14 @@ services:
backend: backend:
build: ./backend build: ./backend
ports: ports:
- "8765:8765"
- "30101:30101" - "30101:30101"
env_file: env_file:
- backend/.env - backend/.env
volumes: volumes:
# 소스를 그대로 물린다 — 호스트에서 고치고 exec으로 바로 돌린다. 산출물도 호스트에 남는다. # 소스를 그대로 물린다 — 호스트에서 고치고 exec으로 바로 돌린다. 산출물도 호스트에 남는다.
# 가상환경은 이미지의 /opt/venv에 있어 이 마운트에 가려지지 않는다.
- ./backend:/app - ./backend:/app
- /app/.venv # 컨테이너 안에서 만든 것을 덮지 않는다
# Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다. # Higgsfield CLI 토큰을 호스트에서 참조한다. 만료 시 갱신해야 해서 읽기 전용이 아니다.
- ${HOME}/.config/higgsfield:/root/.config/higgsfield - ${HOME}/.config/higgsfield:/root/.config/higgsfield
restart: unless-stopped restart: unless-stopped

14
frontend/.dockerignore Normal file
View File

@ -0,0 +1,14 @@
# COPY . . 가 호스트 것을 가져오면 npm ci 로 깐 것을 덮어쓰고 빌드도 느려진다
node_modules
.next
out
build
.git
.gitignore
.env
.env.*
npm-debug.log*
*.tsbuildinfo
.DS_Store

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo"; import Ado2Logo from "@/components/ado2-logo";
import AccountBadge from "@/components/account-badge";
import AuthGate from "@/components/auth-gate";
import NavLink from "@/components/nav-link"; import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features"; import { FEATURES } from "@/lib/features";
@ -24,10 +26,13 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
{FEATURES.styling && <NavLink href="/studio" icon="image">포스터 스타일링</NavLink>} {FEATURES.styling && <NavLink href="/studio" icon="image">포스터 스타일링</NavLink>}
{FEATURES.archive && <NavLink href="/archive" icon="folder">아카이브</NavLink>} {FEATURES.archive && <NavLink href="/archive" icon="folder">아카이브</NavLink>}
</nav> </nav>
<AccountBadge />
<div className="sidebar-foot">무빙포스터 · 내부 빌드</div> <div className="sidebar-foot">무빙포스터 · 내부 빌드</div>
</aside> </aside>
<main className="main-content"> <main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div> <div style={{ maxWidth: 1080, margin: "0 auto" }}>
<AuthGate>{children}</AuthGate>
</div>
</main> </main>
</> </>
); );

View File

@ -2,7 +2,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation";
import { apiFetch, type Job } from "@/lib/api"; import { apiFetch, type Job } from "@/lib/api";
import { useMe } from "@/lib/auth";
import { FEATURES } from "@/lib/features";
import { GATE_META, type PlayreelJob } from "@/lib/playreel"; import { GATE_META, type PlayreelJob } from "@/lib/playreel";
/* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */ /* 무빙포스터 진입 — 두 갈래 (MOVING_POSTER_ENTRY_PLAN.md §1·§5) */
@ -12,14 +15,14 @@ const ENTRIES = [
href: "/poster", icon: "IMG", title: "이미지로 시작하기", href: "/poster", icon: "IMG", title: "이미지로 시작하기",
desc: "포스터 한 장이 첫 프레임 그대로 살아나는 8~15초 무빙포스터", desc: "포스터 한 장이 첫 프레임 그대로 살아나는 8~15초 무빙포스터",
flow: ["포스터 원본이 첫 프레임", "불꽃·물결·조명 등 요소만 움직임", "제목·일시 나레이션 + 음악"], flow: ["포스터 원본이 첫 프레임", "불꽃·물결·조명 등 요소만 움직임", "제목·일시 나레이션 + 음악"],
facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"], ["비용", "14크레딧"]], facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"]],
cta: "포스터 올리기", hot: false, badge: null, cta: "포스터 올리기", hot: false, badge: null,
}, },
{ {
href: "/playreel", icon: "URL", title: "공연상품페이지로 시작하기", href: "/playreel", icon: "URL", title: "공연상품페이지로 시작하기",
desc: "상품페이지 주소 하나로 캐스팅·일정·줄거리까지 담긴 30초 예고편", desc: "상품페이지 주소 하나로 캐스팅·일정·줄거리까지 담긴 30초 예고편",
flow: ["포스터 무빙 훅 8초", "상세페이지 스크롤 (줄거리·캐스트·캐스팅 스케줄·할인)", "예매 안내 밴드 + QR"], flow: ["포스터 무빙 훅 8초", "상세페이지 스크롤 (줄거리·캐스트·캐스팅 스케줄·할인)", "예매 안내 밴드 + QR"],
facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"], ["비용", "16크레딧"]], facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"]],
cta: "주소 넣기", hot: true, badge: "상세페이지까지", cta: "주소 넣기", hot: true, badge: "상세페이지까지",
}, },
] as const; ] as const;
@ -35,10 +38,14 @@ function tone(status: string) {
} }
export default function HomePage() { export default function HomePage() {
const router = useRouter();
const { me } = useMe();
const [recent, setRecent] = useState<Recent[]>([]); const [recent, setRecent] = useState<Recent[]>([]);
useEffect(() => { useEffect(() => {
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽 API가 없어도(playreel 미구현) 다른 쪽은 보인다. // 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다
if (!me) return;
// 두 갈래의 최근 작업을 합쳐 최신순 6개. 한쪽이 비어도 다른 쪽은 보인다.
Promise.all([ Promise.all([
apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]), apiFetch<Job[]>("/api/f1/jobs").catch(() => [] as Job[]),
apiFetch<PlayreelJob[]>("/api/playreel/jobs").catch(() => [] as PlayreelJob[]), apiFetch<PlayreelJob[]>("/api/playreel/jobs").catch(() => [] as PlayreelJob[]),
@ -50,7 +57,7 @@ export default function HomePage() {
]; ];
setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6)); setRecent(a.sort((x, y) => y.t - x.t).slice(0, 6));
}); });
}, []); }, [me]);
return ( return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}> <div style={{ display: "flex", flexDirection: "column", alignItems: "center", paddingTop: "1.5rem" }}>
@ -81,7 +88,18 @@ export default function HomePage() {
<div key={k}><b>{k}</b><span>{v}</span></div> <div key={k}><b>{k}</b><span>{v}</span></div>
))} ))}
</div> </div>
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}>{e.cta}</Link> {e.href === "/playreel" && !FEATURES.playreelEntry ? (
<button className="btn-cta btn-lg" disabled>준비 중</button>
) : (
/* 여기서 처음으로 로그인이 필요해진다. Link 는 그대로 둬서
새 탭으로 열거나 주소를 복사하는 동작은 살린다 */
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}
onClick={(event) => {
if (me !== false) return;
event.preventDefault();
router.push(`/login?next=${encodeURIComponent(e.href)}`);
}}>{e.cta}</Link>
)}
</div> </div>
))} ))}
</div> </div>

View File

@ -4,10 +4,14 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import PosterDropzone from "@/components/poster-dropzone"; import PosterDropzone from "@/components/poster-dropzone";
import { useRequireLogin } from "@/components/auth-gate";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { refreshMe } from "@/lib/auth";
import { FEATURES } from "@/lib/features";
export default function HomePage() { export default function HomePage() {
const router = useRouter(); const router = useRouter();
const requireLogin = useRequireLogin();
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -15,6 +19,8 @@ export default function HomePage() {
const submit = async () => { const submit = async () => {
if (!file) return; if (!file) return;
// 여기서 처음으로 로그인이 필요해진다
if (!requireLogin()) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
@ -22,6 +28,8 @@ export default function HomePage() {
fd.append("poster", file); fd.append("poster", file);
fd.append("name", name); fd.append("name", name);
const { id } = await apiFetch<{ id: string }>("/api/f1/jobs", { method: "POST", body: fd }); const { id } = await apiFetch<{ id: string }>("/api/f1/jobs", { method: "POST", body: fd });
// 남은 개수가 하나 줄었다
refreshMe();
router.push(`/poster/${id}`); router.push(`/poster/${id}`);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@ -45,9 +53,11 @@ export default function HomePage() {
/> />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", textAlign: "center", margin: 0 }}>{error}</p>} {error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", textAlign: "center", margin: 0 }}>{error}</p>}
<button className="btn-cta btn-lg" disabled={!file || busy} onClick={submit}> <button className="btn-cta btn-lg" disabled={!file || busy} onClick={submit}>
{busy ? "업로드 중…" : <>숏폼 만들기 <span className="btn-sub">14크레딧</span></>} {busy ? "업로드 중…" : "숏폼 만들기"}
</button> </button>
{FEATURES.playreelEntry && (
<p className="note">공연 상품페이지 주소가 있나요? <Link href="/playreel">상품페이지로 시작하면 캐스팅·일정까지 담깁니다 →</Link></p> <p className="note">공연 상품페이지 주소가 있나요? <Link href="/playreel">상품페이지로 시작하면 캐스팅·일정까지 담깁니다 →</Link></p>
)}
</div> </div>
</div> </div>

View File

@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import Ado2Logo from "@/components/ado2-logo"; import Ado2Logo from "@/components/ado2-logo";
import AuthGate from "@/components/auth-gate";
import NavLink from "@/components/nav-link"; import NavLink from "@/components/nav-link";
import { FEATURES } from "@/lib/features"; import { FEATURES } from "@/lib/features";
@ -38,7 +39,9 @@ export default function PlayreelLayout({ children }: { children: React.ReactNode
<div className="sidebar-foot">Playreel · an ADO2 product · 내부 빌드</div> <div className="sidebar-foot">Playreel · an ADO2 product · 내부 빌드</div>
</aside> </aside>
<main className="main-content"> <main className="main-content">
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div> <div style={{ maxWidth: 1080, margin: "0 auto" }}>
<AuthGate>{children}</AuthGate>
</div>
</main> </main>
</> </>
); );

View File

@ -108,7 +108,6 @@ export default function PlayreelJobPage({ params }: { params: Promise<{ id: stri
job.status === "running" ? (RUNNING_COPY[job.stage ?? ""] ?? "만드는 중") : job.status === "running" ? (RUNNING_COPY[job.stage ?? ""] ?? "만드는 중") :
job.status === "queued" ? `대기 중 · 앞에 ${job.queue_size ?? 0}편` : job.status === "queued" ? `대기 중 · 앞에 ${job.queue_size ?? 0}편` :
job.status === "failed" ? "작업이 중단되었습니다" : "아카이브에 저장되었습니다"} job.status === "failed" ? "작업이 중단되었습니다" : "아카이브에 저장되었습니다"}
{job.credits_used > 0 && <span style={{ color: "var(--color-text-gray-500)" }}> · 사용 {job.credits_used}크레딧</span>}
</p> </p>
<div className="card" style={{ marginTop: "2rem", padding: "var(--spacing-page-md)" }}> <div className="card" style={{ marginTop: "2rem", padding: "var(--spacing-page-md)" }}>

View File

@ -66,7 +66,6 @@ function PlayreelArchivePageInner() {
</p> </p>
<p style={{ margin: "0.3rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}> <p style={{ margin: "0.3rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>
{e.version ? `v${e.version}` : "—"} {e.version ? `v${e.version}` : "—"}
{e.credits_used != null && ` · ${e.credits_used}크레딧`}
</p> </p>
{e.video_url && ( {e.video_url && (
<a href={e.video_url} download className="btn-outline" style={{ display: "block", textAlign: "center", marginTop: "0.6rem", padding: "0.5rem", fontSize: "var(--text-sm)", textDecoration: "none" }}> <a href={e.video_url} download className="btn-outline" style={{ display: "block", textAlign: "center", marginTop: "0.6rem", padding: "0.5rem", fontSize: "var(--text-sm)", textDecoration: "none" }}>

View File

@ -6,6 +6,8 @@ import { useRouter } from "next/navigation";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel"; import { GATE_META, parseGoodsId, type PlayreelJob } from "@/lib/playreel";
import { AutoApprovePanel } from "@/components/auto-approve"; import { AutoApprovePanel } from "@/components/auto-approve";
import { useRequireLogin } from "@/components/auth-gate";
import { refreshMe, useMe } from "@/lib/auth";
const STATUS_LABEL: Record<string, string> = { const STATUS_LABEL: Record<string, string> = {
queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료", queued: "대기 중", running: "만드는 중", awaiting_review: "검수 대기", failed: "실패", done: "완료",
@ -20,6 +22,8 @@ function statusTone(s: string) {
export default function PlayreelStartPage() { export default function PlayreelStartPage() {
const router = useRouter(); const router = useRouter();
const requireLogin = useRequireLogin();
const { me } = useMe();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -27,13 +31,17 @@ export default function PlayreelStartPage() {
// 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다. // 독립 서비스가 되면서 ADO2 홈의 "최근 작업"을 잃는다. 여기서 대신 보여준다.
const [recent, setRecent] = useState<PlayreelJob[]>([]); const [recent, setRecent] = useState<PlayreelJob[]>([]);
useEffect(() => { useEffect(() => {
// 잡 목록은 로그인해야 볼 수 있다. 로그인 전에는 부르지 않는다
if (!me) return;
apiFetch<PlayreelJob[]>("/api/playreel/jobs") apiFetch<PlayreelJob[]>("/api/playreel/jobs")
.then((j) => setRecent(j.slice(0, 6))) .then((j) => setRecent(j.slice(0, 6)))
.catch(() => setRecent([])); .catch(() => setRecent([]));
}, []); }, [me]);
const submit = async () => { const submit = async () => {
if (!goodsId) return; if (!goodsId) return;
// 여기서 처음으로 로그인이 필요해진다
if (!requireLogin()) return;
setBusy(true); setBusy(true);
setError(null); setError(null);
try { try {
@ -42,6 +50,8 @@ export default function PlayreelStartPage() {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: url.trim() }), body: JSON.stringify({ url: url.trim() }),
}); });
// 남은 개수가 하나 줄었다
refreshMe();
router.push(`/playreel/${id}`); router.push(`/playreel/${id}`);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@ -101,7 +111,7 @@ export default function PlayreelStartPage() {
<p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--color-mint)", fontWeight: 700 }}>{g.step}단계</p> <p style={{ margin: 0, fontSize: "var(--text-xs)", color: "var(--color-mint)", fontWeight: 700 }}>{g.step}단계</p>
<p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700 }}>{g.name}</p> <p style={{ margin: "0.25rem 0 0", fontSize: "var(--text-sm)", fontWeight: 700 }}>{g.name}</p>
<p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}> <p style={{ margin: "0.35rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>
{g.credits > 0 ? `${g.credits}크레딧` : "무료"} · {g.eta} {g.eta}
</p> </p>
</div> </div>
); );

View File

@ -103,6 +103,59 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
z-index: 50; z-index: 50;
} }
.sidebar-logo { padding: 1.5rem 1.5rem 0.5rem; } .sidebar-logo { padding: 1.5rem 1.5rem 0.5rem; }
/* 사이드바 맨 아래 계정 카드 */
.account-card {
display: flex;
align-items: center;
gap: 0.6rem;
margin: 0 0.75rem;
padding: 0.7rem 0.8rem;
border-radius: var(--radius-md);
background: var(--color-bg-darker);
border: 1px solid var(--color-border-white-10);
min-width: 0;
}
.account-photo {
flex: none;
width: 34px;
height: 34px;
border-radius: var(--radius-full);
object-fit: cover;
background: var(--color-bg-dark);
}
.account-photo--blank {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 700;
color: var(--color-mint);
}
.account-body { flex: 1; min-width: 0; }
.account-name {
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--text-sm);
font-weight: 700;
}
.account-credit {
margin: 2px 0 0;
font-size: var(--text-xs);
color: var(--color-text-gray-400);
}
.account-link {
flex: none;
padding: 0;
border: none;
background: none;
cursor: pointer;
font-family: var(--font);
font-size: var(--text-xs);
color: var(--color-mint);
}
.account-link:hover { text-decoration: underline; }
.sidebar-menu { flex: 1; padding: 0 0.75rem; margin-top: 1rem; overflow-y: auto; } .sidebar-menu { flex: 1; padding: 0 0.75rem; margin-top: 1rem; overflow-y: auto; }
.sidebar-item { .sidebar-item {
display: flex; display: flex;

View File

@ -0,0 +1,39 @@
"use client";
import { Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import GoogleSignIn from "@/components/google-sign-in";
import { setMe, signIn } from "@/lib/auth";
import { useState } from "react";
function LoginForm() {
const router = useRouter();
// 로그인 전에 가려던 곳. 없으면 홈으로 보낸다
const next = useSearchParams().get("next") || "/";
const [error, setError] = useState<string | null>(null);
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
gap: "1.5rem", paddingTop: "8rem" }}>
<h1 className="page-title">로그인</h1>
<p className="page-subtitle">구글 계정으로 들어와 주세요.</p>
<GoogleSignIn onCredential={(credential) => {
setError(null);
signIn(credential)
// 사이드바 배지와 목록이 같은 값을 보므로 여기서 넣어줘야 같이 바뀐다
.then(setMe)
.then(() => router.replace(next))
.catch((failure) => setError((failure as Error).message));
}} />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)" }}>{error}</p>}
</div>
);
}
export default function LoginPage() {
return (
<Suspense fallback={<p style={{ color: "var(--color-text-gray-400)" }}>불러오는 중…</p>}>
<LoginForm />
</Suspense>
);
}

View File

@ -0,0 +1,45 @@
"use client";
/** 사이드바 맨 아래 계정 카드. 누구로 들어와 있는지, 남은 크레딧, 나가는 길 */
import { useRouter } from "next/navigation";
import { signOut, useMe } from "@/lib/auth";
export default function AccountBadge() {
const router = useRouter();
const { me } = useMe();
if (me === null) return null;
if (me === false) {
return (
<div className="account-card">
<button type="button" className="account-link" onClick={() => router.push("/login")}>
로그인
</button>
</div>
);
}
return (
<div className="account-card">
{me.picture ? (
// eslint-disable-next-line @next/next/no-img-element
<img className="account-photo" src={me.picture} alt="" referrerPolicy="no-referrer" />
) : (
<span className="account-photo account-photo--blank">
{(me.name || me.email).slice(0, 1)}
</span>
)}
<div className="account-body">
<p className="account-name" title={me.email}>{me.name || me.email}</p>
<p className="account-credit">보유 크레딧: {me.jobs_left}</p>
</div>
{/* 화면 여기저기 남의 계정 데이터가 남아 있어 통째로 새로 받는다 */}
<button type="button" className="account-link"
onClick={() => { signOut().finally(() => { window.location.href = "/"; }); }}>
로그아웃
</button>
</div>
);
}

View File

@ -0,0 +1,66 @@
"use client";
/**
* 로그인하지 않았으면 화면 대신 로그인만 보여준다.
*
* 들어오는 화면과 아카이브는 열어 둔다 — 로그인은 실제로 만들려고 누를 때 요구한다.
* 그 요구는 여기가 아니라 시작 화면의 제출 버튼이 한다(useRequireLogin).
*/
import { usePathname, useRouter } from "next/navigation";
import GoogleSignIn from "@/components/google-sign-in";
import { signIn, useMe } from "@/lib/auth";
import { useState } from "react";
// 이 주소들은 그대로 보여준다. 하위 경로까지 여는 것은 아카이브뿐이다
const OPEN_PAGES = ["/", "/poster", "/playreel", "/login"];
const OPEN_TREES = ["/archive", "/playreel/archive"];
function isOpen(pathname: string): boolean {
return OPEN_PAGES.includes(pathname)
|| OPEN_TREES.some((open) => pathname === open || pathname.startsWith(`${open}/`));
}
/** 시작 화면이 제출 직전에 부른다. 로그인 안 돼 있으면 로그인 화면으로 보낸다 */
export function useRequireLogin() {
const router = useRouter();
const pathname = usePathname();
const { me } = useMe();
return () => {
if (me === false) {
router.push(`/login?next=${encodeURIComponent(pathname)}`);
return false;
}
return true;
};
}
export default function AuthGate({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { me, setMe } = useMe();
const [error, setError] = useState<string | null>(null);
if (isOpen(pathname)) return <>{children}</>;
if (me === null) {
return <p style={{ color: "var(--color-text-gray-400)" }}>확인하는 중…</p>;
}
if (me === false) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center",
gap: "1.5rem", paddingTop: "6rem" }}>
<h1 className="page-title">로그인이 필요합니다</h1>
<p className="page-subtitle">구글 계정으로 들어와 주세요.</p>
<GoogleSignIn onCredential={(credential) => {
setError(null);
signIn(credential).then(setMe).catch((failure) => setError((failure as Error).message));
}} />
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)" }}>{error}</p>}
</div>
);
}
return <>{children}</>;
}

View File

@ -13,8 +13,8 @@ export const isAutoApprovable = (g: GateKey | null | undefined): g is AutoApprov
!!g && (AUTO_APPROVABLE as readonly string[]).includes(g); !!g && (AUTO_APPROVABLE as readonly string[]).includes(g);
const AUTO_COPY: Record<AutoApprovable, string> = { const AUTO_COPY: Record<AutoApprovable, string> = {
fetch_confirm: "무료 · 상세페이지에서 읽은 정보와 기본 섹션을 그대로 사용", fetch_confirm: "상세페이지에서 읽은 정보와 기본 섹션을 그대로 사용",
narration_confirm: "무료 · 자동 생성 문장과 기본 목소리로 바로 진행", narration_confirm: "자동 생성 문장과 기본 목소리로 바로 진행",
}; };
const Lock = () => ( const Lock = () => (
@ -52,7 +52,7 @@ export function AutoApprovePanel() {
<div className="pref-row" style={{ opacity: 0.55 }}> <div className="pref-row" style={{ opacity: 0.55 }}>
<div style={{ minWidth: 0 }}> <div style={{ minWidth: 0 }}>
<p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600 }}>2 · 4 · 5단계</p> <p style={{ margin: 0, fontSize: "var(--text-sm)", fontWeight: 600 }}>2 · 4 · 5단계</p>
<p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>크레딧이 들거나 되돌릴 수 없어 항상 확인합니다</p> <p style={{ margin: "2px 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>되돌릴 수 없는 단계라 항상 확인합니다</p>
</div> </div>
<span style={{ color: "var(--color-text-gray-500)" }}><Lock /></span> <span style={{ color: "var(--color-text-gray-500)" }}><Lock /></span>
</div> </div>

View File

@ -8,8 +8,8 @@
import { useState } from "react"; import { useState } from "react";
import { import {
GATE_META, type AnalysisReview, type ClipReview, type FetchReview, type FinalReview, GATE_META, type AnalysisReview, type ClipReview, type DetailSection, type FetchReview,
type GateKey, type GateReview, type NarrationReview, type FinalReview, type GateKey, type GateReview, type NarrationReview,
} from "@/lib/playreel"; } from "@/lib/playreel";
// ── 공통 셸 ────────────────────────────────────────────────────────── // ── 공통 셸 ──────────────────────────────────────────────────────────
@ -38,7 +38,6 @@ export function GateShell({ gate, children, onApprove, onBack, busy, error, appr
</div> </div>
<p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}> <p style={{ margin: "0.75rem 0 0", fontSize: "var(--text-sm)", color: "var(--color-text-gray-500)" }}>
다음 단계 · {g.next} · {g.eta} 다음 단계 · {g.next} · {g.eta}
{g.credits > 0 && <> · <strong style={{ color: "#ffd27a" }}>{g.credits}크레딧</strong></>}
</p> </p>
{error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{error}</p>} {error && <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", marginTop: "0.75rem" }}>{error}</p>}
</div> </div>
@ -85,6 +84,46 @@ function Warn({ children }: { children: React.ReactNode }) {
const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const; const two = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "var(--spacing-page-md)", alignItems: "start" } as const;
// 상세페이지 조각은 라벨이 겹친다 — "유의사항"만 일곱 개인 페이지가 흔하다.
// 칩으로 그리면 같은 글자가 줄줄이 서서 무엇을 고르는지 알 수 없어 썸네일을 그린다.
function SectionPicker({ sections, onToggle }: {
sections: DetailSection[]; onToggle: (id: string) => void;
}) {
const chosen = sections.filter((s) => s.selected).length;
return (
<>
<p className="field-label" style={{ margin: "1.5rem 0 0.35rem" }}>상세페이지에서 훑기 시작할 지점</p>
<p style={{ margin: "0 0 0.7rem", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", lineHeight: 1.5 }}>
고른 지점부터 상세페이지를 이어서 훑습니다. 조각을 잘라 붙이는 것이 아닙니다.
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(84px, 1fr))", gap: 8, maxHeight: 340, overflowY: "auto", paddingRight: 4 }}>
{sections.map((s, i) => (
<button key={s.id} type="button" title={`${s.label} · ${s.height.toLocaleString()}px`}
onClick={s.required ? undefined : () => onToggle(s.id)}
style={{
position: "relative", padding: 0, borderRadius: "var(--radius-md)", overflow: "hidden",
cursor: s.required ? "default" : "pointer", textAlign: "left", background: "transparent",
border: s.selected ? "2px solid var(--color-mint)" : "2px solid var(--color-border-white-10)",
opacity: s.selected ? 1 : 0.5,
}}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={s.thumb_url} alt={s.label}
style={{ width: "100%", aspectRatio: "3 / 4", objectFit: "cover", objectPosition: "top", display: "block", background: "#000" }} />
<span style={{ position: "absolute", top: 4, left: 4, minWidth: 16, padding: "1px 4px", borderRadius: 4, background: "rgba(0,0,0,0.65)", color: "#fff", fontSize: 10, textAlign: "center" }}>{i + 1}</span>
<span style={{ display: "flex", alignItems: "center", gap: 4, padding: "3px 5px", fontSize: 10, lineHeight: 1.3, background: "rgba(0,0,0,0.65)", color: s.selected ? "var(--color-mint)" : "var(--color-text-gray-400)" }}>
{s.required ? <Lock size={10} /> : s.selected ? <Ico d={CHECK} size={10} /> : <Ico d={PLUS} size={10} />}
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{s.label}</span>
</span>
</button>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> {sections.length}개 중 {chosen}개 선택 · 캐스팅 스케줄은 항상 포함됩니다.
</p>
</>
);
}
// ── ① 수집 확인 ─────────────────────────────────────────────────────── // ── ① 수집 확인 ───────────────────────────────────────────────────────
export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) { export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (edits: { sections: string[]; meta: FetchReview["meta"] }) => void }) {
const [sections, setSections] = useState(data.sections); const [sections, setSections] = useState(data.sections);
@ -106,20 +145,13 @@ export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (ed
캐스트 · {meta.cast.join(", ")} 캐스트 · {meta.cast.join(", ")}
</p> </p>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>영상에 넣을 상세페이지 부분</p> <SectionPicker sections={sections}
<div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem" }}> onToggle={(id) => {
{sections.map((s) => ( const n = sections.map((x) => x.id === id ? { ...x, selected: !x.selected } : x);
<Chip key={s.id} on={s.selected} locked={s.required} setSections(n); emit(n);
onClick={() => { const n = sections.map((x) => x.id === s.id ? { ...x, selected: !x.selected } : x); setSections(n); emit(n); }}> }} />
{s.label}
</Chip>
))}
</div>
<p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)", display: "flex", alignItems: "center", gap: 6 }}>
<Lock size={11} /> 캐스팅 스케줄은 항상 포함됩니다.
</p>
{data.poster_width <= 800 && ( {data.poster_width <= 800 && (
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn> <Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다. 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
)} )}
</div> </div>
<div> <div>
@ -157,7 +189,7 @@ export function AnalysisGate({ data, onChange }: { data: AnalysisReview; onChang
</div> </div>
<p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>생성 모델</p> <p className="field-label" style={{ margin: "1.5rem 0 0.5rem" }}>생성 모델</p>
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8초 · 14크레딧</p> <p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8초</p>
{data.ip_risk && <Warn>알려진 IP(디즈니 등) 작품입니다. 다른 모델은 저작권 필터로 거부된 이력이 있어 Kling으로만 진행합니다. 실패하면 아트워크 푸시인으로 대체됩니다.</Warn>} {data.ip_risk && <Warn>알려진 IP(디즈니 등) 작품입니다. 다른 모델은 저작권 필터로 거부된 이력이 있어 Kling으로만 진행합니다. 실패하면 아트워크 푸시인으로 대체됩니다.</Warn>}
{!data.has_qr && <p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>포스터에 QR이 없어 엔딩 밴드 QR은 상품 페이지 링크로 자동 생성합니다.</p>} {!data.has_qr && <p style={{ margin: "0.6rem 0 0", fontSize: "var(--text-xs)", color: "var(--color-text-gray-500)" }}>포스터에 QR이 없어 엔딩 밴드 QR은 상품 페이지 링크로 자동 생성합니다.</p>}
</div> </div>
@ -225,7 +257,7 @@ export function ClipGate({ data }: { data: ClipReview }) {
<p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>5시점 프레임</p> <p className="field-label" style={{ margin: "1.25rem 0 0.5rem" }}>5시점 프레임</p>
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src={data.frames_url} alt="프레임 시트" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} /> <img src={data.frames_url} alt="프레임 시트" className="card-inner" style={{ width: "100%", padding: 0, display: "block" }} />
<Warn>다시 만들기는 영상 생성을 처음부터 반복하며 {data.retry_credits}크레딧이 다시 듭니다. 이 클립 위에 원본 글자를 덮는 합성은 무료입니다.</Warn> <Warn>다시 만들기는 영상 생성을 처음부터 반복합니다. 이 클립 위에 원본 글자를 덮는 합성은 다시 만들지 않습니다.</Warn>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,54 @@
"use client";
/** 구글이 그려주는 버튼. 우리가 모양을 흉내 내면 브랜드 규정에 걸린다 */
import { useEffect, useRef, useState } from "react";
import {
fetchConfig, loadGoogleIdentity, type GoogleButtonOptions,
} from "@/lib/auth";
export default function GoogleSignIn({ onCredential, text = "signin_with" }: {
onCredential: (credential: string) => void;
text?: GoogleButtonOptions["text"];
}) {
const host = useRef<HTMLDivElement>(null);
const [error, setError] = useState<string | null>(null);
// 콜백이 매 렌더마다 바뀌어도 구글에 다시 등록하지 않게 최신 것만 들고 있는다
const latest = useRef(onCredential);
latest.current = onCredential;
useEffect(() => {
let cancelled = false;
(async () => {
const config = await fetchConfig();
if (!config.enabled) throw new Error("구글 로그인이 설정되지 않았습니다");
const google = await loadGoogleIdentity();
if (cancelled || !host.current) return;
google.initialize({
client_id: config.client_id,
callback: (response) => {
if (response.credential) latest.current(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
});
google.renderButton(host.current, {
type: "standard", theme: "filled_black", size: "large",
shape: "pill", text, logo_alignment: "center", width: 320,
});
})().catch((failure) => {
if (!cancelled) setError((failure as Error).message);
});
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [text]);
if (error) {
return <p style={{ color: "#ff7a7a", fontSize: "var(--text-sm)", margin: 0 }}>{error}</p>;
}
return <div ref={host} style={{ display: "flex", justifyContent: "center" }} />;
}

142
frontend/lib/auth.ts Normal file
View File

@ -0,0 +1,142 @@
"use client";
/**
* 구글 로그인.
* 브라우저가 구글에서 받은 credential 을 서버에 한 번 넘기면, 그 뒤로는 HttpOnly 쿠키가
* 붙는다 — 토큰을 프론트가 들고 있지 않는다.
*
* GIS 스크립트는 여기서 붙인다. 로그인 화면에 오기 전에는 서드파티 요청을 만들지 않는다.
*/
import { useEffect, useSyncExternalStore } from "react";
import { apiFetch } from "@/lib/api";
const SCRIPT_URL = "https://accounts.google.com/gsi/client?hl=ko";
const SCRIPT_ID = "google-identity-services";
export interface Me {
email: string;
name: string;
picture: string;
jobs_created: number;
job_limit: number;
jobs_left: number;
}
export interface AuthConfig {
enabled: boolean;
client_id: string;
}
type CredentialResponse = { credential?: string };
export type GoogleButtonOptions = {
type?: "standard" | "icon";
theme?: "outline" | "filled_blue" | "filled_black";
size?: "small" | "medium" | "large";
shape?: "rectangular" | "pill" | "circle" | "square";
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
width?: number;
logo_alignment?: "left" | "center";
};
type GoogleIdApi = {
initialize(config: {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
}): void;
renderButton(parent: HTMLElement, options: GoogleButtonOptions): void;
disableAutoSelect(): void;
};
declare global {
interface Window {
google?: { accounts?: { id?: GoogleIdApi } };
}
}
// 스크립트는 한 번만 받는다
let loading: Promise<GoogleIdApi> | null = null;
export function loadGoogleIdentity(): Promise<GoogleIdApi> {
const ready = window.google?.accounts?.id;
if (ready) return Promise.resolve(ready);
if (loading) return loading;
loading = new Promise<GoogleIdApi>((resolve, reject) => {
const done = () => {
const api = window.google?.accounts?.id;
if (api) resolve(api);
else reject(new Error("구글 스크립트에 accounts.id 가 없습니다"));
};
const fail = () => {
// 다음 시도에서 다시 받을 수 있게 비운다 — 광고 차단기나 사내망에서 한 번 막히는 일이 흔하다
loading = null;
reject(new Error("구글 로그인 스크립트를 받지 못했습니다"));
};
const existing = document.getElementById(SCRIPT_ID) as HTMLScriptElement | null;
if (existing) {
existing.addEventListener("load", done, { once: true });
existing.addEventListener("error", fail, { once: true });
return;
}
const script = document.createElement("script");
script.id = SCRIPT_ID;
script.src = SCRIPT_URL;
script.async = true;
script.defer = true;
script.addEventListener("load", done, { once: true });
script.addEventListener("error", fail, { once: true });
document.head.appendChild(script);
});
return loading;
}
export const fetchConfig = () => apiFetch<AuthConfig>("/api/auth/config");
export const signIn = (credential: string) =>
apiFetch<Me>("/api/auth/google", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ credential }),
});
export const signOut = () => apiFetch<{ ok: boolean }>("/api/auth/logout", { method: "POST" });
// 로그인 정보는 화면 여러 곳이 같이 본다 — 사이드바 배지와 페이지가 따로 들고 있으면
// 한쪽에서 갱신해도 다른 쪽이 옛 값을 그린다. 한 군데 두고 나눠 쓴다
let cached: Me | null | false = null;
const listeners = new Set<() => void>();
function publish(next: Me | null | false) {
cached = next;
listeners.forEach((notify) => notify());
}
function subscribe(notify: () => void) {
listeners.add(notify);
return () => { listeners.delete(notify); };
}
/** 서버에 다시 물어본다. 잡을 만든 뒤처럼 남은 개수가 바뀌는 자리에서 부른다 */
export function refreshMe(): Promise<void> {
return apiFetch<Me>("/api/auth/me")
.then((me) => publish(me))
.catch(() => publish(false));
}
export const setMe = publish;
/** 로그인 여부. null 이면 아직 확인 중, false 면 로그아웃 상태 */
export function useMe() {
const me = useSyncExternalStore(subscribe, () => cached, () => null);
useEffect(() => {
if (cached === null) refreshMe();
}, []);
return { me, refresh: refreshMe, setMe: publish };
}

View File

@ -4,8 +4,10 @@
* 네비게이션에서 감추고 본문 대신 안내를 띄운다. * 네비게이션에서 감추고 본문 대신 안내를 띄운다.
* *
* styling /studio — /api/f2 없음 * styling /studio — /api/f2 없음
* playreelEntry — 무빙포스터 화면에서 롱컷으로 넘어가는 자리. 임시로 닫아둠
*/ */
export const FEATURES = { export const FEATURES = {
styling: false, styling: false,
archive: true, archive: true,
playreelEntry: false,
} as const; } as const;

View File

@ -110,34 +110,34 @@ export function parseGoodsId(url: string): string | null {
return m ? m[1] : null; return m ? m[1] : null;
} }
// ── 게이트 메타 (카드 헤더·비용 문구) ─────────────────────────────── // ── 게이트 메타 (카드 헤더 문구) ───────────────────────────────────
export const GATE_META: Record<GateKey, { export const GATE_META: Record<GateKey, {
step: number; name: string; title: string; why: string; next: string; credits: number; eta: string; canBack: boolean; step: number; name: string; title: string; why: string; next: string; eta: string; canBack: boolean;
}> = { }> = {
fetch_confirm: { fetch_confirm: {
step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요", step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요",
why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.", why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.",
next: "포스터 화질 보정 · 요소 분석", credits: 2, eta: "약 2분", canBack: false, next: "포스터 화질 보정 · 요소 분석", eta: "약 2분", canBack: false,
}, },
analysis_confirm: { analysis_confirm: {
step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요", step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요",
why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.", why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.",
next: "Kling 3.0 영상 생성", credits: 14, eta: "약 5~8분", canBack: true, next: "Kling 3.0 영상 생성", eta: "약 5~8분", canBack: true,
}, },
narration_confirm: { narration_confirm: {
step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요", step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요",
why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.", why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.",
next: "음성 합성 · 배경음악 생성", credits: 0, eta: "약 3분", canBack: true, next: "음성 합성 · 배경음악 생성", eta: "약 3분", canBack: true,
}, },
clip_confirm: { clip_confirm: {
step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요", step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요",
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 크레딧이 다시 듭니다.", why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 처음부터 다시 만듭니다.",
next: "원본 글자 합성 · 상세페이지 스크롤 조립", credits: 0, eta: "약 5분", canBack: false, next: "원본 글자 합성 · 상세페이지 스크롤 조립", eta: "약 5분", canBack: false,
}, },
final_confirm: { final_confirm: {
step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요", step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요",
why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.", why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.",
next: "아카이브 저장 · 다운로드", credits: 0, eta: "즉시", canBack: false, next: "아카이브 저장 · 다운로드", eta: "즉시", canBack: false,
}, },
}; };
@ -158,6 +158,23 @@ const PH = (w: number, h: number, text: string) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`, `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}"><rect width="100%" height="100%" fill="#003538"/><text x="50%" y="50%" fill="#a6ffea" font-size="20" font-family="sans-serif" text-anchor="middle" dominant-baseline="middle">${text}</text></svg>`,
)}`; )}`;
// 실물이 오는 모양. 심규선 콘서트 상세페이지 실측 19조각이다 — 라벨이 겹치고 대부분 공지다.
// 이 자리에 6개짜리 깔끔한 목을 두었다가 게이트 ①이 같은 글자 열아홉 개만 뱉는 걸 놓쳤다.
const MOCK_SECTIONS: DetailSection[] = ([
["keyvisual", "키비주얼", 1108], ["synopsis", "작품 소개", 313], ["fragment", "조각", 244],
["info", "관람 정보", 270], ["notice", "유의사항", 608], ["info", "관람 정보", 346],
["fragment", "조각", 270], ["notice", "유의사항", 887], ["schedule", "캐스팅 스케줄", 582],
["notice", "유의사항", 1149], ["notice", "유의사항", 968], ["info", "관람 정보", 383],
["discount", "할인 안내", 473], ["fragment", "조각", 285], ["notice", "유의사항", 875],
["notice", "유의사항", 1063], ["fragment", "조각", 152], ["notice", "유의사항", 1068],
["info", "관람 정보", 371],
] as const).map(([tag, label, height], i) => ({
id: `detail_01_s${String(i + 1).padStart(2, "0")}`,
tag, label, height, thumb_url: PH(180, 240, String(i + 1)),
required: tag === "schedule" || undefined,
selected: tag !== "fragment",
}));
export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob { export function mockJob(gate: GateKey | "running" | "done" | "failed"): PlayreelJob {
const base: PlayreelJob = { const base: PlayreelJob = {
id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null, id: "mock", kind: "playreel", name: "뮤지컬 〈겨울왕국〉", status: "awaiting_review", stage: null,
@ -178,14 +195,7 @@ export function mockJob(gate: GateKey | "running" | "done" | "failed"): Playreel
return { ...base, gate, stages: stages(2), review: { gate, data: { return { ...base, gate, stages: stages(2), review: { gate, data: {
poster_url: PH(300, 420, "포스터 750px"), poster_width: 750, poster_url: PH(300, 420, "포스터 750px"), poster_width: 750,
meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" }, meta: { title: "뮤지컬 〈겨울왕국〉", date_text: "2026.11.25 ~ 2027.03.01", place: "샤롯데씨어터", cast: ["박혜나", "정선아", "이지혜"], genre: "뮤지컬" },
sections: [ sections: MOCK_SECTIONS,
{ id: "s1", tag: "story", label: "작품 소개", thumb_url: PH(160, 90, "소개"), height: 1800, selected: true },
{ id: "s2", tag: "awards", label: "수상·세계관", thumb_url: PH(160, 90, "수상"), height: 900, selected: true },
{ id: "s3", tag: "cast", label: "캐스트", thumb_url: PH(160, 90, "캐스트"), height: 1400, selected: true },
{ id: "s4", tag: "schedule", label: "캐스팅 스케줄", thumb_url: PH(160, 90, "스케줄"), height: 2200, required: true, selected: true },
{ id: "s5", tag: "discount", label: "할인 안내", thumb_url: PH(160, 90, "할인"), height: 700, selected: true },
{ id: "s6", tag: "notice", label: "유의사항", thumb_url: PH(160, 90, "유의"), height: 1200, selected: false },
],
} } }; } } };
case "analysis_confirm": case "analysis_confirm":
return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: { return { ...base, gate, stages: stages(4), credits_used: 2, review: { gate, data: {

View File

@ -3,7 +3,7 @@
/** /**
* 브라우저 로컬 환경설정 — 게이트 자동 승인. * 브라우저 로컬 환경설정 — 게이트 자동 승인.
* 키 `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true } * 키 `playreel.autoApprove` = { fetch_confirm?: true, narration_confirm?: true }
* 대상은 크레딧이 들지 않고 되돌릴 수 있는 게이트(①·③)뿐. ②·④·⑤는 GATE_META.credits/비가역이라 제외. * 대상은 되돌릴 수 있는 게이트(①·③)뿐. ②·④·⑤는 비가역이라 제외.
* 서버에는 저장하지 않는다(사용자별 계정 개념이 아직 없음). 서버 `approve` 자동 호출은 /playreel/[id] 에서. * 서버에는 저장하지 않는다(사용자별 계정 개념이 아직 없음). 서버 `approve` 자동 호출은 /playreel/[id] 에서.
*/ */