diff --git a/docker/Dockerfile b/docker/Dockerfile index c6681d2..001df7c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,45 +1,31 @@ -############################################################################## -# Install App -############################################################################## -FROM python:3.14.0a2-alpine3.20 -WORKDIR /spatialmediatools/app -ENV PATH="${PATH}:/spatialmediatools/app" - -RUN apk update && \ - apk upgrade && \ - apk --no-cache add --virtual wget unzip ca-certificates - -COPY ./requirements.txt /spatialmediatools/app/requirements.txt -RUN python -m venv spatialmediatools -RUN source spatialmediatools/bin/activate -RUN spatialmediatools/bin/python -m pip install --upgrade pip -RUN spatialmediatools/bin/python -m pip install -r requirements.txt -RUN spatialmediatools/bin/python -m pip install -I gunicorn - -COPY ./app.py /spatialmediatools/app -COPY ./wsgi.py /spatialmediatools/app -COPY ./startup.sh /spatialmediatools/app -RUN chmod 777 /spatialmediatools/app/startup.sh -RUN mkdir ./data - -############################################################################## -# Download and extract Spatial Metadata Tools Code -############################################################################## -ENV GIT_URL="https://github.com/google/spatial-media/archive/refs/heads/master.zip" -ENV APP_DIR="/spatialmediatools/app" - -RUN wget --no-check-certificate -O spatialmediatools.zip $GIT_URL; -RUN unzip $APP_DIR/spatialmediatools.zip; - -############################################################################## -# Clean up of unneeded packages and download -############################################################################## -RUN rm -rf /var/cache/apk/*; -RUN rm $APP_DIR/spatialmediatools.zip -RUN apk del wget unzip ca-certificates; - -############################################################################## -# Run app.py -############################################################################## -#CMD [ "spatialmediatools/bin/python", "app.py" ] -ENTRYPOINT [ "/spatialmediatools/app/startup.sh" ] +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# Copy Flask requirements from the docker folder +COPY docker/requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy source code (Assuming build context is the repository root) +COPY spatialmedia /app/spatialmedia +COPY docker/app.py /app/app.py +COPY docker/templates /app/templates +COPY docker/static /app/static +COPY docker/startup.sh /app/startup.sh + +# Fix line endings (CRLF to LF) and make executable +RUN sed -i 's/\r$//' /app/startup.sh && chmod +x /app/startup.sh + +# Create upload directory +RUN mkdir -p /app/uploads && chmod 777 /app/uploads + +EXPOSE 5000 + +CMD ["/app/startup.sh"] diff --git a/docker/README.md b/docker/README.md index 19d380b..976313f 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,33 +1,61 @@ -This is the first attempt at taking a different path for the Spatial Media Tools and creating a Docker container to use the [CLI commands](https://github.com/google/spatial-media/tree/master/spatialmedia#spatial-media-metadata-injector) to inject the Spatial Media metadata required for VR360/180 video with or without ambisonic audio. +# Spatial Media Metadata Injector - Web Version -This should remove any OS specific requirements for Python TK that are tied to different Python versions in use. It will be based on the latest available Python/Alpine image at the time of release. +This is a modern Flask web application for injecting spatial media metadata, running inside Docker. It replaces the legacy Tkinter desktop application with a web browser interface. -To build this image clone this repository to a machine with Docker installed and run the following from this ./docker folder where the Dockerfile exists: +## Features +- **Web Interface**: A clean, dark-mode web UI supporting drag-and-drop for multiple files. + - **Progress Bar**: Visual indicator for file upload status. +- **Backend Refactor**: Logic ported from `gui.py` to a Python Flask application. + - **Persistence**: Fixed storage path to persist uploads and processed files. +- **Docker Integration**: Builds directly from the local source code. +- **Headerless**: No dependency on X11 or GUI libraries in the container. -`docker build -t spatialmedia/tools .` +## How to Run -To run this newly built image in Docker use the following command: +### Prerequisite +Ensure you have Docker installed. -**Note:** Map an OS path in the first section of the -v flag to /app/data within the container and ensure that it has read/write access. +### 1. Build the Image +Run this command from the **root of the repository** (the parent folder containing both `spatialmedia` and `docker` directories): +```bash +docker build -t spatial-media-web -f docker/Dockerfile . ``` -docker run -it \ --p 8888:5000 \ ---net=bridge \ --h spatialmedia \ ---name SpatialMediaTools \ --v /path/to/OS/folder:/spatialmediatools/app/data \ --d spatialmedia/tools + +### 2. Run the Container +We use the `--name` flag to assign a consistent name to the container, and a volume map to persist data locally. + +**PowerShell example:** +```powershell +# Create a local folder for data (if it doesn't exist) +mkdir data -ErrorAction SilentlyContinue + +# Run with volume mapping and container name +docker run -p 5000:5000 --name spatial-media-metadata-injector -v ${PWD}\data:/app/uploads spatial-media-web ``` -Once the image is running copy a file to inject to the above OS path and run the following to connect to the running image: +**Bash/Mac/Linux example:** +```bash +# Run with volume mapping and container name +docker run -p 5000:5000 --name spatial-media-metadata-injector -v $(pwd)/data:/app/uploads spatial-media-web +``` -`docker exec -it SpatialMediaTools sh` +### 3. Use the Tool +Open your browser and navigate to: +[http://localhost:5000](http://localhost:5000) -Change to the directory where the code was installed to in the image: +1. **Drag and drop** your .mp4 or .mov files. +2. Select the appropriate metadata options (360, 3D, Spatial Audio). +3. Click **Inject Metadata**. +4. Download the processed files via the web UI or find them in your local `data` folder. -`cd spatial-media-master` +## Troubleshooting -Using the [CLI commands](https://github.com/google/spatial-media/tree/master/spatialmedia#spatial-media-metadata-injector) as a reference attempt to inject the spatial media metadata into the video file you copied to the above path. Example: +### "File Not Found" Error +Ensure you are using the volume mapping `-v ...:/app/uploads` as shown above. This ensures files are saved to a location that persists across the application lifecycle and is accessible to all worker threads. -`python spatialmedia -i /spatialmediatools/app/data/ /spatialmediatools/app/data/` +### "Name already in use" Error +If you try to run the command again and get an error that the name `spatial-media-metadata-injector` is already in use, you need to remove the old container first: +```bash +docker rm -f spatial-media-metadata-injector +``` diff --git a/docker/app.py b/docker/app.py index 34b1c5c..c4004f4 100644 --- a/docker/app.py +++ b/docker/app.py @@ -1,9 +1,134 @@ -from flask import Flask +import os +import sys +import tempfile +import zipfile +import shutil +from flask import Flask, render_template, request, send_file, jsonify, after_this_request + +# Ensure we can import spatialmedia +# Assuming the Dockerfile places spatialmedia in a reachable location or we are running from root +try: + from spatialmedia import metadata_utils + from spatialmedia import mpeg +except ImportError: + # Fallback for local dev if running from docker folder + sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + from spatialmedia import metadata_utils + from spatialmedia import mpeg + app = Flask(__name__) +# Use a static path so all workers access the same directory +# Also allows mounting a volume to /app/uploads +app.config['UPLOAD_FOLDER'] = os.getenv('UPLOAD_FOLDER', '/app/uploads') +app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 * 1024 # 16GB max upload + +# Ensure upload folder exists +os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + +class Console: + def __init__(self): + self.log = [] + def append(self, text): + self.log.append(text) + def __call__(self, text): + self.append(text) + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/upload', methods=['POST']) +def upload_files(): + if 'files[]' not in request.files: + return jsonify({'error': 'No files provided'}), 400 + + files = request.files.getlist('files[]') + uploaded_files = [] + + for file in files: + if file.filename == '': + continue + + filename = file.filename + filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file.save(filepath) + + # Analyze file skipped as per user request + + metadata_info = { + 'filename': filename, + 'spherical': False, + 'stereo': 'none', + 'audio': False, + 'audio_desc': 'Unknown' + } + + uploaded_files.append(metadata_info) + + return jsonify({'files': uploaded_files}) + +@app.route('/inject', methods=['POST']) +def inject_metadata(): + data = request.json + files_to_process = data.get('files', []) + options = data.get('options', {}) + + if not files_to_process: + return jsonify({'error': 'No files specified'}), 400 + + stereo_mode = "none" + if options.get('stereo'): # Checkbox for 3D + stereo_mode = "top-bottom" # As per GUI logic + + metadata = metadata_utils.Metadata() + if options.get('spherical'): + metadata.video = metadata_utils.generate_spherical_xml(stereo=stereo_mode) + + # Audio handling logic copied from gui.py + # Note: In the GUI 'spatial_audio' checkbox is only enabled if supported. + # We will assume backend logic needs to re-verify or trust frontend. + # For now, simplistic approach: + + results = [] + + for filename in files_to_process: + input_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + output_filename = f"injected_{filename}" + output_path = os.path.join(app.config['UPLOAD_FOLDER'], output_filename) + + console = Console() + try: + # Re-parse to get specific audio audio capabilities for this file if needed + if options.get('spatial_audio'): + parsed = metadata_utils.parse_metadata(input_path, lambda x: None) + if parsed and parsed.num_audio_channels: + desc = metadata_utils.get_spatial_audio_description(parsed.num_audio_channels) + if desc.is_supported: + metadata.audio = metadata_utils.get_spatial_audio_metadata( + desc.order, + desc.has_head_locked_stereo + ) + + metadata_utils.inject_metadata(input_path, output_path, metadata, console.append) + results.append({ + 'filename': filename, + 'output_url': f"/download/{output_filename}", + 'logs': console.log, + 'success': True + }) + except Exception as e: + results.append({ + 'filename': filename, + 'error': str(e), + 'logs': console.log, + 'success': False + }) + + return jsonify({'results': results}) -@app.route("/") -def hello(): - return "

