page.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. "use client";
  2. import { useState, useCallback, useRef, useEffect } from "react";
  3. import { createSpecStreamCompiler } from "@json-render/core";
  4. import { Player, PlayerRef } from "@remotion/player";
  5. import { Renderer, type TimelineSpec } from "@json-render/remotion";
  6. import { createHighlighter, type Highlighter } from "shiki";
  7. /**
  8. * Check if spec is complete enough to render
  9. */
  10. function isSpecComplete(spec: TimelineSpec): spec is Required<TimelineSpec> {
  11. return !!(
  12. spec.composition &&
  13. spec.tracks &&
  14. Array.isArray(spec.clips) &&
  15. spec.clips.length > 0
  16. );
  17. }
  18. /**
  19. * Shiki theme (Vercel-inspired dark theme)
  20. */
  21. const darkTheme = {
  22. name: "custom-dark",
  23. type: "dark" as const,
  24. colors: {
  25. "editor.background": "transparent",
  26. "editor.foreground": "#EDEDED",
  27. },
  28. settings: [
  29. {
  30. scope: ["string", "string.quoted"],
  31. settings: { foreground: "#50E3C2" },
  32. },
  33. {
  34. scope: [
  35. "constant.numeric",
  36. "constant.language.boolean",
  37. "constant.language.null",
  38. ],
  39. settings: { foreground: "#50E3C2" },
  40. },
  41. {
  42. scope: ["punctuation", "meta.brace", "meta.bracket"],
  43. settings: { foreground: "#888888" },
  44. },
  45. {
  46. scope: ["support.type.property-name", "entity.name.tag.json"],
  47. settings: { foreground: "#EDEDED" },
  48. },
  49. ],
  50. };
  51. // Preload highlighter
  52. let highlighterPromise: Promise<Highlighter> | null = null;
  53. function getHighlighter() {
  54. if (!highlighterPromise) {
  55. highlighterPromise = createHighlighter({
  56. themes: [darkTheme],
  57. langs: ["json"],
  58. });
  59. }
  60. return highlighterPromise;
  61. }
  62. // Start loading immediately
  63. if (typeof window !== "undefined") {
  64. getHighlighter();
  65. }
  66. /**
  67. * Code block with syntax highlighting
  68. */
  69. function CodeBlock({ code }: { code: string }) {
  70. const [html, setHtml] = useState<string>("");
  71. useEffect(() => {
  72. getHighlighter().then((highlighter) => {
  73. setHtml(
  74. highlighter.codeToHtml(code, {
  75. lang: "json",
  76. theme: "custom-dark",
  77. }),
  78. );
  79. });
  80. }, [code]);
  81. if (!html) {
  82. return (
  83. <pre className="p-4 text-left">
  84. <code className="text-muted-foreground">{code}</code>
  85. </pre>
  86. );
  87. }
  88. return (
  89. <div
  90. className="p-4 text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_code]:bg-transparent!"
  91. dangerouslySetInnerHTML={{ __html: html }}
  92. />
  93. );
  94. }
  95. /**
  96. * Copy button component
  97. */
  98. function CopyButton({ text }: { text: string }) {
  99. const [copied, setCopied] = useState(false);
  100. const handleCopy = async () => {
  101. await navigator.clipboard.writeText(text);
  102. setCopied(true);
  103. setTimeout(() => setCopied(false), 2000);
  104. };
  105. return (
  106. <button
  107. onClick={handleCopy}
  108. className="p-1.5 rounded hover:bg-white/10 transition-colors text-muted-foreground hover:text-foreground"
  109. aria-label="Copy JSON"
  110. >
  111. {copied ? (
  112. <svg
  113. width="14"
  114. height="14"
  115. viewBox="0 0 24 24"
  116. fill="none"
  117. stroke="currentColor"
  118. strokeWidth="2"
  119. strokeLinecap="round"
  120. strokeLinejoin="round"
  121. >
  122. <polyline points="20 6 9 17 4 12" />
  123. </svg>
  124. ) : (
  125. <svg
  126. width="14"
  127. height="14"
  128. viewBox="0 0 24 24"
  129. fill="none"
  130. stroke="currentColor"
  131. strokeWidth="2"
  132. strokeLinecap="round"
  133. strokeLinejoin="round"
  134. >
  135. <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
  136. <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
  137. </svg>
  138. )}
  139. </button>
  140. );
  141. }
  142. const EXAMPLE_PROMPTS = [
  143. "Create a 10-second product intro with title cards and images",
  144. "Make a photo slideshow with 5 different images",
  145. "Build a testimonial video with quotes and background images",
  146. "Design a dynamic company intro with animated entrances",
  147. ];
  148. export default function Home() {
  149. const [prompt, setPrompt] = useState("");
  150. const [isGenerating, setIsGenerating] = useState(false);
  151. const [spec, setSpec] = useState<TimelineSpec | null>(null);
  152. const [error, setError] = useState<string | null>(null);
  153. const inputRef = useRef<HTMLInputElement>(null);
  154. const playerRef = useRef<PlayerRef>(null);
  155. const generate = useCallback(async () => {
  156. if (!prompt.trim() || isGenerating) return;
  157. setIsGenerating(true);
  158. setError(null);
  159. setSpec(null);
  160. try {
  161. const response = await fetch("/api/generate", {
  162. method: "POST",
  163. headers: { "Content-Type": "application/json" },
  164. body: JSON.stringify({ prompt }),
  165. });
  166. if (!response.ok) {
  167. throw new Error("Generation failed");
  168. }
  169. const reader = response.body?.getReader();
  170. if (!reader) throw new Error("No response body");
  171. const decoder = new TextDecoder();
  172. const compiler = createSpecStreamCompiler<TimelineSpec>();
  173. while (true) {
  174. const { done, value } = await reader.read();
  175. if (done) break;
  176. const chunk = decoder.decode(value, { stream: true });
  177. const { result, newPatches } = compiler.push(chunk);
  178. if (newPatches.length > 0) {
  179. setSpec(result);
  180. }
  181. }
  182. // Get final result (processes any remaining buffer)
  183. const finalSpec = compiler.getResult();
  184. setSpec(finalSpec);
  185. // Final validation and auto-play
  186. if (isSpecComplete(finalSpec)) {
  187. // Auto-play after a short delay to ensure Player is mounted
  188. setTimeout(() => {
  189. playerRef.current?.play();
  190. }, 100);
  191. } else {
  192. setError("Generated timeline is incomplete");
  193. }
  194. } catch (err) {
  195. setError(err instanceof Error ? err.message : "Generation failed");
  196. } finally {
  197. setIsGenerating(false);
  198. }
  199. }, [prompt, isGenerating]);
  200. const handleSubmit = (e: React.FormEvent) => {
  201. e.preventDefault();
  202. generate();
  203. };
  204. const handleExampleClick = (examplePrompt: string) => {
  205. setPrompt(examplePrompt);
  206. setTimeout(() => inputRef.current?.focus(), 0);
  207. };
  208. const handleExport = useCallback(() => {
  209. if (!spec) return;
  210. const blob = new Blob([JSON.stringify(spec, null, 2)], {
  211. type: "application/json",
  212. });
  213. const url = URL.createObjectURL(blob);
  214. const a = document.createElement("a");
  215. a.href = url;
  216. a.download = "timeline.json";
  217. a.click();
  218. URL.revokeObjectURL(url);
  219. }, [spec]);
  220. return (
  221. <div className="min-h-screen">
  222. {/* Hero */}
  223. <section className="max-w-5xl mx-auto px-6 pt-24 pb-16 text-center">
  224. <div className="text-xs font-mono text-muted-foreground mb-4">
  225. @json-render/remotion
  226. </div>
  227. <h1 className="text-5xl sm:text-6xl md:text-7xl font-bold tracking-tighter mb-6">
  228. AI &rarr; json-render &rarr; Video
  229. </h1>
  230. <p className="text-lg text-muted-foreground max-w-2xl mx-auto mb-12 leading-relaxed">
  231. Define a video catalog. Users prompt. AI outputs timeline JSON
  232. constrained to your components. Remotion renders it.
  233. </p>
  234. {/* Demo */}
  235. <div className="max-w-4xl mx-auto">
  236. {/* Prompt Input */}
  237. <form onSubmit={handleSubmit} className="mb-4">
  238. <div className="border border-border rounded p-3 bg-background font-mono text-sm flex items-center gap-2">
  239. <span className="text-muted-foreground">&gt;</span>
  240. <input
  241. ref={inputRef}
  242. type="text"
  243. value={prompt}
  244. onChange={(e) => setPrompt(e.target.value)}
  245. placeholder="Describe the video you want to create..."
  246. className="flex-1 bg-transparent outline-none placeholder:text-muted-foreground/50"
  247. disabled={isGenerating}
  248. maxLength={500}
  249. />
  250. {isGenerating ? (
  251. <button
  252. type="button"
  253. onClick={() => setIsGenerating(false)}
  254. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors"
  255. >
  256. <svg
  257. width="12"
  258. height="12"
  259. viewBox="0 0 24 24"
  260. fill="currentColor"
  261. >
  262. <rect x="6" y="6" width="12" height="12" />
  263. </svg>
  264. </button>
  265. ) : (
  266. <button
  267. type="submit"
  268. disabled={!prompt.trim()}
  269. className="w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center hover:bg-primary/90 transition-colors disabled:opacity-30"
  270. >
  271. <svg
  272. width="14"
  273. height="14"
  274. viewBox="0 0 24 24"
  275. fill="none"
  276. stroke="currentColor"
  277. strokeWidth="2"
  278. >
  279. <path d="M5 12h14M12 5l7 7-7 7" />
  280. </svg>
  281. </button>
  282. )}
  283. </div>
  284. </form>
  285. {/* Example prompts */}
  286. <div className="flex flex-wrap gap-2 justify-center mb-6">
  287. {EXAMPLE_PROMPTS.map((examplePrompt) => (
  288. <button
  289. key={examplePrompt}
  290. onClick={() => handleExampleClick(examplePrompt)}
  291. className="text-xs px-3 py-1.5 rounded-full border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
  292. >
  293. {examplePrompt}
  294. </button>
  295. ))}
  296. </div>
  297. {error && (
  298. <div className="mb-4 p-3 bg-red-500/10 border border-red-500/20 rounded text-red-500 text-sm">
  299. {error}
  300. </div>
  301. )}
  302. <div className="grid lg:grid-cols-2 gap-4">
  303. {/* JSON Panel */}
  304. <div className="text-left">
  305. <div className="flex items-center gap-4 mb-2 h-6">
  306. <span className="text-xs font-mono text-muted-foreground">
  307. json
  308. </span>
  309. {isGenerating && (
  310. <span className="text-xs text-muted-foreground animate-pulse">
  311. generating...
  312. </span>
  313. )}
  314. </div>
  315. <div className="border border-border rounded bg-background font-mono text-xs h-[28rem] overflow-auto relative group">
  316. {spec && (
  317. <div className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
  318. <CopyButton text={JSON.stringify(spec, null, 2)} />
  319. </div>
  320. )}
  321. {spec ? (
  322. <CodeBlock code={JSON.stringify(spec, null, 2)} />
  323. ) : isGenerating ? (
  324. <div className="p-4 text-muted-foreground/50 h-full flex items-center justify-center">
  325. <div className="flex flex-col items-center gap-2">
  326. <div className="flex gap-1">
  327. <span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce" />
  328. <span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce [animation-delay:0.1s]" />
  329. <span className="w-2 h-2 bg-muted-foreground/50 rounded-full animate-bounce [animation-delay:0.2s]" />
  330. </div>
  331. <span>Generating timeline...</span>
  332. </div>
  333. </div>
  334. ) : (
  335. <div className="p-4 text-muted-foreground/50 h-full flex items-center justify-center">
  336. Enter a prompt to generate a video timeline
  337. </div>
  338. )}
  339. </div>
  340. </div>
  341. {/* Video Preview Panel */}
  342. <div>
  343. <div className="flex items-center justify-between mb-2 h-6">
  344. <span className="text-xs font-mono text-muted-foreground">
  345. preview
  346. </span>
  347. <div className="flex items-center gap-2">
  348. {spec && isSpecComplete(spec) && (
  349. <button
  350. onClick={handleExport}
  351. className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
  352. title="Export timeline JSON"
  353. >
  354. Export
  355. </button>
  356. )}
  357. </div>
  358. </div>
  359. <div
  360. className="border border-border rounded bg-black h-[28rem] relative overflow-hidden"
  361. data-player-container
  362. >
  363. {spec && isSpecComplete(spec) ? (
  364. <Player
  365. ref={playerRef}
  366. component={Renderer}
  367. inputProps={{ spec }}
  368. durationInFrames={spec.composition.durationInFrames}
  369. fps={spec.composition.fps}
  370. compositionWidth={spec.composition.width}
  371. compositionHeight={spec.composition.height}
  372. style={{
  373. width: "100%",
  374. height: "100%",
  375. }}
  376. controls
  377. loop
  378. />
  379. ) : (
  380. <div className="h-full flex items-center justify-center text-white/30 text-sm">
  381. {isGenerating ? (
  382. <div className="flex flex-col items-center gap-2">
  383. <div className="flex gap-1">
  384. <span className="w-2 h-2 bg-white/50 rounded-full animate-bounce" />
  385. <span className="w-2 h-2 bg-white/50 rounded-full animate-bounce [animation-delay:0.1s]" />
  386. <span className="w-2 h-2 bg-white/50 rounded-full animate-bounce [animation-delay:0.2s]" />
  387. </div>
  388. <span>Generating timeline...</span>
  389. </div>
  390. ) : spec ? (
  391. <div className="flex flex-col items-center gap-2">
  392. <div className="flex gap-1">
  393. <span className="w-2 h-2 bg-white/50 rounded-full animate-bounce" />
  394. <span className="w-2 h-2 bg-white/50 rounded-full animate-bounce [animation-delay:0.1s]" />
  395. <span className="w-2 h-2 bg-white/50 rounded-full animate-bounce [animation-delay:0.2s]" />
  396. </div>
  397. <span>Building timeline...</span>
  398. </div>
  399. ) : (
  400. "Enter a prompt to generate a video"
  401. )}
  402. </div>
  403. )}
  404. </div>
  405. </div>
  406. </div>
  407. </div>
  408. </section>
  409. </div>
  410. );
  411. }