숏폼생성 기능 추가
parent
cb68b2da97
commit
530416026d
|
|
@ -1,24 +0,0 @@
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
import React, { useMemo } from 'react';
|
|
||||||
import { ArrowLeft, Sparkles, MapPin, Target, Zap, LayoutGrid, Users, Crown, TrendingUp } from 'lucide-react';
|
|
||||||
import { LALA_CABIN_DATA } from './constants';
|
|
||||||
import { GeometricChart } from './components/GeometricChart';
|
|
||||||
import { KeywordBubble } from './components/KeywordBubble';
|
|
||||||
import { BrandData, USP } from './types';
|
|
||||||
|
|
||||||
// Logic to calculate scores based on ICP (Ideal Customer Profile) signals
|
|
||||||
const calculateDynamicScores = (data: BrandData): USP[] => {
|
|
||||||
// 1. Extract Demand Signals from Targets (Needs + Triggers)
|
|
||||||
const marketSignals = data.targets.flatMap(t => [...t.needs, ...t.triggers]).map(s => s.replace(/\s+/g, ''));
|
|
||||||
|
|
||||||
// High value keywords that represent the "Core Value" of this specific property type
|
|
||||||
const coreKeywords = ['프라이빗', '감성', '독채', '캐빈', '조명', '불멍', 'Private', 'Mood'];
|
|
||||||
|
|
||||||
return data.usps.map(usp => {
|
|
||||||
let calculatedScore = 65; // Base score
|
|
||||||
const contentStr = (usp.label + usp.subLabel + usp.description).replace(/\s+/g, '');
|
|
||||||
|
|
||||||
// 2. Cross-reference USP with Market Signals
|
|
||||||
marketSignals.forEach(signal => {
|
|
||||||
if (contentStr.includes(signal)) calculatedScore += 4;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Boost based on Core Keywords (Weighted Importance)
|
|
||||||
coreKeywords.forEach(keyword => {
|
|
||||||
if (contentStr.includes(keyword)) calculatedScore += 6;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Special Boost based on specific Marketing Pillars (checking English SubLabels)
|
|
||||||
if (usp.subLabel === 'CONCEPT') calculatedScore += 10;
|
|
||||||
if (usp.subLabel === 'PRIVACY') calculatedScore += 8;
|
|
||||||
if (usp.subLabel === 'NIGHT MOOD') calculatedScore += 8;
|
|
||||||
if (usp.subLabel === 'PHOTO SPOT') calculatedScore += 5;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...usp,
|
|
||||||
score: Math.min(Math.round(calculatedScore), 99) // Cap at 99
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const rawData = LALA_CABIN_DATA;
|
|
||||||
|
|
||||||
// Calculate scores on mount (or when data changes)
|
|
||||||
const scoredUSPs = useMemo(() => calculateDynamicScores(rawData), [rawData]);
|
|
||||||
|
|
||||||
// Find the top selling point
|
|
||||||
const topUSP = useMemo(() => [...scoredUSPs].sort((a, b) => b.score - a.score)[0], [scoredUSPs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-brand-bg text-brand-text pb-24 selection:bg-brand-accent/30 font-sans">
|
|
||||||
{/* Top Navigation */}
|
|
||||||
<div className="p-6">
|
|
||||||
<button className="flex items-center gap-2 px-4 py-2 rounded-full border border-brand-muted/30 text-brand-muted hover:text-brand-accent hover:border-brand-accent transition-all text-sm group">
|
|
||||||
<ArrowLeft size={16} className="group-hover:-translate-x-1 transition-transform" />
|
|
||||||
<span>뒤로가기</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Header */}
|
|
||||||
<div className="text-center mb-10 px-4">
|
|
||||||
<div className="flex justify-center mb-4">
|
|
||||||
<div className="relative">
|
|
||||||
<Sparkles className="text-brand-accent w-8 h-8 animate-pulse relative z-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-4xl font-bold mb-3 tracking-tight text-white">브랜드 인텔리전스</h1>
|
|
||||||
<p className="text-brand-muted text-lg max-w-xl mx-auto">
|
|
||||||
<span className="text-brand-accent font-semibold">AI 데이터 분석</span>을 통해 도출된 라라캐빈의 핵심 전략입니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid Container */}
|
|
||||||
<div className="max-w-7xl mx-auto px-4 md:px-8 grid grid-cols-1 lg:grid-cols-2 gap-8">
|
|
||||||
|
|
||||||
{/* LEFT COLUMN: Identity & Text Analysis */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
|
|
||||||
{/* Main Identity Card */}
|
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 shadow-lg relative overflow-hidden group hover:border-brand-accent/20 transition-all duration-500">
|
|
||||||
<div className="absolute top-0 left-0 w-1.5 h-full bg-gradient-to-b from-brand-accent to-brand-purple"></div>
|
|
||||||
<div className="mb-4 flex items-center gap-2">
|
|
||||||
<span className="text-brand-accent font-bold text-sm uppercase tracking-wider flex items-center gap-2">
|
|
||||||
<LayoutGrid size={14} /> 브랜드 정체성
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h2 className="text-3xl font-bold mb-2 text-white tracking-tight">{rawData.name}</h2>
|
|
||||||
<div className="flex items-start gap-2 text-brand-muted text-sm mb-6">
|
|
||||||
<MapPin size={14} className="mt-0.5 shrink-0 text-brand-accent" />
|
|
||||||
<div>
|
|
||||||
<p>{rawData.address}</p>
|
|
||||||
<p className="opacity-70">{rawData.subAddress}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-5 text-gray-300 leading-relaxed border-t border-white/5 pt-5">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted">입지 특성 분석</h3>
|
|
||||||
<p className="text-sm opacity-90 leading-6">{rawData.locationAnalysis}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 className="text-white font-semibold mb-2 text-sm text-brand-muted">컨셉 확장성</h3>
|
|
||||||
<p className="text-sm opacity-90 leading-6">{rawData.conceptAnalysis}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Positioning & Strategy Card */}
|
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
|
|
||||||
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
|
|
||||||
<Target size={20} className="text-brand-purple" /> 시장 포지셔닝
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4">
|
|
||||||
<div className="bg-brand-bg/50 p-5 rounded-xl border border-brand-muted/20 hover:border-brand-accent/30 transition-colors group">
|
|
||||||
<span className="block text-xs text-brand-muted mb-1 group-hover:text-brand-accent transition-colors">카테고리 정의</span>
|
|
||||||
<span className="font-bold text-lg text-white">{rawData.positioning.category}</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-gradient-to-r from-brand-bg/50 to-brand-cardHover p-5 rounded-xl border border-brand-muted/20 border-l-4 border-l-brand-accent">
|
|
||||||
<span className="block text-xs text-brand-accent mb-1 font-semibold">핵심 가치 (Core Value)</span>
|
|
||||||
<span className="font-semibold text-white">{rawData.positioning.coreValue}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Target Audience Card */}
|
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10">
|
|
||||||
<h3 className="text-xl font-bold mb-6 flex items-center gap-2 text-white">
|
|
||||||
<Users size={20} className="text-brand-purple" /> 타겟 페르소나
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{rawData.targets.map((target, idx) => (
|
|
||||||
<div key={idx} className="flex flex-col sm:flex-row sm:items-center gap-4 p-4 bg-brand-bg/30 rounded-xl border border-white/5 hover:border-brand-accent/20 transition-all group">
|
|
||||||
<div className="min-w-[120px]">
|
|
||||||
<div className="font-bold text-white group-hover:text-brand-accent transition-colors">{target.segment}</div>
|
|
||||||
<div className="text-xs text-brand-muted">{target.age}</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
||||||
{target.needs.map((need, i) => (
|
|
||||||
<span key={i} className="text-[10px] px-2 py-0.5 bg-brand-accent/10 text-brand-accent rounded-sm font-medium">{need}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-gray-400 border-t border-white/5 pt-2 mt-2">
|
|
||||||
<span className="text-brand-muted">Trigger:</span> {target.triggers.join(', ')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* RIGHT COLUMN: Visuals & Keywords */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
|
|
||||||
{/* Chart Card */}
|
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 min-h-[500px] flex flex-col relative overflow-hidden">
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center mb-2 z-10">
|
|
||||||
<h3 className="text-xl font-bold text-white">주요 셀링 포인트 (USP)</h3>
|
|
||||||
<div className="flex items-center gap-1 text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded">
|
|
||||||
<TrendingUp size={12} />
|
|
||||||
<span>AI Data Analysis</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 flex items-center justify-center relative z-10 -my-4">
|
|
||||||
<GeometricChart data={scoredUSPs} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Core Competitiveness Highlight */}
|
|
||||||
{topUSP && (
|
|
||||||
<div className="mt-2 mb-6 p-4 rounded-xl bg-gradient-to-r from-brand-accent/10 to-transparent border border-brand-accent/20 relative overflow-hidden">
|
|
||||||
<div className="absolute top-0 right-0 p-2 opacity-10">
|
|
||||||
<Crown size={64} className="text-brand-accent" />
|
|
||||||
</div>
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="text-xs text-brand-accent font-bold uppercase tracking-wider mb-1 flex items-center gap-1">
|
|
||||||
<Crown size={12} /> Core Competitiveness
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-end">
|
|
||||||
<div>
|
|
||||||
<div className="text-lg font-bold text-white">{topUSP.label}</div>
|
|
||||||
<div className="text-xs text-brand-accent/80 font-mono tracking-wider">{topUSP.subLabel}</div>
|
|
||||||
</div>
|
|
||||||
{/* Score removed */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 z-10">
|
|
||||||
{scoredUSPs.filter(u => u.label !== topUSP.label).slice(0, 4).map((usp, idx) => (
|
|
||||||
<div key={idx} className="p-3 rounded-xl bg-brand-bg/40 border border-white/5 hover:bg-brand-bg/60 transition-colors">
|
|
||||||
<div className="flex justify-between items-start mb-1">
|
|
||||||
<div className="text-xs text-brand-muted font-bold uppercase tracking-tight">{usp.subLabel}</div>
|
|
||||||
{/* Score removed */}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-bold text-white mb-1">{usp.label}</div>
|
|
||||||
<div className="text-xs text-gray-400 leading-tight line-clamp-1">{usp.description}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Keywords Card */}
|
|
||||||
<div className="bg-brand-card rounded-3xl p-8 border border-brand-muted/10 relative overflow-hidden">
|
|
||||||
<h3 className="text-xl font-bold mb-6 text-center text-white">추천 타겟 키워드</h3>
|
|
||||||
<div className="flex flex-wrap justify-center gap-3 relative z-10">
|
|
||||||
{rawData.keywords.map((keyword, idx) => (
|
|
||||||
<KeywordBubble key={idx} text={keyword} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Floating Action Button (Sticky Bottom) */}
|
|
||||||
<div className="fixed bottom-8 left-0 right-0 flex justify-center z-50 pointer-events-none">
|
|
||||||
<button className="pointer-events-auto bg-brand-purple hover:bg-brand-purpleHover text-white font-bold py-3 px-12 rounded-full shadow-2xl shadow-brand-purple/40 transform hover:scale-105 transition-all duration-300 flex items-center gap-2">
|
|
||||||
<Sparkles size={18} />
|
|
||||||
콘텐츠 생성
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,20 +0,0 @@
|
||||||
<div align="center">
|
|
||||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
# Run and deploy your AI Studio app
|
|
||||||
|
|
||||||
This contains everything you need to run your app locally.
|
|
||||||
|
|
||||||
View your app in AI Studio: https://ai.studio/apps/drive/198bB4uG9EOOi0btQWINncwYJz7xEjWS3
|
|
||||||
|
|
||||||
## Run Locally
|
|
||||||
|
|
||||||
**Prerequisites:** Node.js
|
|
||||||
|
|
||||||
|
|
||||||
1. Install dependencies:
|
|
||||||
`npm install`
|
|
||||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
|
||||||
3. Run the app:
|
|
||||||
`npm run dev`
|
|
||||||
|
|
@ -1,159 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { USP } from '../types';
|
|
||||||
|
|
||||||
interface GeometricChartProps {
|
|
||||||
data: USP[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GeometricChart: React.FC<GeometricChartProps> = ({ data }) => {
|
|
||||||
// Increased canvas size to prevent labels (especially on the right side like 'Privacy') from being cut off
|
|
||||||
const size = 500;
|
|
||||||
const center = size / 2;
|
|
||||||
const radius = 110; // Slightly increased radius for better visibility
|
|
||||||
const sides = data.length;
|
|
||||||
|
|
||||||
// Mint Color #94FBE0
|
|
||||||
const accentColor = "#94FBE0";
|
|
||||||
|
|
||||||
// Calculate polygon points
|
|
||||||
const getPoints = (r: number) => {
|
|
||||||
return data.map((_, i) => {
|
|
||||||
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
|
||||||
const x = center + r * Math.cos(angle);
|
|
||||||
const y = center + r * Math.sin(angle);
|
|
||||||
return `${x},${y}`;
|
|
||||||
}).join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate data points based on score
|
|
||||||
const getDataPoints = () => {
|
|
||||||
return data.map((item, i) => {
|
|
||||||
const normalizedScore = item.score / 100;
|
|
||||||
const r = radius * normalizedScore;
|
|
||||||
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
|
||||||
const x = center + r * Math.cos(angle);
|
|
||||||
const y = center + r * Math.sin(angle);
|
|
||||||
return `${x},${y}`;
|
|
||||||
}).join(' ');
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate label positions (pushed out slightly further)
|
|
||||||
const labelRadius = radius + 55; // Increased padding for labels
|
|
||||||
const labels = data.map((item, i) => {
|
|
||||||
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
|
||||||
const x = center + labelRadius * Math.cos(angle);
|
|
||||||
const y = center + labelRadius * Math.sin(angle);
|
|
||||||
|
|
||||||
// Adjust text anchor based on position
|
|
||||||
let anchor: 'start' | 'middle' | 'end' = 'middle';
|
|
||||||
if (x < center - 20) anchor = 'end';
|
|
||||||
if (x > center + 20) anchor = 'start';
|
|
||||||
|
|
||||||
return { x, y, text: item.label, sub: item.subLabel, anchor, score: item.score };
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-2 relative w-full h-full">
|
|
||||||
<svg viewBox={`0 0 ${size} ${size}`} className="w-full h-auto max-w-[500px]" style={{ overflow: 'visible' }}>
|
|
||||||
<defs>
|
|
||||||
<radialGradient id="polyGradient" cx="50%" cy="50%" r="50%" fx="50%" fy="50%">
|
|
||||||
<stop offset="0%" stopColor={accentColor} stopOpacity="0.3" />
|
|
||||||
<stop offset="100%" stopColor={accentColor} stopOpacity="0.05" />
|
|
||||||
</radialGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* Background Grids (Concentric) - Increased Opacity for Visibility */}
|
|
||||||
{[1, 0.75, 0.5, 0.25].map((scale, i) => (
|
|
||||||
<polygon
|
|
||||||
key={i}
|
|
||||||
points={getPoints(radius * scale)}
|
|
||||||
fill="none"
|
|
||||||
stroke={accentColor}
|
|
||||||
strokeOpacity={0.25 - (0.02 * i)}
|
|
||||||
strokeWidth="1"
|
|
||||||
strokeDasharray={i === 0 ? "0" : "4 2"}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Axes Lines - Increased Opacity */}
|
|
||||||
{data.map((_, i) => {
|
|
||||||
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
|
||||||
const x = center + radius * Math.cos(angle);
|
|
||||||
const y = center + radius * Math.sin(angle);
|
|
||||||
return (
|
|
||||||
<line
|
|
||||||
key={i}
|
|
||||||
x1={center}
|
|
||||||
y1={center}
|
|
||||||
x2={x}
|
|
||||||
y2={y}
|
|
||||||
stroke={accentColor}
|
|
||||||
strokeOpacity="0.25"
|
|
||||||
strokeWidth="1"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Data Shape */}
|
|
||||||
<polygon
|
|
||||||
points={getDataPoints()}
|
|
||||||
fill="url(#polyGradient)"
|
|
||||||
stroke={accentColor}
|
|
||||||
strokeWidth="2.5"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Data Points (Dots) - Increased base size and highlight size */}
|
|
||||||
{data.map((item, i) => {
|
|
||||||
const normalizedScore = item.score / 100;
|
|
||||||
const r = radius * normalizedScore;
|
|
||||||
const angle = (Math.PI * 2 * i) / sides - Math.PI / 2;
|
|
||||||
const x = center + r * Math.cos(angle);
|
|
||||||
const y = center + r * Math.sin(angle);
|
|
||||||
const isHigh = item.score >= 90;
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
{isHigh && (
|
|
||||||
<circle cx={x} cy={y} r="10" fill={accentColor} fillOpacity="0.4" />
|
|
||||||
)}
|
|
||||||
{/* Base dot is brighter (white) for all, slightly smaller for non-high */}
|
|
||||||
<circle cx={x} cy={y} r={isHigh ? 5 : 4} fill="#fff" />
|
|
||||||
</g>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
{labels.map((l, i) => {
|
|
||||||
const isHigh = l.score >= 90;
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
<text
|
|
||||||
x={l.x}
|
|
||||||
y={l.y - 7}
|
|
||||||
textAnchor={l.anchor}
|
|
||||||
fill={isHigh ? "#fff" : "#e2e8f0"}
|
|
||||||
fontSize={isHigh ? "14" : "12"}
|
|
||||||
fontWeight={isHigh ? "700" : "600"}
|
|
||||||
className="tracking-tight"
|
|
||||||
style={{ fontFamily: "sans-serif" }}
|
|
||||||
>
|
|
||||||
{l.text}
|
|
||||||
</text>
|
|
||||||
<text
|
|
||||||
x={l.x}
|
|
||||||
y={l.y + 9}
|
|
||||||
textAnchor={l.anchor}
|
|
||||||
fill={isHigh ? accentColor : "#94a3b8"}
|
|
||||||
fontSize={isHigh ? "11" : "10"}
|
|
||||||
fontWeight={isHigh ? "600" : "400"}
|
|
||||||
className="uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
{l.sub}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
interface KeywordBubbleProps {
|
|
||||||
text: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const KeywordBubble: React.FC<KeywordBubbleProps> = ({ text }) => {
|
|
||||||
return (
|
|
||||||
<div className="bg-brand-cardHover/50 border border-brand-accent/20 rounded-full px-4 py-2 text-sm text-brand-text shadow-sm hover:border-brand-accent hover:text-brand-accent hover:bg-brand-accent/5 transition-all duration-300 cursor-default whitespace-nowrap">
|
|
||||||
# {text}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { BrandData } from './types';
|
|
||||||
|
|
||||||
export const LALA_CABIN_DATA: BrandData = {
|
|
||||||
name: "라라캐빈 펜션",
|
|
||||||
address: "경기 가평군 조종면 와곡길 176",
|
|
||||||
subAddress: "[별관] 경기 가평군 조종면 운악리 175-6",
|
|
||||||
summary: "서울·경기 북부 근교 1~2시간대 숏브레이크 권역에 위치한 캐빈 감성 스테이. 조종면의 한적함과 프라이빗한 독채 구조를 강점으로 내세우며, '숲속 캐빈 무드'와 '야간 조명 감성'이 핵심입니다.",
|
|
||||||
locationAnalysis: "조종면은 청평/가평읍 대비 '한적함·프라이빗' 이미지가 강해 커플 및 소규모 힐링 고객에게 유리하며, 운악산 권역의 자연형 스테이 포지셔닝에 적합합니다.",
|
|
||||||
conceptAnalysis: "'라라캐빈'은 오두막(Cabin) 연상을 강하게 주며, 감성 목조/숲속/프라이빗 스테이로 스토리텔링 확장성이 큽니다. 별관의 동 분리 구조는 '세컨드 하우스' 느낌을 강화합니다.",
|
|
||||||
usps: [
|
|
||||||
{ label: "입지 환경", subLabel: "LOCATION", score: 85, description: "서울근교 한적한 조종면 자연권 숲속 드라이브" },
|
|
||||||
{ label: "브랜드 컨셉", subLabel: "CONCEPT", score: 95, description: "숙소 자체가 여행 목적이 되는 캐빈 감성" },
|
|
||||||
{ label: "프라이버시", subLabel: "PRIVACY", score: 90, description: "우리만 쓰는 느낌의 별관 분리동 독립 동선" },
|
|
||||||
{ label: "야간 무드", subLabel: "NIGHT MOOD", score: 92, description: "어두울수록 예쁜 스테이 장면과 불멍" },
|
|
||||||
{ label: "힐링 요소", subLabel: "HEALING", score: 88, description: "창밖 자연로딩, 숲뷰 산책 리셋" },
|
|
||||||
{ label: "포토 스팟", subLabel: "PHOTO SPOT", score: 94, description: "찍는 곳마다 커버샷 무드, 인스타 각" },
|
|
||||||
{ label: "숏브레이크", subLabel: "SHORT GETAWAY", score: 80, description: "1박2일 가볍게 떠나는 주말 리셋 여행" }
|
|
||||||
],
|
|
||||||
keywords: [
|
|
||||||
"가평 독채 펜션", "숲속 오두막", "감성 숙소", "불멍 스테이",
|
|
||||||
"서울근교 드라이브", "프라이빗 힐링", "커플 여행", "운악산 펜션",
|
|
||||||
"반려견 동반", "비오는날 감성"
|
|
||||||
],
|
|
||||||
targets: [
|
|
||||||
{
|
|
||||||
segment: "서울·경기 커플",
|
|
||||||
age: "25~39세",
|
|
||||||
needs: ["사진", "분위기", "프라이빗"],
|
|
||||||
triggers: ["숲속 캐빈 무드", "비 오는 날 감성", "밤 조명"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
segment: "소규모 우정여행",
|
|
||||||
age: "20~34세",
|
|
||||||
needs: ["예쁜 공간", "수다", "바베큐"],
|
|
||||||
triggers: ["인스타 각", "감성 포토존", "테이블 셋업"]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
segment: "힐링 부부",
|
|
||||||
age: "30~50세",
|
|
||||||
needs: ["조용한 휴식", "숙면", "따뜻함"],
|
|
||||||
triggers: ["조용한 숲뷰", "따뜻한 캐빈", "기념일"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
positioning: {
|
|
||||||
category: "서울근교 숲속 캐빈 감성 스테이",
|
|
||||||
coreValue: "프라이빗한 휴식 + 사진이 되는 공간 + 밤 무드",
|
|
||||||
strategy: "시설 나열보다 '장면(Scenes)' 중심의 마케팅 전개"
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>ADO2 Brand Analysis</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
brand: {
|
|
||||||
bg: '#011c1e',
|
|
||||||
card: '#083336',
|
|
||||||
cardHover: '#0b3f42',
|
|
||||||
accent: '#94FBE0',
|
|
||||||
purple: '#a855f7',
|
|
||||||
purpleHover: '#9333ea',
|
|
||||||
text: '#e2e8f0',
|
|
||||||
muted: '#94a3b8'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ['Pretendard', 'Apple SD Gothic Neo', 'Malgun Gothic', 'sans-serif'],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
body { font-family: 'Inter', sans-serif; background-color: #011c1e; color: white; }
|
|
||||||
/* Custom scrollbar for refined look */
|
|
||||||
::-webkit-scrollbar { width: 8px; }
|
|
||||||
::-webkit-scrollbar-track { background: #011c1e; }
|
|
||||||
::-webkit-scrollbar-thumb { background: #083336; border-radius: 4px; }
|
|
||||||
::-webkit-scrollbar-thumb:hover { background: #94FBE0; }
|
|
||||||
</style>
|
|
||||||
<script type="importmap">
|
|
||||||
{
|
|
||||||
"imports": {
|
|
||||||
"react-dom/": "https://esm.sh/react-dom@^19.2.3/",
|
|
||||||
"react/": "https://esm.sh/react@^19.2.3/",
|
|
||||||
"react": "https://esm.sh/react@^19.2.3",
|
|
||||||
"lucide-react": "https://esm.sh/lucide-react@^0.563.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<link rel="stylesheet" href="/index.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/index.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import ReactDOM from 'react-dom/client';
|
|
||||||
import App from './App';
|
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
|
||||||
if (!rootElement) {
|
|
||||||
throw new Error("Could not find root element to mount to");
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = ReactDOM.createRoot(rootElement);
|
|
||||||
root.render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<App />
|
|
||||||
</React.StrictMode>
|
|
||||||
);
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
{
|
|
||||||
"name": "ADO2 Marketing Intelligence - Lala Cabin",
|
|
||||||
"description": "AI-powered marketing analysis dashboard for Lala Cabin Pension visualizing brand identity, target demographics, and key selling points.",
|
|
||||||
"requestFramePermissions": []
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
{
|
|
||||||
"name": "ado2-marketing-intelligence---lala-cabin",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react-dom": "^19.2.3",
|
|
||||||
"react": "^19.2.3",
|
|
||||||
"lucide-react": "^0.563.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/node": "^22.14.0",
|
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
|
||||||
"typescript": "~5.8.2",
|
|
||||||
"vite": "^6.2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"experimentalDecorators": true,
|
|
||||||
"useDefineForClassFields": false,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": [
|
|
||||||
"ES2022",
|
|
||||||
"DOM",
|
|
||||||
"DOM.Iterable"
|
|
||||||
],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"types": [
|
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"isolatedModules": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"allowJs": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"paths": {
|
|
||||||
"@/*": [
|
|
||||||
"./*"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"noEmit": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
export interface USP {
|
|
||||||
label: string;
|
|
||||||
subLabel: string;
|
|
||||||
score: number; // 0-100
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TargetPersona {
|
|
||||||
segment: string;
|
|
||||||
age: string;
|
|
||||||
needs: string[];
|
|
||||||
triggers: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BrandData {
|
|
||||||
name: string;
|
|
||||||
address: string;
|
|
||||||
subAddress: string;
|
|
||||||
summary: string;
|
|
||||||
locationAnalysis: string;
|
|
||||||
conceptAnalysis: string;
|
|
||||||
usps: USP[];
|
|
||||||
keywords: string[];
|
|
||||||
targets: TargetPersona[];
|
|
||||||
positioning: {
|
|
||||||
category: string;
|
|
||||||
coreValue: string;
|
|
||||||
strategy: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
import path from 'path';
|
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
|
||||||
import react from '@vitejs/plugin-react';
|
|
||||||
|
|
||||||
export default defineConfig(({ mode }) => {
|
|
||||||
const env = loadEnv(mode, '.', '');
|
|
||||||
return {
|
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
host: '0.0.0.0',
|
|
||||||
},
|
|
||||||
plugins: [react()],
|
|
||||||
define: {
|
|
||||||
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
|
||||||
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
|
||||||
},
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
'@': path.resolve(__dirname, '.'),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
|
@ -52,6 +54,7 @@
|
||||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.27.1",
|
"@babel/code-frame": "^7.27.1",
|
||||||
"@babel/generator": "^7.28.5",
|
"@babel/generator": "^7.28.5",
|
||||||
|
|
@ -1288,10 +1291,32 @@
|
||||||
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react": {
|
||||||
|
"version": "19.2.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
|
||||||
|
"integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/react-dom": {
|
||||||
|
"version": "19.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
|
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "^19.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/use-sync-external-store": {
|
"node_modules/@types/use-sync-external-store": {
|
||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||||
|
|
@ -1349,6 +1374,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.9.0",
|
"baseline-browser-mapping": "^2.9.0",
|
||||||
"caniuse-lite": "^1.0.30001759",
|
"caniuse-lite": "^1.0.30001759",
|
||||||
|
|
@ -1400,6 +1426,13 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/csstype": {
|
||||||
|
"version": "3.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/d3-array": {
|
"node_modules/d3-array": {
|
||||||
"version": "3.2.4",
|
"version": "3.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
|
@ -1691,6 +1724,7 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.28.4"
|
"@babel/runtime": "^7.28.4"
|
||||||
},
|
},
|
||||||
|
|
@ -1811,6 +1845,7 @@
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|
@ -1852,6 +1887,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
|
|
@ -1861,6 +1897,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
|
|
@ -1907,6 +1944,7 @@
|
||||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/use-sync-external-store": "^0.0.6",
|
"@types/use-sync-external-store": "^0.0.6",
|
||||||
"use-sync-external-store": "^1.4.0"
|
"use-sync-external-store": "^1.4.0"
|
||||||
|
|
@ -1969,7 +2007,8 @@
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
"license": "MIT"
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/redux-thunk": {
|
"node_modules/redux-thunk": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
|
|
@ -2083,6 +2122,7 @@
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|
@ -2166,6 +2206,7 @@
|
||||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.4.4",
|
"fdir": "^6.4.4",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.14.0",
|
"@types/node": "^22.14.0",
|
||||||
|
"@types/react": "^19.2.17",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^5.0.0",
|
"@vitejs/plugin-react": "^5.0.0",
|
||||||
"typescript": "~5.8.2",
|
"typescript": "~5.8.2",
|
||||||
"vite": "^6.2.0"
|
"vite": "^6.2.0"
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,22 @@
|
||||||
|
{
|
||||||
|
"name": "@ado2/higgsfield-remotion",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "ADO2 Higgsfield Shorts — Remotion 자막·타이틀 오버레이 합성",
|
||||||
|
"scripts": {
|
||||||
|
"studio": "remotion studio",
|
||||||
|
"render": "remotion render MumumShort out/mumum-poc2-final.mp4",
|
||||||
|
"upgrade": "remotion upgrade"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@remotion/cli": "4.0.468",
|
||||||
|
"@remotion/google-fonts": "4.0.468",
|
||||||
|
"remotion": "4.0.468",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "19.2.0",
|
||||||
|
"typescript": "5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,6 @@
|
||||||
|
import { Config } from "@remotion/cli/config";
|
||||||
|
|
||||||
|
Config.setVideoImageFormat("jpeg");
|
||||||
|
Config.setOverwriteOutput(true);
|
||||||
|
// H.264 + AAC, 모바일 9:16 Shorts/Reels 호환
|
||||||
|
Config.setCodec("h264");
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
import React from "react";
|
||||||
|
import { AbsoluteFill, OffthreadVideo, Sequence, staticFile } from "remotion";
|
||||||
|
import { ShortProps } from "./data/types";
|
||||||
|
import { HookTitle } from "./components/HookTitle";
|
||||||
|
import { BrandLines } from "./components/BrandLines";
|
||||||
|
import { SellingBadge } from "./components/SellingBadge";
|
||||||
|
import { EndCard } from "./components/EndCard";
|
||||||
|
|
||||||
|
// Higgsfield 완성본을 배경으로 깔고(오디오 포함), 자막/타이틀만 오버레이.
|
||||||
|
// 모든 콘텐츠는 props로 주입 (펜션/제품마다 교체).
|
||||||
|
export const MumumShort: React.FC<ShortProps> = ({
|
||||||
|
videoSrc,
|
||||||
|
hook,
|
||||||
|
sellingPoint,
|
||||||
|
brandLines,
|
||||||
|
endCard,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ backgroundColor: "black" }}>
|
||||||
|
<OffthreadVideo
|
||||||
|
src={staticFile(videoSrc)}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 상단 후킹 타이틀 */}
|
||||||
|
<Sequence from={hook.fromFrame} durationInFrames={hook.toFrame - hook.fromFrame}>
|
||||||
|
<HookTitle
|
||||||
|
eyebrow={hook.eyebrow}
|
||||||
|
title={hook.title}
|
||||||
|
fromFrame={0}
|
||||||
|
toFrame={hook.toFrame - hook.fromFrame}
|
||||||
|
/>
|
||||||
|
</Sequence>
|
||||||
|
|
||||||
|
{/* 셀링포인트: 3개 독립 박스 */}
|
||||||
|
<SellingBadge
|
||||||
|
items={sellingPoint.items}
|
||||||
|
fromFrame={sellingPoint.fromFrame}
|
||||||
|
toFrame={sellingPoint.toFrame}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 브랜드 감성 2줄 (상단 중앙) */}
|
||||||
|
<BrandLines
|
||||||
|
lines={brandLines.lines}
|
||||||
|
fromFrame={brandLines.fromFrame}
|
||||||
|
toFrame={brandLines.toFrame}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 엔드카드 + AI 디스클로저 */}
|
||||||
|
<EndCard
|
||||||
|
brand={endCard.brand}
|
||||||
|
location={endCard.location}
|
||||||
|
disclosure={endCard.disclosure}
|
||||||
|
fromFrame={endCard.fromFrame}
|
||||||
|
/>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Composition } from "remotion";
|
||||||
|
import { MumumShort } from "./MumumShort";
|
||||||
|
import { defaultProps } from "./data/mumum";
|
||||||
|
|
||||||
|
export const RemotionRoot: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<Composition
|
||||||
|
id="MumumShort"
|
||||||
|
component={MumumShort}
|
||||||
|
defaultProps={defaultProps}
|
||||||
|
durationInFrames={defaultProps.durationInFrames}
|
||||||
|
fps={defaultProps.fps}
|
||||||
|
width={defaultProps.width}
|
||||||
|
height={defaultProps.height}
|
||||||
|
// props(영상 크기·길이)로 메타데이터 동적 설정 → 한 컴포지션이 모든 job 처리
|
||||||
|
calculateMetadata={({ props }) => ({
|
||||||
|
durationInFrames: props.durationInFrames,
|
||||||
|
fps: props.fps,
|
||||||
|
width: props.width,
|
||||||
|
height: props.height,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useCurrentFrame, interpolate, AbsoluteFill } from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 브랜드 감성 2줄: 우상단 통일 위치에 한 덩어리로 쌓아 노출.
|
||||||
|
// 첫 줄 먼저, 둘째 줄 살짝 뒤따라 등장 → 같은 자리에서 읽힘.
|
||||||
|
export const BrandLines: React.FC<{
|
||||||
|
lines: string[];
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ lines, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const LINE_STAGGER = 18; // 줄 간 등장 간격(frame)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "center", // 가로 중앙 정렬
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingTop: 230, // 상단 (~18%) 높이 유지
|
||||||
|
gap: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
const start = fromFrame + i * LINE_STAGGER;
|
||||||
|
// interpolate는 배열이 단조증가해야 한다.
|
||||||
|
// 영상이 짧아 toFrame 이 start+24 이하로 내려오면 범위가 뒤집혀 크래시 발생.
|
||||||
|
const fadeIn = start + 12;
|
||||||
|
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
|
||||||
|
const end = Math.max(fadeOut + 1, toFrame);
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[start, fadeIn, fadeOut, end],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
const rise = interpolate(frame, [start, start + 16], [16, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
opacity,
|
||||||
|
transform: `translateY(${rise}px)`,
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 46,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 1,
|
||||||
|
textShadow: "0 2px 16px rgba(0,0,0,0.85)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 엔드카드: 브랜드 + 위치 + AI 디스클로저 (가드레일 필수)
|
||||||
|
export const EndCard: React.FC<{
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
disclosure: string;
|
||||||
|
fromFrame: number;
|
||||||
|
}> = ({ brand, location, disclosure, fromFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
const appear = interpolate(frame, [fromFrame, fromFrame + 12], [0, 1], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
const brandSpring = spring({
|
||||||
|
frame: frame - fromFrame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200, mass: 0.5 },
|
||||||
|
});
|
||||||
|
const brandScale = interpolate(brandSpring, [0, 1], [0.92, 1]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
// 하단을 살짝 어둡게 덮어 브랜드 가독성 확보
|
||||||
|
background: `rgba(0,0,0,${0.42 * appear})`,
|
||||||
|
opacity: appear,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
transform: `scale(${brandScale})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 76,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
letterSpacing: 1,
|
||||||
|
textShadow: "0 3px 20px rgba(0,0,0,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{brand}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 30,
|
||||||
|
color: "#FFE9C7",
|
||||||
|
marginTop: 18,
|
||||||
|
letterSpacing: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{location}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
bottom: 70,
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: 19,
|
||||||
|
color: "rgba(255,255,255,0.72)",
|
||||||
|
textAlign: "center",
|
||||||
|
padding: "0 40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{disclosure}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { hookFont } from "../fonts";
|
||||||
|
|
||||||
|
export const HookTitle: React.FC<{
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ eyebrow, title, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
|
||||||
|
// 스프링 슬라이드-다운 등장
|
||||||
|
const enter = spring({
|
||||||
|
frame: frame - fromFrame,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200, mass: 0.6 },
|
||||||
|
});
|
||||||
|
const translateY = interpolate(enter, [0, 1], [-60, 0]);
|
||||||
|
// 퇴장 페이드
|
||||||
|
const fadeIn = fromFrame + 8;
|
||||||
|
const fadeOut = Math.max(fadeIn + 1, toFrame - 12);
|
||||||
|
const end = Math.max(fadeOut + 1, toFrame);
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[fromFrame, fadeIn, fadeOut, end],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill style={{ opacity }}>
|
||||||
|
{/* 상단 가독성 그라데이션 */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 580,
|
||||||
|
background:
|
||||||
|
"linear-gradient(180deg, rgba(0,0,0,0.55) 0%, rgba(0,0,0,0) 100%)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 280,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
transform: `translateY(${translateY}px)`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: "0 48px",
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: hookFont,
|
||||||
|
fontSize: 34,
|
||||||
|
color: "#FFE9C7",
|
||||||
|
letterSpacing: 2,
|
||||||
|
marginBottom: 14,
|
||||||
|
textShadow: "0 2px 12px rgba(0,0,0,0.7)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{eyebrow}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: hookFont,
|
||||||
|
fontSize: 62,
|
||||||
|
lineHeight: 1.18,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textShadow: "0 3px 18px rgba(0,0,0,0.85)",
|
||||||
|
WebkitTextStroke: "1.5px rgba(0,0,0,0.35)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
useVideoConfig,
|
||||||
|
interpolate,
|
||||||
|
spring,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 셀링포인트: 3개 독립 pill 박스를 세로로 쌓고 순차 등장.
|
||||||
|
// 컬러 지양 → 반투명 다크 pill + 흰색 텍스트. 앞 구간 단독 노출(시선 집중).
|
||||||
|
export const SellingBadge: React.FC<{
|
||||||
|
items: string[];
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ items, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
const { fps } = useVideoConfig();
|
||||||
|
const STAGGER = 9; // 박스 간 등장 간격(frame)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "column",
|
||||||
|
paddingTop: 200, // 상단 배치 (~16%) — 이미지 가림 최소화
|
||||||
|
gap: 18,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{items.map((item, i) => {
|
||||||
|
const start = fromFrame + i * STAGGER;
|
||||||
|
const enter = spring({
|
||||||
|
frame: frame - start,
|
||||||
|
fps,
|
||||||
|
config: { damping: 200, mass: 0.5 },
|
||||||
|
});
|
||||||
|
const rise = interpolate(enter, [0, 1], [22, 0]);
|
||||||
|
const fadeIn = start + 8;
|
||||||
|
const fadeOut = Math.max(fadeIn + 1, toFrame - 10);
|
||||||
|
const end = Math.max(fadeOut + 1, toFrame);
|
||||||
|
const appear = interpolate(
|
||||||
|
frame,
|
||||||
|
[start, fadeIn, fadeOut, end],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
style={{
|
||||||
|
opacity: appear,
|
||||||
|
transform: `translateY(${rise}px)`,
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 38,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
// 배경 제거 → 블러리 소프트 쉐도우로 가독성 확보
|
||||||
|
textShadow:
|
||||||
|
"0 2px 22px rgba(0,0,0,0.95), 0 0 14px rgba(0,0,0,0.85), 0 0 40px rgba(0,0,0,0.6)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
useCurrentFrame,
|
||||||
|
interpolate,
|
||||||
|
AbsoluteFill,
|
||||||
|
} from "remotion";
|
||||||
|
import { serifFont } from "../fonts";
|
||||||
|
|
||||||
|
// 브랜드 자막: 페이드 + 미세 상승 (Profile A 모션 프리셋)
|
||||||
|
export const Subtitle: React.FC<{
|
||||||
|
text: string;
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
}> = ({ text, fromFrame, toFrame }) => {
|
||||||
|
const frame = useCurrentFrame();
|
||||||
|
|
||||||
|
const opacity = interpolate(
|
||||||
|
frame,
|
||||||
|
[fromFrame, fromFrame + 10, toFrame - 10, toFrame],
|
||||||
|
[0, 1, 1, 0],
|
||||||
|
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" },
|
||||||
|
);
|
||||||
|
const rise = interpolate(frame, [fromFrame, fromFrame + 18], [18, 0], {
|
||||||
|
extrapolateLeft: "clamp",
|
||||||
|
extrapolateRight: "clamp",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AbsoluteFill
|
||||||
|
style={{
|
||||||
|
justifyContent: "flex-end",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingBottom: 220,
|
||||||
|
opacity,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
fontFamily: serifFont,
|
||||||
|
fontWeight: 700,
|
||||||
|
fontSize: 50,
|
||||||
|
color: "#FFFFFF",
|
||||||
|
textAlign: "center",
|
||||||
|
letterSpacing: 1,
|
||||||
|
transform: `translateY(${rise}px)`,
|
||||||
|
textShadow: "0 2px 16px rgba(0,0,0,0.8)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
</AbsoluteFill>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Default props = the verified 머뭄 PoC #2 layout.
|
||||||
|
// The Python render bridge overrides these per-job via --props.
|
||||||
|
import { ShortProps } from "./types";
|
||||||
|
|
||||||
|
export const defaultProps: ShortProps = {
|
||||||
|
videoSrc: "mumum_marketing_v1.mp4",
|
||||||
|
fps: 30,
|
||||||
|
width: 720,
|
||||||
|
height: 1280,
|
||||||
|
durationInFrames: 241, // ≈ 8.04s
|
||||||
|
|
||||||
|
hook: {
|
||||||
|
eyebrow: "아무도 모르는 군산",
|
||||||
|
title: "골목 안, 단 한 채의 독채 스테이 머뭄",
|
||||||
|
fromFrame: 0,
|
||||||
|
toFrame: 78,
|
||||||
|
},
|
||||||
|
sellingPoint: {
|
||||||
|
items: ["프라이빗 독채펜션", "넓은 정원", "핵심 관광지 가까이"],
|
||||||
|
fromFrame: 80,
|
||||||
|
toFrame: 138,
|
||||||
|
},
|
||||||
|
brandLines: {
|
||||||
|
lines: ["머무는 시간이", "다르게 흐르는 곳"],
|
||||||
|
fromFrame: 138,
|
||||||
|
toFrame: 196,
|
||||||
|
},
|
||||||
|
endCard: {
|
||||||
|
brand: "Stay, 머뭄",
|
||||||
|
location: "전북 군산시 절골길 18",
|
||||||
|
disclosure: "실제 사진 기반, AI 카메라 효과를 적용한 영상입니다.",
|
||||||
|
fromFrame: 192,
|
||||||
|
toFrame: 241,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Props contract — shared by Remotion composition and the Python render bridge.
|
||||||
|
// Mirrors server/app/schemas.py VideoSpec (+ video config + timings).
|
||||||
|
export type ShortProps = {
|
||||||
|
videoSrc: string; // file in public/ (staticFile name)
|
||||||
|
fps: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
durationInFrames: number;
|
||||||
|
hook: { eyebrow: string; title: string; fromFrame: number; toFrame: number };
|
||||||
|
sellingPoint: { items: string[]; fromFrame: number; toFrame: number };
|
||||||
|
brandLines: { lines: string[]; fromFrame: number; toFrame: number };
|
||||||
|
endCard: {
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
disclosure: string;
|
||||||
|
fromFrame: number;
|
||||||
|
toFrame: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
// 한글 폰트 로딩 (@remotion/google-fonts, korean subset)
|
||||||
|
// Hook = Black Han Sans (굵고 강렬한 바이럴 헤드라인)
|
||||||
|
// Body/Brand = Nanum Myeongjo (우아한 명조 — 머뭄의 정적 브랜드 톤)
|
||||||
|
//
|
||||||
|
// 주의: korean subset 자체가 unicode-range 파일이 많아 요청 수가 많다.
|
||||||
|
// weights 를 최소화(NanumMyeongjo는 400/700만 실제 존재)해 요청 수를 줄임.
|
||||||
|
// latin 서브셋 제거 — 콘텐츠가 한국어 전용이므로 불필요.
|
||||||
|
import { loadFont as loadHook } from "@remotion/google-fonts/BlackHanSans";
|
||||||
|
import { loadFont as loadSerif } from "@remotion/google-fonts/NanumMyeongjo";
|
||||||
|
|
||||||
|
export const hookFont = loadHook("normal", {
|
||||||
|
weights: ["400"],
|
||||||
|
subsets: ["korean"],
|
||||||
|
ignoreTooManyRequestsWarning: true,
|
||||||
|
}).fontFamily;
|
||||||
|
|
||||||
|
export const serifFont = loadSerif("normal", {
|
||||||
|
weights: ["400", "700"], // 800은 NanumMyeongjo에 없음 → 제거
|
||||||
|
subsets: ["korean"],
|
||||||
|
ignoreTooManyRequestsWarning: true,
|
||||||
|
}).fontFamily;
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { registerRoot } from "remotion";
|
||||||
|
import { RemotionRoot } from "./Root";
|
||||||
|
|
||||||
|
registerRoot(RemotionRoot);
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
32
src/App.tsx
32
src/App.tsx
|
|
@ -358,32 +358,6 @@ const App: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테스트 데이터로 브랜드 분석 페이지 이동
|
|
||||||
const handleTestData = (data: CrawlingResponse) => {
|
|
||||||
const tagged = { ...data, _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
setViewMode('analysis');
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 변경 시 테스트 데이터 다시 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (!parsed._isTestData) return;
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
fetch(jsonFile)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then((data: CrawlingResponse) => {
|
|
||||||
const tagged = { ...data, _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Failed to reload test data:', err));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
const handleToLogin = async () => {
|
const handleToLogin = async () => {
|
||||||
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
// 이미 로그인된 상태면 바로 generation_flow로 이동
|
||||||
|
|
@ -504,12 +478,8 @@ const App: React.FC = () => {
|
||||||
<Header onStartClick={handleHeaderStart} />
|
<Header onStartClick={handleHeaderStart} />
|
||||||
<section className="landing-section">
|
<section className="landing-section">
|
||||||
<HeroSection
|
<HeroSection
|
||||||
onAnalyze={handleStartAnalysis}
|
onLogin={handleToLogin}
|
||||||
onAutocomplete={handleAutocomplete}
|
|
||||||
onManualInput={handleManualInput}
|
|
||||||
onTestData={handleTestData}
|
|
||||||
onNext={() => scrollToSection(1)}
|
onNext={() => scrollToSection(1)}
|
||||||
error={error}
|
|
||||||
scrollProgress={scrollProgress}
|
scrollProgress={scrollProgress}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ interface SearchInputFormProps {
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
onManualInput?: (businessName: string, address: string) => void;
|
||||||
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
/** 직접입력 버튼 클릭 시 기본 동작(모달 열기)을 대체합니다. 제공 시 모달을 직접 관리해야 합니다. */
|
||||||
onManualButtonClick?: () => void;
|
onManualButtonClick?: () => void;
|
||||||
|
onShortform?: () => void;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -32,6 +33,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
onAutocomplete,
|
onAutocomplete,
|
||||||
onManualInput,
|
onManualInput,
|
||||||
onManualButtonClick,
|
onManualButtonClick,
|
||||||
|
onShortform,
|
||||||
error: externalError,
|
error: externalError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -68,6 +70,7 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
const searchTypeOptions = [
|
const searchTypeOptions = [
|
||||||
{ value: 'url' as SearchType, label: 'URL' },
|
{ value: 'url' as SearchType, label: 'URL' },
|
||||||
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
{ value: 'name' as SearchType, label: t('landing.hero.searchTypeBusinessName') },
|
||||||
|
{ value: 'manual' as SearchType, label: t('landing.hero.searchTypeManual') },
|
||||||
];
|
];
|
||||||
|
|
||||||
const getPlaceholder = () =>
|
const getPlaceholder = () =>
|
||||||
|
|
@ -196,8 +199,12 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
type="button"
|
type="button"
|
||||||
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
className={`hero-dropdown-item ${searchType === option.value ? 'active' : ''}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchType(option.value);
|
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
|
if (option.value === 'manual') {
|
||||||
|
handleManualButtonClick();
|
||||||
|
} else {
|
||||||
|
setSearchType(option.value);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
|
@ -321,9 +328,19 @@ const SearchInputForm: React.FC<SearchInputFormProps> = ({
|
||||||
{t('landing.hero.analyzeButton')}
|
{t('landing.hero.analyzeButton')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button type="button" className="hero-manual-button" onClick={handleManualButtonClick}>
|
{onShortform && (
|
||||||
{t('landing.hero.searchTypeManual')}
|
<button
|
||||||
|
type="button"
|
||||||
|
className="url-input-shortform-btn"
|
||||||
|
onClick={onShortform}
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<polygon points="23 7 16 12 23 17 23 7" />
|
||||||
|
<rect x="1" y="5" width="15" height="14" rx="2" ry="2" />
|
||||||
|
</svg>
|
||||||
|
숏폼 영상 만들기
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
|
{/* onManualButtonClick 미제공 시 모달을 내부에서 관리 */}
|
||||||
{!onManualButtonClick && isManualModalOpen && (
|
{!onManualButtonClick && isManualModalOpen && (
|
||||||
|
|
|
||||||
|
|
@ -157,6 +157,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
const [uploadVideoTitle, setUploadVideoTitle] = useState<string>('');
|
||||||
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
const [uploadChannelName, setUploadChannelName] = useState<string>('');
|
||||||
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
|
const [uploadIsScheduled, setUploadIsScheduled] = useState(false);
|
||||||
|
const [uploadScheduledAt, setUploadScheduledAt] = useState<string | null>(null);
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 시 닫기
|
// 드롭다운 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -373,6 +374,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
// 예약 업로드: 폴링 없이 바로 완료 처리
|
// 예약 업로드: 폴링 없이 바로 완료 처리
|
||||||
setUploadStatus('completed');
|
setUploadStatus('completed');
|
||||||
setUploadProgress(100);
|
setUploadProgress(100);
|
||||||
|
setUploadScheduledAt(uploadResponse.scheduled_at ?? null);
|
||||||
onClose();
|
onClose();
|
||||||
resetForm();
|
resetForm();
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -432,6 +434,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
setUploadVideoTitle('');
|
setUploadVideoTitle('');
|
||||||
setUploadChannelName('');
|
setUploadChannelName('');
|
||||||
setUploadIsScheduled(false);
|
setUploadIsScheduled(false);
|
||||||
|
setUploadScheduledAt(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|
@ -461,6 +464,7 @@ const SocialPostingModal: React.FC<SocialPostingModalProps> = ({
|
||||||
youtubeUrl={uploadYoutubeUrl}
|
youtubeUrl={uploadYoutubeUrl}
|
||||||
errorMessage={uploadErrorMessage}
|
errorMessage={uploadErrorMessage}
|
||||||
isScheduled={uploadIsScheduled}
|
isScheduled={uploadIsScheduled}
|
||||||
|
scheduledAt={uploadScheduledAt}
|
||||||
onGoToCalendar={onGoToCalendar}
|
onGoToCalendar={onGoToCalendar}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -45,44 +45,7 @@ export const TUTORIAL_PAGE_GROUPS: string[][] = [
|
||||||
export const tutorialSteps: TutorialStepDef[] = [
|
export const tutorialSteps: TutorialStepDef[] = [
|
||||||
{
|
{
|
||||||
key: TUTORIAL_KEYS.LANDING,
|
key: TUTORIAL_KEYS.LANDING,
|
||||||
hints: [
|
hints: [],
|
||||||
{
|
|
||||||
targetSelector: '.hero-dropdown-trigger',
|
|
||||||
titleKey: 'tutorial.landing.dropdown.title',
|
|
||||||
descriptionKey: 'tutorial.landing.dropdown.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-input-wrapper',
|
|
||||||
titleKey: 'tutorial.landing.field.title',
|
|
||||||
descriptionKey: 'tutorial.landing.field.desc',
|
|
||||||
position: 'top',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-manual-button',
|
|
||||||
titleKey: 'tutorial.landing.manual.title',
|
|
||||||
descriptionKey: 'tutorial.landing.manual.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: false,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
targetSelector: '.hero-button',
|
|
||||||
titleKey: 'tutorial.landing.button.title',
|
|
||||||
descriptionKey: 'tutorial.landing.button.desc',
|
|
||||||
position: 'bottom',
|
|
||||||
clickToAdvance: true,
|
|
||||||
noSpotlight: true,
|
|
||||||
variant: 'bubble',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: TUTORIAL_KEYS.ASSET,
|
key: TUTORIAL_KEYS.ASSET,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ interface UploadProgressModalProps {
|
||||||
youtubeUrl?: string;
|
youtubeUrl?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
isScheduled?: boolean;
|
isScheduled?: boolean;
|
||||||
|
scheduledAt?: string | null;
|
||||||
onGoToCalendar?: () => void;
|
onGoToCalendar?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +28,7 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
youtubeUrl,
|
youtubeUrl,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
isScheduled = false,
|
isScheduled = false,
|
||||||
|
scheduledAt,
|
||||||
onGoToCalendar,
|
onGoToCalendar,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -148,6 +150,28 @@ const UploadProgressModal: React.FC<UploadProgressModalProps> = ({
|
||||||
{t('upload.viewOnYoutube')}
|
{t('upload.viewOnYoutube')}
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Scheduled conflict notice */}
|
||||||
|
{status === 'completed' && isScheduled && scheduledAt && (
|
||||||
|
<div className="upload-progress-schedule-notice">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<line x1="12" y1="8" x2="12" y2="12" />
|
||||||
|
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
{t('upload.scheduleConflict', {
|
||||||
|
time: new Date(scheduledAt).toLocaleString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
}),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,8 @@
|
||||||
"confirm": "OK",
|
"confirm": "OK",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"doNotClose": "Upload is in progress. Do not close this window.",
|
"doNotClose": "Upload is in progress. Do not close this window.",
|
||||||
"goToCalendar": "View in Calendar"
|
"goToCalendar": "View in Calendar",
|
||||||
|
"scheduleConflict": "Schedule adjusted to {{time}} due to a conflict."
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,8 @@
|
||||||
"confirm": "확인",
|
"confirm": "확인",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
"doNotClose": "업로드가 진행 중입니다. 창을 닫지 마세요.",
|
||||||
"goToCalendar": "캘린더에서 확인"
|
"goToCalendar": "캘린더에서 확인",
|
||||||
|
"scheduleConflict": "예약 시간이 충돌하여 {{time}}으로 조정되었습니다."
|
||||||
},
|
},
|
||||||
"landing": {
|
"landing": {
|
||||||
"hero": {
|
"hero": {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Sidebar from '../../components/Sidebar';
|
import Sidebar from '../../components/Sidebar';
|
||||||
|
import ShortformContent from './ShortformContent';
|
||||||
import AssetManagementContent from './AssetManagementContent';
|
import AssetManagementContent from './AssetManagementContent';
|
||||||
import SoundStudioContent from './SoundStudioContent';
|
import SoundStudioContent from './SoundStudioContent';
|
||||||
import CompletionContent from './CompletionContent';
|
import CompletionContent from './CompletionContent';
|
||||||
|
|
@ -54,6 +55,7 @@ interface GenerationFlowProps {
|
||||||
initialImageList?: string[];
|
initialImageList?: string[];
|
||||||
businessInfo?: BusinessInfo;
|
businessInfo?: BusinessInfo;
|
||||||
initialAnalysisData?: CrawlingResponse | null;
|
initialAnalysisData?: CrawlingResponse | null;
|
||||||
|
initialShortform?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 위저드 단계:
|
// 위저드 단계:
|
||||||
|
|
@ -69,7 +71,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
initialActiveItem = '대시보드',
|
initialActiveItem = '대시보드',
|
||||||
initialImageList = [],
|
initialImageList = [],
|
||||||
businessInfo,
|
businessInfo,
|
||||||
initialAnalysisData
|
initialAnalysisData,
|
||||||
|
initialShortform = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
// localStorage에서 저장된 상태 복원
|
// localStorage에서 저장된 상태 복원
|
||||||
|
|
@ -94,6 +97,9 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
|
|
||||||
// 초기 위저드 단계 결정
|
// 초기 위저드 단계 결정
|
||||||
const getInitialWizardStep = (): number => {
|
const getInitialWizardStep = (): number => {
|
||||||
|
// 숏폼 진입 플래그가 있으면 숏폼 화면(-3)으로 바로
|
||||||
|
if (initialShortform) return -3;
|
||||||
|
|
||||||
if (savedWizardStep !== null) {
|
if (savedWizardStep !== null) {
|
||||||
const step = parseInt(savedWizardStep, 10);
|
const step = parseInt(savedWizardStep, 10);
|
||||||
// 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우:
|
// 분석 데이터가 있는데 URL 입력(-2) 또는 로딩(-1) 단계로 저장된 경우:
|
||||||
|
|
@ -289,37 +295,6 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
|
// 테스트용 랜덤 m_id 생성 (99 ~ 300)
|
||||||
const generateRandomMId = (): number => {
|
|
||||||
return Math.floor(Math.random() * (300 - 99 + 1)) + 99;
|
|
||||||
};
|
|
||||||
|
|
||||||
// 테스트 데이터로 브랜드 분석 페이지 이동
|
|
||||||
const handleTestData = (data: CrawlingResponse) => {
|
|
||||||
const tagged = { ...data, m_id: generateRandomMId(), _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
goToWizardStep(0); // 브랜드 분석 결과로
|
|
||||||
};
|
|
||||||
|
|
||||||
// 언어 변경 시 테스트 데이터 다시 로드
|
|
||||||
useEffect(() => {
|
|
||||||
const saved = localStorage.getItem(ANALYSIS_DATA_KEY);
|
|
||||||
if (!saved) return;
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (!parsed._isTestData) return;
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
fetch(jsonFile)
|
|
||||||
.then(res => res.json())
|
|
||||||
.then((data: CrawlingResponse) => {
|
|
||||||
// 언어 변경 시에는 기존 m_id 유지
|
|
||||||
const tagged = { ...data, m_id: parsed.m_id || generateRandomMId(), _isTestData: true };
|
|
||||||
setAnalysisData(tagged);
|
|
||||||
localStorage.setItem(ANALYSIS_DATA_KEY, JSON.stringify(tagged));
|
|
||||||
})
|
|
||||||
.catch(err => console.error('Failed to reload test data:', err));
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, [i18n.language]);
|
|
||||||
|
|
||||||
// URL 분석 시작
|
// URL 분석 시작
|
||||||
const handleStartAnalysis = async (url: string) => {
|
const handleStartAnalysis = async (url: string) => {
|
||||||
|
|
@ -432,6 +407,13 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
// 새 프로젝트 만들기 - 단계별 컨텐츠 렌더링
|
||||||
const renderWizardContent = () => {
|
const renderWizardContent = () => {
|
||||||
switch (wizardStep) {
|
switch (wizardStep) {
|
||||||
|
case -3:
|
||||||
|
// 숏폼 생성 단계
|
||||||
|
return (
|
||||||
|
<ShortformContent
|
||||||
|
onComplete={refreshCredits}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case -2:
|
case -2:
|
||||||
// URL 입력 단계
|
// URL 입력 단계
|
||||||
return (
|
return (
|
||||||
|
|
@ -439,7 +421,7 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
onAnalyze={handleStartAnalysis}
|
onAnalyze={handleStartAnalysis}
|
||||||
onAutocomplete={handleAutocomplete}
|
onAutocomplete={handleAutocomplete}
|
||||||
onManualInput={handleManualInput}
|
onManualInput={handleManualInput}
|
||||||
onTestData={handleTestData}
|
onShortform={() => goToWizardStep(-3)}
|
||||||
error={analysisError}
|
error={analysisError}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -551,8 +533,8 @@ const GenerationFlow: React.FC<GenerationFlowProps> = ({
|
||||||
case '내 정보':
|
case '내 정보':
|
||||||
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
return <MyInfoContent initialTab={myInfoInitialTab} />;
|
||||||
case '새 프로젝트 만들기':
|
case '새 프로젝트 만들기':
|
||||||
// 브랜드 분석(0)과 로딩(-1)은 전체 화면으로 표시
|
// 브랜드 분석(0), 로딩(-1), 숏폼(-3)은 전체 화면으로 표시
|
||||||
if (wizardStep === 0 || wizardStep === -1) {
|
if (wizardStep === 0 || wizardStep === -1 || wizardStep === -3) {
|
||||||
return renderWizardContent();
|
return renderWizardContent();
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,478 @@
|
||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import { uploadImages, generateShortform, waitForShortformComplete, previewShortform } from '../../utils/api';
|
||||||
|
import { ShortformStatusResponse, VideoSpec } from '../../types/api';
|
||||||
|
|
||||||
|
type Kind = 'place' | 'product' | 'message';
|
||||||
|
type Step = 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
interface ShortformContentProps {
|
||||||
|
onComplete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_OPTIONS: { value: Kind; label: string; desc: string }[] = [
|
||||||
|
{ value: 'place', label: '장소', desc: '펜션·카페·식당·매장 등 공간' },
|
||||||
|
{ value: 'product', label: '물건', desc: '제품, 음식 등 시각적인 아이템' },
|
||||||
|
{ value: 'message', label: '메시지', desc: '축하·안부·홍보 등' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const STEP_META = [
|
||||||
|
{ label: '사진 업로드', sub: '4~5장' },
|
||||||
|
{ label: '정보 입력', sub: '몇 가지만 답하기' },
|
||||||
|
{ label: '영상 생성', sub: 'AI 카메라 + 자막' },
|
||||||
|
{ label: '완성', sub: '다운로드' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MIN_PHOTOS = 4;
|
||||||
|
const MIN_PHOTOS_LABEL = `${MIN_PHOTOS}장`;
|
||||||
|
|
||||||
|
const ShortformContent: React.FC<ShortformContentProps> = ({ onComplete }) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const dropRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 단계
|
||||||
|
const [step, setStep] = useState<Step>(1);
|
||||||
|
|
||||||
|
// Step 1 — 사진
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [previews, setPreviews] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Step 2 — 폼
|
||||||
|
const [kind, setKind] = useState<Kind>('place');
|
||||||
|
const [bizName, setBizName] = useState('');
|
||||||
|
const [selling, setSelling] = useState('');
|
||||||
|
const [addr, setAddr] = useState('');
|
||||||
|
const [price, setPrice] = useState('');
|
||||||
|
|
||||||
|
// Step 3/4 — 생성 상태
|
||||||
|
const [statusMsg, setStatusMsg] = useState('');
|
||||||
|
const [result, setResult] = useState<ShortformStatusResponse | null>(null);
|
||||||
|
const [errorMsg, setErrorMsg] = useState('');
|
||||||
|
|
||||||
|
// 자막 미리보기
|
||||||
|
const [preview, setPreview] = useState<VideoSpec | null>(null);
|
||||||
|
const [previewLoading, setPreviewLoading] = useState(false);
|
||||||
|
const [previewError, setPreviewError] = useState('');
|
||||||
|
|
||||||
|
// --- 사진 관리 ---
|
||||||
|
const addFiles = (newFiles: File[]) => {
|
||||||
|
const images = newFiles.filter(f => f.type.startsWith('image/'));
|
||||||
|
if (!images.length) return;
|
||||||
|
const newUrls = images.map(f => URL.createObjectURL(f));
|
||||||
|
setFiles(prev => [...prev, ...images]);
|
||||||
|
setPreviews(prev => [...prev, ...newUrls]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFile = (index: number) => {
|
||||||
|
URL.revokeObjectURL(previews[index]);
|
||||||
|
setFiles(prev => prev.filter((_, i) => i !== index));
|
||||||
|
setPreviews(prev => prev.filter((_, i) => i !== index));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); };
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
addFiles(Array.from(e.dataTransfer.files));
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 미리보기 필드 수정 ---
|
||||||
|
const patchPreview = (updater: (prev: NonNullable<typeof preview>) => NonNullable<typeof preview>) => {
|
||||||
|
setPreview(prev => prev ? updater(prev) : prev);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 자막 미리보기 ---
|
||||||
|
const handlePreview = async () => {
|
||||||
|
if (!selling.trim()) return;
|
||||||
|
setPreviewLoading(true);
|
||||||
|
setPreviewError('');
|
||||||
|
try {
|
||||||
|
const spec = await previewShortform({
|
||||||
|
kind,
|
||||||
|
biz_name: bizName.trim(),
|
||||||
|
selling: selling.trim(),
|
||||||
|
addr: addr.trim() || undefined,
|
||||||
|
price: price.trim() || undefined,
|
||||||
|
});
|
||||||
|
setPreview(spec);
|
||||||
|
} catch (err) {
|
||||||
|
setPreviewError(err instanceof Error ? err.message : '미리보기 생성에 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setPreviewLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 다운로드 ---
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const handleDownload = async (url: string) => {
|
||||||
|
if (isDownloading) return;
|
||||||
|
setIsDownloading(true);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = blobUrl;
|
||||||
|
link.download = `${bizName || 'shortform'}.mp4`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
URL.revokeObjectURL(blobUrl);
|
||||||
|
} catch {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = url;
|
||||||
|
link.download = `${bizName || 'shortform'}.mp4`;
|
||||||
|
link.target = '_blank';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
} finally {
|
||||||
|
setIsDownloading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 생성 실행 ---
|
||||||
|
const handleGenerate = async () => {
|
||||||
|
setStep(3);
|
||||||
|
setErrorMsg('');
|
||||||
|
setResult(null);
|
||||||
|
try {
|
||||||
|
setStatusMsg('사진 업로드 중...');
|
||||||
|
const uploadResp = await uploadImages([], files);
|
||||||
|
|
||||||
|
setStatusMsg('영상 생성 요청 중...');
|
||||||
|
await generateShortform({
|
||||||
|
task_id: uploadResp.task_id,
|
||||||
|
kind,
|
||||||
|
biz_name: bizName.trim(),
|
||||||
|
addr: addr.trim() || undefined,
|
||||||
|
price: price.trim() || undefined,
|
||||||
|
selling: selling.trim(),
|
||||||
|
spec: preview ?? undefined,
|
||||||
|
dry_run: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
setStatusMsg('AI 카메라 무브·자막 렌더링 중...');
|
||||||
|
const statusResp = await waitForShortformComplete(uploadResp.task_id, s => {
|
||||||
|
setStatusMsg(s === 'processing' ? 'AI 카메라 무브·자막 렌더링 중...' : s);
|
||||||
|
});
|
||||||
|
|
||||||
|
setResult(statusResp);
|
||||||
|
setStep(4);
|
||||||
|
onComplete?.();
|
||||||
|
} catch (err) {
|
||||||
|
setErrorMsg(err instanceof Error ? err.message : '생성 중 오류가 발생했습니다.');
|
||||||
|
setStep(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const step1Valid = files.length >= MIN_PHOTOS;
|
||||||
|
const step2Valid = bizName.trim() && selling.trim();
|
||||||
|
|
||||||
|
// ===================== 렌더 =====================
|
||||||
|
|
||||||
|
// 스텝 인디케이터
|
||||||
|
const StepBar = () => (
|
||||||
|
<div className="sf-step-bar">
|
||||||
|
{STEP_META.map((s, i) => {
|
||||||
|
const n = (i + 1) as Step;
|
||||||
|
const done = step > n;
|
||||||
|
const active = step === n;
|
||||||
|
return (
|
||||||
|
<div key={n} className={`sf-step-item ${active ? 'active' : ''} ${done ? 'done' : ''}`}>
|
||||||
|
<div className="sf-step-circle">
|
||||||
|
{done
|
||||||
|
? <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3"><polyline points="20 6 9 17 4 12"/></svg>
|
||||||
|
: n}
|
||||||
|
</div>
|
||||||
|
<div className="sf-step-label">
|
||||||
|
<span className="sf-step-name">{s.label}</span>
|
||||||
|
<span className="sf-step-sub">{s.sub}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 1: 사진 업로드 ----
|
||||||
|
if (step === 1) return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<div className="sf-hero">
|
||||||
|
<h1 className="sf-hero-title">사진 몇 장과 한마디면, <span className="sf-hero-accent">8초 홍보 영상</span>이 됩니다</h1>
|
||||||
|
<p className="sf-hero-desc">복잡한 분석 없이 — 사진을 올리고 몇 가지만 답하면, AI 카메라 무브와 자막을 입힌 세로형 숏폼이 완성됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<StepBar />
|
||||||
|
|
||||||
|
<div className="sf-card">
|
||||||
|
<h2 className="sf-card-title">사진 업로드</h2>
|
||||||
|
<p className="sf-card-desc">홍보할 장소나 물건 사진 4~5장. 전경 1장·디테일 2~3장을 섞으면 영상의 서사가 살아납니다.</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={dropRef}
|
||||||
|
className="sf-drop-zone"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div className="sf-drop-icon">
|
||||||
|
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
|
||||||
|
</div>
|
||||||
|
<p className="sf-drop-text">여기로 사진을 끌어다 놓거나 클릭</p>
|
||||||
|
<p className="sf-drop-hint">JPG·PNG·{MIN_PHOTOS_LABEL} 이상</p>
|
||||||
|
</div>
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" multiple style={{ display: 'none' }} onChange={e => { addFiles(Array.from(e.target.files || [])); e.target.value = ''; }} />
|
||||||
|
|
||||||
|
{previews.length > 0 && (
|
||||||
|
<div className="sf-photo-grid">
|
||||||
|
{previews.map((url, i) => (
|
||||||
|
<div key={i} className="sf-photo-item">
|
||||||
|
<span className="sf-photo-num">{i + 1}</span>
|
||||||
|
<img src={url} alt={`사진 ${i + 1}`} />
|
||||||
|
<button className="sf-photo-remove" onClick={() => removeFile(i)}>×</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sf-footer">
|
||||||
|
<div className="sf-footer-info">
|
||||||
|
<span className="sf-footer-count">{files.length}장</span>
|
||||||
|
{step1Valid
|
||||||
|
? <span className="sf-footer-status ok">업로드됨·준비 완료</span>
|
||||||
|
: <span className="sf-footer-status">{MIN_PHOTOS - files.length}장 더 추가해주세요</span>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="sf-btn-next"
|
||||||
|
disabled={!step1Valid}
|
||||||
|
onClick={() => setStep(2)}
|
||||||
|
>
|
||||||
|
다음: 정보 입력 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 2: 정보 입력 ----
|
||||||
|
if (step === 2) return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<StepBar />
|
||||||
|
|
||||||
|
<div className="sf-card">
|
||||||
|
<h2 className="sf-card-title">몇 가지만 알려주세요</h2>
|
||||||
|
<p className="sf-card-desc">사장님·마케터가 가장 잘 아는 정보로 카피를 만듭니다.</p>
|
||||||
|
|
||||||
|
{/* Kind */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">영상의 목적이 무엇인가요? <span className="sf-required">*</span></label>
|
||||||
|
<div className="sf-kind-grid">
|
||||||
|
{KIND_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
className={`sf-kind-card ${kind === opt.value ? 'active' : ''}`}
|
||||||
|
onClick={() => setKind(opt.value)}
|
||||||
|
>
|
||||||
|
<span className="sf-kind-label">{opt.label}</span>
|
||||||
|
<span className="sf-kind-desc">{opt.desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 업체명 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">업체·상품·메시지 제목 <span className="sf-required">*</span></label>
|
||||||
|
<input
|
||||||
|
className="sf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 스테이 머뭄"
|
||||||
|
value={bizName}
|
||||||
|
onChange={e => setBizName(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 셀링포인트 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">가장 자랑하고 싶은 한 가지 <span className="sf-required">*</span></label>
|
||||||
|
<textarea
|
||||||
|
className="sf-textarea"
|
||||||
|
placeholder="예: 열방 손님 없는 완전한 독채, 개별 정원까지"
|
||||||
|
value={selling}
|
||||||
|
onChange={e => setSelling(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<p className="sf-field-hint">이 한마디가 영상의 후킹 타이틀·핵심 메시지가 됩니다. 구체적일수록 좋아요.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자막 미리보기 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<div className="sf-subtitle-header">
|
||||||
|
<label className="sf-label">영상 자막·스크립트</label>
|
||||||
|
<button
|
||||||
|
className="sf-subtitle-btn"
|
||||||
|
disabled={!selling.trim() || previewLoading}
|
||||||
|
onClick={handlePreview}
|
||||||
|
>
|
||||||
|
{previewLoading ? '생성 중...' : preview ? '재생성하기' : '자막 생성하기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="sf-field-hint">위 한 가지를 적은 뒤 누르면, AI가 인트로·셀링포인트·감성 스토리·CTA 4가지를 만들어줍니다.</p>
|
||||||
|
{previewError && <p className="sf-field-error">{previewError}</p>}
|
||||||
|
{preview && (
|
||||||
|
<div className="sf-preview-spec">
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">인트로</span>
|
||||||
|
<input className="sf-preview-input" value={preview.hook.eyebrow}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, hook: { ...p.hook, eyebrow: e.target.value } }))} />
|
||||||
|
<input className="sf-preview-input sf-preview-input-bold" value={preview.hook.title}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, hook: { ...p.hook, title: e.target.value } }))} />
|
||||||
|
</div>
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">셀링포인트</span>
|
||||||
|
<div className="sf-preview-badge-inputs">
|
||||||
|
{preview.selling_point.items.slice(0, 3).map((item, i) => (
|
||||||
|
<input key={i} className="sf-preview-badge-input" value={item}
|
||||||
|
onChange={e => patchPreview(p => {
|
||||||
|
const items = [...p.selling_point.items];
|
||||||
|
items[i] = e.target.value;
|
||||||
|
return { ...p, selling_point: { items } };
|
||||||
|
})} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">감성 스토리</span>
|
||||||
|
{preview.brand_lines.slice(0, 2).map((line, i) => (
|
||||||
|
<input key={i} className="sf-preview-input" value={line}
|
||||||
|
onChange={e => patchPreview(p => {
|
||||||
|
const brand_lines = [...p.brand_lines];
|
||||||
|
brand_lines[i] = e.target.value;
|
||||||
|
return { ...p, brand_lines };
|
||||||
|
})} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="sf-preview-block">
|
||||||
|
<span className="sf-preview-tag">CTA</span>
|
||||||
|
<input className="sf-preview-input" value={preview.end_card.brand}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, end_card: { ...p.end_card, brand: e.target.value } }))} />
|
||||||
|
<input className="sf-preview-input" value={preview.end_card.location}
|
||||||
|
onChange={e => patchPreview(p => ({ ...p, end_card: { ...p.end_card, location: e.target.value } }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 주소 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">주소 또는 판매 사이트 URL <span className="sf-optional">(선택)</span></label>
|
||||||
|
<input
|
||||||
|
className="sf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 전북 군산시 절골길 18"
|
||||||
|
value={addr}
|
||||||
|
onChange={e => setAddr(e.target.value)}
|
||||||
|
maxLength={100}
|
||||||
|
/>
|
||||||
|
<p className="sf-field-hint">영상 엔드카드·해시태그에 활용됩니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가격 */}
|
||||||
|
<div className="sf-field">
|
||||||
|
<label className="sf-label">가격 정보 <span className="sf-optional">(선택)</span></label>
|
||||||
|
<input
|
||||||
|
className="sf-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="예: 1박 19만원~"
|
||||||
|
value={price}
|
||||||
|
onChange={e => setPrice(e.target.value)}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sf-footer sf-footer-two">
|
||||||
|
<button className="sf-btn-prev" onClick={() => setStep(1)}>← 사진 다시</button>
|
||||||
|
<button className="sf-btn-generate" disabled={!step2Valid} onClick={handleGenerate}>
|
||||||
|
영상 생성 →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMsg && (
|
||||||
|
<div className="sf-error-toast">{errorMsg}</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 3: 생성 중 ----
|
||||||
|
if (step === 3) return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<StepBar />
|
||||||
|
<div className="sf-generating">
|
||||||
|
<div className="sf-gen-orb-ring">
|
||||||
|
<div className="sf-gen-spinner-lg" />
|
||||||
|
<div className="sf-gen-icon">
|
||||||
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M15 10l4.553-2.069A1 1 0 0121 8.87V15.13a1 1 0 01-1.447.899L15 14M3 8a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V8z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h2 className="sf-gen-title">AI가 영상을 만들고 있어요</h2>
|
||||||
|
<p className="sf-gen-msg">{statusMsg || 'AI 카메라 무브·자막 렌더링 중...'}</p>
|
||||||
|
<p className="sf-gen-sub">보통 1~3분 정도 소요됩니다. 잠시만 기다려주세요.</p>
|
||||||
|
<div className="sf-gen-steps">
|
||||||
|
<div className={`sf-gen-step ${statusMsg.includes('업로드') ? 'active' : statusMsg ? 'done' : ''}`}>
|
||||||
|
<span className="sf-gen-step-dot" />사진 업로드
|
||||||
|
</div>
|
||||||
|
<div className={`sf-gen-step ${statusMsg.includes('요청') ? 'active' : statusMsg.includes('렌더링') || statusMsg.includes('완성') ? 'done' : ''}`}>
|
||||||
|
<span className="sf-gen-step-dot" />영상 생성 요청
|
||||||
|
</div>
|
||||||
|
<div className={`sf-gen-step ${statusMsg.includes('렌더링') ? 'active' : ''}`}>
|
||||||
|
<span className="sf-gen-step-dot" />AI 렌더링
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---- Step 4: 완성 ----
|
||||||
|
return (
|
||||||
|
<main className="sf-page">
|
||||||
|
<div className="sf-scroll-area">
|
||||||
|
<StepBar />
|
||||||
|
<div className="sf-result">
|
||||||
|
<p className="sf-result-label">✓ 영상이 완성됐습니다!</p>
|
||||||
|
{result?.result_url && (
|
||||||
|
<div className="sf-result-video-wrap">
|
||||||
|
<video className="sf-result-video" src={result.result_url} controls playsInline />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="sf-result-actions">
|
||||||
|
{result?.result_url && (
|
||||||
|
<button
|
||||||
|
className="sf-download-btn"
|
||||||
|
disabled={isDownloading}
|
||||||
|
onClick={() => handleDownload(result.result_url!)}
|
||||||
|
>
|
||||||
|
{isDownloading ? '다운로드 중...' : '다운로드'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="sf-btn-prev" onClick={() => { setStep(1); setFiles([]); setPreviews([]); setBizName(''); setSelling(''); setAddr(''); setPrice(''); }}>
|
||||||
|
새 영상 만들기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ShortformContent;
|
||||||
|
|
@ -1,39 +1,16 @@
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutocompleteRequest } from '../../utils/api';
|
import { AutocompleteRequest } from '../../utils/api';
|
||||||
import { CrawlingResponse } from '../../types/api';
|
|
||||||
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
import SearchInputForm, { SearchType } from '../../components/SearchInputForm';
|
||||||
|
|
||||||
// 환경변수에서 테스트 모드 확인
|
|
||||||
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
|
||||||
|
|
||||||
interface UrlInputContentProps {
|
interface UrlInputContentProps {
|
||||||
onAnalyze: (value: string, type?: SearchType) => void;
|
onAnalyze: (value: string, type?: SearchType) => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
onAutocomplete?: (data: AutocompleteRequest) => void;
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
onManualInput?: (businessName: string, address: string) => void;
|
||||||
onTestData?: (data: CrawlingResponse) => void;
|
onShortform?: () => void;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, error }) => {
|
const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomplete, onManualInput, onShortform, error }) => {
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
|
||||||
|
|
||||||
const handleTestData = async () => {
|
|
||||||
if (!onTestData) return;
|
|
||||||
setIsLoadingTest(true);
|
|
||||||
try {
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
const response = await fetch(jsonFile);
|
|
||||||
const data: CrawlingResponse = await response.json();
|
|
||||||
onTestData(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.error('테스트 데이터 로드 실패:', err);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTest(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="url-input-container">
|
<div className="url-input-container">
|
||||||
<div className="url-input-content">
|
<div className="url-input-content">
|
||||||
|
|
@ -46,20 +23,10 @@ const UrlInputContent: React.FC<UrlInputContentProps> = ({ onAnalyze, onAutocomp
|
||||||
onAnalyze={onAnalyze}
|
onAnalyze={onAnalyze}
|
||||||
onAutocomplete={onAutocomplete}
|
onAutocomplete={onAutocomplete}
|
||||||
onManualInput={onManualInput}
|
onManualInput={onManualInput}
|
||||||
|
onShortform={onShortform}
|
||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테스트 버튼 (VITE_IS_TESTPAGE=true일 때만 표시) */}
|
|
||||||
{isTestPage && onTestData && (
|
|
||||||
<button
|
|
||||||
onClick={handleTestData}
|
|
||||||
disabled={isLoadingTest}
|
|
||||||
className="test-data-button"
|
|
||||||
>
|
|
||||||
{isLoadingTest ? t('urlInput.testDataLoading') : t('urlInput.testData')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,4 @@
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AutocompleteRequest, isLoggedIn } from '../../utils/api';
|
|
||||||
import { CrawlingResponse } from '../../types/api';
|
|
||||||
import { useTutorial } from '../../components/Tutorial/useTutorial';
|
|
||||||
import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps';
|
|
||||||
import TutorialOverlay from '../../components/Tutorial/TutorialOverlay';
|
|
||||||
import BusinessNameInputModal from '../../components/BusinessNameInputModal';
|
|
||||||
import LoginPromptModal from '../../components/LoginPromptModal';
|
|
||||||
import SearchInputForm from '../../components/SearchInputForm';
|
|
||||||
import { SearchType } from '../../components/SearchInputForm';
|
|
||||||
|
|
||||||
// 환경변수에서 테스트 모드 확인
|
|
||||||
const isTestPage = import.meta.env.VITE_IS_TESTPAGE === 'true';
|
|
||||||
|
|
||||||
// Orb configuration with movement zones to prevent overlap
|
// Orb configuration with movement zones to prevent overlap
|
||||||
interface OrbConfig {
|
interface OrbConfig {
|
||||||
|
|
@ -37,50 +23,14 @@ const orbConfigs: OrbConfig[] = [
|
||||||
];
|
];
|
||||||
|
|
||||||
interface HeroSectionProps {
|
interface HeroSectionProps {
|
||||||
onAnalyze?: (value: string, type?: SearchType) => void;
|
onLogin?: () => void;
|
||||||
onAutocomplete?: (data: AutocompleteRequest) => void;
|
|
||||||
onManualInput?: (businessName: string, address: string) => void;
|
|
||||||
onTestData?: (data: CrawlingResponse) => void;
|
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
error?: string | null;
|
|
||||||
scrollProgress?: number;
|
scrollProgress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, onManualInput, onTestData, onNext, error: externalError, scrollProgress = 0 }) => {
|
const HeroSection: React.FC<HeroSectionProps> = ({ onLogin, onNext, scrollProgress = 0 }) => {
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const [isManualModalOpen, setIsManualModalOpen] = useState(false);
|
|
||||||
const [isLoginPromptOpen, setIsLoginPromptOpen] = useState(false);
|
|
||||||
const [testError, setTestError] = useState('');
|
|
||||||
const [isLoadingTest, setIsLoadingTest] = useState(false);
|
|
||||||
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
const orbRefs = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
const animationRefs = useRef<number[]>([]);
|
const animationRefs = useRef<number[]>([]);
|
||||||
const tutorial = useTutorial();
|
|
||||||
|
|
||||||
// 첫 방문 시 랜딩 튜토리얼 시작
|
|
||||||
useEffect(() => {
|
|
||||||
if (!tutorial.hasSeen(TUTORIAL_KEYS.LANDING)) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
tutorial.startTutorial(TUTORIAL_KEYS.LANDING);
|
|
||||||
}, 800);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 테스트 데이터 로드 핸들러
|
|
||||||
const handleTestData = async () => {
|
|
||||||
if (!onTestData) return;
|
|
||||||
setIsLoadingTest(true);
|
|
||||||
try {
|
|
||||||
const jsonFile = i18n.language === 'en' ? '/example_analysis_en.json' : '/example_analysis.json';
|
|
||||||
const response = await fetch(jsonFile);
|
|
||||||
const data: CrawlingResponse = await response.json();
|
|
||||||
onTestData(data);
|
|
||||||
} catch {
|
|
||||||
setTestError(t('landing.hero.testDataLoadFailed'));
|
|
||||||
} finally {
|
|
||||||
setIsLoadingTest(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Orb 랜덤 이동 애니메이션
|
// Orb 랜덤 이동 애니메이션
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -168,63 +118,20 @@ const HeroSection: React.FC<HeroSectionProps> = ({ onAnalyze, onAutocomplete, on
|
||||||
className="hero-logo"
|
className="hero-logo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SearchInputForm
|
<button className="hero-login-btn" onClick={onLogin}>
|
||||||
onAnalyze={onAnalyze}
|
로그인
|
||||||
onAutocomplete={onAutocomplete}
|
</button>
|
||||||
onManualInput={onManualInput}
|
|
||||||
onManualButtonClick={() => {
|
|
||||||
if (isLoggedIn()) {
|
|
||||||
setIsManualModalOpen(true);
|
|
||||||
} else {
|
|
||||||
setIsLoginPromptOpen(true);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
error={externalError || testError || null}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Indicator */}
|
{/* Footer Indicator */}
|
||||||
<button onClick={onNext} className="scroll-indicator">
|
<button onClick={onNext} className="scroll-indicator">
|
||||||
<span className="scroll-indicator-text">{t('landing.hero.scrollMore')}</span>
|
<span className="scroll-indicator-text">더 알아보기</span>
|
||||||
<div className="scroll-indicator-icon">
|
<div className="scroll-indicator-icon">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M7 10l5 5 5-5" />
|
<path d="M7 10l5 5 5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 테스트 버튼 */}
|
|
||||||
{isTestPage && onTestData && (
|
|
||||||
<button onClick={handleTestData} disabled={isLoadingTest} className="test-data-button">
|
|
||||||
{isLoadingTest ? t('landing.hero.testDataLoading') : t('landing.hero.testData')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tutorial.isActive && !isManualModalOpen && (
|
|
||||||
<TutorialOverlay
|
|
||||||
hints={tutorial.hints}
|
|
||||||
currentIndex={tutorial.currentHintIndex}
|
|
||||||
onNext={tutorial.nextHint}
|
|
||||||
onPrev={tutorial.prevHint}
|
|
||||||
onSkip={tutorial.skipTutorial}
|
|
||||||
groupProgress={tutorial.groupProgress}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isManualModalOpen && (
|
|
||||||
<BusinessNameInputModal
|
|
||||||
onClose={() => setIsManualModalOpen(false)}
|
|
||||||
onSubmit={(businessName, address) => {
|
|
||||||
if (tutorial.isActive) tutorial.nextHint();
|
|
||||||
setIsManualModalOpen(false);
|
|
||||||
onManualInput?.(businessName, address);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isLoginPromptOpen && (
|
|
||||||
<LoginPromptModal onClose={() => setIsLoginPromptOpen(false)} />
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,58 @@ export interface ImageUploadResponse {
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 숏폼 생성 요청
|
||||||
|
export interface ShortformGenerateRequest {
|
||||||
|
task_id: string;
|
||||||
|
kind: string;
|
||||||
|
biz_name: string;
|
||||||
|
addr?: string;
|
||||||
|
price?: string;
|
||||||
|
selling: string;
|
||||||
|
spec?: VideoSpec;
|
||||||
|
dry_run?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 생성 응답
|
||||||
|
export interface ShortformGenerateResponse {
|
||||||
|
task_id: string;
|
||||||
|
video_id: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 상태 조회 응답
|
||||||
|
export interface ShortformStatusResponse {
|
||||||
|
task_id: string;
|
||||||
|
status: 'processing' | 'completed' | 'failed';
|
||||||
|
result_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 미리보기 요청
|
||||||
|
export interface ShortformPreviewRequest {
|
||||||
|
kind: string;
|
||||||
|
biz_name: string;
|
||||||
|
selling: string;
|
||||||
|
addr?: string;
|
||||||
|
price?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 미리보기 응답 (VideoSpec)
|
||||||
|
export interface VideoSpec {
|
||||||
|
hook: {
|
||||||
|
eyebrow: string;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
selling_point: {
|
||||||
|
items: string[];
|
||||||
|
};
|
||||||
|
brand_lines: string[];
|
||||||
|
end_card: {
|
||||||
|
brand: string;
|
||||||
|
location: string;
|
||||||
|
};
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
// 언어 매핑
|
// 언어 매핑
|
||||||
export const LANGUAGE_MAP: Record<string, string> = {
|
export const LANGUAGE_MAP: Record<string, string> = {
|
||||||
'한국어': 'Korean',
|
'한국어': 'Korean',
|
||||||
|
|
@ -412,9 +464,11 @@ export interface SocialUploadRequest {
|
||||||
// 소셜 업로드 응답
|
// 소셜 업로드 응답
|
||||||
export interface SocialUploadResponse {
|
export interface SocialUploadResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
upload_id: string;
|
upload_id: number;
|
||||||
status: 'pending' | 'uploading' | 'completed' | 'failed';
|
platform: string;
|
||||||
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
|
scheduled_at?: string | null; // 예약 업로드 충돌 시 실제 예약 시간 반환
|
||||||
}
|
}
|
||||||
|
|
||||||
// 소셜 업로드 상태 조회 응답
|
// 소셜 업로드 상태 조회 응답
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ import {
|
||||||
CommentsResponse,
|
CommentsResponse,
|
||||||
CommentItem,
|
CommentItem,
|
||||||
LikeToggleResponse,
|
LikeToggleResponse,
|
||||||
|
ShortformGenerateRequest,
|
||||||
|
ShortformGenerateResponse,
|
||||||
|
ShortformStatusResponse,
|
||||||
|
ShortformPreviewRequest,
|
||||||
|
VideoSpec,
|
||||||
} from '../types/api';
|
} from '../types/api';
|
||||||
|
|
||||||
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
|
||||||
|
|
@ -557,6 +562,85 @@ export async function waitForVideoComplete(
|
||||||
return poll();
|
return poll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 숏폼 생성 API
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
// 숏폼 생성 요청 (POST /shortform/generate, 202 Accepted)
|
||||||
|
export async function generateShortform(req: ShortformGenerateRequest): Promise<ShortformGenerateResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/shortform/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 상태 조회 (GET /shortform/status/{task_id})
|
||||||
|
export async function getShortformStatus(taskId: string): Promise<ShortformStatusResponse> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/shortform/status/${taskId}`, {
|
||||||
|
method: 'GET',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 완료까지 폴링 (10분 타임아웃, 3초 간격)
|
||||||
|
const SHORTFORM_POLL_TIMEOUT = 10 * 60 * 1000;
|
||||||
|
const SHORTFORM_POLL_INTERVAL = 3000;
|
||||||
|
|
||||||
|
export async function waitForShortformComplete(
|
||||||
|
taskId: string,
|
||||||
|
onStatusChange?: (status: string) => void
|
||||||
|
): Promise<ShortformStatusResponse> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const poll = async (): Promise<ShortformStatusResponse> => {
|
||||||
|
if (Date.now() - startTime > SHORTFORM_POLL_TIMEOUT) {
|
||||||
|
throw new Error('TIMEOUT');
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusResponse = await getShortformStatus(taskId);
|
||||||
|
onStatusChange?.(statusResponse.status);
|
||||||
|
|
||||||
|
if (statusResponse.status === 'completed') {
|
||||||
|
return statusResponse;
|
||||||
|
} else if (statusResponse.status === 'failed') {
|
||||||
|
throw new Error('Shortform generation failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, SHORTFORM_POLL_INTERVAL));
|
||||||
|
return poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
return poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숏폼 자막 미리보기 (POST /shortform/preview)
|
||||||
|
export async function previewShortform(req: ShortformPreviewRequest): Promise<VideoSpec> {
|
||||||
|
const response = await authenticatedFetch(`${API_URL}/shortform/preview`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(req),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json().catch(() => ({}));
|
||||||
|
throw new Error((err as { detail?: string }).detail || '미리보기 생성에 실패했습니다.');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// 카카오 인증 API
|
// 카카오 인증 API
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue