Compare commits

...

2 Commits

Author SHA1 Message Date
d548bd6fde YT Audio Encoding 2026-01-07 04:34:24 +00:00
abceb1be7b Docker File Update 2026-01-07 04:16:40 +00:00
9 changed files with 397 additions and 17 deletions

52
Dockerfile Normal file
View File

@@ -0,0 +1,52 @@
# Stage 1: Build Frontend (SvelteKit)
FROM node:20-slim AS frontend-builder
WORKDIR /app
COPY package.json package-lock.json ./
# Install dependencies
RUN npm ci
# Copy source
COPY . .
# Build static files (outputs to build/)
RUN npm run build
# Stage 2: Backend (Python Flask + GPU Support)
# Use official PyTorch image with CUDA 12.1 support
FROM pytorch/pytorch:2.2.0-cuda12.1-cudnn8-runtime
# Install system dependencies for audio (libsndfile) and ffmpeg
# reliable ffmpeg install on debian-based images
RUN apt-get update && apt-get install -y \
libsndfile1 \
ffmpeg \
git \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy python requirements
# We remove torch/torchaudio from requirements briefly during install to avoid re-installing CPU versions
# or we trust pip to see the installed version satisfies it.
COPY server/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend code
COPY server/ .
# Copy built frontend from Stage 1 into the expected relative path
# app.py expects '../build', so we copy to /build and structure appropriately
# However, simpler is to copy build/ to /app/build and adjust app.py or folder structure.
# Let's mirror the structure: /app/server (WORKDIR) and /app/build
COPY --from=frontend-builder /app/build /app/build
# Set working directory to server where app.py resides
WORKDIR /app/server
# Environment variables
ENV PYTHONUNBUFFERED=1
ENV PORT=5000
# Expose port
EXPOSE ${PORT}
# Run the application
CMD ["python", "app.py"]

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.
* **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.
@@ -21,6 +22,18 @@ AudioImage is a powerful web-based tool for Audio Spectrogram Art and Digital St
## Installation
### Method 1: Docker (Recommended for GPU)
The easiest way to run AudioImage with full GPU acceleration is using Docker.
1. **Prerequisites**: Docker Desktop + NVIDIA Container Toolkit (for GPU).
2. **Run**:
```bash
docker compose up --build
```
3. Open `http://localhost:5000`.
### Method 2: Manual Setup
1. **Backend Setup**:
```bash
cd server
@@ -29,12 +42,6 @@ AudioImage is a powerful web-based tool for Audio Spectrogram Art and Digital St
pip install -r requirements.txt
```
*Optional for GPU support:*
```bash
pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu118
```
*(Adjust CUDA version as needed)*
2. **Frontend Setup**:
```bash
# Root directory
@@ -42,15 +49,11 @@ AudioImage is a powerful web-based tool for Audio Spectrogram Art and Digital St
npm run build
```
## Running the Application
1. Start the Flask server (which serves the frontend):
3. **Run**:
```bash
cd server
source venv/bin/activate
python app.py
```
2. Open your browser to `http://127.0.0.1:5000`.
## Architecture

19
docker-compose.yml Normal file
View File

@@ -0,0 +1,19 @@
services:
audio-image:
build: .
container_name: audio-image
ports:
- "${PORT:-5000}:5000"
volumes:
- ./server/uploads:/app/server/uploads
environment:
- FLASK_APP=app.py
- FLASK_DEBUG=${FLASK_DEBUG:-false}
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [ gpu ]

12
example.env Normal file
View File

@@ -0,0 +1,12 @@
# Server Configuration
PORT=5000
FLASK_DEBUG=false
FLASK_ENV=production
# Application Settings
# Max upload size in MB (default 40 in code)
MAX_UPLOAD_MB=40
# Optional: UID/GID for permission handling in Docker volume mapping
# PUID=1000
# PGID=1000

View File

@@ -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'])
stego_path = processor.encode_stego(data_path, host_path)
return send_file(stego_path, mimetype='image/png')
try:
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:
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:
@@ -234,4 +274,4 @@ def get_result(result_id):
return jsonify({"error": "Result not found"}), 404
if __name__ == '__main__':
app.run(debug=True, port=5000, threaded=True)
app.run(host='0.0.0.0', debug=True, port=5000, threaded=True)

View File

@@ -1,3 +1,4 @@
--extra-index-url https://download.pytorch.org/whl/cu121
Flask
Flask-Cors
numpy
@@ -6,3 +7,4 @@ librosa
matplotlib
torch
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 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">