add karaoke mode: vocal isolation via unsandbox zerotrust containers
Per-shop unsandbox API keys in shop settings. On upload, audio/video products are processed through voxsplit in a zerotrust container (no network, destroyed after use) producing both instrumentals and vocals tracks. Background backfill with ThreadPoolExecutor sized to account concurrency, exponential backoff retries. Player cycles Original → Instrumentals → Vocals with toggle button or K shortcut.
This commit is contained in:
parent
6e40886cd7
commit
1135eb24e4
11 changed files with 681 additions and 2 deletions
292
make_post_sell/lib/karaoke.py
Normal file
292
make_post_sell/lib/karaoke.py
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
"""Karaoke mode — vocal isolation via unsandbox zerotrust containers.
|
||||
|
||||
Downloads media from S3, sends it + voxsplit.c into a zerotrust container
|
||||
(no network, destroyed after use), gets back both instrumentals and vocals,
|
||||
uploads them to S3.
|
||||
|
||||
Uses the unsandbox Python SDK (~/git/un-inception/un.py) for API auth.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Use the unsandbox Python SDK for API requests
|
||||
sys.path.insert(0, os.path.expanduser("~/git/un-inception"))
|
||||
from un import _make_request, _resolve_credentials # noqa: E402
|
||||
|
||||
VOXSPLIT_SOURCE = os.environ.get(
|
||||
"VOXSPLIT_SOURCE_PATH",
|
||||
os.path.expanduser("~/git/uncloseai-cli/audiotoys/voxsplit.c"),
|
||||
)
|
||||
|
||||
|
||||
def process_karaoke(s3_client, bucket, s3_key, s3_path,
|
||||
is_video, extension, public_key=None, secret_key=None):
|
||||
"""Download from S3 -> process in unsandbox zerotrust -> upload both tracks.
|
||||
|
||||
Produces two files under s3_path:
|
||||
{s3_path}/instrumentals
|
||||
{s3_path}/vocals
|
||||
|
||||
Returns dict {"instrumentals": size, "vocals": size} on success, None on failure.
|
||||
"""
|
||||
public_key, secret_key = _resolve_credentials(public_key, secret_key)
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="karaoke_") as tmpdir:
|
||||
# 1. Download original from S3
|
||||
local_path = os.path.join(tmpdir, "media")
|
||||
s3_client.download_file(bucket, s3_key, local_path)
|
||||
|
||||
# 2. Read and base64 encode
|
||||
with open(local_path, "rb") as f:
|
||||
media_b64 = base64.b64encode(f.read()).decode()
|
||||
with open(VOXSPLIT_SOURCE, "r") as f:
|
||||
voxsplit_src = f.read()
|
||||
voxsplit_b64 = base64.b64encode(voxsplit_src.encode()).decode()
|
||||
|
||||
# 3. Build execution script — produce both tracks
|
||||
if is_video:
|
||||
inst_name = f"instrumentals.{extension}"
|
||||
vocals_name = f"vocals.{extension}"
|
||||
script = (
|
||||
"gcc -O2 -o /tmp/voxsplit /tmp/input/voxsplit.c -lm && "
|
||||
"/tmp/voxsplit /tmp/input/media -o /tmp/split && "
|
||||
"mkdir -p /tmp/artifacts && "
|
||||
f"ffmpeg -y -i /tmp/input/media -i /tmp/split-instrumental.wav "
|
||||
f"-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/{inst_name} && "
|
||||
f"ffmpeg -y -i /tmp/input/media -i /tmp/split-vocal.wav "
|
||||
f"-c:v copy -map 0:v -map 1:a -shortest /tmp/artifacts/{vocals_name}"
|
||||
)
|
||||
else:
|
||||
inst_name = "instrumentals.wav"
|
||||
vocals_name = "vocals.wav"
|
||||
script = (
|
||||
"gcc -O2 -o /tmp/voxsplit /tmp/input/voxsplit.c -lm && "
|
||||
"/tmp/voxsplit /tmp/input/media -o /tmp/split && "
|
||||
"mkdir -p /tmp/artifacts && "
|
||||
"cp /tmp/split-instrumental.wav /tmp/artifacts/instrumentals.wav && "
|
||||
"cp /tmp/split-vocal.wav /tmp/artifacts/vocals.wav"
|
||||
)
|
||||
|
||||
# 4. Call unsandbox execute API via SDK
|
||||
payload = {
|
||||
"language": "bash",
|
||||
"code": script,
|
||||
"network_mode": "zerotrust",
|
||||
"ttl": 300,
|
||||
"vcpu": 2,
|
||||
"input_files": [
|
||||
{"filename": "media", "content": media_b64},
|
||||
{"filename": "voxsplit.c", "content": voxsplit_b64},
|
||||
],
|
||||
}
|
||||
|
||||
try:
|
||||
result = _make_request("POST", "/execute", public_key, secret_key, payload)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if not result.get("success") or result.get("exit_code") != 0:
|
||||
return None
|
||||
|
||||
# 5. Extract artifacts and upload to S3
|
||||
artifacts = result.get("artifacts", [])
|
||||
if len(artifacts) < 2:
|
||||
return None
|
||||
|
||||
# Map artifact filenames to track names
|
||||
artifact_map = {}
|
||||
for a in artifacts:
|
||||
fname = a.get("filename", "")
|
||||
if fname.startswith("instrumentals"):
|
||||
artifact_map["instrumentals"] = a
|
||||
elif fname.startswith("vocals"):
|
||||
artifact_map["vocals"] = a
|
||||
|
||||
if "instrumentals" not in artifact_map or "vocals" not in artifact_map:
|
||||
return None
|
||||
|
||||
sizes = {}
|
||||
content_type = "video/mp4" if is_video else "audio/wav"
|
||||
|
||||
for track_name in ("instrumentals", "vocals"):
|
||||
artifact = artifact_map[track_name]
|
||||
data = base64.b64decode(artifact["content_base64"])
|
||||
local_file = os.path.join(tmpdir, track_name)
|
||||
with open(local_file, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
s3_client.upload_file(
|
||||
local_file, bucket, f"{s3_path}/{track_name}",
|
||||
ExtraArgs={
|
||||
"CacheControl": "private, max-age=172800",
|
||||
"ContentType": content_type,
|
||||
},
|
||||
)
|
||||
sizes[track_name] = len(data)
|
||||
|
||||
return sizes
|
||||
|
||||
|
||||
def tracks_exist(s3_client, bucket, s3_path):
|
||||
"""Check if instrumentals and vocals already exist in S3."""
|
||||
for track in ("instrumentals", "vocals"):
|
||||
try:
|
||||
s3_client.head_object(Bucket=bucket, Key=f"{s3_path}/{track}")
|
||||
except Exception:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# Background backfill guard — one per shop, same pattern as reforge_discovery_ring_async
|
||||
_backfill_lock = threading.Lock()
|
||||
_backfill_running = set()
|
||||
|
||||
|
||||
def backfill_karaoke_async(shop_id, session_factory, app_settings):
|
||||
"""Backfill vocal isolation tracks for a shop's catalog using a thread pool.
|
||||
|
||||
Pool size matches the unsandbox account's concurrency limit (from validate_keys).
|
||||
"""
|
||||
shop_id_str = str(shop_id)
|
||||
|
||||
with _backfill_lock:
|
||||
if shop_id_str in _backfill_running:
|
||||
return # already running for this shop
|
||||
_backfill_running.add(shop_id_str)
|
||||
|
||||
def _backfill():
|
||||
try:
|
||||
import boto3
|
||||
from sqlalchemy.orm import Session as SASession
|
||||
from ..models.product import Product, get_media_type
|
||||
from ..models.shop import Shop
|
||||
from un import validate_keys
|
||||
|
||||
session = SASession(bind=session_factory().get_bind())
|
||||
|
||||
def _make_s3():
|
||||
return boto3.session.Session().client(
|
||||
"s3",
|
||||
region_name=app_settings["bucket.secure_uploads.region"],
|
||||
endpoint_url=app_settings["bucket.secure_uploads.post_endpoint"],
|
||||
aws_access_key_id=app_settings["bucket.secure_uploads.access_key"],
|
||||
aws_secret_access_key=app_settings["bucket.secure_uploads.secret_key"],
|
||||
)
|
||||
|
||||
s3 = _make_s3()
|
||||
bucket = app_settings["bucket.secure_uploads"]
|
||||
|
||||
try:
|
||||
shop = session.get(Shop, shop_id)
|
||||
if not shop or not shop.unsandbox_public_key or not shop.unsandbox_secret_key:
|
||||
return
|
||||
|
||||
pk, sk = shop.unsandbox_public_key, shop.unsandbox_secret_key
|
||||
|
||||
# Query account concurrency from unsandbox API
|
||||
try:
|
||||
key_info = validate_keys(pk, sk)
|
||||
concurrency = int(key_info.get("concurrency", 1))
|
||||
except Exception:
|
||||
concurrency = 1
|
||||
concurrency = max(1, concurrency)
|
||||
log.info("Backfill karaoke for shop %s — %d concurrent workers", shop.name, concurrency)
|
||||
|
||||
# Collect work items
|
||||
work = []
|
||||
products = session.query(Product).filter(Product.shop_id == shop_id).all()
|
||||
for product in products:
|
||||
for file_key in ("product", "preview"):
|
||||
ext = product.extensions.get(file_key)
|
||||
if not ext:
|
||||
continue
|
||||
media_type = get_media_type(ext)
|
||||
if media_type not in ("video", "audio"):
|
||||
continue
|
||||
if tracks_exist(s3, bucket, product.s3_path):
|
||||
continue
|
||||
work.append((product.id, product.s3_path, product.title, file_key, ext, media_type))
|
||||
|
||||
if not work:
|
||||
log.info("No products need backfill for shop %s", shop.name)
|
||||
return
|
||||
|
||||
MAX_RETRIES = 3
|
||||
BACKOFF_BASE = 5 # seconds
|
||||
|
||||
# Process a single product with retries (runs in pool thread)
|
||||
def _process_one(item):
|
||||
product_id, s3_path, title, file_key, ext, media_type = item
|
||||
thread_s3 = _make_s3()
|
||||
is_video = (media_type == "video")
|
||||
|
||||
for attempt in range(MAX_RETRIES):
|
||||
log.info("Backfill karaoke: %s (%s) attempt %d/%d",
|
||||
title, file_key, attempt + 1, MAX_RETRIES)
|
||||
try:
|
||||
sizes = process_karaoke(
|
||||
thread_s3, bucket,
|
||||
f"{s3_path}/{file_key}",
|
||||
s3_path, is_video, ext,
|
||||
public_key=pk, secret_key=sk,
|
||||
)
|
||||
if sizes:
|
||||
return product_id, title, file_key, ext, media_type, sizes
|
||||
except Exception:
|
||||
log.exception("Backfill attempt %d failed for %s", attempt + 1, title)
|
||||
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
import time as _time
|
||||
wait = BACKOFF_BASE * (2 ** attempt)
|
||||
log.info(" Retrying %s in %ds...", title, wait)
|
||||
_time.sleep(wait)
|
||||
|
||||
return product_id, title, file_key, ext, media_type, None
|
||||
|
||||
# Fan out with thread pool
|
||||
with ThreadPoolExecutor(max_workers=concurrency) as pool:
|
||||
futures = {pool.submit(_process_one, item): item for item in work}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
product_id, title, file_key, ext, media_type, sizes = future.result()
|
||||
except Exception:
|
||||
item = futures[future]
|
||||
log.exception("Backfill worker crashed for %s", item[2])
|
||||
continue
|
||||
|
||||
if sizes:
|
||||
# DB write only on success — no partial state
|
||||
product = session.get(Product, product_id)
|
||||
if not product:
|
||||
continue
|
||||
track_ext = ext if media_type == "video" else "wav"
|
||||
for track_name in ("instrumentals", "vocals"):
|
||||
product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}")
|
||||
tmp = product.file_bytes
|
||||
tmp.update(sizes)
|
||||
product.file_bytes = tmp
|
||||
session.add(product)
|
||||
session.commit()
|
||||
log.info(" OK %s (inst=%dB, vox=%dB)", title, sizes["instrumentals"], sizes["vocals"])
|
||||
else:
|
||||
log.warning(" FAILED %s after %d attempts", title, MAX_RETRIES)
|
||||
|
||||
except Exception:
|
||||
session.rollback()
|
||||
log.exception("Background karaoke backfill failed for shop %s", shop_id_str)
|
||||
finally:
|
||||
session.close()
|
||||
finally:
|
||||
with _backfill_lock:
|
||||
_backfill_running.discard(shop_id_str)
|
||||
|
||||
t = threading.Thread(target=_backfill, daemon=True)
|
||||
t.start()
|
||||
|
|
@ -168,6 +168,8 @@ class Product(RBase, Base):
|
|||
"thumbnail2",
|
||||
"thumbnail3",
|
||||
"thumbnail4",
|
||||
"instrumentals",
|
||||
"vocals",
|
||||
]
|
||||
|
||||
file_public_keys = [
|
||||
|
|
|
|||
|
|
@ -100,6 +100,10 @@ class Shop(RBase, Base):
|
|||
adyen_hmac_key = Column(Unicode(128), nullable=True)
|
||||
adyen_enabled = Column(Boolean, default=True)
|
||||
|
||||
# Unsandbox API credentials for karaoke vocal isolation
|
||||
unsandbox_public_key = Column(Unicode(128), nullable=True)
|
||||
unsandbox_secret_key = Column(Unicode(128), nullable=True)
|
||||
|
||||
created_timestamp = Column(BigInteger, nullable=False)
|
||||
updated_timestamp = Column(BigInteger, nullable=False)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
"""add unsandbox API keys to shop
|
||||
|
||||
Revision ID: a1b2c3d4e5f6
|
||||
Revises: d4c7d6a1e961
|
||||
Create Date: 2026-02-17 00:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a1b2c3d4e5f6'
|
||||
down_revision = 'd4c7d6a1e961'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def _column_exists(table, column):
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(sa.text(f"PRAGMA table_info({table})"))
|
||||
return any(row[1] == column for row in result.fetchall())
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _column_exists("mps_shop", "unsandbox_public_key"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("unsandbox_public_key", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
if not _column_exists("mps_shop", "unsandbox_secret_key"):
|
||||
op.add_column(
|
||||
"mps_shop",
|
||||
sa.Column("unsandbox_secret_key", sa.Unicode(128), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_column("mps_shop", "unsandbox_secret_key")
|
||||
op.drop_column("mps_shop", "unsandbox_public_key")
|
||||
107
make_post_sell/scripts/backfill_karaoke.py
Normal file
107
make_post_sell/scripts/backfill_karaoke.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Backfill vocal isolation tracks for all existing audio/video products.
|
||||
|
||||
Usage:
|
||||
python -m make_post_sell.scripts.backfill_karaoke production.ini
|
||||
python -m make_post_sell.scripts.backfill_karaoke production.ini --execute
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from pyramid.paster import bootstrap
|
||||
|
||||
from make_post_sell.models.product import Product, get_media_type
|
||||
from make_post_sell.lib.karaoke import process_karaoke, tracks_exist
|
||||
|
||||
|
||||
def backfill(env, dry_run=True):
|
||||
request = env["request"]
|
||||
dbsession = request.dbsession
|
||||
s3 = request.secure_uploads_client
|
||||
bucket = request.app["bucket.secure_uploads"]
|
||||
|
||||
products = dbsession.query(Product).all()
|
||||
print(f"Found {len(products)} products to scan")
|
||||
print(f"Dry run: {dry_run}")
|
||||
print("-" * 50)
|
||||
|
||||
processed = 0
|
||||
skipped = 0
|
||||
errors = 0
|
||||
|
||||
for product in products:
|
||||
shop = product.shop
|
||||
if not shop.unsandbox_public_key or not shop.unsandbox_secret_key:
|
||||
continue
|
||||
|
||||
for file_key in ("product", "preview"):
|
||||
ext = product.extensions.get(file_key)
|
||||
if not ext:
|
||||
continue
|
||||
media_type = get_media_type(ext)
|
||||
if media_type not in ("video", "audio"):
|
||||
continue
|
||||
|
||||
s3_key = f"{product.s3_path}/{file_key}"
|
||||
|
||||
if tracks_exist(s3, bucket, product.s3_path):
|
||||
print(f"SKIP {product.title} ({file_key}) — tracks already exist")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
print(f"WOULD PROCESS {product.title} ({file_key})")
|
||||
continue
|
||||
|
||||
print(f"PROCESSING {product.title} ({file_key})...")
|
||||
is_video = (media_type == "video")
|
||||
sizes = process_karaoke(
|
||||
s3, bucket, s3_key, product.s3_path, is_video, ext,
|
||||
public_key=shop.unsandbox_public_key,
|
||||
secret_key=shop.unsandbox_secret_key,
|
||||
)
|
||||
if sizes:
|
||||
track_ext = ext if is_video else "wav"
|
||||
for track_name in ("instrumentals", "vocals"):
|
||||
product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}")
|
||||
tmp = product.file_bytes
|
||||
tmp.update(sizes)
|
||||
product.file_bytes = tmp
|
||||
dbsession.add(product)
|
||||
dbsession.flush()
|
||||
print(f" OK (instrumentals: {sizes['instrumentals']}B, vocals: {sizes['vocals']}B)")
|
||||
processed += 1
|
||||
else:
|
||||
print(f" FAILED")
|
||||
errors += 1
|
||||
|
||||
if not dry_run:
|
||||
import transaction
|
||||
transaction.commit()
|
||||
|
||||
print("-" * 50)
|
||||
print(f"Processed: {processed} Skipped: {skipped} Errors: {errors} Dry run: {dry_run}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Backfill vocal isolation tracks for audio/video products"
|
||||
)
|
||||
parser.add_argument("config_uri", help="Configuration file (e.g., production.ini)")
|
||||
parser.add_argument(
|
||||
"--execute", action="store_true",
|
||||
help="Actually process files (default is dry run)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
with bootstrap(args.config_uri) as env:
|
||||
try:
|
||||
backfill(env, dry_run=not args.execute)
|
||||
except KeyboardInterrupt:
|
||||
print("\nCancelled")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -385,6 +385,13 @@
|
|||
togglePip();
|
||||
}
|
||||
break;
|
||||
case 'k':
|
||||
case 'K':
|
||||
e.preventDefault();
|
||||
if (typeof toggleKaraoke === 'function') {
|
||||
toggleKaraoke();
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -237,7 +237,11 @@
|
|||
<!-- Row 3: More options -->
|
||||
<button id="autoResizeBtn" onclick="toggleAutoResize()" title="Auto-Resize Window (R)">↔ Resize</button>
|
||||
<button onclick="window.close()" title="Close Window (ESC)">✕ Close</button>
|
||||
<span></span><!-- Empty cell for grid alignment -->
|
||||
{% if instrumentals_url or vocals_url %}
|
||||
<button id="karaokeBtn" onclick="toggleKaraoke()" title="Karaoke Mode (K)">🎤 Original</button>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -252,9 +256,51 @@
|
|||
productId: "{{ product.id }}",
|
||||
productUrl: "{{ product_url }}",
|
||||
prevProductUrl: {{ ('"%s"' % prev_product_url) if prev_product_url else 'null' }},
|
||||
nextProductUrl: {{ ('"%s"' % next_product_url) if next_product_url else 'null' }}
|
||||
nextProductUrl: {{ ('"%s"' % next_product_url) if next_product_url else 'null' }},
|
||||
instrumentalsUrl: {{ ('"%s"' % instrumentals_url) if instrumentals_url else 'null' }},
|
||||
vocalsUrl: {{ ('"%s"' % vocals_url) if vocals_url else 'null' }}
|
||||
};
|
||||
|
||||
// Karaoke toggle — cycles: Original → Instrumentals → Vocals → Original
|
||||
let originalSrc = null;
|
||||
const trackModes = [];
|
||||
(function() {
|
||||
trackModes.push({name: 'Original', url: null});
|
||||
if (window.playerData.instrumentalsUrl)
|
||||
trackModes.push({name: 'Instrumentals', url: window.playerData.instrumentalsUrl});
|
||||
if (window.playerData.vocalsUrl)
|
||||
trackModes.push({name: 'Vocals', url: window.playerData.vocalsUrl});
|
||||
})();
|
||||
let trackIndex = 0;
|
||||
|
||||
function toggleKaraoke() {
|
||||
const media = document.getElementById('media');
|
||||
const btn = document.getElementById('karaokeBtn');
|
||||
if (!media || trackModes.length <= 1) return;
|
||||
|
||||
if (!originalSrc) originalSrc = media.src;
|
||||
|
||||
const currentTime = media.currentTime;
|
||||
const wasPlaying = !media.paused;
|
||||
|
||||
trackIndex = (trackIndex + 1) % trackModes.length;
|
||||
const mode = trackModes[trackIndex];
|
||||
|
||||
media.src = mode.url || originalSrc;
|
||||
btn.textContent = '🎤 ' + mode.name;
|
||||
if (trackIndex === 0) {
|
||||
btn.classList.remove('active');
|
||||
} else {
|
||||
btn.classList.add('active');
|
||||
}
|
||||
|
||||
media.addEventListener('loadedmetadata', function once() {
|
||||
media.currentTime = currentTime;
|
||||
if (wasPlaying) media.play();
|
||||
media.removeEventListener('loadedmetadata', once);
|
||||
});
|
||||
}
|
||||
|
||||
// Go to the product page (always opens in new tab)
|
||||
function goToProductPage() {
|
||||
window.open(window.playerData.productUrl, '_blank');
|
||||
|
|
|
|||
|
|
@ -390,6 +390,73 @@
|
|||
<br />
|
||||
{% endif %}
|
||||
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
||||
<h3>Unsandbox Settings 🎤</h3>
|
||||
<p>API keys for vocal isolation (karaoke mode). Media is processed in zerotrust containers — no network, destroyed after use.</p>
|
||||
|
||||
<form method="post" action="/s/{{ request.shop.id }}/settings" onsubmit="submit.disabled = true; return true;">
|
||||
<input type="hidden" name="form_section" value="unsandbox-settings" />
|
||||
|
||||
<details class="api-key-details">
|
||||
<summary class="toggle-summary">Show Unsandbox API Keys</summary>
|
||||
|
||||
<div class="api-key-fields">
|
||||
|
||||
<br />
|
||||
|
||||
<label for="unsandbox_public_key_input">Public Key</label>
|
||||
<input
|
||||
name = "unsandbox_public_key"
|
||||
type = "text"
|
||||
id = "unsandbox_public_key_input"
|
||||
class = "full-width-input"
|
||||
value = "{% if unsandbox_public_key %}{{ unsandbox_public_key }}{% endif %}"
|
||||
placeholder = "unsb-pk-xxxx-xxxx-xxxx-xxxx"
|
||||
/>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<label for="unsandbox_secret_key_input">Secret Key</label>
|
||||
<input
|
||||
name = "unsandbox_secret_key"
|
||||
type = "text"
|
||||
id = "unsandbox_secret_key_input"
|
||||
class = "full-width-input"
|
||||
value = "{% if unsandbox_secret_key %}{{ unsandbox_secret_key }}{% endif %}"
|
||||
placeholder = "unsb-sk-xxxxx-xxxxx-xxxxx-xxxxx"
|
||||
/>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% if request.shop.unsandbox_public_key and request.shop.unsandbox_secret_key %}
|
||||
<small class="success-indicator">✓ Unsandbox configured — karaoke mode active on new uploads</small>
|
||||
{% else %}
|
||||
<small class="status-message">Add your unsandbox.com API keys to enable vocal isolation on audio/video products.</small>
|
||||
{% endif %}
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<input type="submit" name="submit" class="mps-submit" value="Save Unsandbox Settings" />
|
||||
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
</form>
|
||||
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
{% if request.monero_enabled %}
|
||||
<section class="one-column">
|
||||
<section class="shop-settings well">
|
||||
|
|
|
|||
|
|
@ -117,6 +117,31 @@ def player(request):
|
|||
if next_product_id and current_index is not None and current_index < len(media_products) - 1:
|
||||
next_product_url = media_products[current_index + 1].absolute_url(request)
|
||||
|
||||
# Generate presigned URLs for isolated tracks if available
|
||||
instrumentals_url = None
|
||||
vocals_url = None
|
||||
if media_type in ("video", "audio"):
|
||||
for track_name, attr in (("instrumentals", "instrumentals_url"), ("vocals", "vocals_url")):
|
||||
if track_name in product.extensions:
|
||||
track_ext = product.extensions.get(track_name)
|
||||
track_ct = product.get_content_type(track_name) or (
|
||||
"video/mp4" if media_type == "video" else "audio/wav"
|
||||
)
|
||||
url = request.secure_uploads_client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={
|
||||
"Bucket": bucket_name,
|
||||
"Key": f"{product.s3_path}/{track_name}",
|
||||
"ResponseContentDisposition": f'inline; filename="{product.title} ({track_name.title()}).{track_ext}"',
|
||||
"ResponseContentType": track_ct,
|
||||
},
|
||||
ExpiresIn=900,
|
||||
)
|
||||
if track_name == "instrumentals":
|
||||
instrumentals_url = url
|
||||
else:
|
||||
vocals_url = url
|
||||
|
||||
return {
|
||||
"product": product,
|
||||
"presigned_url": presigned_url,
|
||||
|
|
@ -127,6 +152,8 @@ def player(request):
|
|||
"product_url": product_url,
|
||||
"prev_product_url": prev_product_url,
|
||||
"next_product_url": next_product_url,
|
||||
"instrumentals_url": instrumentals_url,
|
||||
"vocals_url": vocals_url,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -222,6 +249,31 @@ def player_json(request):
|
|||
if current_index < len(media_products) - 1:
|
||||
next_product_id = str(media_products[current_index + 1].id)
|
||||
|
||||
# Generate presigned URLs for isolated tracks if available
|
||||
instrumentals_url = None
|
||||
vocals_url = None
|
||||
if media_type in ("video", "audio"):
|
||||
for track_name in ("instrumentals", "vocals"):
|
||||
if track_name in product.extensions:
|
||||
track_ext = product.extensions.get(track_name)
|
||||
track_ct = product.get_content_type(track_name) or (
|
||||
"video/mp4" if media_type == "video" else "audio/wav"
|
||||
)
|
||||
url = request.secure_uploads_client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={
|
||||
"Bucket": bucket_name,
|
||||
"Key": f"{product.s3_path}/{track_name}",
|
||||
"ResponseContentDisposition": f'inline; filename="{product.title} ({track_name.title()}).{track_ext}"',
|
||||
"ResponseContentType": track_ct,
|
||||
},
|
||||
ExpiresIn=900,
|
||||
)
|
||||
if track_name == "instrumentals":
|
||||
instrumentals_url = url
|
||||
else:
|
||||
vocals_url = url
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"videoUrl": presigned_url,
|
||||
|
|
@ -230,6 +282,8 @@ def player_json(request):
|
|||
"productId": str(product.id),
|
||||
"prevProductId": prev_product_id,
|
||||
"nextProductId": next_product_id,
|
||||
"instrumentalsUrl": instrumentals_url,
|
||||
"vocalsUrl": vocals_url,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,32 @@ def product_edit(request):
|
|||
product.shop.id, request.registry["dbsession_factory"]
|
||||
)
|
||||
|
||||
# Generate vocal isolation tracks if this is audio/video
|
||||
from ..models.product import get_media_type
|
||||
from ..lib.karaoke import process_karaoke
|
||||
upload_media_type = get_media_type(product.extensions.get(file_key))
|
||||
shop = product.shop
|
||||
if upload_media_type in ("video", "audio") and shop.unsandbox_public_key and shop.unsandbox_secret_key:
|
||||
is_video = (upload_media_type == "video")
|
||||
ext = product.extensions.get(file_key)
|
||||
sizes = process_karaoke(
|
||||
request.secure_uploads_client,
|
||||
request.app["bucket.secure_uploads"],
|
||||
f"{product.s3_path}/{file_key}",
|
||||
product.s3_path, is_video, ext,
|
||||
public_key=shop.unsandbox_public_key,
|
||||
secret_key=shop.unsandbox_secret_key,
|
||||
)
|
||||
if sizes:
|
||||
track_ext = ext if is_video else "wav"
|
||||
for track_name in ("instrumentals", "vocals"):
|
||||
product.set_file_metadata(track_name, track_ext, f"{track_name}.{track_ext}")
|
||||
tmp = product.file_bytes
|
||||
tmp.update(sizes)
|
||||
product.file_bytes = tmp
|
||||
request.dbsession.add(product)
|
||||
request.dbsession.flush()
|
||||
|
||||
# redirect back to this page to clear
|
||||
# the params posted by the s3 webhooks.
|
||||
return HTTPFound(f"{product.absolute_edit_url(request)}?uploaded={file_key}")
|
||||
|
|
|
|||
|
|
@ -771,6 +771,38 @@ def shop_settings(request):
|
|||
shop.adyen_hmac_key = adyen_hmac_key
|
||||
request.session.flash(("You set the shop's Adyen HMAC Key.", "success"))
|
||||
|
||||
# Handle unsandbox settings form
|
||||
if form_section == "unsandbox-settings":
|
||||
unsandbox_public_key = request.params.get("unsandbox_public_key", "").strip()
|
||||
unsandbox_secret_key = request.params.get("unsandbox_secret_key", "").strip()
|
||||
keys_changed = False
|
||||
|
||||
if unsandbox_public_key and unsandbox_public_key != (shop.unsandbox_public_key or ""):
|
||||
if unsandbox_public_key.startswith("unsb-pk-"):
|
||||
shop.unsandbox_public_key = unsandbox_public_key
|
||||
keys_changed = True
|
||||
request.session.flash(("Unsandbox public key saved.", "success"))
|
||||
else:
|
||||
request.session.flash(("Unsandbox public key must start with 'unsb-pk-'.", "error"))
|
||||
|
||||
if unsandbox_secret_key and unsandbox_secret_key != (shop.unsandbox_secret_key or ""):
|
||||
if unsandbox_secret_key.startswith("unsb-sk-"):
|
||||
shop.unsandbox_secret_key = unsandbox_secret_key
|
||||
keys_changed = True
|
||||
request.session.flash(("Unsandbox secret key saved.", "success"))
|
||||
else:
|
||||
request.session.flash(("Unsandbox secret key must start with 'unsb-sk-'.", "error"))
|
||||
|
||||
# Backfill existing catalog when both keys are now present
|
||||
if keys_changed and shop.unsandbox_public_key and shop.unsandbox_secret_key:
|
||||
from ..lib.karaoke import backfill_karaoke_async
|
||||
backfill_karaoke_async(
|
||||
shop.id,
|
||||
request.registry["dbsession_factory"],
|
||||
request.app,
|
||||
)
|
||||
request.session.flash(("Backfilling vocal isolation for existing catalog in background.", "success"))
|
||||
|
||||
# Handle maintenance settings form
|
||||
if form_section == "maintenance-settings":
|
||||
if shop.maint_mode != maint_mode:
|
||||
|
|
@ -1067,6 +1099,8 @@ def shop_settings(request):
|
|||
),
|
||||
"xmr_processor": xmr_processor,
|
||||
"doge_processor": doge_processor,
|
||||
"unsandbox_public_key": shop.unsandbox_public_key or "",
|
||||
"unsandbox_secret_key": shop.unsandbox_secret_key or "",
|
||||
"signed_posts": signed_posts,
|
||||
"get_endpoints": get_endpoints,
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue