page.tsx 18 KB

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