feat: p2v 연동
This commit is contained in:
parent
487d0f6d6a
commit
3b68845e2d
@ -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;
|
||||||
|
|||||||
143
src/components/P2vViewerModal.tsx
Normal file
143
src/components/P2vViewerModal.tsx
Normal 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;
|
||||||
@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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": "이 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다."
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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)}
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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')}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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';
|
||||||
|
};
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user