| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525 |
- "use client";
- import { useState, useEffect } from "react";
- import { TEMPLATE_TYPES, PLATFORM_PRESET_KEYS } from "@pipeline/shared";
- const TEMPLATES_INFO: Record<string, { label: string; desc: string; color: string }> = {
- news: { label: "资讯报道", desc: "Breaking news style with headline cards and ticker", color: "#1a56db" },
- knowledge: { label: "知识讲解", desc: "Step-by-step explanation with key point cards", color: "#0d9488" },
- opinion: { label: "观点分享", desc: "Quote-style layout for editorial content", color: "#ea580c" },
- marketing: { label: "产品营销", desc: "Product showcase with CTA overlays", color: "#9333ea" },
- "github-trending": { label: "GitHub 热榜", desc: "Repo cards with social preview + star history", color: "#22c55e" },
- };
- const PLATFORMS_INFO: Record<string, string> = {
- bilibili: "Bilibili 1920x1080 16:9",
- "douyin-long": "抖音 竖屏长视频 1080x1920",
- "douyin-short": "抖音 竖屏短视频 1080x1920",
- "universal-16x9": "通用横屏 1920x1080",
- "universal-9x16": "通用竖屏 1080x1920",
- };
- const JSON_PLACEHOLDER = `{
- "title": "标题",
- "subtitle": "副标题",
- "cover": {
- "keyframes": [
- { "type": "text", "content": "要点预览" }
- ]
- },
- "scenes": [
- {
- "id": "scene-1",
- "narration": "配音文本",
- "keyframes": [
- { "type": "text", "content": "要点一" }
- ]
- }
- ],
- "outro": {
- "text": "感谢观看",
- "cta": "点赞 | 关注"
- }
- }`;
- type Step = "input" | "style" | "voice" | "review";
- type InputMode = "manual" | "source";
- interface SourceInfo {
- name: string;
- url: string;
- config: Record<string, any>;
- }
- export default function CreatePage() {
- const [step, setStep] = useState<Step>("input");
- const [inputMode, setInputMode] = useState<InputMode>("manual");
- const [text, setText] = useState("");
- const [template, setTemplate] = useState("news");
- const [platforms, setPlatforms] = useState<string[]>(["bilibili"]);
- const [ttsProvider, setTtsProvider] = useState("openai-tts");
- const [voiceId, setVoiceId] = useState("");
- const [noTts, setNoTts] = useState(false);
- const [submitting, setSubmitting] = useState(false);
- const [jobId, setJobId] = useState<string | null>(null);
- const [error, setError] = useState<string | null>(null);
- // Data source state
- const [sources, setSources] = useState<SourceInfo[]>([]);
- const [selectedSource, setSelectedSource] = useState("");
- const [sourceArgs, setSourceArgs] = useState<Record<string, string>>({});
- const [collecting, setCollecting] = useState(false);
- const [collectError, setCollectError] = useState<string | null>(null);
- useEffect(() => {
- fetch("/api/collect/sources")
- .then((res) => res.json())
- .then((data) => {
- setSources(data.sources ?? []);
- if (data.sources?.length > 0 && !selectedSource) {
- setSelectedSource(data.sources[0].name);
- }
- })
- .catch(() => {});
- }, []);
- const steps: { key: Step; label: string }[] = [
- { key: "input", label: "1. Input" },
- { key: "style", label: "2. Template" },
- { key: "voice", label: "3. Voice" },
- { key: "review", label: "4. Review" },
- ];
- const stepIndex = steps.findIndex((s) => s.key === step);
- async function handleCollect() {
- if (!selectedSource) return;
- setCollecting(true);
- setCollectError(null);
- try {
- const res = await fetch("/api/collect", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ source: selectedSource, args: sourceArgs }),
- });
- const data = await res.json();
- if (data.error) {
- setCollectError(data.error);
- } else {
- setText(data.content);
- }
- } catch (err: any) {
- setCollectError(err.message);
- } finally {
- setCollecting(false);
- }
- }
- async function handleRender() {
- setSubmitting(true);
- setError(null);
- try {
- const payload: any = {
- input: { template, platforms, ttsProvider, voiceId },
- options: { noTts },
- };
- // Prefer already-collected (and possibly edited) text. Only fall back to
- // server-side collection if the textarea is still empty.
- if (text.trim()) {
- payload.input.text = text;
- } else if (inputMode === "source" && selectedSource) {
- payload.input.source = selectedSource;
- if (Object.keys(sourceArgs).length > 0) {
- payload.input.sourceArgs = sourceArgs;
- }
- }
- const res = await fetch("/api/render", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(payload),
- });
- const data = await res.json();
- if (data.jobId) {
- setJobId(data.jobId);
- } else {
- setError(data.error || "Unknown error");
- }
- } catch (err: any) {
- setError(err.message);
- } finally {
- setSubmitting(false);
- }
- }
- const canProceed =
- inputMode === "manual" ? text.trim().length > 0 : selectedSource.length > 0;
- const togglePlatform = (p: string) => {
- setPlatforms((prev) =>
- prev.includes(p) ? prev.filter((x) => x !== p) : [...prev, p]
- );
- };
- // Source-specific arg fields
- const currentSource = sources.find((s) => s.name === selectedSource);
- const needsOwnerRepo = currentSource?.name === "github-repo";
- if (jobId) {
- return (
- <div>
- <div className="page-header">
- <h1>Rendering...</h1>
- <p>Your video is being generated</p>
- </div>
- <div className="card" style={{ textAlign: "center", padding: 48 }}>
- <div className="animate-pulse" style={{ fontSize: 48, marginBottom: 16 }}>⚙</div>
- <p style={{ marginBottom: 16 }}>Job ID: <code>{jobId}</code></p>
- <a href={`/jobs/${jobId}`}>
- <button className="btn btn-primary">View Job Status</button>
- </a>
- </div>
- </div>
- );
- }
- return (
- <div>
- <div className="page-header">
- <h1>Create Video</h1>
- <p>Provide content manually or collect from external data sources</p>
- </div>
- <div className="steps">
- {steps.map((s, i) => (
- <div
- key={s.key}
- className={`step ${s.key === step ? "step-active" : ""} ${i < stepIndex ? "step-done" : ""}`}
- >
- {s.label}
- </div>
- ))}
- </div>
- <div className="card" style={{ padding: 32 }}>
- {step === "input" && (
- <div>
- {/* Mode toggle */}
- <div style={{ display: "flex", gap: 12, marginBottom: 20 }}>
- <button
- className={`btn ${inputMode === "manual" ? "btn-primary" : "btn-secondary"}`}
- onClick={() => setInputMode("manual")}
- >
- Manual Input
- </button>
- <button
- className={`btn ${inputMode === "source" ? "btn-primary" : "btn-secondary"}`}
- onClick={() => setInputMode("source")}
- >
- Data Source
- </button>
- </div>
- {inputMode === "manual" && (
- <div className="form-group">
- <label>Video JSON</label>
- <textarea
- className="form-textarea"
- value={text}
- onChange={(e) => setText(e.target.value)}
- placeholder={JSON_PLACEHOLDER}
- style={{ minHeight: 300, fontFamily: "monospace", fontSize: 13 }}
- />
- </div>
- )}
- {inputMode === "source" && (
- <div>
- <div className="form-group">
- <label>Data Source</label>
- <select
- className="form-select"
- value={selectedSource}
- onChange={(e) => {
- setSelectedSource(e.target.value);
- setSourceArgs({});
- setCollectError(null);
- }}
- >
- {sources.map((s) => (
- <option key={s.name} value={s.name}>{s.name}</option>
- ))}
- </select>
- </div>
- {needsOwnerRepo && (
- <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 12 }}>
- <div className="form-group">
- <label>Owner</label>
- <input
- className="form-input"
- value={sourceArgs.owner ?? ""}
- onChange={(e) =>
- setSourceArgs((prev) => ({ ...prev, owner: e.target.value }))
- }
- placeholder="e.g. facebook"
- />
- </div>
- <div className="form-group">
- <label>Repo</label>
- <input
- className="form-input"
- value={sourceArgs.repo ?? ""}
- onChange={(e) =>
- setSourceArgs((prev) => ({ ...prev, repo: e.target.value }))
- }
- placeholder="e.g. react"
- />
- </div>
- </div>
- )}
- {currentSource && (
- <div style={{ fontSize: 13, color: "var(--text-muted)", marginBottom: 16 }}>
- URL: <code>{currentSource.url}</code>
- </div>
- )}
- <button
- className="btn btn-secondary"
- disabled={collecting || !selectedSource}
- onClick={handleCollect}
- style={{ marginBottom: 16 }}
- >
- {collecting ? "Collecting..." : "Collect Data"}
- </button>
- {collectError && (
- <div style={{ color: "var(--error)", marginBottom: 16, fontSize: 14 }}>
- {collectError}
- </div>
- )}
- {text && (
- <div className="form-group">
- <label>Collected Content (editable)</label>
- <textarea
- className="form-textarea"
- value={text}
- onChange={(e) => setText(e.target.value)}
- style={{ minHeight: 200, fontFamily: "monospace", fontSize: 13 }}
- />
- </div>
- )}
- </div>
- )}
- <button
- className="btn btn-primary"
- disabled={!canProceed}
- onClick={() => setStep("style")}
- >
- Next
- </button>
- </div>
- )}
- {step === "style" && (
- <div>
- <div className="form-group">
- <label>Template</label>
- <div className="card-grid">
- {TEMPLATE_TYPES.map((t) => (
- <div
- key={t}
- className={`card card-hover template-card ${template === t ? "selected" : ""}`}
- style={template === t ? { borderColor: "var(--primary)", boxShadow: "0 0 0 1px var(--primary)" } : {}}
- onClick={() => setTemplate(t)}
- >
- <div
- className="template-color-bar"
- style={{ background: TEMPLATES_INFO[t].color }}
- />
- <h3>{TEMPLATES_INFO[t].label}</h3>
- <p>{TEMPLATES_INFO[t].desc}</p>
- </div>
- ))}
- </div>
- </div>
- <div className="form-group">
- <label>Platforms (select one or more)</label>
- <div className="card-grid">
- {PLATFORM_PRESET_KEYS.map((p) => {
- const selected = platforms.includes(p);
- return (
- <div
- key={p}
- className={`card card-hover template-card ${selected ? "selected" : ""}`}
- style={selected ? { borderColor: "var(--primary)", boxShadow: "0 0 0 1px var(--primary)" } : {}}
- onClick={() => togglePlatform(p)}
- >
- <h3>{p}</h3>
- <p>{PLATFORMS_INFO[p]}</p>
- </div>
- );
- })}
- </div>
- </div>
- <div style={{ display: "flex", gap: 12 }}>
- <button className="btn btn-secondary" onClick={() => setStep("input")}>
- Back
- </button>
- <button
- className="btn btn-primary"
- disabled={platforms.length === 0}
- onClick={() => setStep("voice")}
- >
- Next
- </button>
- </div>
- </div>
- )}
- {step === "voice" && (
- <div>
- <div className="form-group">
- <label
- style={{
- display: "flex",
- alignItems: "center",
- gap: 8,
- cursor: "pointer",
- userSelect: "none",
- }}
- >
- <input
- type="checkbox"
- checked={noTts}
- onChange={(e) => setNoTts(e.target.checked)}
- style={{ width: 16, height: 16 }}
- />
- Skip TTS (generate silent video)
- </label>
- {noTts && (
- <p
- style={{
- fontSize: 13,
- color: "var(--text-muted)",
- marginTop: 8,
- marginLeft: 24,
- }}
- >
- No narration will be synthesized. Each scene uses its{" "}
- <code>duration</code> (default 5s) and subtitles are spread
- evenly across the timeline.
- </p>
- )}
- </div>
- <div
- className="form-group"
- style={{
- opacity: noTts ? 0.5 : 1,
- pointerEvents: noTts ? "none" : "auto",
- }}
- >
- <label>TTS Provider</label>
- <select
- className="form-select"
- value={ttsProvider}
- onChange={(e) => setTtsProvider(e.target.value)}
- >
- <option value="openai-tts">OpenAI TTS (Seed TTS)</option>
- <option value="fish-audio">Fish Audio</option>
- <option value="minimax">MiniMax</option>
- <option value="elevenlabs">ElevenLabs</option>
- </select>
- </div>
- <div
- className="form-group"
- style={{
- opacity: noTts ? 0.5 : 1,
- pointerEvents: noTts ? "none" : "auto",
- }}
- >
- <label>Voice ID (optional)</label>
- <input
- className="form-input"
- value={voiceId}
- onChange={(e) => setVoiceId(e.target.value)}
- placeholder="Leave empty for default voice"
- />
- </div>
- <div style={{ display: "flex", gap: 12 }}>
- <button className="btn btn-secondary" onClick={() => setStep("style")}>
- Back
- </button>
- <button className="btn btn-primary" onClick={() => setStep("review")}>
- Next
- </button>
- </div>
- </div>
- )}
- {step === "review" && (
- <div>
- <h3 style={{ marginBottom: 20 }}>Review Configuration</h3>
- <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, marginBottom: 24 }}>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Template</div>
- <div style={{ fontWeight: 600 }}>{TEMPLATES_INFO[template]?.label} ({template})</div>
- </div>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Platforms</div>
- <div style={{ fontWeight: 600 }}>{platforms.join(", ")}</div>
- </div>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>TTS Provider</div>
- <div style={{ fontWeight: 600 }}>
- {noTts ? "Skipped (silent)" : ttsProvider}
- </div>
- </div>
- <div className="card">
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 4 }}>Input</div>
- <div style={{ fontWeight: 600 }}>
- {text.trim()
- ? `${text.length} chars`
- : inputMode === "source"
- ? `Source: ${selectedSource} (will collect at render time)`
- : "empty"}
- </div>
- </div>
- </div>
- <div className="card" style={{ marginBottom: 24 }}>
- <div style={{ fontSize: 12, color: "var(--text-muted)", marginBottom: 8 }}>
- {inputMode === "source" ? "Source Content" : "JSON Preview"}
- </div>
- <pre style={{ fontSize: 13, color: "var(--text-muted)", maxHeight: 120, overflow: "hidden" }}>
- {text.slice(0, 500)}{text.length > 500 ? "\n..." : ""}
- </pre>
- </div>
- {error && (
- <div style={{ color: "var(--error)", marginBottom: 16 }}>{error}</div>
- )}
- <div style={{ display: "flex", gap: 12 }}>
- <button className="btn btn-secondary" onClick={() => setStep("voice")}>
- Back
- </button>
- <button
- className="btn btn-primary"
- disabled={submitting}
- onClick={handleRender}
- >
- {submitting ? "Starting..." : "Generate Video"}
- </button>
- </div>
- </div>
- )}
- </div>
- </div>
- );
- }
|