Compare commits
3 Commits
main
...
fix/longim
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb9c84aa62 | ||
|
|
d50dc52c39 | ||
|
|
0e53fa294b |
@ -13,11 +13,6 @@ 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
|
||||||
@ -25,7 +20,8 @@ RUN uv sync --frozen --no-install-project
|
|||||||
COPY . .
|
COPY . .
|
||||||
RUN uv sync --frozen
|
RUN uv sync --frozen
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
# CLI 토큰은 컨테이너가 켜질 때 호스트의 것을 참조한다:
|
# CLI 토큰은 컨테이너가 켜질 때 호스트의 것을 참조한다:
|
||||||
# -v ~/.config/higgsfield:/root/.config/higgsfield
|
# -v ~/.config/higgsfield:/root/.config/higgsfield
|
||||||
|
|||||||
@ -26,29 +26,8 @@ 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_HOST` · `MYSQL_PORT` · `MYSQL_USER` · `MYSQL_PASSWORD` · `MYSQL_DB` | DB 세션 |
|
| `MYSQL_URL` | 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
|
||||||
|
|
||||||
|
|||||||
@ -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, auth, f1, playreel
|
from routers import archive, 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,7 +21,6 @@ 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)
|
||||||
|
|||||||
@ -33,7 +33,6 @@ 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
|
||||||
@ -80,10 +79,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, slug: str, *,
|
async def create_task(session: AsyncSession, url: str, goods_id: str,
|
||||||
user_id: str = UNASSIGNED_USER_ID) -> PlayreelTask:
|
slug: str) -> 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}", user_id=user_id,
|
name=f"공연 {goods_id}",
|
||||||
stage_timings=initial_timings(PlayreelState))
|
stage_timings=initial_timings(PlayreelState))
|
||||||
session.add(task)
|
session.add(task)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@ -24,7 +24,6 @@ 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
|
||||||
|
|
||||||
@ -32,12 +31,10 @@ 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,
|
skip_review: bool = False) -> PosterAliveTask:
|
||||||
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
|
||||||
|
|||||||
@ -11,7 +11,6 @@ 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",
|
||||||
|
|||||||
@ -15,7 +15,6 @@ 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"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,113 +0,0 @@
|
|||||||
"""/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
|
|
||||||
@ -11,17 +11,13 @@ 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
|
||||||
@ -64,7 +60,6 @@ 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})")
|
||||||
@ -86,21 +81,14 @@ 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(user: User = Depends(current_user),
|
async def list_jobs(session: AsyncSession = Depends(get_session)):
|
||||||
session: AsyncSession = Depends(get_session)):
|
|
||||||
"""자기가 만든 것만 돌려준다"""
|
|
||||||
found = await session.scalars(
|
found = await session.scalars(
|
||||||
select(PosterAliveTask).where(PosterAliveTask.user_id == user.id)
|
select(PosterAliveTask).order_by(PosterAliveTask.created_at.desc()))
|
||||||
.order_by(PosterAliveTask.created_at.desc()))
|
|
||||||
return [poster_alive_view(task) for task in found]
|
return [poster_alive_view(task) for task in found]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -11,15 +11,11 @@ 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 = (
|
||||||
@ -60,8 +56,7 @@ async def find_task(session: AsyncSession, task_id: str) -> PlayreelTask:
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/jobs")
|
@router.post("/jobs")
|
||||||
async def create(body: CreateBody, user: User = Depends(creating_user),
|
async def create(body: CreateBody, session: AsyncSession = Depends(get_session)):
|
||||||
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, "지원하지 않는 주소입니다. 공연 상품페이지 주소를 넣어 주세요.")
|
||||||
@ -69,21 +64,14 @@ async def create(body: CreateBody, user: User = Depends(creating_user),
|
|||||||
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(user: User = Depends(current_user),
|
async def list_jobs(session: AsyncSession = Depends(get_session)):
|
||||||
session: AsyncSession = Depends(get_session)):
|
|
||||||
"""자기가 만든 것만 돌려준다"""
|
|
||||||
found = await session.scalars(
|
found = await session.scalars(
|
||||||
select(PlayreelTask).where(PlayreelTask.user_id == user.id)
|
select(PlayreelTask).order_by(PlayreelTask.created_at.desc()))
|
||||||
.order_by(PlayreelTask.created_at.desc()))
|
|
||||||
return [playreel_view(task) for task in found]
|
return [playreel_view(task) for task in found]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -53,8 +53,7 @@ NOL_WORDMARK = ASSET_DIR / "nol_wordmark_white.png"
|
|||||||
|
|
||||||
# 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다.
|
# 큐 순서대로 채울 씬. 앞 둘은 클립, 마지막은 포스터, 나머지는 이 태그 우선순위로 고른다.
|
||||||
CLIP_CUES = 2
|
CLIP_CUES = 2
|
||||||
# 여기 없는 태그는 시작점이 안 된다 — 유의사항·제작진·타 공연 배너·조각은 읽힐 내용이
|
# 공지·교차배너는 스크롤 시작점으로 쓰지 않는다 — 약관을 읽히려고 만드는 영상이 아니다
|
||||||
# 아니고, 키비주얼은 앞 큐의 포스터와 겹친다. 관람 정보는 러닝타임·관람등급이라 남긴다
|
|
||||||
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "info")
|
SCROLL_TAG_PRIORITY = ("synopsis", "still", "cast", "schedule", "discount", "event", "info")
|
||||||
REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다
|
REQUIRED_TAGS = ("schedule",) # 캐스팅 스케줄은 예매 전에 확인하는 정보라 빠지면 안 된다
|
||||||
|
|
||||||
@ -243,21 +242,13 @@ def scroll_starts(sections: Sequence[DetailSection],
|
|||||||
return ordered
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
def scroll_window(surface: Image.Image, start_px: int, seconds: float) -> tuple[float, float]:
|
def scroll_window(section: DetailSection, source: Image.Image, surface: Image.Image,
|
||||||
"""시작 픽셀과 큐 길이로 훑을 구간을 정한다. 롱이미지 높이 대비 비율로 돌려준다.
|
seconds: float) -> tuple[float, float]:
|
||||||
|
"""섹션이 시작점, 큐 길이가 훑는 거리. 둘 다 롱이미지 높이 대비 비율로 돌려준다."""
|
||||||
여기서 crop 가능한 범위로 묶는다 — 플랜에 남는 값이 곧 실제 이동량이어야
|
scale = W / source.width
|
||||||
게이트가 0픽셀 스크롤을 걸러낼 수 있다.
|
start = round(section.y0 * scale)
|
||||||
"""
|
reach = round(seconds * SCROLL_SCREENS_PER_SECOND * H)
|
||||||
span = max(0, surface.height - H)
|
return start / surface.height, (start + reach) / surface.height
|
||||||
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]],
|
def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[float, float]],
|
||||||
@ -269,9 +260,7 @@ def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[fl
|
|||||||
|
|
||||||
scenes: list[Scene] = []
|
scenes: list[Scene] = []
|
||||||
index = 0
|
index = 0
|
||||||
# 롱이미지마다 어디까지 읽었는지 픽셀로 따로 센다. 비율은 이미지 높이가 다르면
|
cursor = 0.0 # 폴백이 이어 읽을 지점
|
||||||
# 서로 다른 자리를 가리키므로 이어 읽기 지점으로 쓸 수 없다
|
|
||||||
read_to: dict[str, int] = {}
|
|
||||||
for cue, (start, end) in enumerate(spans):
|
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"))
|
||||||
@ -279,21 +268,19 @@ def build_scene_plan(sections: Sequence[DetailSection], spans: Sequence[tuple[fl
|
|||||||
scenes.append(Scene(cue=cue, kind="poster"))
|
scenes.append(Scene(cue=cue, kind="poster"))
|
||||||
elif index < len(ordered):
|
elif index < len(ordered):
|
||||||
section = ordered[index]
|
section = ordered[index]
|
||||||
surface = surfaces[section.source]
|
y0, y1 = scroll_window(section, sources[section.source],
|
||||||
y0, y1 = scroll_window(surface,
|
surfaces[section.source], end - start)
|
||||||
section_start_px(section, sources[section.source]),
|
|
||||||
end - start)
|
|
||||||
scenes.append(Scene(cue=cue, kind="scroll", section=section.name,
|
scenes.append(Scene(cue=cue, kind="scroll", section=section.name,
|
||||||
tag=section.tag, source=section.source, y0=y0, y1=y1))
|
tag=section.tag, source=section.source, y0=y0, y1=y1))
|
||||||
index = index + 1
|
index, cursor = index + 1, y1
|
||||||
read_to[section.source] = round(y1 * surface.height)
|
|
||||||
elif fallback is not None:
|
elif fallback is not None:
|
||||||
# 쓸 섹션이 떨어져도 포스터로 때우지 않는다. 그 롱이미지를 읽던 자리에서
|
# 쓸 섹션이 떨어져도 포스터로 때우지 않는다. 앞 씬이 멈춘 자리에서 이어 읽어야
|
||||||
# 이어 읽어야 같은 화면이 두 번 나오지 않는다
|
# 같은 화면이 두 번 나오지 않는다
|
||||||
surface = surfaces[fallback]
|
reach = round((end - start) * SCROLL_SCREENS_PER_SECOND * H)
|
||||||
y0, y1 = scroll_window(surface, read_to.get(fallback, 0), end - start)
|
y1 = cursor + reach / surfaces[fallback].height
|
||||||
scenes.append(Scene(cue=cue, kind="scroll", source=fallback, y0=y0, y1=y1))
|
scenes.append(Scene(cue=cue, kind="scroll", source=fallback,
|
||||||
read_to[fallback] = round(y1 * surface.height)
|
y0=cursor, y1=y1))
|
||||||
|
cursor = y1
|
||||||
else:
|
else:
|
||||||
scenes.append(Scene(cue=cue, kind="poster"))
|
scenes.append(Scene(cue=cue, kind="poster"))
|
||||||
return LongcutPlan(scenes=scenes)
|
return LongcutPlan(scenes=scenes)
|
||||||
|
|||||||
@ -30,8 +30,6 @@ 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
|
||||||
@ -262,8 +260,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 + NARRATION_OVERFLOW_TOLERANCE:
|
if timeline and timeline.cues[-1].end > duration:
|
||||||
raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.3f}초로 영상 {duration:.3f}초를 "
|
raise RuntimeError(f"나레이션이 {timeline.cues[-1].end:.2f}초로 영상 {duration:.2f}초를 "
|
||||||
"넘는다 — overlap을 줄이거나 나레이션을 짧게")
|
"넘는다 — overlap을 줄이거나 나레이션을 짧게")
|
||||||
|
|
||||||
# 2회차 — 순서대로 합성해 인코더에 바로 밀어넣는다
|
# 2회차 — 순서대로 합성해 인코더에 바로 밀어넣는다
|
||||||
|
|||||||
@ -21,12 +21,6 @@ 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)
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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
|
||||||
@ -32,9 +31,6 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
"""구글 계정으로 로그인한 사람
|
|
||||||
|
|
||||||
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)
|
|
||||||
@ -1,5 +1,6 @@
|
|||||||
"""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
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
"""구글 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 ""))
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
"""자체 세션 토큰
|
|
||||||
구글 검증을 통과한 뒤로는 이걸 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
181
backend/uv.lock
generated
@ -95,7 +95,6 @@ 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" },
|
||||||
@ -112,7 +111,6 @@ 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" },
|
||||||
@ -128,65 +126,6 @@ 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"
|
||||||
@ -196,68 +135,6 @@ 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"
|
||||||
@ -573,24 +450,6 @@ 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"
|
||||||
@ -670,25 +529,6 @@ 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"
|
||||||
@ -724,18 +564,6 @@ 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"
|
||||||
@ -787,15 +615,6 @@ 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"
|
||||||
|
|||||||
@ -4,14 +4,13 @@ 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
|
||||||
|
|||||||
@ -1,14 +0,0 @@
|
|||||||
# COPY . . 가 호스트 것을 가져오면 npm ci 로 깐 것을 덮어쓰고 빌드도 느려진다
|
|
||||||
node_modules
|
|
||||||
.next
|
|
||||||
out
|
|
||||||
build
|
|
||||||
|
|
||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.env
|
|
||||||
.env.*
|
|
||||||
|
|
||||||
npm-debug.log*
|
|
||||||
*.tsbuildinfo
|
|
||||||
.DS_Store
|
|
||||||
@ -1,8 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -16,9 +14,9 @@ export default function Ado2Layout({ children }: { children: React.ReactNode })
|
|||||||
<>
|
<>
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-logo">
|
<div className="sidebar-logo">
|
||||||
<Link href="/" style={{ color: "var(--color-text-white)", display: "inline-flex", alignItems: "baseline", gap: 8, textDecoration: "none" }}>
|
<Link href="/" className="mp-wordmark">
|
||||||
<Ado2Logo height={20} />
|
<Ado2Logo height={24} />
|
||||||
<span className="sidebar-product">MOVING POSTER</span>
|
<span className="mp-mark">MOVING POSTER</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<nav className="sidebar-menu">
|
<nav className="sidebar-menu">
|
||||||
@ -26,13 +24,10 @@ 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" }}>
|
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div>
|
||||||
<AuthGate>{children}</AuthGate>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
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) */
|
||||||
@ -15,14 +12,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분"]],
|
facts: [["필요한 것", "이미지 1장"], ["확인", "1회"], ["시간", "약 3분"], ["비용", "14크레딧"]],
|
||||||
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분"]],
|
facts: [["필요한 것", "상품페이지 URL"], ["확인", "5회"], ["시간", "약 15분"], ["비용", "16크레딧"]],
|
||||||
cta: "주소 넣기", hot: true, badge: "상세페이지까지",
|
cta: "주소 넣기", hot: true, badge: "상세페이지까지",
|
||||||
},
|
},
|
||||||
] as const;
|
] as const;
|
||||||
@ -38,14 +35,10 @@ 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[]),
|
||||||
@ -57,7 +50,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" }}>
|
||||||
@ -88,18 +81,7 @@ 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>
|
||||||
{e.href === "/playreel" && !FEATURES.playreelEntry ? (
|
<Link href={e.href} className="btn-cta btn-lg" style={{ textDecoration: "none" }}>{e.cta}</Link>
|
||||||
<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>
|
||||||
|
|||||||
@ -4,14 +4,10 @@ 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);
|
||||||
@ -19,8 +15,6 @@ 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 {
|
||||||
@ -28,8 +22,6 @@ 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);
|
||||||
@ -53,11 +45,9 @@ 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 ? "업로드 중…" : "숏폼 만들기"}
|
{busy ? "업로드 중…" : <>숏폼 만들기 <span className="btn-sub">14크레딧</span></>}
|
||||||
</button>
|
</button>
|
||||||
{FEATURES.playreelEntry && (
|
<p className="note">공연 상품페이지 주소가 있나요? <Link href="/playreel">상품페이지로 시작하면 캐스팅·일정까지 담깁니다 →</Link></p>
|
||||||
<p className="note">공연 상품페이지 주소가 있나요? <Link href="/playreel">상품페이지로 시작하면 캐스팅·일정까지 담깁니다 →</Link></p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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";
|
||||||
|
|
||||||
@ -39,9 +38,7 @@ 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" }}>
|
<div style={{ maxWidth: 1080, margin: "0 auto" }}>{children}</div>
|
||||||
<AuthGate>{children}</AuthGate>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -108,6 +108,7 @@ 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)" }}>
|
||||||
|
|||||||
@ -66,6 +66,7 @@ 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" }}>
|
||||||
|
|||||||
@ -6,8 +6,6 @@ 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: "완료",
|
||||||
@ -22,8 +20,6 @@ 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);
|
||||||
@ -31,17 +27,13 @@ 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 {
|
||||||
@ -50,8 +42,6 @@ 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);
|
||||||
@ -111,7 +101,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.eta}
|
{g.credits > 0 ? `${g.credits}크레딧` : "무료"} · {g.eta}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -103,59 +103,6 @@ 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;
|
||||||
@ -472,6 +419,19 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
|
|||||||
.note a { color: var(--color-mint); }
|
.note a { color: var(--color-mint); }
|
||||||
.sidebar-product { font-size: 10px; font-weight: 800; letter-spacing: 0.1em; color: var(--color-mint); white-space: nowrap; }
|
.sidebar-product { font-size: 10px; font-weight: 800; letter-spacing: 0.1em; color: var(--color-mint); white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ── (ado2) 셸 워드마크 — Playreel 락업(.pr-wordmark)과 같은 문법 ──
|
||||||
|
ADO2 로고 위 · 제품 워드마크 아래. 아래 줄을 로고 폭에 맞춰 락업이 사각형으로 앉는다.
|
||||||
|
로고 폭은 height × 149/22 이므로 h24 = 162.5px. MOVING POSTER 는 PLAYREEL 보다
|
||||||
|
글자가 길어 자간·크기를 그 폭에 맞게 따로 잡았다(실측으로 맞춤). */
|
||||||
|
.mp-wordmark { display: inline-flex; flex-direction: column; gap: 6px; text-decoration: none; align-items: flex-start; }
|
||||||
|
.mp-wordmark svg { color: var(--color-text-gray-300); }
|
||||||
|
.mp-mark {
|
||||||
|
/* 16.4px + 0.14em 에서 글자 잉크 폭이 162.3px — 로고(h24 = 162.5px)와 0.2px 차이다.
|
||||||
|
자간은 마지막 글자 뒤에도 붙으므로 그만큼 음수 마진으로 걷어내야 오른쪽 끝이 맞는다. */
|
||||||
|
font-size: 16.4px; font-weight: 800; letter-spacing: 0.14em; margin-right: -0.14em;
|
||||||
|
color: var(--color-text-white); line-height: 1; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-stack { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 560px; margin-top: 1.5rem; }
|
.entry-stack { display: flex; flex-direction: column; gap: 0.75rem; width: 100%; max-width: 560px; margin-top: 1.5rem; }
|
||||||
.entry { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; position: relative; }
|
.entry { display: flex; flex-direction: column; gap: 0.85rem; padding: 1.25rem; position: relative; }
|
||||||
.entry--hot { border-color: var(--color-mint-30); }
|
.entry--hot { border-color: var(--color-mint-30); }
|
||||||
@ -524,6 +484,7 @@ body { margin: 0; -webkit-font-smoothing: antialiased; letter-spacing: -0.006em;
|
|||||||
.sidebar-menu { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
.sidebar-menu { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
|
||||||
.sidebar-menu::-webkit-scrollbar { display: none; }
|
.sidebar-menu::-webkit-scrollbar { display: none; }
|
||||||
.sidebar-product { display: none; }
|
.sidebar-product { display: none; }
|
||||||
|
/* 워드마크는 남긴다 — Playreel 셸(.pr-mark)과 같은 처리 */
|
||||||
.playreel-steps { grid-template-columns: repeat(2, 1fr); }
|
.playreel-steps { grid-template-columns: repeat(2, 1fr); }
|
||||||
.result-grid { grid-template-columns: 1fr; }
|
.result-grid { grid-template-columns: 1fr; }
|
||||||
.result-grid .result-video { min-height: 240px; }
|
.result-grid .result-video { min-height: 240px; }
|
||||||
|
|||||||
@ -1,39 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
"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}</>;
|
|
||||||
}
|
|
||||||
@ -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>
|
||||||
|
|||||||
@ -38,6 +38,7 @@ 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>
|
||||||
@ -151,7 +152,7 @@ export function FetchGate({ data, onChange }: { data: FetchReview; onChange: (ed
|
|||||||
setSections(n); emit(n);
|
setSections(n); emit(n);
|
||||||
}} />
|
}} />
|
||||||
{data.poster_width <= 800 && (
|
{data.poster_width <= 800 && (
|
||||||
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다. 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
|
<Warn>포스터가 {data.poster_width}px로 작습니다. 다음 단계에서 화질을 2배 보정합니다(2크레딧). 원본 파일이 있으면 무빙포스터 경로에서 직접 올리는 편이 더 선명합니다.</Warn>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -189,7 +190,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초</p>
|
<p style={{ margin: 0, fontSize: "var(--text-sm)", color: "var(--color-text-gray-300)" }}>Kling 3.0 pro · 8초 · 14크레딧</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>
|
||||||
@ -257,7 +258,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>다시 만들기는 영상 생성을 처음부터 반복합니다. 이 클립 위에 원본 글자를 덮는 합성은 다시 만들지 않습니다.</Warn>
|
<Warn>다시 만들기는 영상 생성을 처음부터 반복하며 {data.retry_credits}크레딧이 다시 듭니다. 이 클립 위에 원본 글자를 덮는 합성은 무료입니다.</Warn>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
"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" }} />;
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
"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 };
|
|
||||||
}
|
|
||||||
@ -3,11 +3,9 @@
|
|||||||
* 백엔드에 대응하는 API가 없는 화면을 여기서 끈다 — 페이지 코드는 그대로 두고
|
* 백엔드에 대응하는 API가 없는 화면을 여기서 끈다 — 페이지 코드는 그대로 두고
|
||||||
* 네비게이션에서 감추고 본문 대신 안내를 띄운다.
|
* 네비게이션에서 감추고 본문 대신 안내를 띄운다.
|
||||||
*
|
*
|
||||||
* 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;
|
||||||
|
|||||||
@ -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; eta: string; canBack: boolean;
|
step: number; name: string; title: string; why: string; next: string; credits: number; eta: string; canBack: boolean;
|
||||||
}> = {
|
}> = {
|
||||||
fetch_confirm: {
|
fetch_confirm: {
|
||||||
step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요",
|
step: 1, name: "수집 확인", title: "이 공연이 맞는지, 어떤 부분을 넣을지 확인해 주세요",
|
||||||
why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.",
|
why: "상세페이지에서 가져온 정보로 영상의 뼈대를 만듭니다. 캐스팅 스케줄은 항상 들어갑니다.",
|
||||||
next: "포스터 화질 보정 · 요소 분석", eta: "약 2분", canBack: false,
|
next: "포스터 화질 보정 · 요소 분석", credits: 2, eta: "약 2분", canBack: false,
|
||||||
},
|
},
|
||||||
analysis_confirm: {
|
analysis_confirm: {
|
||||||
step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요",
|
step: 2, name: "연출 확인", title: "포스터에서 무엇을 움직일지 정해주세요",
|
||||||
why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.",
|
why: "승인하면 영상 생성이 시작되고 되돌릴 수 없습니다. 제목·로고·인물은 원본 그대로 고정됩니다.",
|
||||||
next: "Kling 3.0 영상 생성", eta: "약 5~8분", canBack: true,
|
next: "Kling 3.0 영상 생성", credits: 14, eta: "약 5~8분", canBack: true,
|
||||||
},
|
},
|
||||||
narration_confirm: {
|
narration_confirm: {
|
||||||
step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요",
|
step: 3, name: "나레이션 확인", title: "나레이션 문장과 목소리를 확인해 주세요",
|
||||||
why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.",
|
why: "이 문장이 그대로 읽힙니다. 캐스팅 스케줄 안내와 일시·CTA 문장은 꼭 필요합니다.",
|
||||||
next: "음성 합성 · 배경음악 생성", eta: "약 3분", canBack: true,
|
next: "음성 합성 · 배경음악 생성", credits: 0, eta: "약 3분", canBack: true,
|
||||||
},
|
},
|
||||||
clip_confirm: {
|
clip_confirm: {
|
||||||
step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요",
|
step: 4, name: "클립 검수", title: "생성된 장면을 확인해 주세요",
|
||||||
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 처음부터 다시 만듭니다.",
|
why: "제목이 깨지지 않았는지 자동 검사한 결과입니다. 다시 만들면 크레딧이 다시 듭니다.",
|
||||||
next: "원본 글자 합성 · 상세페이지 스크롤 조립", eta: "약 5분", canBack: false,
|
next: "원본 글자 합성 · 상세페이지 스크롤 조립", credits: 0, eta: "약 5분", canBack: false,
|
||||||
},
|
},
|
||||||
final_confirm: {
|
final_confirm: {
|
||||||
step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요",
|
step: 5, name: "최종 검수", title: "완성된 예고편을 확인해 주세요",
|
||||||
why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.",
|
why: "승인하면 이 버전이 고정되어 아카이브에 저장됩니다. 이후 수정은 새 버전으로 만들어집니다.",
|
||||||
next: "아카이브 저장 · 다운로드", eta: "즉시", canBack: false,
|
next: "아카이브 저장 · 다운로드", credits: 0, eta: "즉시", canBack: false,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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] 에서.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user