o2o-ado2-short-form/webapp/index.html

590 lines
35 KiB
HTML

<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>ADO2 Hookit — 8초 숏폼 생성기</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
/* ===== ADO2 Design Tokens (from tokens.css) ===== */
@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/static/pretendard.min.css");
:root{
--ado-purple: rgb(174,114,249);
--ado-purple-light: rgb(207,171,251);
--ado-mint: rgb(148,251,224);
--bg-0: rgb(1,25,26);
--bg-1: rgb(0,34,36);
--bg-2: rgb(25,38,40);
--bg-3: rgb(1,57,59);
--stroke-1: rgb(50,75,80);
--stroke-2: rgb(37,57,60);
--text-white: rgb(255,255,255);
--text-teal-1: rgb(155,202,204);
--text-teal-2: rgb(106,176,179);
--text-teal-3: rgb(101,126,131);
--text-mute-1: rgb(178,191,193);
--text-mute-2: rgb(217,223,224);
--r-md:12px; --r-lg:20px; --r-pill:999px;
--font:"Pretendard","Apple SD Gothic Neo",-apple-system,BlinkMacSystemFont,"Segoe UI",system-ui,sans-serif;
--shadow-card:0 1px 0 rgba(0,0,0,0.4),0 12px 32px -8px rgba(0,0,0,0.45);
}
*{box-sizing:border-box;}
html,body{margin:0;background:var(--bg-1);color:var(--text-white);font-family:var(--font);-webkit-font-smoothing:antialiased;letter-spacing:-0.006em;word-break:keep-all;overflow-wrap:anywhere;}
/* ===== Layout: centered, no cluttered sidebar ===== */
.topbar{
position:sticky;top:0;z-index:10;
height:72px;display:flex;align-items:center;justify-content:space-between;
padding:0 32px;background:rgba(0,34,36,0.82);backdrop-filter:blur(12px);
border-bottom:1px solid var(--stroke-2);
}
.wordmark{font:800 22px/1 var(--font);letter-spacing:-0.02em;}
.wordmark em{font-style:normal;background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple-light));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.step-count{font:700 15px/1 var(--font);color:var(--text-teal-2);}
.step-count b{color:var(--text-white);}
.wrap{max-width:1000px;margin:0 auto;padding:48px 32px 120px;}
/* ===== Hero ===== */
.hero{margin-bottom:48px;}
.eyebrow{display:inline-flex;align-items:center;gap:10px;font:700 14px/1 var(--font);letter-spacing:0.14em;text-transform:uppercase;color:var(--ado-mint);margin-bottom:24px;}
.eyebrow::before{content:"";width:9px;height:9px;border-radius:999px;background:var(--ado-mint);box-shadow:0 0 12px var(--ado-mint);}
h1.hero-title{font:800 clamp(40px,6vw,68px)/1.05 var(--font);letter-spacing:-0.035em;margin:0;}
h1.hero-title em{font-style:normal;background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple-light));-webkit-background-clip:text;background-clip:text;-webkit-text-fill-color:transparent;}
.hero-lede{font:500 20px/1.6 var(--font);color:var(--text-teal-1);margin:28px 0 0;max-width:680px;}
/* ===== Stepper (4) ===== */
.stepper{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:48px;}
.step{display:flex;align-items:center;gap:16px;padding:20px 22px;background:var(--bg-2);border:1px solid var(--stroke-1);border-radius:16px;transition:.15s;}
.step.active{border-color:var(--ado-purple);background:linear-gradient(180deg,rgba(174,114,249,0.10),rgba(174,114,249,0.02));}
.step .num{width:42px;height:42px;flex:0 0 42px;border-radius:999px;display:flex;align-items:center;justify-content:center;font:800 18px/1 var(--font);background:rgba(255,255,255,0.06);color:var(--text-teal-1);}
.step.active .num{background:var(--ado-purple);color:#fff;box-shadow:0 0 0 6px rgba(174,114,249,0.18);}
.step.done .num{background:var(--ado-mint);color:var(--bg-1);}
.step b{display:block;font:700 17px/1.2 var(--font);color:var(--text-mute-2);}
.step.active b{color:#fff;}
.step span{display:block;font:500 14px/1.2 var(--font);color:var(--text-teal-3);margin-top:5px;}
@media(max-width:760px){.stepper{grid-template-columns:repeat(2,1fr);} h1.hero-title{font-size:38px;}}
/* ===== Card ===== */
.card{background:var(--bg-2);border:1px solid var(--stroke-1);border-radius:24px;padding:44px;box-shadow:var(--shadow-card);}
.card h2{font:800 32px/1.15 var(--font);margin:0 0 12px;letter-spacing:-0.018em;}
.card .sub{font:500 17px/1.55 var(--font);color:var(--text-teal-1);margin:0 0 36px;max-width:620px;}
/* ===== Upload ===== */
.upload{height:260px;border:2px dashed var(--stroke-1);border-radius:20px;background:var(--bg-1);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;cursor:pointer;transition:.15s;}
.upload:hover,.upload.drag{border-color:var(--ado-mint);background:rgba(148,251,224,0.04);}
.upload .ico{width:64px;height:64px;border-radius:999px;background:rgba(148,251,224,0.12);display:flex;align-items:center;justify-content:center;color:var(--ado-mint);}
.upload b{font:700 20px/1.2 var(--font);}
.upload span{font:500 15px/1.4 var(--font);color:var(--text-teal-1);}
.thumbs{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-top:24px;}
@media(max-width:640px){.thumbs{grid-template-columns:repeat(2,1fr);}}
.thumb{position:relative;aspect-ratio:1;border-radius:14px;overflow:hidden;border:1px solid var(--stroke-1);background:var(--bg-3);}
.thumb img{width:100%;height:100%;object-fit:cover;display:block;}
.thumb .n{position:absolute;top:8px;left:8px;height:24px;padding:0 10px;border-radius:999px;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);font:700 13px/24px var(--font);}
.thumb .x{position:absolute;top:8px;right:8px;width:26px;height:26px;border-radius:999px;background:rgba(0,0,0,0.6);backdrop-filter:blur(8px);border:none;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;}
/* ===== Fields ===== */
.fields{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:28px;}
@media(max-width:640px){.fields{grid-template-columns:1fr;}}
.field label{display:block;font:700 15px/1.3 var(--font);letter-spacing:0.04em;color:var(--text-mute-2);margin-bottom:12px;}
.field input{width:100%;height:58px;padding:0 20px;background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:12px;color:#fff;font:500 16px/1 var(--font);outline:none;}
.field input:focus{border-color:var(--ado-mint);box-shadow:0 0 0 3px rgba(148,251,224,0.14);}
.field textarea{width:100%;min-height:120px;padding:16px 18px;background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:12px;color:#fff;font:500 16px/1.6 var(--font);outline:none;resize:vertical;}
.field textarea:focus{border-color:var(--ado-mint);box-shadow:0 0 0 3px rgba(148,251,224,0.14);}
.field .hint{font:500 14px/1.5 var(--font);color:var(--text-teal-3);margin-top:10px;}
.form-stack{display:flex;flex-direction:column;gap:24px;}
.kind-tiles{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;}
@media(max-width:640px){.kind-tiles{grid-template-columns:1fr;}}
.tile{border:1.5px solid var(--stroke-1);background:var(--bg-1);border-radius:16px;padding:24px;cursor:pointer;transition:.15s;}
.tile:hover{border-color:rgba(148,251,224,0.4);}
.tile.sel{border-color:var(--ado-mint);background:rgba(148,251,224,0.05);}
.tile b{font:800 20px/1.2 var(--font);display:block;}
.tile span{font:500 14px/1.4 var(--font);color:var(--text-teal-1);margin-top:8px;display:block;}
.req{color:var(--ado-mint);}
.opt{color:var(--text-teal-3);font-weight:500;margin-left:6px;text-transform:none;letter-spacing:0;}
/* ===== Action bar ===== */
.actionbar{margin-top:28px;display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--bg-2);border:1px solid var(--stroke-1);border-radius:20px;padding:22px 28px;}
.actionbar .stat b{font:800 22px/1 var(--font);}
.actionbar .stat span{display:block;font:600 13px/1 var(--font);color:var(--text-teal-3);letter-spacing:0.06em;text-transform:uppercase;margin-top:6px;}
.btn{height:58px;padding:0 32px;border-radius:999px;border:none;cursor:pointer;font:700 17px/1 var(--font);display:inline-flex;align-items:center;gap:10px;transition:.15s;}
.btn-primary{background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple));color:var(--bg-0);}
.btn-primary:disabled{opacity:0.4;cursor:not-allowed;}
.btn-ghost{background:transparent;border:1.5px solid var(--stroke-1);color:var(--text-mute-2);}
.btn-ghost:hover{border-color:var(--ado-mint);color:#fff;}
/* ===== Analysis ===== */
.profile-banner{display:flex;align-items:center;gap:24px;padding:28px;border-radius:18px;background:linear-gradient(135deg,rgba(174,114,249,0.16),rgba(148,251,224,0.06));border:1px solid rgba(174,114,249,0.3);margin-bottom:32px;}
.profile-banner .score{width:88px;height:88px;flex:0 0 88px;border-radius:999px;border:3px solid var(--ado-mint);display:flex;flex-direction:column;align-items:center;justify-content:center;}
.profile-banner .score b{font:800 30px/1 var(--font);}
.profile-banner .score span{font:600 11px/1 var(--font);color:var(--text-teal-2);margin-top:4px;letter-spacing:0.08em;}
.profile-banner .pinfo b{font:800 24px/1.2 var(--font);display:block;}
.profile-banner .pinfo p{font:500 16px/1.5 var(--font);color:var(--text-teal-1);margin:8px 0 0;}
.sp-row{display:flex;align-items:center;gap:18px;padding:16px 0;border-top:1px solid var(--stroke-2);}
.sp-row:first-child{border-top:none;}
.sp-row .sp-name{flex:0 0 200px;font:700 17px/1.3 var(--font);}
.sp-row .bar{flex:1;height:8px;border-radius:999px;background:rgba(255,255,255,0.06);overflow:hidden;}
.sp-row .bar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--ado-mint),var(--ado-purple));}
.sp-row .sp-score{flex:0 0 48px;text-align:right;font:800 18px/1 var(--font);font-variant-numeric:tabular-nums;}
.chips{display:flex;flex-wrap:wrap;gap:10px;margin-top:24px;}
.chip{height:38px;padding:0 18px;border-radius:999px;display:inline-flex;align-items:center;font:600 15px/1 var(--font);background:rgba(148,251,224,0.10);border:1px solid rgba(148,251,224,0.3);color:var(--ado-mint);}
/* ===== Generating ===== */
.agent{display:grid;grid-template-columns:1fr 220px;gap:28px;align-items:center;padding:26px 0;border-top:1px solid var(--stroke-2);}
.agent:first-child{border-top:none;}
.agent .aname b{font:700 19px/1.2 var(--font);display:block;}
.agent .aname span{font:500 15px/1.3 var(--font);color:var(--text-teal-1);margin-top:6px;display:block;}
.agent .pbar{height:10px;border-radius:999px;background:rgba(255,255,255,0.06);overflow:hidden;}
.agent .pbar i{display:block;height:100%;border-radius:999px;background:linear-gradient(90deg,var(--ado-mint),var(--ado-purple));transition:width .5s cubic-bezier(.4,0,.2,1);}
.agent .pct{font:800 16px/1 var(--font);color:var(--text-teal-2);text-align:right;margin-top:8px;font-variant-numeric:tabular-nums;}
.dot{width:9px;height:9px;border-radius:999px;display:inline-block;margin-right:10px;}
.dot.run{background:var(--ado-purple);box-shadow:0 0 0 4px rgba(174,114,249,0.18);}
.dot.done{background:var(--ado-mint);}
.dot.queue{background:rgba(255,255,255,0.2);}
/* ===== Result ===== */
.result-grid{display:grid;grid-template-columns:320px 1fr;gap:48px;align-items:start;}
@media(max-width:760px){.result-grid{grid-template-columns:1fr;}}
.phone{width:300px;margin:0 auto;background:#0a1414;border-radius:40px;padding:10px;box-shadow:0 30px 70px -20px rgba(0,0,0,0.6);}
.phone video{width:100%;border-radius:30px;display:block;background:#000;aspect-ratio:9/16;object-fit:cover;}
.res-h{font:800 28px/1.2 var(--font);margin:0 0 8px;}
.res-sub{font:500 16px/1.5 var(--font);color:var(--text-teal-1);margin:0 0 28px;}
.caption-box{background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:16px;padding:24px;font:500 16px/1.7 var(--font);color:var(--text-mute-2);white-space:pre-wrap;}
.res-actions{display:flex;gap:14px;margin-top:24px;flex-wrap:wrap;}
.spinner{width:18px;height:18px;border-radius:999px;border:2.5px solid rgba(148,251,224,0.2);border-top-color:var(--ado-mint);animation:spin .9s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
@keyframes pulse{0%,100%{opacity:.5;}50%{opacity:1;}}
.pulse{animation:pulse 1.6s ease-in-out infinite;}
.note{font:500 14px/1.6 var(--font);color:var(--text-teal-3);margin-top:20px;}
/* Brand logo */
.brand-logo{height:30px;width:auto;display:block;}
/* Hero selling-point field (emphasized) */
.field-hero label{color:var(--ado-mint);}
.field-hero textarea{min-height:64px;border-color:rgba(148,251,224,0.35);} /* 절반 축소 */
/* Tone auto-match chip */
.tone-chip{display:inline-flex;align-items:center;gap:10px;height:40px;padding:0 18px;border-radius:999px;background:rgba(174,114,249,0.16);border:1px solid rgba(174,114,249,0.4);color:var(--ado-purple-light);font:700 15px/1 var(--font);}
.tone-chip i{width:8px;height:8px;border-radius:999px;background:var(--ado-purple-light);box-shadow:0 0 10px var(--ado-purple-light);}
/* Result: editable caption + actions */
.caption-edit{width:100%;min-height:200px;padding:20px 22px;background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:16px;color:var(--text-mute-2);font:500 16px/1.7 var(--font);outline:none;resize:vertical;}
.caption-edit:focus{border-color:var(--ado-mint);box-shadow:0 0 0 3px rgba(148,251,224,0.14);}
.cap-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px;}
.cap-head .lbl{font:700 13px/1 var(--font);letter-spacing:0.08em;text-transform:uppercase;color:var(--text-mute-1);}
.copy-btn{background:transparent;border:none;cursor:pointer;color:var(--ado-mint);font:700 14px/1 var(--font);display:inline-flex;align-items:center;gap:6px;}
.res-badges{display:flex;gap:10px;margin-bottom:20px;flex-wrap:wrap;}
/* 자막 스크립트 생성기 */
.script-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px;}
.btn-mini{height:42px;padding:0 20px;border-radius:999px;border:none;cursor:pointer;font:700 15px/1 var(--font);background:linear-gradient(135deg,var(--ado-mint),var(--ado-purple));color:var(--bg-0);display:inline-flex;align-items:center;gap:8px;}
.btn-mini:disabled{opacity:0.4;cursor:not-allowed;}
.script-card{background:var(--bg-1);border:1px solid var(--stroke-1);border-radius:14px;padding:16px 18px;margin-top:12px;}
.sc-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:10px;}
.sc-label{font:700 12px/1 var(--font);letter-spacing:0.08em;text-transform:uppercase;color:var(--ado-mint);}
.sc-actions{display:flex;gap:8px;}
.sc-actions button{background:transparent;border:1px solid var(--stroke-1);border-radius:999px;height:30px;padding:0 14px;color:var(--text-mute-2);font:600 13px/1 var(--font);cursor:pointer;}
.sc-actions button:hover{border-color:var(--ado-mint);color:#fff;}
.sc-text{font:500 16px/1.6 var(--font);color:var(--text-mute-2);margin:0;white-space:pre-wrap;}
.script-card textarea{width:100%;min-height:70px;padding:12px 14px;background:var(--bg-0);border:1px solid var(--ado-mint);border-radius:10px;color:#fff;font:500 16px/1.6 var(--font);outline:none;resize:vertical;}
</style>
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.production.min.js"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js"></script>
<script type="text/babel">
const { useState, useRef, useEffect } = React;
/* ===== Minimal icon set (7) — 단순 라인 아이콘만 ===== */
const Icon = {
Image: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"><rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="M3 17l5-4 4 3 3-2 6 4"/></svg>,
Check: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4"><path d="M5 12l5 5 9-11"/></svg>,
Right: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 12h14M13 6l6 6-6 6"/></svg>,
Left: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 12H5M11 6l-6 6 6 6"/></svg>,
Play: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="currentColor"><path d="M7 5l13 7-13 7V5z"/></svg>,
Close: (p) => <svg width={p.size||16} height={p.size||16} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M5 5l14 14M19 5L5 19"/></svg>,
Download: (p) => <svg width={p.size||20} height={p.size||20} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8"><path d="M12 3v12M7 11l5 5 5-5M5 21h14"/></svg>,
};
/* ===== Config ===== */
// 같은 출처(FastAPI가 이 페이지를 서빙)면 "" 로 두면 /api/generate 로 전송.
// 백엔드 미연결 시 fetch 실패 → 자동으로 데모 결과로 폴백.
const API_BASE = "";
const DEMO_VIDEO = "./demo/mumum.mp4";
const STEPS = [
{ k:"setup", n:"1", b:"사진 업로드", s:"4~5장" },
{ k:"interview", n:"2", b:"정보 입력", s:"몇 가지만 답하기" },
{ k:"generating", n:"3", b:"영상 생성", s:"AI 카메라 + 자막" },
{ k:"result", n:"4", b:"완성", s:"다운로드" },
];
const SCRIPT_BLOCKS = [
{ key:"intro", label:"인트로" },
{ key:"selling", label:"셀링포인트" },
{ key:"story", label:"감성 스토리" },
{ key:"cta", label:"CTA" },
];
function App(){
const [step, setStep] = useState("setup");
const [photos, setPhotos] = useState([]); // {url, name}
const [drag, setDrag] = useState(false);
// 인터뷰 답변 (사람이 직접 = 인텔리전스)
const [kind, setKind] = useState(""); // "place" | "product"
const [bizName, setBizName] = useState("");
const [addr, setAddr] = useState(""); // 주소 또는 판매 사이트 URL
const [price, setPrice] = useState("");
const [selling, setSelling] = useState(""); // 강력한 한방 셀링포인트
const [prog, setProg] = useState([0,0,0]);
const [videoUrl, setVideoUrl] = useState(DEMO_VIDEO);
const [srvCaption, setSrvCaption] = useState(null);
const [srvProfile, setSrvProfile] = useState(null);
const [editedCaption, setEditedCaption] = useState("");
const [copied, setCopied] = useState(false);
const [note, setNote] = useState("");
const [script, setScript] = useState(null); // {intro,selling,story,cta}
const [scriptEditing, setScriptEditing] = useState({});
const [scriptLoading, setScriptLoading] = useState(false);
const fileRef = useRef();
const doneRef = useRef({ bars:false, fetch:false });
// 톤 자동 매칭 (인텔리전스 IP를 가볍게 노출 — 유형 기반)
const tone = kind === "product"
? { label: "역동·쇼케이스 톤", profile: "Rhythm Reveal" }
: kind === "message"
? { label: "따뜻한 메시지 톤", profile: "Still Cinema" }
: { label: "감성·시네마틱 톤", profile: "Still Cinema" };
// 유형별 입력 placeholder (분기를 한 곳에 모음)
const KIND_PH = {
place: { name: "예: 스테이 머뭄", addr: "예: 전북 군산시 절골길 18", price: "예: 1박 19만원~", selling: "예: 옆방 손님 없는 완전한 독채, 개별 정원까지" },
product: { name: "예: 브랜드명 숄더백", addr: "예: smartstore.naver.com/...", price: "예: 12,900원", selling: "예: 새벽 수확한 감귤만 당일 착즙 — 설탕 무첨가" },
message: { name: "예: 생일 축하", addr: "예: 관련 링크 (선택)", price: "", selling: "예: 늘 곁에 있어줘서 고마워, 생일 축하해" },
};
const ph = KIND_PH[kind] || KIND_PH.place;
const stepIdx = STEPS.findIndex(s=>s.k===step);
const canGenerate = kind && bizName.trim() && selling.trim();
function addFiles(list){
const imgs = Array.from(list).filter(f=>f.type.startsWith("image/"));
const next = imgs.map(f=>({ url:URL.createObjectURL(f), name:f.name, file:f }));
setPhotos(p=>[...p, ...next]);
}
function removePhoto(i){ setPhotos(p=>p.filter((_,idx)=>idx!==i)); }
function finishIfReady(){
if(doneRef.current.bars && doneRef.current.fetch) setStep("result");
}
function startGenerate(){
setStep("generating");
setProg([0,0,0]); setNote(""); setSrvCaption(null); setCopied(false);
setSrvProfile(tone.profile);
setEditedCaption(caption); // 로컬 폴백; 서버 응답 오면 교체
doneRef.current = { bars:false, fetch:false };
// 진행 바 애니메이션 (시각용)
[0,1,2].forEach((agent)=>{
let p = 0; const base = agent*1600;
const tick = ()=>{
p += Math.random()*22+10; if(p>100) p=100;
setProg(prev=>{ const n=[...prev]; n[agent]=Math.round(p); return n; });
if(p<100) setTimeout(tick, 260);
else if(agent===2){ doneRef.current.bars=true; finishIfReady(); }
};
setTimeout(tick, base+300);
});
// 실제 생성 요청 (실패 시 데모 폴백)
const fd = new FormData();
fd.append("kind", kind);
fd.append("biz_name", bizName);
fd.append("selling", selling);
if(addr) fd.append("addr", addr);
if(price) fd.append("price", price);
photos.forEach(p=> p.file && fd.append("photos", p.file, p.name));
fetch(`${API_BASE}/api/generate`, { method:"POST", body:fd })
.then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status)))
.then(res=>{
setVideoUrl((API_BASE||"") + res.video_url);
if(res.caption){ setSrvCaption(res.caption); setEditedCaption(res.caption); }
if(res.profile) setSrvProfile(res.profile);
})
.catch(()=>{
setVideoUrl(DEMO_VIDEO);
setNote("백엔드 미연결 — 데모 결과를 표시합니다. (서버 실행 시 실제 생성)");
})
.finally(()=>{ doneRef.current.fetch=true; finishIfReady(); });
}
function reset(){ setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(DEMO_VIDEO); setScript(null); setScriptEditing({}); }
function copyCaption(){
navigator.clipboard.writeText(editedCaption).then(()=>{ setCopied(true); setTimeout(()=>setCopied(false), 1800); });
}
// 백엔드 없을 때 입력값 기반 폴백 (Vercel 정적 배포에서도 동작)
function localScript(){
if(kind === "message"){
const title = bizName || "전하는 마음";
return {
intro: title,
selling: selling || "전하고 싶은 한마디",
story: "사진 한 장에 마음을 담아, 오래 기억될 순간으로.",
cta: "마음이 닿기를",
};
}
const place = kind !== "product";
const name = bizName || (place ? "이 곳" : "이 제품");
return {
intro: place ? `${addr ? addr + " " : ""}${name}, 아직 아무도 모르는 곳`
: `${name}, 한 번 쓰면 다시 찾게 되는`,
selling: selling || (place ? "옆방 손님 없는 완전한 독채" : "하나하나 정성껏 만든 단 하나"),
story: place ? "도착하는 순간, 일상의 속도가 천천히 느려집니다. 오늘 밤은 오롯이 당신만의 시간."
: "바쁜 하루의 끝, 작은 사치 하나가 마음을 데웁니다.",
cta: place ? `지금 프로필 링크에서 ${name} 예약하기`
: (addr ? `지금 ${addr}에서 만나보기` : "지금 주문하기"),
};
}
async function generateScript(){
setScriptLoading(true); setScriptEditing({});
try {
const r = await fetch(`${API_BASE}/api/captions`, {
method:"POST", headers:{ "Content-Type":"application/json" },
body: JSON.stringify({ kind, biz_name:bizName, addr, price, selling }),
});
if(!r.ok) throw new Error("HTTP "+r.status);
const d = await r.json();
setScript({ intro:d.intro, selling:d.selling, story:d.story, cta:d.cta });
} catch {
setScript(localScript()); // 폴백
} finally { setScriptLoading(false); }
}
function toggleScriptEdit(key){ setScriptEditing(s=>({ ...s, [key]: !s[key] })); }
function delScriptBlock(key){ setScript(s=>({ ...s, [key]: null })); }
// 인터뷰 답변 → 결과 캡션
const caption = `${bizName||"내 가게"}${addr?` · ${addr}`:""}${price?`\n${price}`:""}\n\n${selling||"고객에게 가장 자랑하고 싶은 한 가지"}\n\n${kind==="product"?"#제품추천 #신상 #쇼핑":"#감성장소 #핫플 #여행"} #ADO2\n\n— 실제 사진 기반, AI 카메라 효과를 적용한 영상입니다.`;
return (
<div>
<div className="topbar">
<img className="brand-logo" src="./assets/ado2-logo-white.png" alt="ADO2.AI" />
<div className="step-count"><b>{stepIdx+1}</b> / 4</div>
</div>
<div className="wrap">
{step==="setup" && (
<div className="hero">
<div className="eyebrow">ADO2 Hookit · 8 숏폼</div>
<h1 className="hero-title">사진 장과 한마디면,<br/><em>8 홍보 영상</em> </h1>
<p className="hero-lede">복잡한 분석 없이 사진을 올리고 가지만 답하면, AI 카메라 무브와 자막을 입힌 <span style={{whiteSpace:"nowrap"}}>세로형 숏폼</span> .</p>
</div>
)}
<div className="stepper">
{STEPS.map((s,i)=>(
<div key={s.k} className={"step"+(i===stepIdx?" active":"")+(i<stepIdx?" done":"")}>
<div className="num">{i<stepIdx ? <Icon.Check size={20}/> : s.n}</div>
<div><b>{s.b}</b><span>{s.s}</span></div>
</div>
))}
</div>
{/* STEP 1 — UPLOAD */}
{step==="setup" && (
<div className="card">
<h2>사진 업로드</h2>
<p className="sub">홍보할 장소나 물건 사진 4~5. 전경 1 · 디테일 2~3장을 섞으면 영상의 서사가 살아납니다.</p>
<div className={"upload"+(drag?" drag":"")}
onClick={()=>fileRef.current.click()}
onDragOver={e=>{e.preventDefault();setDrag(true);}}
onDragLeave={()=>setDrag(false)}
onDrop={e=>{e.preventDefault();setDrag(false);addFiles(e.dataTransfer.files);}}>
<div className="ico"><Icon.Image size={30}/></div>
<b>여기로 사진을 끌어다 놓거나 클릭</b>
<span>JPG · PNG · 4 이상</span>
<input ref={fileRef} type="file" accept="image/*" multiple style={{display:"none"}}
onChange={e=>addFiles(e.target.files)} />
</div>
{photos.length>0 && (
<div className="thumbs">
{photos.map((p,i)=>(
<div className="thumb" key={i}>
<img src={p.url} alt=""/>
<div className="n">{i+1}</div>
<button className="x" onClick={()=>removePhoto(i)}><Icon.Close/></button>
</div>
))}
</div>
)}
<div className="actionbar">
<div className="stat"><b>{photos.length}</b><span> {photos.length<4?`· ${4-photos.length} `:"· "}</span></div>
<button className="btn btn-primary" disabled={photos.length<4} onClick={()=>setStep("interview")}>
다음: 정보 입력 <Icon.Right/>
</button>
</div>
</div>
)}
{/* STEP 2 — INTERVIEW (사람이 직접 답 = 인텔리전스) */}
{step==="interview" && (
<div className="card">
<h2> 가지만 알려주세요</h2>
<p className="sub">사장님·마케터가 가장 아는 정보로 카피를 만듭니다.</p>
<div className="form-stack">
<div className="field">
<label>영상의 목적이 무엇인가요? <span className="req">*</span></label>
<div className="kind-tiles">
<div className={"tile"+(kind==="place"?" sel":"")} onClick={()=>setKind("place")}>
<b>장소</b><span> · · · </span>
</div>
<div className={"tile"+(kind==="product"?" sel":"")} onClick={()=>setKind("product")}>
<b>물건</b><span>, </span>
</div>
<div className={"tile"+(kind==="message"?" sel":"")} onClick={()=>setKind("message")}>
<b>메시지</b><span> · · </span>
</div>
</div>
{kind && (
<div style={{marginTop:16}}>
<span className="tone-chip"><i></i> · {tone.label}</span>
</div>
)}
</div>
<div className="field">
<label>업체 · 상품 · 메시지 제목 <span className="req">*</span></label>
<input value={bizName} onChange={e=>setBizName(e.target.value)} placeholder={ph.name}/>
</div>
<div className="field field-hero">
<label>가장 자랑하고 싶은 가지 <span className="req">*</span></label>
<textarea value={selling} onChange={e=>setSelling(e.target.value)} placeholder={ph.selling}></textarea>
<div className="hint"> 한마디가 영상의 후킹 타이틀·핵심 메시지가 됩니다. 구체적일수록 좋아요.</div>
</div>
{/* AI 자막 스크립트 생성기 */}
<div className="field">
<div className="script-head">
<label style={{margin:0}}>영상 자막 · 스크립트</label>
<button className="btn-mini" onClick={generateScript} disabled={scriptLoading || !selling.trim()}>
{scriptLoading ? "생성 중…" : (script ? "다시 생성" : "자막 생성하기")}
</button>
</div>
{!script && (
<div className="hint"> 가지를 적은 누르면, AI가 <b>인트로 · 셀링포인트 · 감성 스토리 · CTA</b> 4 . · .</div>
)}
{script && SCRIPT_BLOCKS.map(b => script[b.key] != null && (
<div className="script-card" key={b.key}>
<div className="sc-top">
<span className="sc-label">{b.label}</span>
<span className="sc-actions">
<button onClick={()=>toggleScriptEdit(b.key)}>{scriptEditing[b.key] ? "완료" : "수정"}</button>
<button onClick={()=>delScriptBlock(b.key)}>삭제</button>
</span>
</div>
{scriptEditing[b.key]
? <textarea value={script[b.key]} onChange={e=>setScript({ ...script, [b.key]: e.target.value })} />
: <p className="sc-text">{script[b.key]}</p>}
</div>
))}
</div>
<div className="field">
<label>주소 또는 판매 사이트 URL<span className="opt">(선택)</span></label>
<input value={addr} onChange={e=>setAddr(e.target.value)} placeholder={ph.addr}/>
<div className="hint">영상 엔드카드·해시태그에 활용됩니다.</div>
</div>
<div className="field">
<label>가격 정보<span className="opt">(선택)</span></label>
<input value={price} onChange={e=>setPrice(e.target.value)} placeholder={ph.price}/>
</div>
</div>
<div className="actionbar">
<button className="btn btn-ghost" onClick={()=>setStep("setup")}><Icon.Left/> 사진 다시</button>
<button className="btn btn-primary" disabled={!canGenerate} onClick={startGenerate}>영상 생성 <Icon.Right/></button>
</div>
</div>
)}
{/* STEP 3 — GENERATING */}
{step==="generating" && (
<div className="card">
<h2>영상 생성 </h2>
<p className="sub"> 번의 생성으로 트랜지션·자막·사운드까지 완성됩니다.</p>
{[
{b:"카피·구성 설계", s:"입력한 셀링포인트 → 후킹 타이틀·자막 구성"},
{b:"AI 영상 생성", s:"사진 → 8초 시네마틱 (AI 카메라 효과)"},
{b:"자막·합성", s:"후킹 타이틀 · 셀링 배지 · 엔드카드"},
].map((a,i)=>{
const done = prog[i]>=100, run = prog[i]>0 && prog[i]<100;
return (
<div className="agent" key={i}>
<div className="aname">
<b><span className={"dot "+(done?"done":run?"run":"queue")}></span>{a.b}</b>
<span>{a.s}</span>
</div>
<div>
<div className="pbar"><i style={{width:prog[i]+"%"}}></i></div>
<div className="pct">{done?"완료":prog[i]+"%"}</div>
</div>
</div>
);
})}
</div>
)}
{/* STEP 4 — RESULT */}
{step==="result" && (
<div className="card">
<div className="result-grid">
<div className="phone">
<video src={videoUrl} controls playsInline poster=""></video>
</div>
<div>
<h2 className="res-h">완성됐습니다</h2>
<p className="res-sub">9:16 · 8 · 사운드 포함 바로 업로드할 있는 완성본입니다.</p>
<div className="res-badges">
<span className="chip">9:16 세로형</span>
<span className="chip">8</span>
{srvProfile && <span className="tone-chip"><i></i>{srvProfile}</span>}
</div>
<div className="cap-head">
<span className="lbl">업로드 캡션 · 수정 가능</span>
<button className="copy-btn" onClick={copyCaption}>{copied ? "복사됨" : "복사"}</button>
</div>
<textarea className="caption-edit" value={editedCaption} onChange={e=>setEditedCaption(e.target.value)}></textarea>
<div className="res-actions">
<a className="btn btn-primary" href={videoUrl} download><Icon.Download/> 영상 다운로드</a>
<button className="btn btn-ghost" onClick={reset}> 영상 만들기</button>
</div>
{note && <p className="note"> {note}</p>}
</div>
</div>
</div>
)}
</div>
</div>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App/>);
</script>
</body>
</html>