Compare commits

..
12 Commits
12 changed files with 242 additions and 76 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,
); );
+8 -5
View File
@@ -33,15 +33,18 @@ REQUIRED JSON STRUCTURE:
"video_prompts": [ "video_prompts": [
{ {
"type": "Abstract", "type": "Abstract",
"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" "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": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}, },
{ {
"type": "Cinematic", "type": "Cinematic",
"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" "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": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
}, },
{ {
"type": "Hybrid", "type": "Hybrid",
"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" "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": "Name of the BEST tool for this prompt from this list: {{VIDEO_TOOLS}}"
} }
] ]
} }
@@ -75,7 +78,7 @@ QUALITY CONSTRAINTS:
- 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: short 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. 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.
@@ -179,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');
+6 -2
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>
<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>}>
<Routes> <Routes>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<HomePage />} /> <Route path="*" element={<HomePage />} />
</Routes> </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));
} }
+24 -6
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,
@@ -143,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) => {
const isRecommended = Boolean(
current.tool_recommendation &&
current.tool_recommendation.toLowerCase().includes(link.name.toLowerCase())
);
return (
<button <button
key={i} key={i}
type="button" type="button"
onClick={() => handleToolClick(link.url)} onClick={() => handleToolClick(link.url)}
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" className={
title={`Copy prompt and open ${link.name}`} 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} {link.name}
<FontAwesomeIcon icon={faExternalLinkAlt} className="w-3 h-3" /> <FontAwesomeIcon icon={faExternalLinkAlt} className={isRecommended ? "w-3 h-3 opacity-90" : "w-3 h-3 opacity-60"} />
</button> </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;
} }
+47 -22
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,7 +166,9 @@ 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 => {
const input = inputRef.current;
return {
input: input.idea.trim(), input: input.idea.trim(),
language: resolveLanguage(input), language: resolveLanguage(input),
...(input.mood.trim() ? { mood: input.mood.trim() } : {}), ...(input.mood.trim() ? { mood: input.mood.trim() } : {}),
@@ -176,8 +176,9 @@ export function HomePage() {
vocals: input.vocals, vocals: input.vocals,
section, section,
...extra, ...extra,
} as unknown as GenerateCall), } as unknown as GenerateCall;
[input], },
[],
); );
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);