Compare commits

..
14 Commits
12 changed files with 276 additions and 90 deletions
+9
View File
@@ -33,3 +33,12 @@ lerna-debug.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
# python / environments
.venv/
venv/
# temporary files
temp.txt
.tmp
scratch/
+11 -5
View File
@@ -82,17 +82,22 @@ console.log(`[startup] Listening on: http://0.0.0.0:${PORT}`);
// History helpers // History helpers
// ────────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────────
let historyCache = null;
async function readHistory() { async function readHistory() {
if (historyCache !== null) return historyCache;
try { try {
const raw = await readFile(HISTORY_FILE, 'utf-8'); const raw = await readFile(HISTORY_FILE, 'utf-8');
const parsed = JSON.parse(raw); const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : []; historyCache = Array.isArray(parsed) ? parsed : [];
} catch { } catch {
return []; historyCache = [];
} }
return historyCache;
} }
async function writeHistory(entries) { async function writeHistory(entries) {
historyCache = entries;
await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8'); await writeFile(HISTORY_FILE, JSON.stringify(entries, null, 2), 'utf-8');
} }
@@ -370,7 +375,8 @@ function handleConfig(req, res) {
if (envLinks.trim()) { if (envLinks.trim()) {
// Expected format: "Kling AI=https://klingai.com,Luma=https://lumalabs.ai" // Expected format: "Kling AI=https://klingai.com,Luma=https://lumalabs.ai"
videoAiLinks = envLinks.split(',').map(part => { videoAiLinks = envLinks.split(',').map(part => {
const idx = part.indexOf('='); const sep = part.includes('=') ? '=' : '|';
const idx = part.indexOf(sep);
if (idx > 0) { if (idx > 0) {
return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() }; return { name: part.slice(0, idx).trim(), url: part.slice(idx + 1).trim() };
} }
@@ -400,7 +406,7 @@ async function handleStyleRandom(req, res) {
{ role: 'system', content: buildStylePrompt(mode, idea) }, { role: 'system', content: buildStylePrompt(mode, idea) },
{ role: 'user', content: userMsg }, { role: 'user', content: userMsg },
], ],
{ max_tokens: 800 }, { max_tokens: 8192 },
signal, signal,
); );
const style = cleanStyleString(content); const style = cleanStyleString(content);
@@ -494,7 +500,7 @@ async function handleGenerate(req, res) {
{ role: 'system', content: buildSystemPrompt(body) }, { role: 'system', content: buildSystemPrompt(body) },
{ role: 'user', content: buildUserMessage(body) }, { role: 'user', content: buildUserMessage(body) },
], ],
{ max_tokens: 4000 }, { max_tokens: 32768 },
signal, signal,
); );
+10 -11
View File
@@ -33,24 +33,23 @@ REQUIRED JSON STRUCTURE:
"video_prompts": [ "video_prompts": [
{ {
"type": "Abstract", "type": "Abstract",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur", "prompt": "short seamless loop, 16:9 aspect ratio. Abstract visuals: particles, fluid colors, geometric shapes, motion graphics. Specify: scene, camera angle, camera movement (slow pan/zoom/static), color palette (2-3 colors only), light source, motion quality. Must loop seamlessly (first frame = last frame). If using reverse playback, only use elements that make physical sense in reverse (e.g. pulsing light, expanding rings — NOT falling rain or rising smoke). Mood must match the song. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason why they suit this prompt" "tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}, },
{ {
"type": "Cinematic", "type": "Cinematic",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur", "prompt": "short seamless loop, 16:9 aspect ratio. Real-world environment: landscape, weather, architecture, nature. Specify: scene, camera angle, camera movement, color palette (2-3 colors), light source, motion quality. Loopable (first frame = last frame). If elements move (rain, wind, water, fire), they must move in a physically correct direction — do NOT suggest rain or smoke as reversible loops. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason" "tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}, },
{ {
"type": "Hybrid", "type": "Hybrid",
"prompt": "5-second seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur", "prompt": "short seamless loop, 16:9 aspect ratio. Blend of real and abstract: e.g. real environment with overlaid light effects, particle overlays on landscape, or a semi-abstract architectural scene. Loopable. Negative: text, watermark, logo, flash, abrupt cuts, camera shake, faces, hands, distortion, blur",
"tool_recommendation": "Tool name(s) + one-sentence reason" "tool_recommendation": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
} }
] ]
} }
Keep each video_prompts[].prompt under 900 characters (excluding the Negative line). Keep each video_prompts[].prompt under 900 characters (excluding the Negative line).`,
Recommended video tools (choose the best fit per prompt): {{VIDEO_TOOLS}}. Never recommend Sora.`,
partial: `You are a music production assistant specializing in Suno AI song creation. partial: `You are a music production assistant specializing in Suno AI song creation.
The user has an existing song and wants to regenerate ONE section. Output ONLY The user has an existing song and wants to regenerate ONE section. Output ONLY
@@ -78,8 +77,8 @@ QUALITY CONSTRAINTS:
ABAB rhyme. No references to instruments, genre, or music production. ABAB rhyme. No references to instruments, genre, or music production.
- style: comma-separated, max 120 words, include tempo + BPM range, never - style: comma-separated, max 120 words, include tempo + BPM range, never
name an artist, no "sounds like" phrases. name an artist, no "sounds like" phrases.
- video_prompts: 5-second seamless loops, 16:9. Include the "Negative" line - video_prompts: short seamless loops, 16:9. Include the "Negative" line
inside each prompt. Keep prompts under 900 characters. Recommended tools: {{VIDEO_TOOLS}}. Never recommend Sora. inside each prompt. Keep prompts under 900 characters. Recommend a tool from: {{VIDEO_TOOLS}}.
- youtube_description: follow the hook description divider lyrics - youtube_description: follow the hook description divider lyrics
divider CTA hashtags structure.`, divider CTA hashtags structure.`,
style_normal: `You are a Suno style-prompt generator. style_normal: `You are a Suno style-prompt generator.
@@ -183,7 +182,7 @@ export function buildUserMessage(req) {
req.context?.lyrics ?? '', req.context?.lyrics ?? '',
'', '',
'Selected passage to rewrite:', 'Selected passage to rewrite:',
req.passage ?? '', req.selected_text ?? '',
] ]
.filter((line) => line !== null) .filter((line) => line !== null)
.join('\n'); .join('\n');
+11 -7
View File
@@ -1,20 +1,24 @@
import { Suspense, lazy } from 'react';
import { Route, Routes } from 'react-router-dom'; import { Route, Routes } from 'react-router-dom';
import { HomePage } from './pages/HomePage';
import { SettingsPage } from './pages/SettingsPage';
import { ToastProvider } from './lib/toast'; import { ToastProvider } from './lib/toast';
import { I18nProvider } from './lib/i18n'; import { I18nProvider } from './lib/i18n';
import { ThemeProvider } from './lib/theme'; import { ThemeProvider } from './lib/theme';
const HomePage = lazy(() => import('./pages/HomePage').then(module => ({ default: module.HomePage })));
const SettingsPage = lazy(() => import('./pages/SettingsPage').then(module => ({ default: module.SettingsPage })));
export default function App() { export default function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<I18nProvider> <I18nProvider>
<ToastProvider> <ToastProvider>
<Routes> <Suspense fallback={<div className="h-screen w-screen flex items-center justify-center bg-bg"><div className="w-8 h-8 rounded-full border-4 border-accent-primary border-t-transparent animate-spin" /></div>}>
<Route path="/" element={<HomePage />} /> <Routes>
<Route path="/settings" element={<SettingsPage />} /> <Route path="/" element={<HomePage />} />
<Route path="*" element={<HomePage />} /> <Route path="/settings" element={<SettingsPage />} />
</Routes> <Route path="*" element={<HomePage />} />
</Routes>
</Suspense>
</ToastProvider> </ToastProvider>
</I18nProvider> </I18nProvider>
</ThemeProvider> </ThemeProvider>
+35 -13
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent, type KeyboardEvent } from "react"; import { useState, useEffect, type FormEvent, type KeyboardEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { import {
faCircleNotch, faCircleNotch,
@@ -51,7 +51,8 @@ const CRAZY_PAIRS: { idea: string; style: string; mood: string; vocals: Vocals }
]; ];
interface InputPanelProps { interface InputPanelProps {
values: InputValues; initialValues: InputValues;
forcedUpdate?: { count: number; values: InputValues };
onChange: (next: InputValues) => void; onChange: (next: InputValues) => void;
onSubmit: () => void; onSubmit: () => void;
loading: boolean; loading: boolean;
@@ -60,19 +61,32 @@ interface InputPanelProps {
} }
export function InputPanel({ export function InputPanel({
values, initialValues,
forcedUpdate,
onChange, onChange,
onSubmit, onSubmit,
loading, loading,
disabled, disabled,
cancelButton, cancelButton,
}: InputPanelProps) { }: InputPanelProps) {
const [values, setValues] = useState<InputValues>(initialValues);
const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null); const [styleLoading, setStyleLoading] = useState<null | "normal" | "crazy">(null);
const elapsed = useElapsed(loading); const elapsed = useElapsed(loading);
const { t } = useI18n(); const { t } = useI18n();
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) => // Handle external updates (e.g. from loading history)
onChange({ ...values, [key]: value }); useEffect(() => {
if (forcedUpdate && forcedUpdate.count > 0) {
setValues(forcedUpdate.values);
onChange(forcedUpdate.values);
}
}, [forcedUpdate, onChange]);
const set = <K extends keyof InputValues>(key: K, value: InputValues[K]) => {
const next = { ...values, [key]: value };
setValues(next);
onChange(next);
};
const canSubmit = !loading && !disabled && values.idea.trim().length > 0; const canSubmit = !loading && !disabled && values.idea.trim().length > 0;
@@ -99,22 +113,26 @@ export function InputPanel({
setStyleLoading("normal"); setStyleLoading("normal");
try { try {
const style = await randomStyle("normal", ctrl.signal, ideaToUse); const style = await randomStyle("normal", ctrl.signal, ideaToUse);
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: style, style_hint: style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style, style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} finally { } finally {
setStyleLoading((curr) => (curr === "normal" ? null : curr)); setStyleLoading((curr) => (curr === "normal" ? null : curr));
} }
@@ -131,22 +149,26 @@ export function InputPanel({
setStyleLoading("crazy"); setStyleLoading("crazy");
try { try {
const style = await randomStyle("crazy", ctrl.signal, ideaToUse); const style = await randomStyle("crazy", ctrl.signal, ideaToUse);
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: style, style_hint: style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === "AbortError") return; if (err instanceof DOMException && err.name === "AbortError") return;
onChange({ const nextValues = {
...values, ...values,
idea: ideaToUse, idea: ideaToUse,
style_hint: hasIdea ? values.style_hint : ex.style, style_hint: hasIdea ? values.style_hint : ex.style,
mood: hasIdea ? values.mood : ex.mood, mood: hasIdea ? values.mood : ex.mood,
vocals: hasIdea ? values.vocals : ex.vocals vocals: hasIdea ? values.vocals : ex.vocals
}); };
setValues(nextValues);
onChange(nextValues);
} finally { } finally {
setStyleLoading((curr) => (curr === "crazy" ? null : curr)); setStyleLoading((curr) => (curr === "crazy" ? null : curr));
} }
+62 -20
View File
@@ -2,7 +2,7 @@ import { useState } from "react";
import { ResultCard } from "../ResultCard"; import { ResultCard } from "../ResultCard";
import { CopyButton } from "../CopyButton"; import { CopyButton } from "../CopyButton";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faVideo, faUndo } from "@fortawesome/free-solid-svg-icons"; import { faVideo, faUndo, faStar } from "@fortawesome/free-solid-svg-icons";
import { useAutoHeight } from "../../lib/useAutoHeight"; import { useAutoHeight } from "../../lib/useAutoHeight";
import { import {
NEGATIVE_VIDEO_PROMPT, NEGATIVE_VIDEO_PROMPT,
@@ -11,6 +11,7 @@ import {
} from "../../lib/types"; } from "../../lib/types";
import { useVideoAiLinks } from "../../lib/useVideoAiLinks"; import { useVideoAiLinks } from "../../lib/useVideoAiLinks";
import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons";
import { useToast } from "../../lib/toast";
interface VideoPromptsCardProps { interface VideoPromptsCardProps {
prompts: VideoPrompt[]; prompts: VideoPrompt[];
@@ -37,6 +38,36 @@ export function VideoPromptsCard({
const ref = useAutoHeight(current?.prompt ?? "", 120); const ref = useAutoHeight(current?.prompt ?? "", 120);
const { links } = useVideoAiLinks(); const { links } = useVideoAiLinks();
const toast = useToast();
const handleToolClick = async (url: string) => {
if (!current) return;
// Copy the prompt to clipboard (with negative prompt if applicable)
const textToCopy = `Prompt: ${current.prompt}\n\nNegative: ${NEGATIVE_VIDEO_PROMPT}`;
try {
await navigator.clipboard.writeText(textToCopy);
toast.success("Prompt copied to clipboard!");
} catch {
// Fallback
const ta = document.createElement("textarea");
ta.value = textToCopy;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try {
document.execCommand("copy");
toast.success("Prompt copied to clipboard!");
} catch {
toast.error("Failed to copy prompt");
}
document.body.removeChild(ta);
}
// Open the tool in a new tab
window.open(url, "_blank", "noopener,noreferrer");
};
return ( return (
<ResultCard <ResultCard
@@ -103,13 +134,6 @@ export function VideoPromptsCard({
/> />
</div> </div>
<p className="text-sm italic text-fg-muted">
Best for:{" "}
<span className="not-italic text-fg">
{current.tool_recommendation}
</span>
</p>
<div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border"> <div className="mt-1 p-3 rounded-lg bg-bg-hover/40 border border-border">
<p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1"> <p className="text-[11px] uppercase tracking-wider text-fg-muted mb-1">
Negative Negative
@@ -119,20 +143,38 @@ export function VideoPromptsCard({
</p> </p>
</div> </div>
{current.tool_recommendation && (
<div className="pt-1 text-xs font-medium text-fg">
Recommended AI: <span className="font-semibold text-accent-primary">{current.tool_recommendation}</span>
</div>
)}
{links && links.length > 0 && ( {links && links.length > 0 && (
<div className="pt-2 flex flex-wrap gap-2"> <div className="pt-2 flex flex-wrap gap-2">
{links.map((link, i) => ( {links.map((link, i) => {
<a const isRecommended = Boolean(
key={i} current.tool_recommendation &&
href={link.url} current.tool_recommendation.toLowerCase().includes(link.name.toLowerCase())
target="_blank" );
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary/10 text-accent-primary hover:bg-accent-primary hover:text-white transition-all duration-150 border border-accent-primary/20 hover:border-accent-primary" return (
> <button
{link.name} key={i}
<FontAwesomeIcon icon={faExternalLinkAlt} className="w-3 h-3" /> type="button"
</a> onClick={() => handleToolClick(link.url)}
))} className={
isRecommended
? "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-accent-primary text-white shadow-lg shadow-accent-primary/30 transition-all duration-150 border border-accent-primary hover:bg-accent-primary/90"
: "inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-[11px] font-bold uppercase tracking-wide bg-bg-card text-fg-muted hover:text-fg hover:bg-bg-hover transition-all duration-150 border border-border hover:border-accent-primary/40"
}
title={isRecommended ? `Recommended! Copy prompt and open ${link.name}` : `Copy prompt and open ${link.name}`}
>
{isRecommended && <FontAwesomeIcon icon={faStar} className="w-3 h-3 text-yellow-300" />}
{link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className={isRecommended ? "w-3 h-3 opacity-90" : "w-3 h-3 opacity-60"} />
</button>
);
})}
</div> </div>
)} )}
</div> </div>
+8 -1
View File
@@ -36,7 +36,11 @@
html, body, #root { html, body, #root {
height: 100%; height: 100%;
font-family: 'Inter', system-ui, sans-serif; font-family: 'Inter', system-ui, sans-serif;
cursor: url('/cursor-default.svg') 4 2, auto; cursor: url('/cursor-default.svg') 4 2, auto !important;
}
label {
cursor: url('/cursor-default.svg') 4 2, auto !important;
} }
a, button, [role="button"], select, .cursor-pointer { a, button, [role="button"], select, .cursor-pointer {
@@ -188,18 +192,21 @@
position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw; position: fixed; top: -20%; left: -15%; width: 70vw; height: 70vw;
background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%); background: radial-gradient(circle, var(--accent-primary) 0%, transparent 65%);
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none; opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-1 18s ease-in-out infinite; animation: blob-drift-1 18s ease-in-out infinite;
} }
.liquid-blob-2 { .liquid-blob-2 {
position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw; position: fixed; bottom: -20%; right: -15%; width: 65vw; height: 65vw;
background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%); background: radial-gradient(circle, var(--accent-secondary) 0%, transparent 65%);
opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none; opacity: 0.18; filter: blur(130px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-2 22s ease-in-out infinite; animation: blob-drift-2 22s ease-in-out infinite;
} }
.liquid-blob-3 { .liquid-blob-3 {
position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw; position: fixed; top: 30%; right: 20%; width: 40vw; height: 40vw;
background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%); background: radial-gradient(circle, var(--accent-warm) 0%, transparent 65%);
opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none; opacity: 0.10; filter: blur(100px); z-index: -1; pointer-events: none;
will-change: transform;
animation: blob-drift-3 26s ease-in-out infinite; animation: blob-drift-3 26s ease-in-out infinite;
} }
+54 -29
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faYoutube } from "@fortawesome/free-brands-svg-icons"; import { faYoutube } from "@fortawesome/free-brands-svg-icons";
import { faLightbulb, faMoon, faHistory, faCog } from "@fortawesome/free-solid-svg-icons"; import { faLightbulb, faMoon, faHistory, faCog, faRobot } from "@fortawesome/free-solid-svg-icons";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { InputPanel } from "../components/InputPanel"; import { InputPanel } from "../components/InputPanel";
import type { InputValues } from "../components/InputPanel"; import type { InputValues } from "../components/InputPanel";
@@ -26,7 +26,6 @@ import { useI18n } from "../lib/i18n";
import { useTheme } from "../lib/theme"; import { useTheme } from "../lib/theme";
const DRAFT_KEY = "melodymuse-draft"; const DRAFT_KEY = "melodymuse-draft";
const DRAFT_DEBOUNCE_MS = 400;
const DEFAULT_INPUT: InputValues = { const DEFAULT_INPUT: InputValues = {
idea: "", idea: "",
@@ -103,9 +102,8 @@ export function HomePage() {
const { t, lang, setLang } = useI18n(); const { t, lang, setLang } = useI18n();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const [input, setInput] = useState<InputValues>( const inputRef = useRef<InputValues>(loadDraft() ?? DEFAULT_INPUT);
() => loadDraft() ?? DEFAULT_INPUT, const [forcedUpdate, setForcedUpdate] = useState<{ count: number; values: InputValues }>();
);
const [assets, setAssets] = useState<SongAssets | null>(null); const [assets, setAssets] = useState<SongAssets | null>(null);
const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null); const [originalAssets, setOriginalAssets] = useState<SongAssets | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -119,14 +117,14 @@ export function HomePage() {
const abortRef = useRef<AbortController | null>(null); const abortRef = useRef<AbortController | null>(null);
const anyRegenerating = isAnyBusy(loading, regenerating); const anyRegenerating = isAnyBusy(loading, regenerating);
// Save draft // Save draft periodically
useEffect(() => { useEffect(() => {
const id = window.setTimeout(() => { const id = window.setInterval(() => {
try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(input)); } try { window.localStorage.setItem(DRAFT_KEY, JSON.stringify(inputRef.current)); }
catch { /* ignore */ } catch { /* ignore */ }
}, DRAFT_DEBOUNCE_MS); }, 1000);
return () => window.clearTimeout(id); return () => window.clearInterval(id);
}, [input]); }, []);
// Server health check // Server health check
useEffect(() => { useEffect(() => {
@@ -168,16 +166,19 @@ export function HomePage() {
}, [anyRegenerating, cancel]); }, [anyRegenerating, cancel]);
const buildRequest = useCallback( const buildRequest = useCallback(
(section: string, extra: Record<string, unknown> = {}): GenerateCall => ({ (section: string, extra: Record<string, unknown> = {}): GenerateCall => {
input: input.idea.trim(), const input = inputRef.current;
language: resolveLanguage(input), return {
...(input.mood.trim() ? { mood: input.mood.trim() } : {}), input: input.idea.trim(),
...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}), language: resolveLanguage(input),
vocals: input.vocals, ...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
section, ...(input.style_hint.trim() ? { style_hint: input.style_hint.trim() } : {}),
...extra, vocals: input.vocals,
} as unknown as GenerateCall), section,
[input], ...extra,
} as unknown as GenerateCall;
},
[],
); );
const runGeneration = useCallback( const runGeneration = useCallback(
@@ -212,6 +213,7 @@ export function HomePage() {
); );
const handleGenerate = useCallback(async () => { const handleGenerate = useCallback(async () => {
const input = inputRef.current;
if (!input.idea.trim()) return; if (!input.idea.trim()) return;
setAssets(null); setAssets(null);
setOriginalAssets(null); setOriginalAssets(null);
@@ -228,9 +230,10 @@ export function HomePage() {
setLoading, setLoading,
t.assetsGenerated, t.assetsGenerated,
); );
}, [input, buildRequest, runGeneration, t]); }, [buildRequest, runGeneration, t]);
const handleRegenerateAll = useCallback(async () => { const handleRegenerateAll = useCallback(async () => {
const input = inputRef.current;
if (!input.idea.trim()) return; if (!input.idea.trim()) return;
setAssets(null); setAssets(null);
setOriginalAssets(null); setOriginalAssets(null);
@@ -250,10 +253,11 @@ export function HomePage() {
}, },
t.regeneratedAll, t.regeneratedAll,
); );
}, [input, buildRequest, runGeneration, t]); }, [buildRequest, runGeneration, t]);
const handleRegenerateSection = useCallback( const handleRegenerateSection = useCallback(
async (section: SectionKey, selectedText?: string) => { async (section: SectionKey, selectedText?: string) => {
const input = inputRef.current;
if (!input.idea.trim() || !assets) return; if (!input.idea.trim() || !assets) return;
const isPartialLyrics = section === "lyrics" && !!selectedText; const isPartialLyrics = section === "lyrics" && !!selectedText;
@@ -269,8 +273,18 @@ export function HomePage() {
let merged: SongAssets; let merged: SongAssets;
if (isPartialLyrics) { if (isPartialLyrics) {
// Replace only the selected text in the existing lyrics // Replace only the selected text in the existing lyrics
const newPartial = (result as any).lyrics || (result as any).text || String(result); let newPartial = (result as any).lyrics || (result as any).text || String(result);
const replaced = assets.lyrics.replace(selectedText, newPartial); // Cleanup quotes or markdown if the LLM leaked them
newPartial = newPartial.replace(/^```[a-z]*\n/gi, '').replace(/\n```$/g, '').trim();
if (newPartial.startsWith('"') && newPartial.endsWith('"')) {
newPartial = newPartial.slice(1, -1).trim();
}
// Normalize newlines to prevent replace() from failing due to \r\n vs \n
const normalizedAssetsLyrics = assets.lyrics.replace(/\r\n/g, '\n');
const normalizedSelected = selectedText.replace(/\r\n/g, '\n');
const replaced = normalizedAssetsLyrics.replace(normalizedSelected, newPartial);
merged = { ...assets, lyrics: replaced }; merged = { ...assets, lyrics: replaced };
} else { } else {
merged = { ...assets, ...result }; merged = { ...assets, ...result };
@@ -289,7 +303,7 @@ export function HomePage() {
`${t.regeneratedSection} ${SECTION_LABELS[section]}`, `${t.regeneratedSection} ${SECTION_LABELS[section]}`,
); );
}, },
[input, assets, buildRequest, runGeneration, t], [assets, buildRequest, runGeneration, t],
); );
// Execute chained regeneration // Execute chained regeneration
@@ -335,7 +349,8 @@ export function HomePage() {
const handleLoadHistory = useCallback( const handleLoadHistory = useCallback(
(entry: HistoryEntry) => { (entry: HistoryEntry) => {
if (anyRegenerating) cancel(); if (anyRegenerating) cancel();
setInput(entry.input); inputRef.current = entry.input;
setForcedUpdate((prev) => ({ count: (prev?.count ?? 0) + 1, values: entry.input }));
setAssets(entry.assets); setAssets(entry.assets);
setOriginalAssets(entry.assets); setOriginalAssets(entry.assets);
setSelectedTitleIndex(0); setSelectedTitleIndex(0);
@@ -376,6 +391,15 @@ export function HomePage() {
> >
Suno Suno
</a> </a>
<a
href="https://chat.orfel.de/c/new"
target="_blank"
rel="noopener noreferrer"
className="w-8 h-8 flex items-center justify-center rounded-lg text-fg-muted hover:text-blue-400 hover:bg-blue-400/10 transition-all duration-150"
title="Open Chat AI"
>
<FontAwesomeIcon icon={faRobot} className="w-4 h-4" />
</a>
<a <a
href="https://www.youtube.com/@AIWentNonsense" href="https://www.youtube.com/@AIWentNonsense"
target="_blank" target="_blank"
@@ -467,8 +491,9 @@ export function HomePage() {
<div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col"> <div className="w-full md:w-[340px] xl:w-[380px] shrink-0 flex flex-col">
<div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0"> <div className="card p-4 flex flex-col flex-none md:flex-1 min-h-0">
<InputPanel <InputPanel
values={input} initialValues={inputRef.current}
onChange={setInput} forcedUpdate={forcedUpdate}
onChange={(next) => { inputRef.current = next; }}
onSubmit={handleGenerate} onSubmit={handleGenerate}
loading={loading} loading={loading}
disabled={anyRegenerating} disabled={anyRegenerating}
+3 -3
View File
@@ -163,7 +163,7 @@ export function SettingsPage() {
return ( return (
<div className="min-h-screen"> <div className="min-h-screen">
<header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border"> <header className="sticky top-0 z-20 bg-bg/70 backdrop-blur border-b border-border">
<div className="max-w-md mx-auto px-4 h-14 flex items-center gap-2"> <div className="max-w-3xl mx-auto px-4 h-14 flex items-center gap-2">
<button <button
type="button" type="button"
onClick={() => navigate("/")} onClick={() => navigate("/")}
@@ -176,7 +176,7 @@ export function SettingsPage() {
</div> </div>
</header> </header>
<main className="max-w-md mx-auto px-4 mt-16"> <main className="max-w-3xl mx-auto px-4 mt-16">
<div className="card p-6 space-y-6"> <div className="card p-6 space-y-6">
<section> <section>
<h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2"> <h2 className="text-sm font-semibold text-fg mb-3 flex items-center gap-2">
@@ -376,7 +376,7 @@ export function SettingsPage() {
</select> </select>
<textarea <textarea
className="input w-full min-h-[300px] text-xs font-mono p-3 leading-relaxed" className="input w-full min-h-[500px] text-xs font-mono p-3 leading-relaxed"
value={editingPrompts[activePromptTab]} value={editingPrompts[activePromptTab]}
onChange={(e) => handlePromptChange(e.target.value)} onChange={(e) => handlePromptChange(e.target.value)}
spellCheck={false} spellCheck={false}
BIN
View File
Binary file not shown.
+26
View File
@@ -0,0 +1,26 @@
import { createServer } from 'node:http';
async function test() {
const LLM_ENDPOINT = 'https://api.minimax.io/v1';
const LLM_API_KEY = process.env.MINIMAX_API_KEY;
const LLM_MODEL = 'MiniMax-M3';
const resp = await fetch(LLM_ENDPOINT + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM_API_KEY}`
},
body: JSON.stringify({
model: LLM_MODEL,
max_tokens: 120,
messages: [
{ role: 'system', content: 'You are a Suno style-prompt generator.' },
{ role: 'user', content: 'Generate one style description now.' }
]
})
});
const data = await resp.json();
console.log(JSON.stringify(data.choices?.[0]?.message?.content));
}
test().catch(console.error);
+46
View File
@@ -0,0 +1,46 @@
import { createServer } from 'node:http';
async function test() {
const LLM_ENDPOINT = 'https://api.minimax.io/v1';
const LLM_API_KEY = process.env.MINIMAX_API_KEY;
const LLM_MODEL = 'MiniMax-M3';
const resp = await fetch(LLM_ENDPOINT + '/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${LLM_API_KEY}`
},
body: JSON.stringify({
model: LLM_MODEL,
max_tokens: 120,
messages: [
{ role: 'system', content: `You are a Suno style-prompt generator with permission to break conventions.
Output ONE short Suno style description.
Requirements:
- DELIBERATELY combine genres, eras, or instruments that don't normally mix.
- If the user provides a music idea, use it as a jumping-off point for an
unexpected, surprising twist on that idea.
- The combination should still be parseable by Suno: use real instrument and
genre names, include a BPM, mention a vocal style.
- Comma-separated keywords. No sentences, no bullet points, no labels.
- Maximum 25 words.
- Output ONLY the style description line no quotes, no commentary, no prefix,
no trailing punctuation.
Examples of the expected output (do NOT copy these verbatim):
- gregorian chant trap, deep male choir, sub 808s, 140 BPM, dark reverb
- baroque chamber orchestra meets dubstep, cellos, glitch drops, 160 BPM
- lo-fi mariachi breakcore, trumpets, chopped breaks, 90 BPM, vinyl crackle
- medieval lute and drum & bass, fingerpicked strings, reese bass, 174 BPM
- tuvan throat singing over tropical house, guttural vocals, marimba, 120 BPM` },
{ role: 'user', content: 'Generate one style description now.' }
]
})
});
const data = await resp.json();
console.log(JSON.stringify(data.choices?.[0]?.message?.content));
}
test().catch(console.error);