page.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525
  1. "use client";
  2. import { useState, useEffect } from "react";
  3. import { TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
  4. const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: string }> = {
  5. news: { label: "资讯报道", desc: "Breaking news style with headline cards and ticker", color: "#1a56db" },
  6. knowledge: { label: "知识讲解", desc: "Step-by-step explanation with key point cards", color: "#0d9488" },
  7. opinion: { label: "观点分享", desc: "Quote-style layout for editorial content", color: "#ea580c" },
  8. marketing: { label: "产品营销", desc: "Product showcase with CTA overlays", color: "#9333ea" },
  9. "github-trending": { label: "GitHub 热榜", desc: "Repo cards with social preview + star history", color: "#22c55e" },
  10. };
  11. const PLATFORMS_INFO: Record<string, string> = {
  12. bilibili: "Bilibili 1920x1080 16:9",
  13. "douyin-long": "抖音 竖屏长视频 1080x1920",
  14. "douyin-short": "抖音 竖屏短视频 1080x1920",
  15. "universal-16x9": "通用横屏 1920x1080",
  16. "universal-9x16": "通用竖屏 1080x1920",
  17. };
  18. const JSON_PLACEHOLDER = `{
  19. "title": "标题",
  20. "subtitle": "副标题",
  21. "cover": {
  22. "keyframes": [
  23. { "type": "text", "content": "要点预览" }
  24. ]
  25. },
  26. "scenes": [
  27. {
  28. "id": "scene-1",
  29. "narration": "配音文本",
  30. "keyframes": [
  31. { "type": "text", "content": "要点一" }
  32. ]
  33. }
  34. ],
  35. "outro": {
  36. "text": "感谢观看",
  37. "cta": "点赞 | 关注"
  38. }
  39. }`;
  40. type Step = "input" | "style" | "voice" | "review";
  41. type InputMode = "manual" | "source";
  42. interface SourceInfo {
  43. name: string;
  44. url: string;
  45. config: Record<string, any>;
  46. }
  47. export default function CreatePage() {
  48. const [step, setStep] = useState<Step>("input");
  49. const [inputMode, setInputMode] = useState<InputMode>("manual");
  50. const [text, setText] = useState("");
  51. const [template, setTemplate] = useState("news");
  52. const [platforms, setPlatforms] = useState<string[]>(["bilibili"]);
  53. const [ttsProvider, setTtsProvider] = useState("openai-tts");
  54. const [voiceId, setVoiceId] = useState("");
  55. const [noTts, setNoTts] = useState(false);
  56. const [submitting, setSubmitting] = useState(false);
  57. const [jobId, setJobId] = useState<string | null>(null);
  58. const [error, setError] = useState<string | null>(null);
  59. // Data source state
  60. const [sources, setSources] = useState<SourceInfo[]>([]);
  61. const [selectedSource, setSelectedSource] = useState("");
  62. const [sourceArgs, setSourceArgs] = useState<Record<string, string>>({});
  63. const [collecting, setCollecting] = useState(false);
  64. const [collectError, setCollectError] = useState<string | null>(null);
  65. useEffect(() => {
  66. fetch("/api/collect/sources")
  67. .then((res) => res.json())
  68. .then((data) => {
  69. setSources(data.sources ?? []);
  70. if (data.sources?.length > 0 && !selectedSource) {
  71. setSelectedSource(data.sources[0].name);
  72. }
  73. })
  74. .catch(() => {});
  75. }, []);
  76. const steps: { key: Step; label: string }[] = [
  77. { key: "input", label: "1. Input" },
  78. { key: "style", label: "2. Template" },
  79. { key: "voice", label: "3. Voice" },
  80. { key: "review", label: "4. Review" },
  81. ];
  82. const stepIndex = steps.findIndex((s) => s.key === step);
  83. async function handleCollect() {
  84. if (!selectedSource) return;
  85. setCollecting(true);
  86. setCollectError(null);
  87. try {
  88. const res = await fetch("/api/collect", {
  89. method: "POST",
  90. headers: { "Content-Type": "application/json" },
  91. body: JSON.stringify({ source: selectedSource, args: sourceArgs }),
  92. });
  93. const data = await res.json();
  94. if (data.error) {
  95. setCollectError(data.error);
  96. } else {
  97. setText(data.content);
  98. }
  99. } catch (err: any) {
  100. setCollectError(err.message);
  101. } finally {
  102. setCollecting(false);
  103. }
  104. }
  105. async function handleRender() {
  106. setSubmitting(true);
  107. setError(null);
  108. try {
  109. const payload: any = {
  110. input: { template, platforms, ttsProvider, voiceId },
  111. options: { noTts },
  112. };
  113. // Prefer already-collected (and possibly edited) text. Only fall back to
  114. // server-side collection if the textarea is still empty.
  115. if (text.trim()) {
  116. payload.input.text = text;
  117. } else if (inputMode === "source" && selectedSource) {
  118. payload.input.source = selectedSource;
  119. if (Object.keys(sourceArgs).length > 0) {
  120. payload.input.sourceArgs = sourceArgs;
  121. }
  122. }
  123. const res = await fetch("/api/render", {
  124. method: "POST",
  125. headers: { "Content-Type": "application/json" },
  126. body: JSON.stringify(payload),
  127. });
  128. const data = await res.json();
  129. if (data.jobId) {
  130. setJobId(data.jobId);
  131. } else {
  132. setError(data.error || "Unknown error");
  133. }
  134. } catch (err: any) {
  135. setError(err.message);
  136. } finally {
  137. setSubmitting(false);
  138. }
  139. }
  140. const canProceed =
  141. inputMode === "manual" ? text.trim().length > 0 : selectedSource.length > 0;
  142. const togglePlatform = (p: string) => {
  143. setPlatforms((prev) =>
  144. prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
  145. );
  146. };
  147. // Source-specific arg fields
  148. const currentSource = sources.find((s) => s.name === selectedSource);
  149. const needsOwnerRepo = currentSource?.name === "github-repo";
  150. if (jobId) {
  151. return (
  152. <div>
  153. <div className="page-header">
  154. <h1>Rendering...</h1>
  155. <p>Your video is being generated</p>
  156. </div>
  157. <div className="card" style={{ textAlign: "center", padding: 48 }}>
  158. <div className="animate-pulse" style={{ fontSize: 48, marginBottom: 16 }}>&#9881;</div>
  159. <p style={{ marginBottom: 16 }}>Job ID: <code>{jobId}</code></p>
  160. <a href={`/jobs/${jobId}`}>
  161. <button className="btn btn-primary">View Job Status</button>
  162. </a>
  163. </div>
  164. </div>
  165. );
  166. }
  167. return (
  168. <div>
  169. <div className="page-header">
  170. <h1>Create Video</h1>
  171. <p>Provide content manually or collect from external data sources</p>
  172. </div>
  173. <div className="steps">
  174. {steps.map((s, i) => (
  175. <div
  176. key={s.key}
  177. className={`step ${s.key === step ? "step-active" : ""} ${i < stepIndex ? "step-done" : ""}`}
  178. >
  179. {s.label}
  180. </div>
  181. ))}
  182. </div>
  183. <div className="card" style={{ padding: 32 }}>
  184. {step === "input" && (
  185. <div>
  186. {/* Mode toggle */}
  187. <div style={{ display: "flex", gap: 12, marginBottom: 20 }}>
  188. <button
  189. className={`btn ${inputMode === "manual" ? "btn-primary" : "btn-secondary"}`}
  190. onClick={() => setInputMode("manual")}
  191. >
  192. Manual Input
  193. </button>
  194. <button
  195. className={`btn ${inputMode === "source" ? "btn-primary" : "btn-secondary"}`}
  196. onClick={() => setInputMode("source")}
  197. >
  198. Data Source
  199. </button>
  200. </div>
  201. {inputMode === "manual" && (
  202. <div className="form-group">
  203. <label>Video JSON</label>
  204. <textarea
  205. className="form-textarea"
  206. value={text}
  207. onChange={(e) => setText(e.target.value)}
  208. placeholder={JSON_PLACEHOLDER}
  209. style={{ minHeight: 300, fontFamily: "monospace", fontSize: 13 }}
  210. />
  211. </div>
  212. )}
  213. {inputMode === "source" && (
  214. <div>
  215. <div className="form-group">
  216. <label>Data Source</label>
  217. <select
  218. className="form-select"
  219. value={selectedSource}
  220. onChange={(e) => {
  221. setSelectedSource(e.target.value);
  222. setSourceArgs({});
  223. setCollectError(null);
  224. }}
  225. >
  226. {sources.map((s) => (
  227. <option key={s.name} value={s.name}>{s.name}</option>
  228. ))}
  229. </select>
  230. </div>
  231. {needsOwnerRepo && (
  232. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
  233. <div className="form-group">
  234. <label>Owner</label>
  235. <input
  236. className="form-input"
  237. value={sourceArgs.owner ?? ""}
  238. onChange={(e) =>
  239. setSourceArgs((prev) => ({ ...prev, owner: e.target.value }))
  240. }
  241. placeholder="e.g. facebook"
  242. />
  243. </div>
  244. <div className="form-group">
  245. <label>Repo</label>
  246. <input
  247. className="form-input"
  248. value={sourceArgs.repo ?? ""}
  249. onChange={(e) =>
  250. setSourceArgs((prev) => ({ ...prev, repo: e.target.value }))
  251. }
  252. placeholder="e.g. react"
  253. />
  254. </div>
  255. </div>
  256. )}
  257. {currentSource && (
  258. <div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
  259. URL: <code>{currentSource.url}</code>
  260. </div>
  261. )}
  262. <button
  263. className="btn btn-secondary"
  264. disabled={collecting || !selectedSource}
  265. onClick={handleCollect}
  266. style={{ marginBottom: 16 }}
  267. >
  268. {collecting ? "Collecting..." : "Collect Data"}
  269. </button>
  270. {collectError && (
  271. <div style={{ color: "var(--error)", marginBottom: 16, fontSize: 14 }}>
  272. {collectError}
  273. </div>
  274. )}
  275. {text && (
  276. <div className="form-group">
  277. <label>Collected Content (editable)</label>
  278. <textarea
  279. className="form-textarea"
  280. value={text}
  281. onChange={(e) => setText(e.target.value)}
  282. style={{ minHeight: 200, fontFamily: "monospace", fontSize: 13 }}
  283. />
  284. </div>
  285. )}
  286. </div>
  287. )}
  288. <button
  289. className="btn btn-primary"
  290. disabled={!canProceed}
  291. onClick={() => setStep("style")}
  292. >
  293. Next
  294. </button>
  295. </div>
  296. )}
  297. {step === "style" && (
  298. <div>
  299. <div className="form-group">
  300. <label>Template</label>
  301. <div className="card-grid">
  302. {TEMPLATE_TYPES.map((t) => (
  303. <div
  304. key={t}
  305. className={`card card-hover template-card ${template === t ? "selected" : ""}`}
  306. style={template === t ? { borderColor: "var(--primary)", boxShadow: "0 0 0 1px var(--primary)" } : {}}
  307. onClick={() => setTemplate(t)}
  308. >
  309. <div
  310. className="template-color-bar"
  311. style={{ background: TEMPLATES_INFO[t].color }}
  312. />
  313. <h3>{TEMPLATES_INFO[t].label}</h3>
  314. <p>{TEMPLATES_INFO[t].desc}</p>
  315. </div>
  316. ))}
  317. </div>
  318. </div>
  319. <div className="form-group">
  320. <label>Platforms (select one or more)</label>
  321. <div className="card-grid">
  322. {PLATFORM_PRESET_KEYS.map((p) => {
  323. const selected = platforms.includes(p);
  324. return (
  325. <div
  326. key={p}
  327. className={`card card-hover template-card ${selected ? "selected" : ""}`}
  328. style={selected ? { borderColor: "var(--primary)", boxShadow: "0 0 0 1px var(--primary)" } : {}}
  329. onClick={() => togglePlatform(p)}
  330. >
  331. <h3>{p}</h3>
  332. <p>{PLATFORMS_INFO[p]}</p>
  333. </div>
  334. );
  335. })}
  336. </div>
  337. </div>
  338. <div style={{ display: "flex", gap: 12 }}>
  339. <button className="btn btn-secondary" onClick={() => setStep("input")}>
  340. Back
  341. </button>
  342. <button
  343. className="btn btn-primary"
  344. disabled={platforms.length === 0}
  345. onClick={() => setStep("voice")}
  346. >
  347. Next
  348. </button>
  349. </div>
  350. </div>
  351. )}
  352. {step === "voice" && (
  353. <div>
  354. <div className="form-group">
  355. <label
  356. style={{
  357. display: "flex",
  358. alignItems: "center",
  359. gap: 8,
  360. cursor: "pointer",
  361. userSelect: "none",
  362. }}
  363. >
  364. <input
  365. type="checkbox"
  366. checked={noTts}
  367. onChange={(e) => setNoTts(e.target.checked)}
  368. style={{ width: 16, height: 16 }}
  369. />
  370. Skip TTS (generate silent video)
  371. </label>
  372. {noTts && (
  373. <p
  374. style={{
  375. fontSize: 13,
  376. color: "var(--text-muted)",
  377. marginTop: 8,
  378. marginLeft: 24,
  379. }}
  380. >
  381. No narration will be synthesized. Each scene uses its{" "}
  382. <code>duration</code> (default 5s) and subtitles are spread
  383. evenly across the timeline.
  384. </p>
  385. )}
  386. </div>
  387. <div
  388. className="form-group"
  389. style={{
  390. opacity: noTts ? 0.5 : 1,
  391. pointerEvents: noTts ? "none" : "auto",
  392. }}
  393. >
  394. <label>TTS Provider</label>
  395. <select
  396. className="form-select"
  397. value={ttsProvider}
  398. onChange={(e) => setTtsProvider(e.target.value)}
  399. >
  400. <option value="openai-tts">OpenAI TTS (Seed TTS)</option>
  401. <option value="fish-audio">Fish Audio</option>
  402. <option value="minimax">MiniMax</option>
  403. <option value="elevenlabs">ElevenLabs</option>
  404. </select>
  405. </div>
  406. <div
  407. className="form-group"
  408. style={{
  409. opacity: noTts ? 0.5 : 1,
  410. pointerEvents: noTts ? "none" : "auto",
  411. }}
  412. >
  413. <label>Voice ID (optional)</label>
  414. <input
  415. className="form-input"
  416. value={voiceId}
  417. onChange={(e) => setVoiceId(e.target.value)}
  418. placeholder="Leave empty for default voice"
  419. />
  420. </div>
  421. <div style={{ display: "flex", gap: 12 }}>
  422. <button className="btn btn-secondary" onClick={() => setStep("style")}>
  423. Back
  424. </button>
  425. <button className="btn btn-primary" onClick={() => setStep("review")}>
  426. Next
  427. </button>
  428. </div>
  429. </div>
  430. )}
  431. {step === "review" && (
  432. <div>
  433. <h3 style={{ marginBottom: 20 }}>Review Configuration</h3>
  434. <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 24 }}>
  435. <div className="card">
  436. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Template</div>
  437. <div style={{ fontWeight: 600 }}>{TEMPLATES_INFO[template]?.label} ({template})</div>
  438. </div>
  439. <div className="card">
  440. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platforms</div>
  441. <div style={{ fontWeight: 600 }}>{platforms.join(", ")}</div>
  442. </div>
  443. <div className="card">
  444. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
  445. <div style={{ fontWeight: 600 }}>
  446. {noTts ? "Skipped (silent)" : ttsProvider}
  447. </div>
  448. </div>
  449. <div className="card">
  450. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>
  451. <div style={{ fontWeight: 600 }}>
  452. {text.trim()
  453. ? `${text.length} chars`
  454. : inputMode === "source"
  455. ? `Source: ${selectedSource} (will collect at render time)`
  456. : "empty"}
  457. </div>
  458. </div>
  459. </div>
  460. <div className="card" style={{ marginBottom: 24 }}>
  461. <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>
  462. {inputMode === "source" ? "Source Content" : "JSON Preview"}
  463. </div>
  464. <pre style={{ fontSize: 13, color: "var(--text-muted)", maxHeight: 120, overflow: "hidden" }}>
  465. {text.slice(0, 500)}{text.length > 500 ? "\n..." : ""}
  466. </pre>
  467. </div>
  468. {error && (
  469. <div style={{ color: "var(--error)", marginBottom: 16 }}>{error}</div>
  470. )}
  471. <div style={{ display: "flex", gap: 12 }}>
  472. <button className="btn btn-secondary" onClick={() => setStep("voice")}>
  473. Back
  474. </button>
  475. <button
  476. className="btn btn-primary"
  477. disabled={submitting}
  478. onClick={handleRender}
  479. >
  480. {submitting ? "Starting..." : "Generate Video"}
  481. </button>
  482. </div>
  483. </div>
  484. )}
  485. </div>
  486. </div>
  487. );
  488. }