YT Audio Encoding

This commit is contained in:
2026-01-07 04:34:24 +00:00
parent abceb1be7b
commit d548bd6fde
6 changed files with 299 additions and 5 deletions

View File

@@ -7,6 +7,7 @@ AudioImage is a powerful web-based tool for Audio Spectrogram Art and Digital St
* **Process Visualizer**: A real-time, interactive visualization page that lets you watch and hear the transformation process. * **Process Visualizer**: A real-time, interactive visualization page that lets you watch and hear the transformation process.
* **Live Waveform**: See the audio signal rendered as a glowing, real-time oscilloscope. * **Live Waveform**: See the audio signal rendered as a glowing, real-time oscilloscope.
* **Step-by-Step Reveal**: Watch the spectrogram and steganographic image being constructed layer by layer. * **Step-by-Step Reveal**: Watch the spectrogram and steganographic image being constructed layer by layer.
* **YouTube Audio Encoder**: Directly download audio from YouTube videos (with length validation) and embed it into images seamlessly.
* **Audio Art Generation**: Convert MP3/AAC audio files into high-resolution visual spectrograms using Python's `librosa` and `matplotlib`. * **Audio Art Generation**: Convert MP3/AAC audio files into high-resolution visual spectrograms using Python's `librosa` and `matplotlib`.
* **GPU Acceleration**: Automatically uses `torchaudio` and CUDA if available for lightning-fast processing. * **GPU Acceleration**: Automatically uses `torchaudio` and CUDA if available for lightning-fast processing.
* **Steganography Hider**: Hide secret audio or image files inside a "host" PNG image effectively using LSB (Least Significant Bit) encoding. * **Steganography Hider**: Hide secret audio or image files inside a "host" PNG image effectively using LSB (Least Significant Bit) encoding.

View File

@@ -102,16 +102,21 @@ def generate_art():
@app.route('/api/hide', methods=['POST']) @app.route('/api/hide', methods=['POST'])
def hide_data(): def hide_data():
if 'data' not in request.files or 'host' not in request.files: if 'data' not in request.files or 'host' not in request.files:
return jsonify({"error": "Requires 'data' and 'host' files"}), 400 return jsonify({"error": "Missing files"}), 400
data_file = request.files['data']
host_file = request.files['host']
data_path = None data_path = None
host_path = None host_path = None
try:
data_path = save_upload(request.files['data'])
host_path = save_upload(request.files['host'])
stego_path = processor.encode_stego(data_path, host_path) try:
return send_file(stego_path, mimetype='image/png') data_path = save_upload(data_file)
host_path = save_upload(host_file)
output_path = processor.encode_stego(data_path, host_path)
return send_file(output_path, mimetype='image/png')
except ValueError as e: except ValueError as e:
return jsonify({"error": str(e)}), 400 return jsonify({"error": str(e)}), 400
except Exception as e: except Exception as e:
@@ -124,6 +129,41 @@ def hide_data():
try: os.remove(host_path) try: os.remove(host_path)
except: pass except: pass
import youtube_utils
@app.route('/api/hide-yt', methods=['POST'])
def hide_yt_data():
if 'url' not in request.form or 'host' not in request.files:
return jsonify({"error": "Missing URL or Host Image"}), 400
youtube_url = request.form['url']
host_file = request.files['host']
audio_path = None
host_path = None
try:
# Download Audio
audio_path = youtube_utils.download_audio(youtube_url, app.config['UPLOAD_FOLDER'])
# Save Host
host_path = save_upload(host_file)
# Encode
output_path = processor.encode_stego(audio_path, host_path)
return send_file(output_path, mimetype='image/png')
except Exception as e:
return jsonify({"error": str(e)}), 500
finally:
# Cleanup
if audio_path and os.path.exists(audio_path):
try: os.remove(audio_path)
except: pass
if host_path and os.path.exists(host_path):
try: os.remove(host_path)
except: pass
@app.route('/api/decode', methods=['POST']) @app.route('/api/decode', methods=['POST'])
def decode(): def decode():
if 'image' not in request.files: if 'image' not in request.files:

View File

@@ -7,3 +7,4 @@ librosa
matplotlib matplotlib
torch torch
torchaudio torchaudio
yt-dlp

52
server/youtube_utils.py Normal file
View File

