From fd17803f142deb904e5efd2b37fcda5c4ac0ee23 Mon Sep 17 00:00:00 2001 From: pk Date: Tue, 10 Mar 2026 13:14:52 +0100 Subject: [PATCH] add video node --- nodes/api/AQ_Cover.py | 15 +-- nodes/api/AQ_VideoFromImage.py | 138 ++++++++++++++++++------- nodes/api/AQ_YouTubeUpload.py | 178 +++++++++++++++++++++------------ 3 files changed, 220 insertions(+), 111 deletions(-) diff --git a/nodes/api/AQ_Cover.py b/nodes/api/AQ_Cover.py index 65f59f7..d761c5e 100644 --- a/nodes/api/AQ_Cover.py +++ b/nodes/api/AQ_Cover.py @@ -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"] ] diff --git a/nodes/api/AQ_VideoFromImage.py b/nodes/api/AQ_VideoFromImage.py index e993e27..21a2849 100644 --- a/nodes/api/AQ_VideoFromImage.py +++ b/nodes/api/AQ_VideoFromImage.py @@ -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()) diff --git a/nodes/api/AQ_YouTubeUpload.py b/nodes/api/AQ_YouTubeUpload.py index 79afc2e..df1beb9 100644 --- a/nodes/api/AQ_YouTubeUpload.py +++ b/nodes/api/AQ_YouTubeUpload.py @@ -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")