Compare commits
2 Commits
983b548d7b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| d548bd6fde | |||
| abceb1be7b |
52
Dockerfile
Normal file
52
Dockerfile
Normal 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"]
|
||||
25
README.md
25
README.md
@@ -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
19
docker-compose.yml
Normal 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
12
example.env
Normal 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
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
|
||||
@@ -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
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