@@ -0,0 +1,52 @@
import yt_dlp
import os
import time
def download_audio(url, output_folder, max_length_seconds=600):
"""
Downloads audio from a YouTube URL.
Returns the path to the downloaded file or raises an Exception.
Enforces max_length_seconds (default 10 mins).
"""
timestamp = int(time.time())
output_template = os.path.join(output_folder, f'yt_{timestamp}_%(id)s.%(ext)s')
ydl_opts = {
'format': 'bestaudio/best',
'outtmpl': output_template,
'postprocessors': [{
'key': 'FFmpegExtractAudio',
'preferredcodec': 'mp3',
'preferredquality': '192',
}],
'quiet': True,
'no_warnings': True,
'noplaylist': True,
'match_filter': yt_dlp.utils.match_filter_func("duration <= " + str(max_length_seconds))
}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(url, download=True)
# The file path might differ slightly because of the postprocessor (mp3 conversion)
# yt-dlp usually returns the final filename in 'requested_downloads' or similar,
# but constructing it from info is safer if we know the template.
# However, extract_info returns the info dict.
# Since we force mp3, the file will end in .mp3
# We used %(id)s in template, so we can reconstruct or find it.
# Let's find the file in the folder that matches the timestamp prefix
# This is safer than guessing what yt-dlp named it exactly
valid_files = [f for f in os.listdir(output_folder) if f.startswith(f'yt_{timestamp}_') and f.endswith('.mp3')]
if not valid_files:
raise Exception("Download failed: Audio file not found after processing.")
return os.path.join(output_folder, valid_files[0])
except yt_dlp.utils.DownloadError as e:
if "video is too long" in str(e).lower() or "duration" in str(e).lower():
raise Exception(f"Video is too long. Maximum allowed duration is {max_length_seconds} seconds.")
raise Exception(f"Failed to download video: {str(e)}")

View File