Hello There!

" +@app.route('/download/') +def download_file(filename): + return send_file(os.path.join(app.config['UPLOAD_FOLDER'], filename), as_attachment=True) -if __name__ == "__main__": - app.run(host='0.0.0.0') +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=True) diff --git a/docker/startup.sh b/docker/startup.sh index 2ee3ebe..8a492c5 100644 --- a/docker/startup.sh +++ b/docker/startup.sh @@ -1,2 +1,4 @@ #!/bin/sh -/spatialmediatools/app/spatialmediatools/bin/gunicorn wsgi:app -w 2 --threads 2 -b 0.0.0.0:5000 +# Run Gunicorn with the Flask app +# app:app refers to module 'app' and callable 'app' +exec gunicorn app:app -w 2 --threads 2 -b 0.0.0.0:5000 diff --git a/docker/static/script.js b/docker/static/script.js new file mode 100644 index 0000000..3bbd7ba --- /dev/null +++ b/docker/static/script.js @@ -0,0 +1,223 @@ +document.addEventListener('DOMContentLoaded', () => { + const dropZone = document.getElementById('drop-zone'); + const inputElement = dropZone.querySelector('input'); + const fileList = document.getElementById('file-list'); + const controls = document.getElementById('controls'); + const injectBtn = document.getElementById('inject-btn'); + const sphericalCb = document.getElementById('spherical'); + const cb3d = document.getElementById('3d'); + const cbAudio = document.getElementById('spatial-audio'); + const statusMessage = document.getElementById('status-message'); + const resultsArea = document.getElementById('results-area'); + const downloadLinks = document.getElementById('download-links'); + + // Progress UI + const progressContainer = document.getElementById('progress-container'); + const progressFill = document.getElementById('progress-fill'); + const progressText = document.getElementById('progress-text'); + + let uploadedFiles = []; + + // Drag and Drop Logic + dropZone.addEventListener('click', () => inputElement.click()); + + inputElement.addEventListener('change', (e) => { + if (inputElement.files.length) { + handleFiles(inputElement.files); + } + }); + + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drop-zone--over'); + }); + + ['dragleave', 'dragend'].forEach(type => { + dropZone.addEventListener(type, () => { + dropZone.classList.remove('drop-zone--over'); + }); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drop-zone--over'); + if (e.dataTransfer.files.length) { + handleFiles(e.dataTransfer.files); + } + }); + + async function handleFiles(files) { + const formData = new FormData(); + // Append all files + for (let i = 0; i < files.length; i++) { + formData.append('files[]', files[i]); + } + + statusMessage.textContent = 'Uploading and analyzing...'; + progressContainer.classList.remove('hidden'); + progressFill.style.width = '0%'; + progressText.textContent = '0%'; + + try { + const data = await uploadFilesValues(formData); + + // Merge new files with existing list if you want cumulative upload, + // but here we might just replace or add. Let's add. + data.files.forEach(f => { + if (f.logs && f.logs.length > 0) { + // Check for errors in logs + const errors = f.logs.filter(l => l.toLowerCase().includes('error') || l.toLowerCase().includes('warning')); + if (errors.length > 0) { + console.warn(`Logs for ${f.filename}:`, f.logs); + } + } + }); + uploadedFiles = [...uploadedFiles, ...data.files]; + + updateFileList(); + updateControlsState(); + statusMessage.textContent = ''; + controls.classList.remove('disabled'); + + } catch (error) { + console.error(error); + statusMessage.textContent = 'Error uploading files.'; + } finally { + progressContainer.classList.add('hidden'); + } + } + + function uploadFilesValues(formData) { + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open('POST', '/upload', true); + + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const percentComplete = (e.loaded / e.total) * 100; + progressFill.style.width = percentComplete + '%'; + progressText.textContent = Math.round(percentComplete) + '%'; + } + }; + + xhr.onload = () => { + if (xhr.status === 200) { + try { + const response = JSON.parse(xhr.responseText); + resolve(response); + } catch (e) { + reject(e); + } + } else { + reject(new Error(xhr.statusText)); + } + }; + + xhr.onerror = () => reject(new Error('Network Error')); + + xhr.send(formData); + }); + } + + function updateFileList() { + fileList.innerHTML = ''; + uploadedFiles.forEach(file => { + const item = document.createElement('div'); + item.classList.add('file-item'); + + item.innerHTML = ` +
+ ${file.filename} + Ready to process +
+ `; + fileList.appendChild(item); + }); + } + + // UI Logic + sphericalCb.addEventListener('change', updateControlsState); + + function updateControlsState() { + if (uploadedFiles.length === 0) { + controls.classList.add('disabled'); + return; + } + + if (sphericalCb.checked) { + cb3d.disabled = false; + cb3d.parentElement.style.opacity = '1'; + } else { + cb3d.disabled = true; + cb3d.checked = false; + cb3d.parentElement.style.opacity = '0.5'; + } + + // Auto-check spatial audio if any uploaded file supports it? + // For now, leave manual. + } + + // Initial Sync + updateControlsState(); + + // Inject Logic + injectBtn.addEventListener('click', async () => { + injectBtn.disabled = true; + injectBtn.textContent = 'Processing...'; + statusMessage.textContent = 'Injecting metadata...'; + resultsArea.classList.add('hidden'); + downloadLinks.innerHTML = ''; + + const options = { + spherical: sphericalCb.checked, + stereo: cb3d.checked, + spatial_audio: cbAudio.checked + }; + + const filesToProcess = uploadedFiles.map(f => f.filename); + + try { + const response = await fetch('/inject', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + files: filesToProcess, + options: options + }) + }); + + if (!response.ok) throw new Error('Injection failed'); + + const data = await response.json(); + + resultsArea.classList.remove('hidden'); + + data.results.forEach(result => { + if (result.success) { + const link = document.createElement('a'); + link.href = result.output_url; + link.classList.add('download-link'); + link.textContent = `Download ${result.filename}`; + // link.download = result.filename; // handled by Content-Disposition usually + downloadLinks.appendChild(link); + } else { + const errorMsg = document.createElement('div'); + errorMsg.style.color = 'var(--error)'; + errorMsg.textContent = `Error processing ${result.filename}: ${result.error}`; + downloadLinks.appendChild(errorMsg); + } + }); + + statusMessage.textContent = 'Processing complete.'; + + } catch (error) { + console.error(error); + statusMessage.textContent = 'An error occurred during injection.'; + } finally { + injectBtn.disabled = false; + injectBtn.textContent = 'Inject Metadata'; + } + }); +}); diff --git a/docker/static/style.css b/docker/static/style.css new file mode 100644 index 0000000..9f63b7d --- /dev/null +++ b/docker/static/style.css @@ -0,0 +1,296 @@ +:root { + --bg-color: #0f172a; + --card-bg: #1e293b; + --text-primary: #f1f5f9; + --text-secondary: #94a3b8; + --accent: #3b82f6; + --accent-hover: #2563eb; + --border: #334155; + --success: #10b981; + --error: #ef4444; +} + +body { + font-family: 'Inter', sans-serif; + background-color: var(--bg-color); + color: var(--text-primary); + margin: 0; + padding: 0; + display: flex; + justify-content: center; + min-height: 100vh; +} + +.container { + width: 100%; + max-width: 800px; + padding: 2rem; +} + +header { + text-align: center; + margin-bottom: 3rem; + animation: fadeIn 0.5s ease-out; +} + +header h1 { + font-weight: 600; + margin-bottom: 0.5rem; + background: linear-gradient(to right, #60a5fa, #a78bfa); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +header p { + color: var(--text-secondary); +} + +.drop-zone { + border: 2px dashed var(--border); + border-radius: 1rem; + padding: 3rem; + text-align: center; + background-color: rgba(30, 41, 59, 0.5); + transition: all 0.2s ease; + cursor: pointer; + margin-bottom: 2rem; +} + +.drop-zone:hover, +.drop-zone--over { + border-color: var(--accent); + background-color: rgba(59, 130, 246, 0.1); +} + +.drop-zone__input { + display: none; +} + +.drop-zone__prompt { + color: var(--text-secondary); + font-size: 1.1rem; +} + +.progress-container { + margin-bottom: 2rem; + display: none; +} + +.progress-container.hidden { + display: none; +} + +.progress-container:not(.hidden) { + display: block; +} + +.progress-bar { + height: 0.5rem; + background-color: var(--border); + border-radius: 0.25rem; + overflow: hidden; + margin-bottom: 0.5rem; +} + +.progress-fill { + height: 100%; + background-color: var(--accent); + width: 0%; + transition: width 0.1s linear; +} + +.progress-text { + text-align: right; + font-size: 0.85rem; + color: var(--text-secondary); +} + +.file-list { + margin-bottom: 2rem; +} + +.file-item { + background-color: var(--card-bg); + padding: 1rem; + border-radius: 0.5rem; + margin-bottom: 0.5rem; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid var(--border); + animation: slideIn 0.3s ease-out; +} + +.file-info { + display: flex; + flex-direction: column; +} + +.file-name { + font-weight: 500; +} + +.file-meta { + font-size: 0.85rem; + color: var(--text-secondary); + margin-top: 0.25rem; +} + +.controls { + background-color: var(--card-bg); + padding: 2rem; + border-radius: 1rem; + border: 1px solid var(--border); + transition: opacity 0.3s ease; +} + +.controls.disabled { + opacity: 0.5; + pointer-events: none; +} + +.options { + display: flex; + flex-direction: column; + gap: 1rem; + margin-bottom: 2rem; +} + +.checkbox-container { + display: flex; + align-items: center; + cursor: pointer; + user-select: none; + position: relative; + padding-left: 35px; +} + +.checkbox-container.indent { + margin-left: 2rem; +} + +.checkbox-container input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 0; + width: 0; +} + +.checkmark { + position: absolute; + top: 0; + left: 0; + height: 20px; + width: 20px; + background-color: #334155; + border-radius: 4px; + transition: all 0.2s; +} + +.checkbox-container:hover input~.checkmark { + background-color: #475569; +} + +.checkbox-container input:checked~.checkmark { + background-color: var(--accent); +} + +.checkmark:after { + content: ""; + position: absolute; + display: none; +} + +.checkbox-container input:checked~.checkmark:after { + display: block; +} + +.checkbox-container .checkmark:after { + left: 7px; + top: 3px; + width: 5px; + height: 10px; + border: solid white; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} + +.btn { + padding: 0.75rem 1.5rem; + border-radius: 0.5rem; + border: none; + font-weight: 500; + cursor: pointer; + font-family: inherit; + transition: all 0.2s; +} + +.btn.primary { + background-color: var(--accent); + color: white; + width: 100%; +} + +.btn.primary:hover { + background-color: var(--accent-hover); +} + +.status-message { + margin-top: 1rem; + text-align: center; + font-size: 0.9rem; + min-height: 1.25rem; +} + +.results-area { + margin-top: 2rem; + padding-top: 2rem; + border-top: 1px solid var(--border); +} + +.results-area.hidden { + display: none; +} + +.download-link { + display: block; + padding: 1rem; + background-color: rgba(16, 185, 129, 0.1); + border: 1px solid var(--success); + color: var(--success); + border-radius: 0.5rem; + text-decoration: none; + margin-bottom: 0.5rem; + text-align: center; + transition: background-color 0.2s; +} + +.download-link:hover { + background-color: rgba(16, 185, 129, 0.2); +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateX(-10px); + } + + to { + opacity: 1; + transform: translateX(0); + } +} \ No newline at end of file diff --git a/docker/templates/index.html b/docker/templates/index.html new file mode 100644 index 0000000..5a31cb2 --- /dev/null +++ b/docker/templates/index.html @@ -0,0 +1,72 @@ + + + + + + + Spatial Media Metadata Injector + + + + + +
+
+

Spatial Media Metadata Injector

+

Inject metadata into your 360° videos and spatial audio files.

+
+ +
+
+ Drag & Drop files here or click to upload + +
+ + + +
+ +
+ +
+
+ + + + + +
+ +
+ +
+
+
+ + +
+
+ + + + \ No newline at end of file