add video node

This commit is contained in:
pk
2026-03-10 13:14:52 +01:00
parent 1ed09fee52
commit fd17803f14
3 changed files with 220 additions and 111 deletions
+3 -12
View File
@@ -1,18 +1,9 @@
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",
f"{root} {quality}"
for quality in ["major", "minor"]
for root in ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B"]
]
+102 -36
View File
@@ -6,6 +6,12 @@ import numpy as np
import torch
from PIL import Image
_KEYSCALES = [
f"{root} {quality}"
for quality in ["major", "minor"]
for root in ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B"]
]
class AQ_StillImageToVideo:
@classmethod
@@ -15,13 +21,16 @@ class AQ_StillImageToVideo:
"image": ("IMAGE",),
"audio": ("AUDIO",),
"filename_prefix": ("STRING", {"default": "song_cover"}),
"fps": ("INT", {"default": 1, "min": 1, "max": 60, "step": 1}),
"encoder": (["auto (gpu→cpu fallback)", "cpu (libx264)", "nvidia (h264_nvenc)", "amd (h264_amf)", "intel (h264_qsv)"],
{"default": "auto (gpu→cpu fallback)"}),
},
"optional": {
"title": ("STRING", {"default": ""}),
"tagline": ("STRING", {"default": ""}),
"headline": ("STRING", {"default": ""}),
"tags": ("STRING", {"default": ""}),
"keyscale": ("STRING", {"default": ""}),
"keyscale": (_KEYSCALES, {"default": "C major"}),
}
}
@@ -31,7 +40,8 @@ class AQ_StillImageToVideo:
FUNCTION = "create_video"
CATEGORY = "Aquasite/Media"
def create_video(self, image, audio, filename_prefix="song_cover",
def create_video(self, image, audio, filename_prefix="song_cover", fps=1,
encoder="auto (gpu→cpu fallback)",
title="", tagline="", headline="", tags="", keyscale=""):
import folder_paths
@@ -51,7 +61,7 @@ class AQ_StillImageToVideo:
pil_image = Image.fromarray(img_np)
# ffmpeg libx264 requires even dimensions
# libx264/nvenc require 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)))
@@ -64,10 +74,11 @@ class AQ_StillImageToVideo:
waveform = waveform.cpu().numpy()
if waveform.ndim == 3:
waveform = waveform[0] # [channels, samples]
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")]
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)
@@ -79,51 +90,106 @@ class AQ_StillImageToVideo:
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}"]
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:]}")
self._encode(encoder, img_path, wav_path, output_path, fps, meta_args)
print(f"[AQ_StillImageToVideo] Saved: {output_path}")
return (output_path,)
# ------------------------------------------------------------------
def _encode(self, encoder_choice, img_path, wav_path, output_path, fps, meta_args):
"""Run ffmpeg, trying GPU first when encoder is 'auto'."""
_GPU_CODECS = {
"nvidia (h264_nvenc)": self._nvenc_args,
"amd (h264_amf)": self._amf_args,
"intel (h264_qsv)": self._qsv_args,
}
if encoder_choice == "cpu (libx264)":
cmd = self._build_cmd(img_path, wav_path, output_path, fps,
self._cpu_args(), meta_args)
self._run(cmd)
elif encoder_choice in _GPU_CODECS:
codec_args = _GPU_CODECS[encoder_choice]()
cmd = self._build_cmd(img_path, wav_path, output_path, fps,
codec_args, meta_args)
self._run(cmd)
else: # auto: try GPU codecs in order, fall back to CPU
for codec_fn in (self._nvenc_args, self._amf_args, self._qsv_args):
cmd = self._build_cmd(img_path, wav_path, output_path, fps,
codec_fn(), meta_args)
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
if result.returncode == 0:
return
print(f"[AQ_StillImageToVideo] GPU encoder failed, trying next: "
f"{result.stderr.splitlines()[-1] if result.stderr else ''}")
# CPU fallback
print("[AQ_StillImageToVideo] Falling back to CPU (libx264 ultrafast)")
cmd = self._build_cmd(img_path, wav_path, output_path, fps,
self._cpu_args(), meta_args)
self._run(cmd)
def _build_cmd(self, img_path, wav_path, output_path, fps, codec_args, meta_args):
return [
"ffmpeg", "-y",
"-loop", "1",
"-framerate", str(fps),
"-i", img_path,
"-i", wav_path,
] + codec_args + [
"-r", str(fps),
"-c:a", "aac",
"-b:a", "192k",
"-pix_fmt", "yuv420p",
"-shortest",
"-movflags", "+faststart",
] + meta_args + [output_path]
def _run(self, cmd):
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:]}"
)
# -- Codec argument sets ----------------------------------------------------
def _cpu_args(self):
return ["-c:v", "libx264", "-preset", "ultrafast", "-crf", "28", "-tune", "stillimage"]
def _nvenc_args(self):
return ["-c:v", "h264_nvenc", "-preset", "p1", "-rc", "vbr", "-cq", "28"]
def _amf_args(self):
return ["-c:v", "h264_amf", "-quality", "speed", "-rc", "vbr_latency"]
def _qsv_args(self):
return ["-c:v", "h264_qsv", "-preset", "veryfast"]
# -- WAV helper -------------------------------------------------------------
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
channels, _ = pcm.shape
interleaved = pcm.T.flatten()
with wave.open(path, "wb") as wf:
wf.setnchannels(channels)
wf.setsampwidth(2) # 16-bit = 2 bytes
wf.setsampwidth(2)
wf.setframerate(sample_rate)
wf.writeframes(interleaved.tobytes())
+115 -63
View File
@@ -1,6 +1,12 @@
import os
import requests
_KEYSCALES = [
f"{root} {quality}"
for quality in ["major", "minor"]
for root in ["C", "C#", "Db", "D", "D#", "Eb", "E", "F", "F#", "Gb", "G", "G#", "Ab", "A", "A#", "Bb", "B"]
]
_INSTRUCTION = """AQ_YouTubeUpload — How to get credentials (one-time setup)
=================================================================
@@ -11,57 +17,95 @@ You need three values: Client ID, Client Secret, Refresh Token.
STEP 1 — Create a Google Cloud project & enable YouTube API
-----------------------------------------------------------
1. Go to https://console.cloud.google.com/
2. Create a new project (top bar → "New Project").
3. Navigate to: APIs & Services → Library
4. Search "YouTube Data API v3" → click it → Enable.
2. Create a new project (top bar -> "New Project").
3. Navigate to: APIs & Services -> Library
4. Search "YouTube Data API v3" -> click it -> Enable.
STEP 2 — Create OAuth 2.0 credentials
--------------------------------------
1. Navigate to: APIs & Services → Credentials
2. Click "Create Credentials" → "OAuth client ID"
3. If prompted, configure the OAuth consent screen first:
- User type: External
- Fill in app name, your email → Save
- Scopes: add "YouTube Data API v3 → .../auth/youtube.upload"
- Add yourself as a Test User (your Google account email)
4. Back in Credentials → Create Credentials → OAuth client ID:
- Application type: "Desktop app"
- Give it a name → Create
5. Copy "Client ID" and "Client Secret" from the dialog.
STEP 2 — Configure OAuth consent screen
----------------------------------------
1. Navigate to: APIs & Services -> OAuth consent screen
2. User type: External -> Create
3. Fill in App name and your email address -> Save and Continue
4. On the Scopes screen -> Add or Remove Scopes ->
manually type: https://www.googleapis.com/auth/youtube.upload
-> Add to table -> Update -> Save and Continue
5. On the Test users screen -> Add Users ->
add the Google account email that owns your YouTube channel
-> Save and Continue
STEP 3 — Generate a Refresh Token (one-time)
--------------------------------------------
1. Visit https://developers.google.com/oauthplayground/
2. Click the gear icon (⚙️) in the top-right:
STEP 3 — Create OAuth 2.0 credentials (WEB APPLICATION type)
--------------------------------------------------------------
!! IMPORTANT: use "Web application" type, NOT "Desktop app" !!
The OAuth Playground requires a web redirect URI. Desktop app
clients do not support it and will cause "unauthorized_client".
1. Navigate to: APIs & Services -> Credentials
2. Click "Create Credentials" -> "OAuth client ID"
3. Application type: "Web application"
4. Under "Authorized redirect URIs" -> Add URI:
https://developers.google.com/oauthplayground
(exactly as written, no trailing slash)
5. Click Create -> copy the Client ID and Client Secret.
STEP 4 — Generate a Refresh Token via OAuth Playground
-------------------------------------------------------
!! IMPORTANT: configure your credentials in the Playground
BEFORE clicking "Authorize APIs" — order matters !!
1. Open https://developers.google.com/oauthplayground/
2. Click the gear icon top-right:
- Check "Use your own OAuth credentials"
- Enter your Client ID and Client Secret → Close
3. In "Step 1 — Select & authorize APIs":
- Paste or find: https://www.googleapis.com/auth/youtube.upload
- Paste your Client ID and Client Secret
- Close the settings panel
3. In "Step 1 — Select & authorize APIs" (left panel):
- In the input box at the bottom type:
https://www.googleapis.com/auth/youtube.upload
- Click "Authorize APIs"
- Sign in with the Google account that owns the YouTube channel
4. In "Step 2 — Exchange authorization code for tokens":
- Sign in with the Google account added as Test User in Step 2
- Click "Allow"
4. You are now in "Step 2 — Exchange authorization code for tokens":
- Click "Exchange authorization code for tokens"
- Copy the "Refresh token" value
- Copy the "Refresh token" value (starts with "1//" or "1/")
STEP 4 — Fill in the node
STEP 5 — Fill in the node
--------------------------
- client_id → your OAuth Client ID
- client_secret → your OAuth Client Secret
- refresh_token → the refresh token from Step 3
client_id -> Client ID from Step 3
client_secret -> Client Secret from Step 3
refresh_token -> Refresh token from Step 4
The refresh token does not expire unless you revoke it.
You only need to do this setup once per YouTube channel.
Refresh tokens do not expire unless revoked.
This setup is needed only once per YouTube channel.
TROUBLESHOOTING
---------------
"unauthorized_client":
Most likely cause A: you created a "Desktop app" client instead
of "Web application". Re-create the OAuth client as Web app and
add the Playground redirect URI (Step 3 above), then redo Step 4.
Most likely cause B: you forgot to set your own credentials in
the Playground gear icon BEFORE authorizing. The token you got
belongs to Google's own Playground client, not yours.
Re-do Step 4 — set credentials in gear icon first, then Authorize.
"access_denied" or 403 Forbidden during upload:
The uploading Google account is not listed as a Test User on the
OAuth consent screen. Add it (Step 2, item 5) and redo Step 4.
"invalid_grant":
The refresh token has been revoked or the authorization was
removed. Re-generate starting from Step 4.
PRIVACY STATUS
--------------
- private → only you can see it
- unlisted → anyone with the link can see it
- public → visible to everyone
private -> only you can see it
unlisted -> anyone with the link can see it
public -> visible to everyone
DESCRIPTION AUTO-BUILD
-----------------------
If you leave 'description' empty, the node auto-composes it
from tagline, tags, and keyscale inputs.
If you leave description empty, the node auto-composes it from
tagline, tags, and keyscale inputs.
"""
@@ -71,19 +115,19 @@ class AQ_YouTubeUpload:
def INPUT_TYPES(s):
return {
"required": {
"video_path": ("STRING", {"default": ""}),
"client_id": ("STRING", {"default": ""}),
"client_secret": ("STRING", {"default": ""}),
"refresh_token": ("STRING", {"default": ""}),
"title": ("STRING", {"default": "My Song"}),
"video_path": ("STRING", {"default": ""}),
"client_id": ("STRING", {"default": ""}),
"client_secret": ("STRING", {"default": ""}),
"refresh_token": ("STRING", {"default": ""}),
"title": ("STRING", {"default": "My Song"}),
"privacy_status": (["private", "unlisted", "public"], {"default": "private"}),
},
"optional": {
"description": ("STRING", {"multiline": True, "default": ""}),
"tagline": ("STRING", {"multiline": False, "default": ""}),
"headline": ("STRING", {"multiline": False, "default": ""}),
"tags": ("STRING", {"multiline": False, "default": ""}),
"keyscale": ("STRING", {"multiline": False, "default": ""}),
"description": ("STRING", {"multiline": True, "default": ""}),
"tagline": ("STRING", {"multiline": False, "default": ""}),
"headline": ("STRING", {"multiline": False, "default": ""}),
"tags": ("STRING", {"multiline": False, "default": ""}),
"keyscale": (_KEYSCALES, {"default": "C major"}),
}
}
@@ -103,7 +147,6 @@ class AQ_YouTubeUpload:
if not os.path.isfile(video_path):
raise FileNotFoundError(f"Video file not found: {video_path}")
# Auto-compose description if not provided
if not description.strip():
parts = []
if tagline:
@@ -127,7 +170,7 @@ class AQ_YouTubeUpload:
description=description,
tags=tags_list,
privacy_status=privacy_status,
category_id="10", # 10 = Music
category_id="10", # 10 = Music
)
video_url = f"https://www.youtube.com/watch?v={video_id}"
@@ -148,7 +191,21 @@ class AQ_YouTubeUpload:
timeout=30,
)
if not resp.ok:
raise RuntimeError(f"Failed to get access token: {resp.status_code} {resp.text}")
body = resp.text
hint = ""
if "unauthorized_client" in body:
hint = (
"\nFix: re-create the OAuth client as 'Web application' type "
"(not Desktop app) and add https://developers.google.com/oauthplayground "
"as an authorized redirect URI, then regenerate the refresh token. "
"Also make sure you set your own credentials in the Playground gear "
"icon BEFORE clicking Authorize APIs."
)
elif "invalid_grant" in body:
hint = "\nFix: the refresh token was revoked. Re-generate it via OAuth Playground."
raise RuntimeError(
f"Failed to get access token ({resp.status_code}): {body}{hint}"
)
return resp.json()["access_token"]
def _resumable_upload(self, access_token, video_path, title, description,
@@ -167,26 +224,24 @@ class AQ_YouTubeUpload:
},
}
# Initiate upload session
init = requests.post(
"https://www.googleapis.com/upload/youtube/v3/videos",
params={"uploadType": "resumable", "part": "snippet,status"},
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Type": "video/mp4",
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json; charset=UTF-8",
"X-Upload-Content-Type": "video/mp4",
"X-Upload-Content-Length": str(file_size),
},
json=metadata,
timeout=60,
)
if not init.ok:
raise RuntimeError(f"Upload init failed: {init.status_code} {init.text}")
raise RuntimeError(f"Upload init failed ({init.status_code}): {init.text}")
upload_url = init.headers["Location"]
# Upload in 10 MB chunks
chunk_size = 10 * 1024 * 1024
chunk_size = 10 * 1024 * 1024 # 10 MB
with open(video_path, "rb") as fh:
start = 0
while start < file_size:
@@ -205,15 +260,12 @@ class AQ_YouTubeUpload:
if put.status_code in (200, 201):
return put.json()["id"]
elif put.status_code == 308: # Resume Incomplete
elif put.status_code == 308: # Resume Incomplete
range_header = put.headers.get("Range", "")
if range_header:
start = int(range_header.split("-")[1]) + 1
else:
start = end + 1
start = int(range_header.split("-")[1]) + 1 if range_header else end + 1
else:
raise RuntimeError(
f"Upload chunk failed: {put.status_code} {put.text}"
f"Upload chunk failed ({put.status_code}): {put.text}"
)
raise RuntimeError("Upload ended without receiving a video ID")