638 lines
38 KiB
HTML
638 lines
38 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 로 전송.
|
|
// 프론트/백엔드를 분리 배포할 땐 빌드/배포 시점에 window.__API_BASE__ 를 head 에 주입
|
|
// 예) window.__API_BASE__ = "https://api.example.com";
|
|
// 생성 실패/서버 미연결 시 데모 영상은 표시하지 않고 에러 카드만 보여준다.
|
|
const API_BASE = (typeof window !== "undefined" && window.__API_BASE__) || "";
|
|
|
|
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(null); // 성공 시에만 채움 (데모 폴백 없음)
|
|
const [errMsg, setErrMsg] = useState(""); // 생성 실패 메시지 (있으면 결과 화면이 에러 카드로 전환)
|
|
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 pollRef = useRef(null); // 진행 중인 폴링 타이머 (언마운트/리셋 시 정리)
|
|
|
|
// 언마운트 시 폴링 타이머 정리
|
|
useEffect(()=>()=>{ if(pollRef.current) clearTimeout(pollRef.current); }, []);
|
|
|
|
// 톤 자동 매칭 (인텔리전스 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)); }
|
|
|
|
// 작업 상태(stage_index 0..2)를 3개 진행바 배열로 변환.
|
|
// 이전 단계 = 100%, 현재 단계 = 진행 중(서버 progress 또는 점진 증가), 이후 단계 = 0%
|
|
function barsForStatus(st, prevBars){
|
|
const idx = st.stage_index || 0;
|
|
return [0,1,2].map(i=>{
|
|
if(st.status === "done") return 100;
|
|
if(i < idx) return 100;
|
|
if(i > idx) return 0;
|
|
// 현재 진행 중인 바: 95%까지 조금씩 차오르게(완료 신호는 폴링이 줌)
|
|
const cur = (prevBars && prevBars[i]) || 0;
|
|
return Math.min(95, Math.max(cur + Math.round(Math.random()*8+4), 8));
|
|
});
|
|
}
|
|
|
|
function stopPolling(){ if(pollRef.current){ clearTimeout(pollRef.current); pollRef.current = null; } }
|
|
|
|
// 작업 완료/실패 시 결과 화면으로
|
|
function finishWith(st){
|
|
if(st.video_url) setVideoUrl((API_BASE||"") + st.video_url);
|
|
if(st.caption){ setSrvCaption(st.caption); setEditedCaption(st.caption); }
|
|
if(st.profile) setSrvProfile(st.profile);
|
|
setProg([100,100,100]);
|
|
setStep("result");
|
|
}
|
|
|
|
// job_id 를 주기적으로 폴링하며 진행바를 갱신
|
|
function pollJob(jobId){
|
|
fetch(`${API_BASE}/api/jobs/${jobId}`)
|
|
.then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status)))
|
|
.then(st=>{
|
|
setProg(prev=> barsForStatus(st, prev));
|
|
if(st.caption && !srvCaption){ setSrvCaption(st.caption); setEditedCaption(st.caption); }
|
|
if(st.profile) setSrvProfile(st.profile);
|
|
if(st.status === "done"){ finishWith(st); return; }
|
|
if(st.status === "error"){
|
|
// 실패 시 데모 영상 표시 안 함 — 에러 카드만 보여준다.
|
|
setVideoUrl(null);
|
|
setErrMsg(st.error || "알 수 없는 오류로 생성에 실패했습니다.");
|
|
setProg([100,100,100]); setStep("result");
|
|
return;
|
|
}
|
|
pollRef.current = setTimeout(()=>pollJob(jobId), 2000); // 2초 간격 폴링
|
|
})
|
|
.catch(()=>{ // 폴링 실패 → 잠시 후 재시도 (네트워크 일시 단절 대비)
|
|
pollRef.current = setTimeout(()=>pollJob(jobId), 3000);
|
|
});
|
|
}
|
|
|
|
function startGenerate(){
|
|
stopPolling();
|
|
setStep("generating");
|
|
setProg([6,0,0]); setNote(""); setErrMsg(""); setVideoUrl(null); setSrvCaption(null); setCopied(false);
|
|
setSrvProfile(tone.profile);
|
|
setEditedCaption(caption); // 로컬 폴백; 서버 응답 오면 교체
|
|
|
|
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));
|
|
|
|
// 1) 작업 제출 → job_id 즉시 수신, 2) job_id 폴링
|
|
fetch(`${API_BASE}/api/generate`, { method:"POST", body:fd })
|
|
.then(r=> r.ok ? r.json() : Promise.reject(new Error("HTTP "+r.status)))
|
|
.then(st=>{
|
|
if(!st.job_id) throw new Error("no job_id");
|
|
pollJob(st.job_id);
|
|
})
|
|
.catch(()=>{ // 서버 연결 실패 → 데모 안 띄우고 에러 카드
|
|
setVideoUrl(null);
|
|
setErrMsg("서버에 연결하지 못했습니다. 잠시 후 다시 시도해 주세요.");
|
|
setProg([100,100,100]); setStep("result");
|
|
});
|
|
}
|
|
function reset(){ stopPolling(); setStep("setup"); setPhotos([]); setKind(""); setBizName(""); setAddr(""); setPrice(""); setSelling(""); setProg([0,0,0]); setNote(""); setErrMsg(""); setSrvCaption(null); setSrvProfile(null); setEditedCaption(""); setCopied(false); setVideoUrl(null); 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" && errMsg && (
|
|
<div className="card">
|
|
<h2 className="res-h">영상 생성에 실패했습니다</h2>
|
|
<p className="res-sub">아래 사유를 확인하고 다시 시도해 주세요. (데모 영상은 표시하지 않습니다)</p>
|
|
<div className="caption-box" style={{borderColor:"rgba(255,120,120,0.4)"}}>{errMsg}</div>
|
|
<div className="res-actions">
|
|
<button className="btn btn-primary" onClick={startGenerate}>다시 시도</button>
|
|
<button className="btn btn-ghost" onClick={reset}>처음으로</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{step==="result" && !errMsg && videoUrl && (
|
|
<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>
|