feat: p2v 연동

This commit is contained in:
김성경 2026-08-26 13:53:10 +09:00
parent 487d0f6d6a
commit 3b68845e2d
16 changed files with 448 additions and 140 deletions

View File

@ -2,7 +2,6 @@ import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
import { ContentType } from '../types/api';
import LoginPromptModal from './LoginPromptModal';
interface ContentCardSocialActionsProps {
@ -10,8 +9,10 @@ interface ContentCardSocialActionsProps {
/**
* . video.id ssul_content.id API
* URL(/video/{id} vs /ssul/{id}) .
* ContentType 이유: 좋아요· video·ssul
* P2V (MyContentsPage ).
*/
contentType?: ContentType;
contentType?: 'video' | 'ssul';
storeName: string;
region?: string;
title?: string | null;

View File

@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VideoListItem } from '../types/api';
import { useOverlayClose } from '../hooks/useOverlayClose';
import { downloadP2vFile } from '../utils/p2vApi';
interface P2vViewerModalProps {
/** null 이면 닫힘. type 이 p2v_video 면 영상, p2v_poster 면 이미지로 그린다 */
item: VideoListItem | null;
onClose: () => void;
}
/**
* P2V ADO2/ (video-detail-*) .
*
* :
* - API . P2V (result_movie_url =
* Blob URL) , .
* - ·· . comment/video_reaction video·ssul
* P2V (DDL) (2026-08-26 ).
* .
*/
const P2vViewerModal: React.FC<P2vViewerModalProps> = ({ item, onClose }) => {
const { t, i18n } = useTranslation();
const [isLandscape, setIsLandscape] = useState(false);
useEffect(() => {
if (!item) return;
setIsLandscape(false);
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.body.style.overflow = prev;
document.removeEventListener('keydown', handleKeyDown);
};
}, [item, onClose]);
const overlayCloseHandlers = useOverlayClose(onClose);
if (!item) return null;
const isVideo = item.type === 'p2v_video';
const fileName = `${item.store_name || 'p2v'}.${isVideo ? 'mp4' : 'png'}`;
const formatDate = (iso: string) =>
new Date(iso).toLocaleDateString(i18n.language === 'ko' ? 'ko-KR' : 'en-US', {
year: 'numeric', month: 'long', day: 'numeric',
});
return (
<div className="video-detail-overlay" {...overlayCloseHandlers}>
<div className="video-detail-modal-box" onClick={(e) => e.stopPropagation()}>
<div className="video-detail-modal-content">
{/* 헤더 — 문구는 ADO2/썰박스 상세와 같은 키를 쓴다 (같은 목록에서 열리는 모달) */}
<div className="video-detail-header">
<h2 className="video-detail-modal-title">{t('sidebar.ado2Contents')}</h2>
<button
className="video-detail-close-btn"
onClick={onClose}
aria-label={t('videoDetail.closeAriaLabel')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div className="video-detail-body">
<div className={`video-detail-content ${isLandscape ? 'landscape' : ''}`}>
<div className="video-detail-player-wrap">
{isVideo ? (
<video
src={item.result_movie_url}
controls
autoPlay
playsInline
className="video-detail-player"
onLoadedMetadata={(e) => {
const v = e.currentTarget;
setIsLandscape(v.videoWidth > v.videoHeight);
}}
/>
) : (
<img
src={item.result_movie_url}
alt={item.store_name}
className="video-detail-player"
style={{ objectFit: 'contain' }}
onLoad={(e) => {
const im = e.currentTarget;
setIsLandscape(im.naturalWidth > im.naturalHeight);
}}
/>
)}
</div>
<div className="video-detail-info">
<h2 className="video-detail-store">{item.store_name}</h2>
<p className="video-detail-date">{formatDate(item.created_at)}</p>
{/* · P2V .
(Blob) URL <a download> fetch */}
<div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
<button
className="video-detail-copy-btn"
onClick={() => downloadP2vFile(item.result_movie_url, fileName)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 3v12M12 15l-4-4M12 15l4-4" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2" />
</svg>
{t('p2vViewer.download')}
</button>
</div>
{/* F1 키워드 해시태그 (있을 때만) — 검수 화면과 같은 칩 스타일 재사용 */}
{item.hashtags && item.hashtags.length > 0 && (
<div className="p2v-scope">
<div className="p2v-tags">
{item.hashtags.map((kw) => (
<span key={kw} className="p2v-tag">#{kw}</span>
))}
</div>
</div>
)}
{/* 스타일링은 사용한 템플릿 이름을 부가 정보로 (title 자리에 실려온다) */}
{item.type === 'p2v_poster' && item.title && (
<p className="video-detail-date">{item.title}</p>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default P2vViewerModal;

View File

@ -600,6 +600,10 @@
"autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
"pageComingSoon": "{{page}} page is coming soon."
},
"p2vViewer": {
"download": "Download",
"close": "Close"
},
"pipelineTabs": {
"ariaLabel": "Content type",
"ado2": "ADO2",
@ -667,6 +671,7 @@
"phaseRender": "Generating",
"failTitle": "Generation failed",
"retry": "Retry",
"force": "Ignore and proceed",
"discard": "Start over",
"discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone."
},

View File

@ -600,6 +600,10 @@
"autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.",
"pageComingSoon": "{{page}} 페이지 준비 중입니다."
},
"p2vViewer": {
"download": "다운로드",
"close": "닫기"
},
"pipelineTabs": {
"ariaLabel": "생성할 콘텐츠 종류",
"ado2": "ADO2",
@ -716,6 +720,7 @@
"phaseRender": "생성 중",
"failTitle": "생성에 실패했어요",
"retry": "다시 시도",
"force": "무시하고 진행",
"discard": "처음부터",
"discardConfirm": "이 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다."
},

View File

@ -5,6 +5,7 @@ import { VideoListItem } from '../../types/api';
import VideoDetailModal from '../../components/VideoDetailModal';
import CitySelectModal from '../../components/CitySelectModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import P2vViewerModal from '../../components/P2vViewerModal';
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
interface ADO2ContentsPageProps {
@ -17,6 +18,8 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
const [videos, setVideos] = useState<VideoListItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
@ -148,19 +151,19 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
key={`${video.type}-${video.video_id}`}
className="ado2-content-card"
style={{ cursor: 'pointer' }}
onClick={() =>
video.type === 'ssul'
? setSelectedSsul(video)
: handleCardClick(video.video_id)
}
onClick={() => {
if (video.type === 'ssul') setSelectedSsul(video);
else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
else handleCardClick(video.video_id);
}}
role="button"
tabIndex={0}
onKeyDown={(e) =>
e.key === 'Enter' &&
(video.type === 'ssul'
? setSelectedSsul(video)
: handleCardClick(video.video_id))
}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
if (video.type === 'ssul') setSelectedSsul(video);
else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
else handleCardClick(video.video_id);
}}
>
<div className="content-card-thumbnail ado2-gallery-thumbnail-wrap">
{video.poster_url ? (
@ -203,7 +206,9 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<div className="content-card-meta">
<p className="content-card-date">{formatDate(video.created_at)}</p>
{/* · API type .
contentType id . */}
contentType id .
(/video/all) video·ssul P2V . */}
{(video.type === 'video' || video.type === 'ssul') && (
<ContentCardSocialActions
videoId={video.video_id}
contentType={video.type}
@ -215,6 +220,7 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
</div>
</div>
</div>
@ -244,6 +250,8 @@ const ADO2ContentsPage: React.FC<ADO2ContentsPageProps> = () => {
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
{selectedVideoId !== null && (
<VideoDetailModal
videoId={String(selectedVideoId)}

View File

@ -38,7 +38,7 @@ import PosterMakingContent from '../Poster/PosterMakingContent';
import PosterReviewContent from '../Poster/PosterReviewContent';
import PosterResultContent from '../Poster/PosterResultContent';
import { useP2vJob } from '../../hooks/useP2vJob';
import { deletePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
import { deletePosterJob, forcePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
import {
K,
clearProjectStorage,
@ -640,6 +640,25 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
}
};
/**
* i2v .
* Higgsfield . 409 .
*/
const handlePosterForce = async () => {
if (!posterJobId || posterBusy) return;
setPosterBusy(true);
try {
await forcePosterJob(posterJobId);
refreshPosterJob();
} catch (e) {
// 409 = 무시할 수 없는 실패(클립 미생성 — 저해상·모션 없음 등).
// 조용히 삼키면 버튼이 아무 반응 없어 보여 재클릭만 유발한다 — 사유를 보여준다.
alert((e as Error).message);
} finally {
setPosterBusy(false);
}
};
/** 실패한 잡과 산출물을 지우고 처음부터. 되돌릴 수 없다 */
const handlePosterDiscard = async () => {
if (!posterJobId || posterBusy) return;
@ -782,6 +801,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<PosterMakingContent
job={posterJob}
onRetry={handlePosterRetry}
onForce={handlePosterForce}
onDiscard={handlePosterDiscard}
busy={posterBusy}
/>

View File

@ -2,10 +2,12 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
import { deletePosterJob, deleteStylingJob } from '../../utils/p2vApi';
import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import P2vViewerModal from '../../components/P2vViewerModal';
import SsulViewerModal from '../Ssulbox/SsulViewerModal';
import { useOverlayClose } from '../../hooks/useOverlayClose';
@ -112,6 +114,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
const pageSize = 12;
@ -201,9 +205,13 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
setIsDeleting(true);
try {
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
// 썰박스 id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
// 썰박스·P2V id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
if (deleteTarget.type === 'ssul') {
await deleteSsulContent(deleteTarget.video_id);
} else if (deleteTarget.type === 'p2v_video') {
await deletePosterJob(deleteTarget.video_id);
} else if (deleteTarget.type === 'p2v_poster') {
await deleteStylingJob(deleteTarget.video_id);
} else {
await deleteVideo(deleteTarget.video_id);
}
@ -258,11 +266,11 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
<div
className="content-card-thumbnail"
style={{ cursor: 'pointer' }}
onClick={() =>
video.type === 'ssul'
? setSelectedSsul(video)
: setSelectedVideoId(video.video_id)
}
onClick={() => {
if (video.type === 'ssul') setSelectedSsul(video);
else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
else setSelectedVideoId(video.video_id);
}}
>
{video.poster_url ? (
<img
@ -304,7 +312,9 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
{formatDate(video.created_at)}
</p>
{/* · API type .
contentType id . */}
contentType id .
P2V · ( ) . */}
{(video.type === 'video' || video.type === 'ssul') && (
<ContentCardSocialActions
videoId={video.video_id}
contentType={video.type}
@ -316,17 +326,20 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
</div>
</div>
{/* Action Buttons */}
<div className="content-card-actions">
{/* SocialPostingModal content_type
(social_upload ). */}
(social_upload ).
P2V video·ssul SNS . */}
<button
className="content-download-btn"
onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url}
style={video.type === 'p2v_video' || video.type === 'p2v_poster' ? { display: 'none' } : undefined}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M10 13V3M10 3l-4 4M10 3l4 4"/>
@ -419,6 +432,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
<SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
{/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal
isOpen={uploadModalOpen}

View File

@ -25,22 +25,20 @@ const PosterCreateForm: React.FC<PosterCreateFormProps> = ({ onSubmitted }) => {
const [authNeeded, setAuthNeeded] = useState(false);
const submit = async () => {
// P2V 서버(:8010) 미연결 — 프론트만 구현된 상태라 제출 동작을 임시로 끈다.
// 서버 연결되면 아래 주석을 해제할 것.
// if (!file || submitting) return;
// setSubmitting(true);
// setError(null);
// try {
// const { id } = await createPosterJob(file, name.trim());
// onSubmitted(id);
// } catch (e) {
// if (e instanceof P2vAuthError) {
// setAuthNeeded(true);
// } else {
// setError((e as Error).message);
// }
// setSubmitting(false);
// }
if (!file || submitting) return;
setSubmitting(true);
setError(null);
try {
const { id } = await createPosterJob(file, name.trim());
onSubmitted(id);
} catch (e) {
if (e instanceof P2vAuthError) {
setAuthNeeded(true);
} else {
setError((e as Error).message);
}
setSubmitting(false);
}
};
if (authNeeded) {

View File

@ -6,6 +6,8 @@ interface PosterMakingContentProps {
job: P2vJob | null;
/** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */
onRetry: () => void;
/** 실패를 무시하고 이미 생성된 클립으로 렌더를 잇는다 (i2v 게이트 실패 전용) */
onForce: () => void;
/** 실패한 잡을 버리고 처음부터 */
onDiscard: () => void;
busy: boolean;
@ -21,13 +23,18 @@ interface PosterMakingContentProps {
* "살아 있다" ( SsulMakingContent ).
*/
const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
job, onRetry, onDiscard, busy,
job, onRetry, onForce, onDiscard, busy,
}) => {
const { t } = useTranslation();
if (!job) return null;
const failed = job.status === 'failed';
// "무시하고 진행"은 i2v 게이트 실패(클립은 이미 생성됨)에서만 의미가 있다.
// 다른 스테이지 실패는 이어갈 산출물이 없어 서버도 409 로 거절한다.
// 개발자 모드(vite dev) 전용 — 품질 게이트를 일반 사용자가 우회하면 훼손된
// 제목의 영상이 그대로 나가므로, 프로덕션 빌드에서는 버튼 자체를 뺀다.
const canForce = import.meta.env.DEV && failed && job.error?.stage === 'i2v';
const percent = p2vProgress(job);
const past = isPastReview(job);
@ -57,6 +64,11 @@ const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
<button type="button" className="p2v-btn-mint" disabled={busy} onClick={onRetry}>
{t('poster.making.retry')}
</button>
{canForce && (
<button type="button" className="p2v-btn-tonal" disabled={busy} onClick={onForce}>
{t('poster.making.force')}
</button>
)}
</div>
</>
) : (

View File

@ -1,6 +1,6 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { P2vJob, p2vFile } from '../../utils/p2vApi';
import { P2vJob, downloadP2vFile, p2vFile } from '../../utils/p2vApi';
interface PosterResultContentProps {
job: P2vJob;
@ -71,15 +71,24 @@ const PosterResultContent: React.FC<PosterResultContentProps> = ({ job, onNew })
)}
<div className="p2v-result__actions">
{/* 교차 출처(Blob) URL 은 <a download> 가 무시된다 — fetch 경유 다운로드 */}
{thumbnail && (
<a href={thumbnail} download className="p2v-btn-tonal">
<button
type="button"
className="p2v-btn-tonal"
onClick={() => downloadP2vFile(thumbnail, `${job.name || 'poster'}_thumb.jpg`)}
>
{t('poster.result.downloadThumb')}
</a>
</button>
)}
{video && (
<a href={video} download className="p2v-btn-cta p2v-btn-cta--compact">
<button
type="button"
className="p2v-btn-cta p2v-btn-cta--compact"
onClick={() => downloadP2vFile(video, `${job.name || 'poster'}.mp4`)}
>
{t('poster.result.downloadVideo')}
</a>
</button>
)}
</div>
</div>

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import {
P2vJob,
approvePosterJob,
p2vFile,
fetchP2vImage,
updatePosterMetadata,
updatePosterNarration,
} from '../../utils/p2vApi';
@ -40,6 +40,23 @@ const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApprov
const [meta, setMeta] = useState<MetaDraft>(emptyMeta);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [checkImageUrl, setCheckImageUrl] = useState<string | null>(null);
// 검수 이미지는 인증이 필요한 프록시 경로(regions/)라 <img src> 로 직접 못 넣는다.
// authenticatedFetch → blob URL 로 받아오고, 잡이 바뀌면 이전 URL 을 회수한다.
useEffect(() => {
const path = job.artifacts.check_jpg;
if (!path) {
setCheckImageUrl(null);
return undefined;
}
let revoked: string | null = null;
fetchP2vImage(path)
.then((url) => { revoked = url; setCheckImageUrl(url); })
.catch(() => setCheckImageUrl(null));
return () => { if (revoked) URL.revokeObjectURL(revoked); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [job.artifacts.check_jpg]);
// 서버 값으로 초안을 채우되, 사용자가 고치기 시작한 뒤에는 폴링 응답이 덮지 않아야 한다.
// 잡 id 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시).
@ -152,10 +169,10 @@ const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApprov
{/* ── 대조용 분석 이미지 ────────────────────── */}
<div className="p2v-review__preview">
<p className="p2v-label">{t('poster.review.analysis')}</p>
{job.artifacts.check_jpg ? (
{checkImageUrl ? (
<img
className="p2v-review__image"
src={p2vFile(job.artifacts.check_jpg)}
src={checkImageUrl}
alt={t('poster.review.analysisAlt')}
/>
) : (

View File

@ -17,6 +17,7 @@ import {
createF2Template,
createStylingJob,
deleteF2Template,
downloadP2vFile,
getF2UploadHint,
isP2vActive,
listF2Categories,
@ -86,10 +87,7 @@ const StylingContent: React.FC = () => {
}, []);
useEffect(() => {
// P2V 서버(:8010) 미연결 — 프론트만 구현된 상태라 자동 로드를 임시로 끈다.
// 서버 연결되면 아래 주석을 해제할 것. (templates/categories/formats/hint 가
// 계속 비어 있으면 아래 렌더링에서 템플릿 카드·제출 버튼이 자연히 비활성 상태가 된다.)
// loadAll();
loadAll();
}, [loadAll]);
const uploadReference = async (f: File) => {
@ -289,9 +287,17 @@ const StylingContent: React.FC = () => {
)}
{job?.status === 'done' && job.artifacts.image && (
<div className="p2v-styling__download">
<a href={p2vFile(job.artifacts.image)} download className="p2v-btn-tonal">
{/* 교차 출처(Blob) URL 은 <a download> 가 무시된다 — fetch 경유 다운로드 */}
<button
type="button"
className="p2v-btn-tonal"
onClick={() => downloadP2vFile(
p2vFile(job.artifacts.image),
`p2v_poster_${job.id}.png`,
)}
>
{t('poster.styling.downloadPng')}
</a>
</button>
</div>
)}
</div>

View File

@ -936,3 +936,4 @@
.p2v-scope .p2v-spinner::after { animation: none; }
.p2v-scope .p2v-bar__fill { transition: none; }
}

View File

@ -270,8 +270,12 @@ export interface UserCreditsResponse {
}
// 비디오 목록 아이템 (갤러리용)
/** 콘텐츠 종류. ADO2 영상과 썰박스가 한 목록에 섞인다 */
export type ContentType = 'video' | 'ssul';
/**
* . ADO2 ··P2V .
* p2v_poster **** result_movie_url URL
* <video> <img> . p2v_* .
*/
export type ContentType = 'video' | 'ssul' | 'p2v_video' | 'p2v_poster';
export interface VideoListItem {
/**

View File

@ -1,30 +1,24 @@
/**
* Poster to Video (P2V) .
*
* castad ** (:8010)** .
* (ADO2·) `utils/api.ts` castad .
* **castad `/p2v/*` ** (2026-08-25 ).
* P2V (:8010) , castad (JWT)·
* ·Blob . (ADO2·)
* `authenticatedFetch` ( · ).
*
* P2V `scripts/*.py` subprocess
* castad (Higgsfield CLI·ffmpeg· )
* . ** ** ,
* castad `/p2v/*` · .
* .
*
* (`P2V_ACCESS_KEY`). 3 :
* - `X-P2V-Key` fetch
* - `p2v_key` ** .** castad P2V
* document.cookie P2V .
* - `?key=` <img>/<video>/<a download> (p2vFile)
* URL :
* - (· ) **Azure Blob URL** ( URL )
* - 릿 · castad (`/p2v/files/...`).
* <img src> , (regions/)
* fetchP2vImage() blob URL .
*/
/** P2V 서버 오리진. 배포 시 `.env` 의 VITE_P2V_URL 로 덮는다 */
export const P2V_URL = (import.meta.env.VITE_P2V_URL || 'http://localhost:8010').replace(/\/$/, '');
import { API_URL, InsufficientCreditError, authenticatedFetch } from './api';
/**
* .
*
* storageKeys.K ·
* , .
* () P2V .
* P2vKeyGate import .
* (401 authenticatedFetch / ).
*/
const KEY_STORAGE = 'castad_p2v_key';
@ -32,7 +26,11 @@ export const getP2vKey = (): string => localStorage.getItem(KEY_STORAGE) ?? '';
export const setP2vKey = (key: string): void => localStorage.setItem(KEY_STORAGE, key.trim());
export const clearP2vKey = (): void => localStorage.removeItem(KEY_STORAGE);
/** 401 — 화면이 키 입력을 띄워야 하는 상황. 일반 실패와 구분해야 해서 타입을 나눈다 */
/**
* () 401 P2V . castad
* authenticatedFetch / .
* instanceof .
*/
export class P2vAuthError extends Error {
constructor() {
super('P2V_AUTH_REQUIRED');
@ -54,16 +52,9 @@ export class P2vNotFoundError extends Error {
}
export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T> {
const key = getP2vKey();
const res = await fetch(`${P2V_URL}${path}`, {
...init,
headers: {
...(init?.headers ?? {}),
...(key ? { 'X-P2V-Key': key } : {}),
},
});
const res = await authenticatedFetch(`${API_URL}/p2v${path}`, init);
if (res.status === 401) throw new P2vAuthError();
if (res.status === 402) throw new InsufficientCreditError();
if (!res.ok) {
// FastAPI 는 {detail: ...} 로 준다. 본문이 없는 에러(502 등)도 있으므로 감싼다
@ -81,18 +72,63 @@ export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T>
}
/**
* (·) URL.
* URL .
*
* artifacts `/files/render/x.mp4` ** **
* castad . ,
* (<video src> ) .
* - Azure Blob URL( URL) ( ·· )
* - `/p2v/files/...` castad (릿 )
*/
export const p2vFile = (path: string | null | undefined): string => {
if (!path) return '';
const key = getP2vKey();
return `${P2V_URL}${path}${key ? `?key=${encodeURIComponent(key)}` : ''}`;
if (/^https?:\/\//.test(path)) return path;
return `${API_URL}${path}`;
};
/**
* ( regions/) <img> blob URL .
* <img src> Authorization fetch .
* URL URL.revokeObjectURL .
*/
export async function fetchP2vImage(path: string): Promise<string> {
const res = await authenticatedFetch(`${API_URL}${path}`);
if (!res.ok) throw new Error(`이미지 로드 실패 (${res.status})`);
return URL.createObjectURL(await res.blob());
}
/**
* .
*
* **Blob URL `<a download>`
* ** `download` URL , Azure Blob
* . (SsulResultContent)
* fetch Blob objectURL .
*
* fetch (CORS )
* .
*/
export async function downloadP2vFile(url: string, fileName: string): Promise<void> {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const blobUrl = URL.createObjectURL(await response.blob());
const link = document.createElement('a');
link.href = blobUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch {
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.target = '_blank';
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
// ── 타입 (서버 jobs.py / routers 와 1:1) ──────────────────────────────
export type P2vStageStatus = 'idle' | 'running' | 'done' | 'failed';
@ -113,18 +149,21 @@ export interface PosterMeta {
}
export interface P2vJob {
id: string;
kind: 'f1' | 'f2';
name: string;
status: 'queued' | 'running' | 'awaiting_review' | 'failed' | 'done';
stage: string | null;
stages: Record<string, P2vStageState>;
narration: string[] | null;
/** castad 잡 id (정수). 이전 P2V 직접 호출 시절엔 문자열 슬러그였다 */
id: number | string;
kind?: 'f1' | 'f2';
name?: string | null;
/** 'archiving' = 완성본을 Blob 으로 옮기는 중 — 진행 중으로 취급한다 */
status: 'queued' | 'running' | 'awaiting_review' | 'archiving' | 'failed' | 'done';
stage?: string | null;
/** 진행 중에만 내려온다 (아카이브 후 조회는 DB 미러라 없음) */
stages?: Record<string, P2vStageState> | null;
narration?: string[] | null;
motion_elements?: string[] | null;
metadata: PosterMeta | null;
error: { stage: string; detail: string } | null;
metadata?: PosterMeta | null;
error?: { stage: string; detail: string } | null;
artifacts: Record<string, string>;
created_at: number;
created_at?: number;
queue_size?: number;
template_id?: string | null;
}
@ -156,66 +195,74 @@ export async function createPosterJob(poster: File, name: string): Promise<{ id:
const fd = new FormData();
fd.append('poster', poster);
fd.append('name', name);
return p2vFetch<{ id: string }>('/api/f1/jobs', { method: 'POST', body: fd });
return p2vFetch<{ id: string }>('/f1/jobs', { method: 'POST', body: fd });
}
export const getPosterJob = (id: string): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/api/f1/jobs/${id}`);
export const getPosterJob = (id: string | number): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/f1/jobs/${id}`);
export const updatePosterNarration = (id: string, narration: string[]): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/narration`, {
export const updatePosterNarration = (id: string | number, narration: string[]): Promise<unknown> =>
p2vFetch(`/f1/jobs/${id}/narration`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ narration }),
});
export const updatePosterMetadata = (
id: string,
id: string | number,
meta: { event_name: string; date_text: string; place: string },
): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/metadata`, {
p2vFetch(`/f1/jobs/${id}/metadata`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta),
});
/** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */
export const approvePosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/approve`, {
export const approvePosterJob = (id: string | number, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/f1/jobs/${id}/approve`, {
method: 'POST',
...(motions
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
: {}),
});
export const retryPosterJob = (id: string, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/retry`, {
/**
* i2v ( ) .
* Higgsfield .
* 409 .
*/
export const forcePosterJob = (id: string | number): Promise<unknown> =>
p2vFetch(`/f1/jobs/${id}/force`, { method: 'POST' });
export const retryPosterJob = (id: string | number, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/f1/jobs/${id}/retry`, {
method: 'POST',
...(motions
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
: {}),
});
export const deletePosterJob = (id: string): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}`, { method: 'DELETE' });
export const deletePosterJob = (id: string | number): Promise<unknown> =>
p2vFetch(`/f1/jobs/${id}`, { method: 'DELETE' });
// ── F2 (포스터 스타일링) ──────────────────────────────────────────────
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/api/f2/templates');
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/api/f2/categories');
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/api/f2/formats');
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/api/f2/upload-hint');
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/f2/templates');
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/f2/categories');
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/f2/formats');
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/f2/upload-hint');
/** 레퍼런스 업로드 — 저장 + 화풍 분석까지 서버가 동기로 끝낸다(10초 안팎) */
export async function createF2Template(reference: File, name: string): Promise<F2Template> {
const fd = new FormData();
fd.append('reference', reference);
fd.append('name', name);
return p2vFetch<F2Template>('/api/f2/templates', { method: 'POST', body: fd });
return p2vFetch<F2Template>('/f2/templates', { method: 'POST', body: fd });
}
export const deleteF2Template = (id: string): Promise<unknown> =>
p2vFetch(`/api/f2/templates/${id}`, { method: 'DELETE' });
p2vFetch(`/f2/templates/${id}`, { method: 'DELETE' });
export async function createStylingJob(
poster: File,
@ -226,22 +273,28 @@ export async function createStylingJob(
fd.append('poster', poster);
fd.append('template_id', templateId);
fd.append('format', format);
return p2vFetch<{ id: string }>('/api/f2/jobs', { method: 'POST', body: fd });
return p2vFetch<{ id: string }>('/f2/jobs', { method: 'POST', body: fd });
}
export const getStylingJob = (id: string): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/api/f2/jobs/${id}`);
export const getStylingJob = (id: string | number): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/f2/jobs/${id}`);
/** 스타일링 잡 삭제 — 내 콘텐츠 목록에서 제거. 미완성 잡은 서버가 크레딧을 환불한다 */
export const deleteStylingJob = (id: string | number): Promise<unknown> =>
p2vFetch(`/f2/jobs/${id}`, { method: 'DELETE' });
// ── 진행 계산 ────────────────────────────────────────────────────────
/** 아직 도는 중인가. 이 두 상태에서만 폴링을 계속한다 */
/** 아직 도는 중인가. 이 상태에서만 폴링을 계속한다 (archiving = Blob 업로드 중) */
export const isP2vActive = (job: P2vJob | null): boolean =>
job !== null && (job.status === 'queued' || job.status === 'running');
job !== null
&& (job.status === 'queued' || job.status === 'running' || job.status === 'archiving');
/** 스테이지 진행률(%) — 선형 진행바용. 도는 중인 스테이지는 절반으로 친다 */
export function p2vProgress(job: P2vJob | null): number {
if (!job) return 0;
const states = Object.values(job.stages);
if (job.status === 'archiving') return 99; // 렌더 끝, 업로드만 남음
const states = Object.values(job.stages ?? {});
if (states.length === 0) return 0;
const done = states.filter((s) => s.status === 'done').length;
const running = states.some((s) => s.status === 'running') ? 0.5 : 0;
@ -258,6 +311,10 @@ export function p2vProgress(job: P2vJob | null): number {
*/
const POST_REVIEW_STAGE = 'tts';
export const isPastReview = (job: P2vJob | null): boolean =>
!!job && job.stages[POST_REVIEW_STAGE]?.status !== undefined
&& job.stages[POST_REVIEW_STAGE].status !== 'idle';
export const isPastReview = (job: P2vJob | null): boolean => {
if (!job) return false;
// 아카이브 이후 조회는 stages 가 없다 — done/archiving 이면 당연히 검수 뒤다
if (job.status === 'done' || job.status === 'archiving') return true;
const st = job.stages?.[POST_REVIEW_STAGE]?.status;
return st !== undefined && st !== 'idle';
};

View File

@ -10,6 +10,13 @@ export default defineConfig({
server: {
port: 3000,
host: '0.0.0.0',
// 도커 컨테이너가 Windows 바인드 마운트를 감시할 때 inotify 이벤트가 오지 않아
// HMR 이 전혀 동작하지 않는다(호스트에서 파일을 고쳐도 옛 모듈을 계속 서빙).
// 2026-08-26 실측: 1시간 편집에 HMR 이벤트 0건 → 폴링으로 전환.
watch: {
usePolling: true,
interval: 500,
},
},
plugins: [react()],
resolve: {