@@ -0,0 +1,190 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import Icon from '@iconify/svelte';
const dispatch = createEventDispatcher();
let { loading = false } = $props();
let youtubeUrl = $state('');
let stegoHostFile: File | null = $state(null);
let hostImageMP = $state(0);
function getYoutubeVideoId(url: string) {
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return match && match[2].length === 11 ? match[2] : null;
}
async function handleHide() {
if (!youtubeUrl || !stegoHostFile) return;
const videoId = getYoutubeVideoId(youtubeUrl);
if (!videoId) {
dispatch('error', 'Invalid YouTube URL');
return;
}
dispatch('start');
const formData = new FormData();
formData.append('url', youtubeUrl);
formData.append('host', stegoHostFile);
try {
const res = await fetch('/api/hide-yt', { method: 'POST', body: formData });
if (!res.ok) {
const text = await res.text();
let errorMsg = text;
try {
const json = JSON.parse(text);
if (json.error) errorMsg = json.error;
} catch {}
throw new Error(errorMsg);
}
const blob = await res.blob();
dispatch('complete', { url: URL.createObjectURL(blob) });
} catch (e: any) {
dispatch('error', e.message);
} finally {
dispatch('end');
}
}
</script>
<section class="glass-panel p-6">
<h2 class="section-title text-red-500">
<Icon icon="heroicons:video-camera" class="h-6 w-6" />
YouTube Encoder
</h2>
<div transition:fade>
<p class="mb-6 text-sm text-(--text-muted)">
Download audio from a YouTube video and hide it inside an image.
</p>
<div class="space-y-4">
<!-- YouTube URL Input -->
<div>
<label
for="yt-url"
class="mb-2 block text-xs font-bold tracking-wider text-(--text-muted) uppercase"
>
YouTube Video URL
</label>
<div class="relative">
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<Icon icon="heroicons:link" class="h-5 w-5 text-white/50" />
</div>
<input
type="text"
id="yt-url"
bind:value={youtubeUrl}
placeholder="https://www.youtube.com/watch?v=..."
class="glass-input w-full py-3 pr-4 !pl-10 text-sm text-white placeholder-white/30 focus:ring-2 focus:ring-red-500/50 focus:outline-none"
disabled={loading}
/>
</div>
{#if youtubeUrl && getYoutubeVideoId(youtubeUrl)}
<div class="mt-2 flex items-center gap-1 text-xs text-emerald-400">
<Icon icon="heroicons:check-circle" class="h-3 w-3" /> Valid YouTube URL detected
</div>
{/if}
</div>
<!-- Host Image Input -->
<div>
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
<label
for="yt-host-input"
class="text-xs font-bold tracking-wider text-(--text-muted) uppercase"
>
Host Image (Cover)
</label>
{#if stegoHostFile && hostImageMP > 0}
<div
class="rounded-full bg-(--accent)/10 px-2 py-0.5 text-[10px] font-medium text-(--accent)"
>
{hostImageMP.toFixed(2)} MP
</div>
{/if}
</div>
<div
class="cursor-pointer rounded-lg border-2 border-dashed border-(--border-subtle) bg-(--bg-deep) p-6 text-center transition-colors hover:border-red-500/50 {loading
? 'pointer-events-none opacity-50'
: ''}"
role="button"
tabindex="0"
ondragover={(e) => e.preventDefault()}
ondrop={(e) => {
e.preventDefault();
if (e.dataTransfer?.files && e.dataTransfer.files[0]) {
const file = e.dataTransfer.files[0];
stegoHostFile = file;
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
hostImageMP = (img.width * img.height) / 1000000;
URL.revokeObjectURL(url);
};
img.src = url;
}
}}
onclick={() => !loading && document.getElementById('yt-host-input')?.click()}
onkeydown={(e) =>
(e.key === 'Enter' || e.key === ' ') &&
document.getElementById('yt-host-input')?.click()}
>
<input
type="file"
id="yt-host-input"
class="hidden"
accept="image/png, .png"
disabled={loading}
onchange={(e) => {
const target = e.target as HTMLInputElement;
if (target && target.files && target.files[0]) {
const file = target.files[0];
stegoHostFile = file;
const img = new Image();
const url = URL.createObjectURL(file);
img.onload = () => {
hostImageMP = (img.width * img.height) / 1000000;
URL.revokeObjectURL(url);
};
img.src = url;
}
}}
/>
{#if stegoHostFile}
<div class="flex flex-col items-center gap-2">
<Icon icon="heroicons:photo" class="h-8 w-8 text-red-500" />
<div class="font-semibold text-white">{stegoHostFile.name}</div>
<div class="text-xs text-(--text-muted)">
{(stegoHostFile.size / 1024 / 1024).toFixed(2)} MB
</div>
</div>
{:else}
<div class="flex flex-col items-center gap-2">
<Icon icon="heroicons:photo" class="h-8 w-8 text-(--text-muted)" />
<div class="text-(--text-muted)">Drop PNG image here or click to browse</div>
</div>
{/if}
</div>
</div>
<button
class="btn-primary group relative flex w-full items-center justify-center gap-2 overflow-hidden border-red-400/30 bg-linear-to-r from-red-600 to-red-500 shadow-lg shadow-red-500/20 transition-all hover:scale-[1.02] hover:from-red-500 hover:to-red-400 active:scale-[0.98]"
onclick={handleHide}
disabled={loading || !youtubeUrl || !stegoHostFile}
>
<div
class="absolute inset-0 bg-white/20 opacity-0 transition-opacity group-hover:opacity-100"
></div>
{#if loading}<div class="loader"></div>{/if}
<span class="relative">Encode Image with YT Audio</span>
</button>
</div>
</div>
</section>

View File

@@ -5,6 +5,7 @@
import ResultPreview from '$lib/components/ResultPreview.svelte'; import ResultPreview from '$lib/components/ResultPreview.svelte';
import SpectrogramTool from '$lib/components/SpectrogramTool.svelte'; import SpectrogramTool from '$lib/components/SpectrogramTool.svelte';
import EncoderTool from '$lib/components/EncoderTool.svelte'; import EncoderTool from '$lib/components/EncoderTool.svelte';
import YouTubeEncoderTool from '$lib/components/YouTubeEncoderTool.svelte';
import DecoderTool from '$lib/components/DecoderTool.svelte'; import DecoderTool from '$lib/components/DecoderTool.svelte';
let loading = false; let loading = false;
@@ -107,6 +108,15 @@
on:error={handleError} on:error={handleError}
on:complete={handleEncoderComplete} on:complete={handleEncoderComplete}
/> />
<!-- TOOL 2b: YOUTUBE AUDIO ENCODER -->
<YouTubeEncoderTool
{loading}
on:start={handleStart}
on:end={handleEnd}
on:error={handleError}
on:complete={handleEncoderComplete}
/>
</div> </div>
<div class="space-y-8"> <div class="space-y-8">