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.
|
* **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.
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ librosa
|
|||||||
matplotlib
|
matplotlib
|
||||||
torch
|
torch
|
||||||
torchaudio
|
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 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">
|
||||||
|
|||||||
Reference in New Issue
Block a user