YT Audio Encoding
This commit is contained in:
@@ -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.
|
||||
* **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.
|
||||
* **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`.
|
||||
* **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.
|
||||
|
||||
@@ -102,16 +102,21 @@ def generate_art():
|
||||
@app.route('/api/hide', methods=['POST'])
|
||||
def hide_data():
|
||||
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
|
||||
host_path = None
|
||||
|
||||
try:
|
||||
data_path = save_upload(request.files['data'])
|
||||
host_path = save_upload(request.files['host'])
|
||||
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')
|
||||
|
||||
stego_path = processor.encode_stego(data_path, host_path)
|
||||
return send_file(stego_path, mimetype='image/png')
|
||||
except ValueError as e:
|
||||
return jsonify({"error": str(e)}), 400
|
||||
except Exception as e:
|
||||
@@ -124,6 +129,41 @@ def hide_data():
|
||||
try: os.remove(host_path)
|
||||
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'])
|
||||
def decode():
|
||||
if 'image' not in request.files:
|
||||
|
||||
@@ -7,3 +7,4 @@ librosa
|
||||
matplotlib
|
||||
torch
|
||||
torchaudio
|
||||
yt-dlp
|
||||
|
||||
52
server/youtube_utils.py
Normal file
52
server/youtube_utils.py
Normal 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)}")
|
||||
190
src/lib/components/YouTubeEncoderTool.svelte
Normal file
190
src/lib/components/YouTubeEncoderTool.svelte
Normal 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>
|
||||
@@ -5,6 +5,7 @@
|
||||
import ResultPreview from '$lib/components/ResultPreview.svelte';
|
||||
import SpectrogramTool from '$lib/components/SpectrogramTool.svelte';
|
||||
import EncoderTool from '$lib/components/EncoderTool.svelte';
|
||||
import YouTubeEncoderTool from '$lib/components/YouTubeEncoderTool.svelte';
|
||||
import DecoderTool from '$lib/components/DecoderTool.svelte';
|
||||
|
||||
let loading = false;
|
||||
@@ -107,6 +108,15 @@
|
||||
on:error={handleError}
|
||||
on:complete={handleEncoderComplete}
|
||||
/>
|
||||
|
||||
<!-- TOOL 2b: YOUTUBE AUDIO ENCODER -->
|
||||
<YouTubeEncoderTool
|
||||
{loading}
|
||||
on:start={handleStart}
|
||||
on:end={handleEnd}
|
||||
on:error={handleError}
|
||||
on:complete={handleEncoderComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-8">
|
||||
|
||||
Reference in New Issue
Block a user