diff --git a/__init__.py b/__init__.py index b7b0f5c..a2dee5e 100755 --- a/__init__.py +++ b/__init__.py @@ -11,6 +11,7 @@ from .nodes.api.AQ_Gemini import ( ) from .nodes.api.AQ_SendImageToAPI import AQ_SendImageToAPI from .nodes.api.AQ_Cover import AQ_CoverPrompt +from .nodes.api.AQ_VideoFromImage import AQ_StillImageToVideo from .nodes.clip.AQ_CLIPSetLastLayer import AQ_CLIPSetLastLayer from .nodes.numbers.AQ_Numbers import AQ_Increment, AQ_Random @@ -46,6 +47,7 @@ NODE_CLASS_MAPPINGS = { "AQ_TextEncodeQwenImageEdit": AQ_TextEncodeQwenImageEdit, "AQ_TextEncodeQwenImageEditPlus": AQ_TextEncodeQwenImageEditPlus, "AQ_CoverPrompt": AQ_CoverPrompt, + "AQ_StillImageToVideo": AQ_StillImageToVideo, } NODE_DISPLAY_NAME_MAPPINGS = { @@ -73,6 +75,7 @@ NODE_DISPLAY_NAME_MAPPINGS = { "AQ_TextEncodeQwenImageEdit": "AQ_TextEncodeQwenImageEdit", "AQ_TextEncodeQwenImageEditPlus": "AQ_TextEncodeQwenImageEditPlus", "AQ_CoverPrompt": "AQ_CoverPrompt", + "AQ_StillImageToVideo": "AQ_StillImageToVideo", } diff --git a/nodes/api/AQ_Cover.py b/nodes/api/AQ_Cover.py index 619c459..65f59f7 100644 --- a/nodes/api/AQ_Cover.py +++ b/nodes/api/AQ_Cover.py @@ -1,5 +1,20 @@ import os +KEYSCALES = [ + "C major", "C minor", + "C# major", "C# minor", "Db major", "Db minor", + "D major", "D minor", + "D# major", "D# minor", "Eb major", "Eb minor", + "E major", "E minor", + "F major", "F minor", + "F# major", "F# minor", "Gb major", "Gb minor", + "G major", "G minor", + "G# major", "G# minor", "Ab major", "Ab minor", + "A major", "A minor", + "A# major", "A# minor", "Bb major", "Bb minor", + "B major", "B minor", +] + class AQ_CoverPrompt: ASPECT_RATIO_NOTES = { @@ -26,7 +41,7 @@ class AQ_CoverPrompt: "tagline": ("STRING", {"multiline": False, "default": ""}), "headline": ("STRING", {"multiline": False, "default": ""}), "tags": ("STRING", {"multiline": False, "default": ""}), - "keyscale": ("STRING", {"multiline": False, "default": "C major"}), + "keyscale": (KEYSCALES, {"default": "C major"}), "style": (["cinematic photo", "digital illustration", "oil painting", "neon art", "minimalist", "watercolor", "3D render"], {"default": "cinematic photo"}), "aspect_ratio": (["16:9 YouTube", "1:1 Square", "4:5 Portrait"], {"default": "16:9 YouTube"}), }, diff --git a/nodes/api/AQ_VideoFromImage.py b/nodes/api/AQ_VideoFromImage.py new file mode 100644 index 0000000..e993e27 --- /dev/null +++ b/nodes/api/AQ_VideoFromImage.py @@ -0,0 +1,129 @@ +import os +import wave +import tempfile +import subprocess +import numpy as np +import torch +from PIL import Image + + +class AQ_StillImageToVideo: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "image": ("IMAGE",), + "audio": ("AUDIO",), + "filename_prefix": ("STRING", {"default": "song_cover"}), + }, + "optional": { + "title": ("STRING", {"default": ""}), + "tagline": ("STRING", {"default": ""}), + "headline": ("STRING", {"default": ""}), + "tags": ("STRING", {"default": ""}), + "keyscale": ("STRING", {"default": ""}), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("video_path",) + OUTPUT_NODE = True + FUNCTION = "create_video" + CATEGORY = "Aquasite/Media" + + def create_video(self, image, audio, filename_prefix="song_cover", + title="", tagline="", headline="", tags="", keyscale=""): + import folder_paths + + output_dir = folder_paths.get_output_directory() + + # -- Image ---------------------------------------------------------------- + if isinstance(image, torch.Tensor): + img_np = image.cpu().numpy() + else: + img_np = np.array(image) + + if img_np.ndim == 4: + img_np = img_np[0] + + if img_np.dtype != np.uint8: + img_np = (np.clip(img_np, 0.0, 1.0) * 255).astype(np.uint8) + + pil_image = Image.fromarray(img_np) + + # ffmpeg libx264 requires even dimensions + w, h = pil_image.size + if w % 2 != 0 or h % 2 != 0: + pil_image = pil_image.crop((0, 0, w - (w % 2), h - (h % 2))) + + # -- Audio ---------------------------------------------------------------- + waveform = audio["waveform"] + sample_rate = int(audio["sample_rate"]) + + if isinstance(waveform, torch.Tensor): + waveform = waveform.cpu().numpy() + + if waveform.ndim == 3: + waveform = waveform[0] # [channels, samples] + + # -- Output filename ------------------------------------------------------ + existing = [f for f in os.listdir(output_dir) if f.startswith(filename_prefix) and f.endswith(".mp4")] + counter = len(existing) + 1 + filename = f"{filename_prefix}_{counter:05d}.mp4" + output_path = os.path.join(output_dir, filename) + + with tempfile.TemporaryDirectory() as tmpdir: + img_path = os.path.join(tmpdir, "frame.png") + wav_path = os.path.join(tmpdir, "audio.wav") + + pil_image.save(img_path, format="PNG") + self._save_wav(waveform, sample_rate, wav_path) + + # ffmpeg metadata + meta_args = [] + if title: + meta_args += ["-metadata", f"title={title}"] + if tagline: + meta_args += ["-metadata", f"comment={tagline}"] + if tags: + meta_args += ["-metadata", f"genre={tags}"] + if keyscale: + meta_args += ["-metadata", f"key={keyscale}"] + if headline: + meta_args += ["-metadata", f"description={headline}"] + + cmd = [ + "ffmpeg", "-y", + "-loop", "1", + "-i", img_path, + "-i", wav_path, + "-c:v", "libx264", + "-tune", "stillimage", + "-c:a", "aac", + "-b:a", "192k", + "-pix_fmt", "yuv420p", + "-shortest", + ] + meta_args + [output_path] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=600) + if result.returncode != 0: + print(f"ffmpeg stderr:\n{result.stderr}") + raise RuntimeError(f"ffmpeg failed (code {result.returncode}): {result.stderr[-500:]}") + + print(f"[AQ_StillImageToVideo] Saved: {output_path}") + return (output_path,) + + def _save_wav(self, waveform, sample_rate, path): + """Write [channels, samples] float32 numpy array as 16-bit PCM WAV.""" + waveform = np.clip(waveform, -1.0, 1.0) + pcm = (waveform * 32767).astype(np.int16) + + channels, n_samples = pcm.shape + # Interleave: shape (n_samples, channels) → flatten + interleaved = pcm.T.flatten() + + with wave.open(path, "wb") as wf: + wf.setnchannels(channels) + wf.setsampwidth(2) # 16-bit = 2 bytes + wf.setframerate(sample_rate) + wf.writeframes(interleaved.tobytes()) diff --git a/prompts/cover.md b/prompts/cover.md index 5513fa9..2c918a5 100644 --- a/prompts/cover.md +++ b/prompts/cover.md @@ -19,9 +19,9 @@ Let the color palette, lighting, and visual atmosphere reflect this musical char **Typography — integrate the following text into the image design:** -- **Title** (bold, highly legible, prominent placement): {title} -- **Tagline** (smaller subtitle beneath or near the title): {tagline} -- **Headline** (largest, most dominant text element — this is the first thing the viewer reads): {headline} +- (bold, highly legible, prominent placement): "{title}" +- (smaller subtitle beneath or near the title): "{tagline}" +- (largest, most dominant text element — this is the first thing the viewer reads): "{headline}" Text must be sharp, high-contrast, and readable at thumbnail size (even at 320px wide). Use a typeface and color that complement the visual style. Avoid script fonts that are hard to read at small sizes. diff --git a/prompts/cover1.md b/prompts/cover1.md new file mode 100644 index 0000000..c9ee143 --- /dev/null +++ b/prompts/cover1.md @@ -0,0 +1 @@ +![alt text](image.png) \ No newline at end of file diff --git a/prompts/image.png b/prompts/image.png new file mode 100644 index 0000000..02bcea9 Binary files /dev/null and b/prompts/image.png differ