add video node
This commit is contained in:
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
+16
-1
@@ -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"}),
|
||||
},
|
||||
|
||||
@@ -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())
|
||||
+3
-3
@@ -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.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||

|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Reference in New Issue
Block a user