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 { useTranslation } from 'react-i18next';
import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api'; import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api';
import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare'; import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare';
import { ContentType } from '../types/api';
import LoginPromptModal from './LoginPromptModal'; import LoginPromptModal from './LoginPromptModal';
interface ContentCardSocialActionsProps { interface ContentCardSocialActionsProps {
@ -10,8 +9,10 @@ interface ContentCardSocialActionsProps {
/** /**
* 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와 * 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와
* 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다. * 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다.
* ContentType 전체가 아니라 이 둘로 좁힌 이유: 좋아요·공유 축은 video·ssul
* 에만 있다 — P2V 항목은 이 컴포넌트를 렌더하지 않는다(MyContentsPage 분기).
*/ */
contentType?: ContentType; contentType?: 'video' | 'ssul';
storeName: string; storeName: string;
region?: string; region?: string;
title?: string | null; 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.", "autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.",
"pageComingSoon": "{{page}} page is coming soon." "pageComingSoon": "{{page}} page is coming soon."
}, },
"p2vViewer": {
"download": "Download",
"close": "Close"
},
"pipelineTabs": { "pipelineTabs": {
"ariaLabel": "Content type", "ariaLabel": "Content type",
"ado2": "ADO2", "ado2": "ADO2",
@ -667,6 +671,7 @@
"phaseRender": "Generating", "phaseRender": "Generating",
"failTitle": "Generation failed", "failTitle": "Generation failed",
"retry": "Retry", "retry": "Retry",
"force": "Ignore and proceed",
"discard": "Start over", "discard": "Start over",
"discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone." "discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone."
}, },

View File

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

View File

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

View File

@ -38,7 +38,7 @@ import PosterMakingContent from '../Poster/PosterMakingContent';
import PosterReviewContent from '../Poster/PosterReviewContent'; import PosterReviewContent from '../Poster/PosterReviewContent';
import PosterResultContent from '../Poster/PosterResultContent'; import PosterResultContent from '../Poster/PosterResultContent';
import { useP2vJob } from '../../hooks/useP2vJob'; import { useP2vJob } from '../../hooks/useP2vJob';
import { deletePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi'; import { deletePosterJob, forcePosterJob, isPastReview, retryPosterJob } from '../../utils/p2vApi';
import { import {
K, K,
clearProjectStorage, 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 () => { const handlePosterDiscard = async () => {
if (!posterJobId || posterBusy) return; if (!posterJobId || posterBusy) return;
@ -782,6 +801,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
<PosterMakingContent <PosterMakingContent
job={posterJob} job={posterJob}
onRetry={handlePosterRetry} onRetry={handlePosterRetry}
onForce={handlePosterForce}
onDiscard={handlePosterDiscard} onDiscard={handlePosterDiscard}
busy={posterBusy} busy={posterBusy}
/> />

View File

@ -2,10 +2,12 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api'; import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api';
import { deletePosterJob, deleteStylingJob } from '../../utils/p2vApi';
import { VideoListItem } from '../../types/api'; import { VideoListItem } from '../../types/api';
import SocialPostingModal from '../../components/SocialPostingModal'; import SocialPostingModal from '../../components/SocialPostingModal';
import VideoDetailModal from '../../components/VideoDetailModal'; import VideoDetailModal from '../../components/VideoDetailModal';
import ContentCardSocialActions from '../../components/ContentCardSocialActions'; import ContentCardSocialActions from '../../components/ContentCardSocialActions';
import P2vViewerModal from '../../components/P2vViewerModal';
import SsulViewerModal from '../Ssulbox/SsulViewerModal'; import SsulViewerModal from '../Ssulbox/SsulViewerModal';
import { useOverlayClose } from '../../hooks/useOverlayClose'; import { useOverlayClose } from '../../hooks/useOverlayClose';
@ -112,6 +114,8 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
// 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로 // 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로
// 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다. // 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다.
const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null); const [selectedSsul, setSelectedSsul] = useState<VideoListItem | null>(null);
// P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다
const [selectedP2v, setSelectedP2v] = useState<VideoListItem | null>(null);
const pageSize = 12; const pageSize = 12;
@ -201,9 +205,13 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
setIsDeleting(true); setIsDeleting(true);
try { try {
// 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로 // 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로
// 썰박스 id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다. // 썰박스·P2V id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다.
if (deleteTarget.type === 'ssul') { if (deleteTarget.type === 'ssul') {
await deleteSsulContent(deleteTarget.video_id); 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 { } else {
await deleteVideo(deleteTarget.video_id); await deleteVideo(deleteTarget.video_id);
} }
@ -258,11 +266,11 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
<div <div
className="content-card-thumbnail" className="content-card-thumbnail"
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
onClick={() => onClick={() => {
video.type === 'ssul' if (video.type === 'ssul') setSelectedSsul(video);
? setSelectedSsul(video) else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video);
: setSelectedVideoId(video.video_id) else setSelectedVideoId(video.video_id);
} }}
> >
{video.poster_url ? ( {video.poster_url ? (
<img <img
@ -304,29 +312,34 @@ const MyContentsPage: React.FC<MyContentsPageProps> = ({ onNavigate, embedded })
{formatDate(video.created_at)} {formatDate(video.created_at)}
</p> </p>
{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다. {/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다.
contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */} contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다.
<ContentCardSocialActions P2V 는 좋아요·댓글 축이 없어(내 콘텐츠 전용) 숨긴다. */}
videoId={video.video_id} {(video.type === 'video' || video.type === 'ssul') && (
contentType={video.type} <ContentCardSocialActions
storeName={video.store_name} videoId={video.video_id}
region={video.region} contentType={video.type}
title={video.title} storeName={video.store_name}
description={video.description} region={video.region}
commentCount={video.comment_count ?? 0} title={video.title}
initialLikeCount={video.like_count ?? 0} description={video.description}
initialIsLiked={video.is_liked_by_me} commentCount={video.comment_count ?? 0}
/> initialLikeCount={video.like_count ?? 0}
initialIsLiked={video.is_liked_by_me}
/>
)}
</div> </div>
</div> </div>
{/* Action Buttons */} {/* Action Buttons */}
<div className="content-card-actions"> <div className="content-card-actions">
{/* SocialPostingModal 이 content_type 을 함께 보내므로 {/* SocialPostingModal 이 content_type 을 함께 보내므로
썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). */} 썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합).
P2V 는 업로드 파이프라인이 video·ssul 전용이라 SNS 버튼을 숨긴다. */}
<button <button
className="content-download-btn" className="content-download-btn"
onClick={() => handleUploadClick(video)} onClick={() => handleUploadClick(video)}
disabled={!video.result_movie_url} 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"> <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"/> <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)} /> <SsulViewerModal item={selectedSsul} onClose={() => setSelectedSsul(null)} />
<P2vViewerModal item={selectedP2v} onClose={() => setSelectedP2v(null)} />
{/* 소셜 미디어 업로드 모달 */} {/* 소셜 미디어 업로드 모달 */}
<SocialPostingModal <SocialPostingModal
isOpen={uploadModalOpen} isOpen={uploadModalOpen}

View File

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

View File

@ -6,6 +6,8 @@ interface PosterMakingContentProps {
job: P2vJob | null; job: P2vJob | null;
/** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */ /** 실패 시 같은 잡을 다시 태운다 (업로드부터 다시 하지 않는다) */
onRetry: () => void; onRetry: () => void;
/** 실패를 무시하고 이미 생성된 클립으로 렌더를 잇는다 (i2v 게이트 실패 전용) */
onForce: () => void;
/** 실패한 잡을 버리고 처음부터 */ /** 실패한 잡을 버리고 처음부터 */
onDiscard: () => void; onDiscard: () => void;
busy: boolean; busy: boolean;
@ -21,13 +23,18 @@ interface PosterMakingContentProps {
* 스피너가 "살아 있다"를 따로 알린다(썰박스 SsulMakingContent 와 같은 판단). * 스피너가 "살아 있다"를 따로 알린다(썰박스 SsulMakingContent 와 같은 판단).
*/ */
const PosterMakingContent: React.FC<PosterMakingContentProps> = ({ const PosterMakingContent: React.FC<PosterMakingContentProps> = ({
job, onRetry, onDiscard, busy, job, onRetry, onForce, onDiscard, busy,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
if (!job) return null; if (!job) return null;
const failed = job.status === 'failed'; 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 percent = p2vProgress(job);
const past = isPastReview(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}> <button type="button" className="p2v-btn-mint" disabled={busy} onClick={onRetry}>
{t('poster.making.retry')} {t('poster.making.retry')}
</button> </button>
{canForce && (
<button type="button" className="p2v-btn-tonal" disabled={busy} onClick={onForce}>
{t('poster.making.force')}
</button>
)}
</div> </div>
</> </>
) : ( ) : (

View File

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

View File

@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { import {
P2vJob, P2vJob,
approvePosterJob, approvePosterJob,
p2vFile, fetchP2vImage,
updatePosterMetadata, updatePosterMetadata,
updatePosterNarration, updatePosterNarration,
} from '../../utils/p2vApi'; } from '../../utils/p2vApi';
@ -40,6 +40,23 @@ const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApprov
const [meta, setMeta] = useState<MetaDraft>(emptyMeta); const [meta, setMeta] = useState<MetaDraft>(emptyMeta);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); 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 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시). // 잡 id 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시).
@ -152,10 +169,10 @@ const PosterReviewContent: React.FC<PosterReviewContentProps> = ({ job, onApprov
{/* ── 대조용 분석 이미지 ────────────────────── */} {/* ── 대조용 분석 이미지 ────────────────────── */}
<div className="p2v-review__preview"> <div className="p2v-review__preview">
<p className="p2v-label">{t('poster.review.analysis')}</p> <p className="p2v-label">{t('poster.review.analysis')}</p>
{job.artifacts.check_jpg ? ( {checkImageUrl ? (
<img <img
className="p2v-review__image" className="p2v-review__image"
src={p2vFile(job.artifacts.check_jpg)} src={checkImageUrl}
alt={t('poster.review.analysisAlt')} alt={t('poster.review.analysisAlt')}
/> />
) : ( ) : (

View File

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

View File

@ -936,3 +936,4 @@
.p2v-scope .p2v-spinner::after { animation: none; } .p2v-scope .p2v-spinner::after { animation: none; }
.p2v-scope .p2v-bar__fill { transition: 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 { export interface VideoListItem {
/** /**

View File

@ -1,30 +1,24 @@
/** /**
* Poster to Video (P2V) 클라이언트. * Poster to Video (P2V) 클라이언트.
* *
* castad 백엔드가 아니라 **별도 서버(:8010)** 를 직접 호출한다. 다른 파이프라인들 * **castad 백엔드의 `/p2v/*` 프록시**를 호출한다 (2026-08-25 전환). 이전에는
* (ADO2·썰박스)이 `utils/api.ts` 로 castad 백엔드에 붙는 것과 대비된다. * P2V 서버(:8010)를 팀 키로 직접 호출했지만, 이제 castad 가 카카오 인증(JWT)·
* 크레딧 선차감·Blob 아카이빙을 얹는다. 다른 파이프라인(ADO2·썰박스)과 같은
* 인증 축이 됐으므로 `authenticatedFetch` 를 그대로 쓴다 (토큰 갱신·재시도 포함).
* *
* 왜 프록시를 두지 않았나 — P2V 서버는 검증된 `scripts/*.py` 를 subprocess 로 감싼 * 산출물 URL 두 종류:
* 단일 워커라 castad 백엔드에 흡수하려면 실행 환경(Higgsfield CLI·ffmpeg·모델 키)을 * - 완성 결과물(영상·결과 이미지) → **Azure Blob 공개 URL** (절대 URL 그대로 사용)
* 통째로 이식해야 한다. **내부 시연 범위**에서는 직접 호출이 맞고, 대외 공개 단계에서 * - 템플릿 썸네일·검수 이미지 → castad 프록시 경로(`/p2v/files/...`).
* castad 백엔드에 `/p2v/*` 프록시를 세워 카카오 인증·크레딧 차감을 얹는 게 다음 수순이다. * 썸네일은 공개라 <img src> 로 바로 되고, 검수 이미지(regions/)는 인증이
* 그때 갈아끼울 지점은 이 파일 하나다. * 필요해 fetchP2vImage() 로 blob URL 을 만들어 넣는다.
*
* 인증은 단일 팀 키(`P2V_ACCESS_KEY`). 서버는 3경로로 받는다:
* - `X-P2V-Key` 헤더 → fetch 호출
* - `p2v_key` 쿠키 → **여기서는 못 쓴다.** castad 와 P2V 는 다른 오리진이라
* document.cookie 로 심어도 P2V 요청에 실리지 않는다.
* - `?key=` 쿼리 → 헤더를 못 붙이는 <img>/<video>/<a download> 용 (p2vFile)
*/ */
/** P2V 서버 오리진. 배포 시 `.env` 의 VITE_P2V_URL 로 덮는다 */ import { API_URL, InsufficientCreditError, authenticatedFetch } from './api';
export const P2V_URL = (import.meta.env.VITE_P2V_URL || 'http://localhost:8010').replace(/\/$/, '');
/** /**
* 접근 키 저장소. * (구) P2V 접근 키 저장소 — 프록시 전환으로 더 이상 쓰지 않는다.
* * P2vKeyGate 가 아직 import 하고 있어 심볼만 유지한다. 게이트가 뜰 일은 없다
* storageKeys.K 에 넣지 않은 것은 의도적이다 — 그쪽 키들은 로그아웃·새 프로젝트에 * (401 은 authenticatedFetch 가 토큰 갱신/로그인 이동으로 처리한다).
* 일괄 삭제되는데, 이건 서버 자격증명이라 지워지면 시연 중에 매번 다시 물어야 한다.
*/ */
const KEY_STORAGE = 'castad_p2v_key'; 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 setP2vKey = (key: string): void => localStorage.setItem(KEY_STORAGE, key.trim());
export const clearP2vKey = (): void => localStorage.removeItem(KEY_STORAGE); export const clearP2vKey = (): void => localStorage.removeItem(KEY_STORAGE);
/** 401 — 화면이 키 입력을 띄워야 하는 상황. 일반 실패와 구분해야 해서 타입을 나눈다 */ /**
* (구) 401 — P2V 키 입력이 필요하던 상황. 프록시 전환 후에는 castad 로그인 문제라
* authenticatedFetch 가 갱신/리다이렉트로 처리하므로 이 예외는 던져지지 않는다.
* 컴포넌트들의 instanceof 분기가 남아 있어 타입만 유지한다.
*/
export class P2vAuthError extends Error { export class P2vAuthError extends Error {
constructor() { constructor() {
super('P2V_AUTH_REQUIRED'); super('P2V_AUTH_REQUIRED');
@ -54,16 +52,9 @@ export class P2vNotFoundError extends Error {
} }
export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T> { export async function p2vFetch<T>(path: string, init?: RequestInit): Promise<T> {
const key = getP2vKey(); const res = await authenticatedFetch(`${API_URL}/p2v${path}`, init);
const res = await fetch(`${P2V_URL}${path}`, {
...init,
headers: {
...(init?.headers ?? {}),
...(key ? { 'X-P2V-Key': key } : {}),
},
});
if (res.status === 401) throw new P2vAuthError(); if (res.status === 402) throw new InsufficientCreditError();
if (!res.ok) { if (!res.ok) {
// FastAPI 는 {detail: ...} 로 준다. 본문이 없는 에러(502 등)도 있으므로 감싼다 // 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` 같은 **루트 상대 경로**라 * - Azure Blob 공개 URL(절대 URL) → 그대로 (완성 영상·썸네일·결과 이미지)
* 다른 오리진인 castad 에서는 그대로 쓸 수 없다. 오리진을 붙이고, 헤더를 실을 수 없는 * - `/p2v/files/...` 프록시 경로 → castad 오리진을 붙인다 (템플릿 썸네일 등 공개 파일)
* 태그(<video src> 등)를 위해 키를 쿼리로 태운다.
*/ */
export const p2vFile = (path: string | null | undefined): string => { export const p2vFile = (path: string | null | undefined): string => {
if (!path) return ''; if (!path) return '';
const key = getP2vKey(); if (/^https?:\/\//.test(path)) return path;
return `${P2V_URL}${path}${key ? `?key=${encodeURIComponent(key)}` : ''}`; 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) ────────────────────────────── // ── 타입 (서버 jobs.py / routers 와 1:1) ──────────────────────────────
export type P2vStageStatus = 'idle' | 'running' | 'done' | 'failed'; export type P2vStageStatus = 'idle' | 'running' | 'done' | 'failed';
@ -113,18 +149,21 @@ export interface PosterMeta {
} }
export interface P2vJob { export interface P2vJob {
id: string; /** castad 잡 id (정수). 이전 P2V 직접 호출 시절엔 문자열 슬러그였다 */
kind: 'f1' | 'f2'; id: number | string;
name: string; kind?: 'f1' | 'f2';
status: 'queued' | 'running' | 'awaiting_review' | 'failed' | 'done'; name?: string | null;
stage: string | null; /** 'archiving' = 완성본을 Blob 으로 옮기는 중 — 진행 중으로 취급한다 */
stages: Record<string, P2vStageState>; status: 'queued' | 'running' | 'awaiting_review' | 'archiving' | 'failed' | 'done';
narration: string[] | null; stage?: string | null;
/** 진행 중에만 내려온다 (아카이브 후 조회는 DB 미러라 없음) */
stages?: Record<string, P2vStageState> | null;
narration?: string[] | null;
motion_elements?: string[] | null; motion_elements?: string[] | null;
metadata: PosterMeta | null; metadata?: PosterMeta | null;
error: { stage: string; detail: string } | null; error?: { stage: string; detail: string } | null;
artifacts: Record<string, string>; artifacts: Record<string, string>;
created_at: number; created_at?: number;
queue_size?: number; queue_size?: number;
template_id?: string | null; template_id?: string | null;
} }
@ -156,66 +195,74 @@ export async function createPosterJob(poster: File, name: string): Promise<{ id:
const fd = new FormData(); const fd = new FormData();
fd.append('poster', poster); fd.append('poster', poster);
fd.append('name', name); 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> => export const getPosterJob = (id: string | number): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/api/f1/jobs/${id}`); p2vFetch<P2vJob>(`/f1/jobs/${id}`);
export const updatePosterNarration = (id: string, narration: string[]): Promise<unknown> => export const updatePosterNarration = (id: string | number, narration: string[]): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/narration`, { p2vFetch(`/f1/jobs/${id}/narration`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ narration }), body: JSON.stringify({ narration }),
}); });
export const updatePosterMetadata = ( export const updatePosterMetadata = (
id: string, id: string | number,
meta: { event_name: string; date_text: string; place: string }, meta: { event_name: string; date_text: string; place: string },
): Promise<unknown> => ): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/metadata`, { p2vFetch(`/f1/jobs/${id}/metadata`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(meta), body: JSON.stringify(meta),
}); });
/** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */ /** 검수 승인 → TTS·BGM·애니메이션·렌더가 이어진다. motions 를 넘기면 모션 선정을 덮어쓴다 */
export const approvePosterJob = (id: string, motions?: string[] | null): Promise<unknown> => export const approvePosterJob = (id: string | number, motions?: string[] | null): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}/approve`, { p2vFetch(`/f1/jobs/${id}/approve`, {
method: 'POST', method: 'POST',
...(motions ...(motions
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ 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', method: 'POST',
...(motions ...(motions
? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) } ? { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ motions }) }
: {}), : {}),
}); });
export const deletePosterJob = (id: string): Promise<unknown> => export const deletePosterJob = (id: string | number): Promise<unknown> =>
p2vFetch(`/api/f1/jobs/${id}`, { method: 'DELETE' }); p2vFetch(`/f1/jobs/${id}`, { method: 'DELETE' });
// ── F2 (포스터 스타일링) ────────────────────────────────────────────── // ── F2 (포스터 스타일링) ──────────────────────────────────────────────
export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/api/f2/templates'); export const listF2Templates = (): Promise<F2Template[]> => p2vFetch<F2Template[]>('/f2/templates');
export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/api/f2/categories'); export const listF2Categories = (): Promise<F2Category[]> => p2vFetch<F2Category[]>('/f2/categories');
export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/api/f2/formats'); export const listF2Formats = (): Promise<F2Format[]> => p2vFetch<F2Format[]>('/f2/formats');
export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/api/f2/upload-hint'); export const getF2UploadHint = (): Promise<F2UploadHint> => p2vFetch<F2UploadHint>('/f2/upload-hint');
/** 레퍼런스 업로드 — 저장 + 화풍 분석까지 서버가 동기로 끝낸다(10초 안팎) */ /** 레퍼런스 업로드 — 저장 + 화풍 분석까지 서버가 동기로 끝낸다(10초 안팎) */
export async function createF2Template(reference: File, name: string): Promise<F2Template> { export async function createF2Template(reference: File, name: string): Promise<F2Template> {
const fd = new FormData(); const fd = new FormData();
fd.append('reference', reference); fd.append('reference', reference);
fd.append('name', name); 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> => export const deleteF2Template = (id: string): Promise<unknown> =>
p2vFetch(`/api/f2/templates/${id}`, { method: 'DELETE' }); p2vFetch(`/f2/templates/${id}`, { method: 'DELETE' });
export async function createStylingJob( export async function createStylingJob(
poster: File, poster: File,
@ -226,22 +273,28 @@ export async function createStylingJob(
fd.append('poster', poster); fd.append('poster', poster);
fd.append('template_id', templateId); fd.append('template_id', templateId);
fd.append('format', format); 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> => export const getStylingJob = (id: string | number): Promise<P2vJob> =>
p2vFetch<P2vJob>(`/api/f2/jobs/${id}`); 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 => 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 { export function p2vProgress(job: P2vJob | null): number {
if (!job) return 0; 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; if (states.length === 0) return 0;
const done = states.filter((s) => s.status === 'done').length; const done = states.filter((s) => s.status === 'done').length;
const running = states.some((s) => s.status === 'running') ? 0.5 : 0; 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'; const POST_REVIEW_STAGE = 'tts';
export const isPastReview = (job: P2vJob | null): boolean => export const isPastReview = (job: P2vJob | null): boolean => {
!!job && job.stages[POST_REVIEW_STAGE]?.status !== undefined if (!job) return false;
&& job.stages[POST_REVIEW_STAGE].status !== 'idle'; // 아카이브 이후 조회는 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: { server: {
port: 3000, port: 3000,
host: '0.0.0.0', host: '0.0.0.0',
// 도커 컨테이너가 Windows 바인드 마운트를 감시할 때 inotify 이벤트가 오지 않아
// HMR 이 전혀 동작하지 않는다(호스트에서 파일을 고쳐도 옛 모듈을 계속 서빙).
// 2026-08-26 실측: 1시간 편집에 HMR 이벤트 0건 → 폴링으로 전환.
watch: {
usePolling: true,
interval: 500,
},
}, },
plugins: [react()], plugins: [react()],
resolve: { resolve: {