From 3b68845e2d100cf5f5fa3e68c4b396eb25649214 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Wed, 26 Aug 2026 13:53:10 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20p2v=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ContentCardSocialActions.tsx | 5 +- src/components/P2vViewerModal.tsx | 143 ++++++++++++++ src/locales/en.json | 5 + src/locales/ko.json | 5 + src/pages/Dashboard/ADO2ContentsPage.tsx | 32 +-- src/pages/Dashboard/GenerationFlow.tsx | 22 ++- src/pages/Dashboard/MyContentsPage.tsx | 53 +++-- src/pages/Poster/PosterCreateForm.tsx | 30 ++- src/pages/Poster/PosterMakingContent.tsx | 14 +- src/pages/Poster/PosterResultContent.tsx | 19 +- src/pages/Poster/PosterReviewContent.tsx | 23 ++- src/pages/Poster/StylingContent.tsx | 18 +- src/styles/poster-p2v.css | 1 + src/types/api.ts | 8 +- src/utils/p2vApi.ts | 203 +++++++++++++------- vite.config.ts | 7 + 16 files changed, 448 insertions(+), 140 deletions(-) create mode 100644 src/components/P2vViewerModal.tsx diff --git a/src/components/ContentCardSocialActions.tsx b/src/components/ContentCardSocialActions.tsx index f2dcdcf..1e0f125 100644 --- a/src/components/ContentCardSocialActions.tsx +++ b/src/components/ContentCardSocialActions.tsx @@ -2,7 +2,6 @@ import React, { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { API_URL, toggleVideoLike, isLoggedIn } from '../utils/api'; import { buildContentShareUrl, tryNativeShare } from '../utils/nativeShare'; -import { ContentType } from '../types/api'; import LoginPromptModal from './LoginPromptModal'; interface ContentCardSocialActionsProps { @@ -10,8 +9,10 @@ interface ContentCardSocialActionsProps { /** * 콘텐츠 종류. video.id 와 ssul_content.id 가 겹치므로 좋아요 API 와 * 공유 URL(/video/{id} vs /ssul/{id}) 분기에 반드시 필요하다. + * ContentType 전체가 아니라 이 둘로 좁힌 이유: 좋아요·공유 축은 video·ssul + * 에만 있다 — P2V 항목은 이 컴포넌트를 렌더하지 않는다(MyContentsPage 분기). */ - contentType?: ContentType; + contentType?: 'video' | 'ssul'; storeName: string; region?: string; title?: string | null; diff --git a/src/components/P2vViewerModal.tsx b/src/components/P2vViewerModal.tsx new file mode 100644 index 0000000..da44463 --- /dev/null +++ b/src/components/P2vViewerModal.tsx @@ -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 = ({ 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 ( +
+
e.stopPropagation()}> +
+ {/* 헤더 — 문구는 ADO2/썰박스 상세와 같은 키를 쓴다 (같은 목록에서 열리는 모달) */} +
+

{t('sidebar.ado2Contents')}

+ +
+ +
+
+
+ {isVideo ? ( +
+ + +
+
+
+
+
+ ); +}; + +export default P2vViewerModal; diff --git a/src/locales/en.json b/src/locales/en.json index 9ff332d..7b99f48 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -600,6 +600,10 @@ "autocompleteGeneralError": "An error occurred while retrieving business information. Please try again.", "pageComingSoon": "{{page}} page is coming soon." }, + "p2vViewer": { + "download": "Download", + "close": "Close" + }, "pipelineTabs": { "ariaLabel": "Content type", "ado2": "ADO2", @@ -667,6 +671,7 @@ "phaseRender": "Generating", "failTitle": "Generation failed", "retry": "Retry", + "force": "Ignore and proceed", "discard": "Start over", "discardConfirm": "This deletes the job and every generated video, audio and analysis file. This cannot be undone." }, diff --git a/src/locales/ko.json b/src/locales/ko.json index 4141fd6..69bfa7c 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -600,6 +600,10 @@ "autocompleteGeneralError": "업체 정보 조회 중 오류가 발생했습니다. 다시 시도해주세요.", "pageComingSoon": "{{page}} 페이지 준비 중입니다." }, + "p2vViewer": { + "download": "다운로드", + "close": "닫기" + }, "pipelineTabs": { "ariaLabel": "생성할 콘텐츠 종류", "ado2": "ADO2", @@ -716,6 +720,7 @@ "phaseRender": "생성 중", "failTitle": "생성에 실패했어요", "retry": "다시 시도", + "force": "무시하고 진행", "discard": "처음부터", "discardConfirm": "이 작업과 만들어진 영상·음성·분석 파일을 모두 지웁니다. 되돌릴 수 없습니다." }, diff --git a/src/pages/Dashboard/ADO2ContentsPage.tsx b/src/pages/Dashboard/ADO2ContentsPage.tsx index a013f90..9d02e36 100644 --- a/src/pages/Dashboard/ADO2ContentsPage.tsx +++ b/src/pages/Dashboard/ADO2ContentsPage.tsx @@ -5,6 +5,7 @@ import { VideoListItem } from '../../types/api'; import VideoDetailModal from '../../components/VideoDetailModal'; import CitySelectModal from '../../components/CitySelectModal'; import ContentCardSocialActions from '../../components/ContentCardSocialActions'; +import P2vViewerModal from '../../components/P2vViewerModal'; import SsulViewerModal from '../Ssulbox/SsulViewerModal'; interface ADO2ContentsPageProps { @@ -17,6 +18,8 @@ const ADO2ContentsPage: React.FC = () => { // 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로 // 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다. const [selectedSsul, setSelectedSsul] = useState(null); + // P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다 + const [selectedP2v, setSelectedP2v] = useState(null); const [videos, setVideos] = useState([]); const [total, setTotal] = useState(0); const [loading, setLoading] = useState(true); @@ -148,19 +151,19 @@ const ADO2ContentsPage: React.FC = () => { key={`${video.type}-${video.video_id}`} className="ado2-content-card" style={{ cursor: 'pointer' }} - onClick={() => - video.type === 'ssul' - ? setSelectedSsul(video) - : handleCardClick(video.video_id) - } + onClick={() => { + if (video.type === 'ssul') setSelectedSsul(video); + else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video); + else handleCardClick(video.video_id); + }} role="button" tabIndex={0} - onKeyDown={(e) => - e.key === 'Enter' && - (video.type === 'ssul' - ? setSelectedSsul(video) - : handleCardClick(video.video_id)) - } + onKeyDown={(e) => { + if (e.key !== 'Enter') return; + if (video.type === 'ssul') setSelectedSsul(video); + else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video); + else handleCardClick(video.video_id); + }} >
{video.poster_url ? ( @@ -203,7 +206,9 @@ const ADO2ContentsPage: React.FC = () => {

{formatDate(video.created_at)}

{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다. - contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */} + contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. + 갤러리(/video/all)에는 video·ssul 만 내려온다 — P2V 는 내 콘텐츠 전용. */} + {(video.type === 'video' || video.type === 'ssul') && ( = () => { initialLikeCount={video.like_count ?? 0} initialIsLiked={video.is_liked_by_me} /> + )}
@@ -244,6 +250,8 @@ const ADO2ContentsPage: React.FC = () => { setSelectedSsul(null)} /> + setSelectedP2v(null)} /> + {selectedVideoId !== null && ( = ({ } }; + /** + * 실패를 무시하고 진행 — i2v 게이트 실패에서 이미 생성된 클립으로 렌더를 잇는다. + * 재시도와 달리 Higgsfield 재생성 비용이 없다. 무시 불가한 실패는 서버가 409 를 준다. + */ + const handlePosterForce = async () => { + if (!posterJobId || posterBusy) return; + setPosterBusy(true); + try { + await forcePosterJob(posterJobId); + refreshPosterJob(); + } catch (e) { + // 409 = 무시할 수 없는 실패(클립 미생성 — 저해상·모션 없음 등). + // 조용히 삼키면 버튼이 아무 반응 없어 보여 재클릭만 유발한다 — 사유를 보여준다. + alert((e as Error).message); + } finally { + setPosterBusy(false); + } + }; + /** 실패한 잡과 산출물을 지우고 처음부터. 되돌릴 수 없다 */ const handlePosterDiscard = async () => { if (!posterJobId || posterBusy) return; @@ -782,6 +801,7 @@ const GenerationFlow: React.FC = ({ diff --git a/src/pages/Dashboard/MyContentsPage.tsx b/src/pages/Dashboard/MyContentsPage.tsx index c101266..273a845 100644 --- a/src/pages/Dashboard/MyContentsPage.tsx +++ b/src/pages/Dashboard/MyContentsPage.tsx @@ -2,10 +2,12 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { getVideosList, deleteVideo, deleteSsulContent } from '../../utils/api'; +import { deletePosterJob, deleteStylingJob } from '../../utils/p2vApi'; import { VideoListItem } from '../../types/api'; import SocialPostingModal from '../../components/SocialPostingModal'; import VideoDetailModal from '../../components/VideoDetailModal'; import ContentCardSocialActions from '../../components/ContentCardSocialActions'; +import P2vViewerModal from '../../components/P2vViewerModal'; import SsulViewerModal from '../Ssulbox/SsulViewerModal'; import { useOverlayClose } from '../../hooks/useOverlayClose'; @@ -112,6 +114,8 @@ const MyContentsPage: React.FC = ({ onNavigate, embedded }) // 썰박스는 별도 뷰어를 쓴다 — VideoDetailModal 은 video_id 로 조회하므로 // 썰박스 id 를 넘기면 id 가 겹치는 다른 영상이 열린다. const [selectedSsul, setSelectedSsul] = useState(null); + // P2V(무빙 포스터·스타일링)도 별도 뷰어 — 상세 API 없이 목록 데이터로 그린다 + const [selectedP2v, setSelectedP2v] = useState(null); const pageSize = 12; @@ -201,9 +205,13 @@ const MyContentsPage: React.FC = ({ onNavigate, embedded }) setIsDeleting(true); try { // 종류별 삭제 API 가 다르다. deleteVideo 는 Video.id 로 지우므로 - // 썰박스 id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다. + // 썰박스·P2V id 를 넘기면 id 가 겹치는 엉뚱한 ADO2 영상이 삭제된다. if (deleteTarget.type === 'ssul') { await deleteSsulContent(deleteTarget.video_id); + } else if (deleteTarget.type === 'p2v_video') { + await deletePosterJob(deleteTarget.video_id); + } else if (deleteTarget.type === 'p2v_poster') { + await deleteStylingJob(deleteTarget.video_id); } else { await deleteVideo(deleteTarget.video_id); } @@ -258,11 +266,11 @@ const MyContentsPage: React.FC = ({ onNavigate, embedded })
- video.type === 'ssul' - ? setSelectedSsul(video) - : setSelectedVideoId(video.video_id) - } + onClick={() => { + if (video.type === 'ssul') setSelectedSsul(video); + else if (video.type === 'p2v_video' || video.type === 'p2v_poster') setSelectedP2v(video); + else setSelectedVideoId(video.video_id); + }} > {video.poster_url ? ( = ({ onNavigate, embedded }) {formatDate(video.created_at)}

{/* 좋아요·댓글 API 가 type 을 받으므로 썰박스에도 노출한다. - contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. */} - + contentType 이 없으면 id 가 겹치는 다른 영상에 반영된다. + P2V 는 좋아요·댓글 축이 없어(내 콘텐츠 전용) 숨긴다. */} + {(video.type === 'video' || video.type === 'ssul') && ( + + )}
{/* Action Buttons */}
{/* SocialPostingModal 이 content_type 을 함께 보내므로 - 썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). */} + 썰박스 항목도 같은 모달로 업로드한다 (social_upload 병합). + P2V 는 업로드 파이프라인이 video·ssul 전용이라 SNS 버튼을 숨긴다. */}
) : ( diff --git a/src/pages/Poster/PosterResultContent.tsx b/src/pages/Poster/PosterResultContent.tsx index 0db61bc..34e8f7e 100644 --- a/src/pages/Poster/PosterResultContent.tsx +++ b/src/pages/Poster/PosterResultContent.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { P2vJob, p2vFile } from '../../utils/p2vApi'; +import { P2vJob, downloadP2vFile, p2vFile } from '../../utils/p2vApi'; interface PosterResultContentProps { job: P2vJob; @@ -71,15 +71,24 @@ const PosterResultContent: React.FC = ({ job, onNew }) )}
+ {/* 교차 출처(Blob) URL 은 가 무시된다 — fetch 경유 다운로드 */} {thumbnail && ( - + )} {video && ( - + )}
diff --git a/src/pages/Poster/PosterReviewContent.tsx b/src/pages/Poster/PosterReviewContent.tsx index c49d867..bca5acb 100644 --- a/src/pages/Poster/PosterReviewContent.tsx +++ b/src/pages/Poster/PosterReviewContent.tsx @@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next'; import { P2vJob, approvePosterJob, - p2vFile, + fetchP2vImage, updatePosterMetadata, updatePosterNarration, } from '../../utils/p2vApi'; @@ -40,6 +40,23 @@ const PosterReviewContent: React.FC = ({ job, onApprov const [meta, setMeta] = useState(emptyMeta); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [checkImageUrl, setCheckImageUrl] = useState(null); + + // 검수 이미지는 인증이 필요한 프록시 경로(regions/)라 로 직접 못 넣는다. + // authenticatedFetch → blob URL 로 받아오고, 잡이 바뀌면 이전 URL 을 회수한다. + useEffect(() => { + const path = job.artifacts.check_jpg; + if (!path) { + setCheckImageUrl(null); + return undefined; + } + let revoked: string | null = null; + fetchP2vImage(path) + .then((url) => { revoked = url; setCheckImageUrl(url); }) + .catch(() => setCheckImageUrl(null)); + return () => { if (revoked) URL.revokeObjectURL(revoked); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [job.artifacts.check_jpg]); // 서버 값으로 초안을 채우되, 사용자가 고치기 시작한 뒤에는 폴링 응답이 덮지 않아야 한다. // 잡 id 가 바뀔 때만 다시 채운다(같은 잡의 재조회는 무시). @@ -152,10 +169,10 @@ const PosterReviewContent: React.FC = ({ job, onApprov {/* ── 대조용 분석 이미지 ────────────────────── */}

{t('poster.review.analysis')}

- {job.artifacts.check_jpg ? ( + {checkImageUrl ? ( {t('poster.review.analysisAlt')} ) : ( diff --git a/src/pages/Poster/StylingContent.tsx b/src/pages/Poster/StylingContent.tsx index c5e9230..b57fc80 100644 --- a/src/pages/Poster/StylingContent.tsx +++ b/src/pages/Poster/StylingContent.tsx @@ -17,6 +17,7 @@ import { createF2Template, createStylingJob, deleteF2Template, + downloadP2vFile, getF2UploadHint, isP2vActive, listF2Categories, @@ -86,10 +87,7 @@ const StylingContent: React.FC = () => { }, []); useEffect(() => { - // P2V 서버(:8010) 미연결 — 프론트만 구현된 상태라 자동 로드를 임시로 끈다. - // 서버 연결되면 아래 주석을 해제할 것. (templates/categories/formats/hint 가 - // 계속 비어 있으면 아래 렌더링에서 템플릿 카드·제출 버튼이 자연히 비활성 상태가 된다.) - // loadAll(); + loadAll(); }, [loadAll]); const uploadReference = async (f: File) => { @@ -289,9 +287,17 @@ const StylingContent: React.FC = () => { )} {job?.status === 'done' && job.artifacts.image && ( )}
diff --git a/src/styles/poster-p2v.css b/src/styles/poster-p2v.css index 3cfe820..8336c68 100644 --- a/src/styles/poster-p2v.css +++ b/src/styles/poster-p2v.css @@ -936,3 +936,4 @@ .p2v-scope .p2v-spinner::after { animation: none; } .p2v-scope .p2v-bar__fill { transition: none; } } + diff --git a/src/types/api.ts b/src/types/api.ts index ec53760..5241071 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -270,8 +270,12 @@ export interface UserCreditsResponse { } // 비디오 목록 아이템 (갤러리용) -/** 콘텐츠 종류. ADO2 영상과 썰박스가 한 목록에 섞인다 */ -export type ContentType = 'video' | 'ssul'; +/** + * 콘텐츠 종류. ADO2 영상·썰박스·P2V 가 한 목록에 섞인다. + * p2v_poster 는 영상이 아니라 **이미지**다 — result_movie_url 에 이미지 URL 이 실리므로 + *