feat(video): add video upload with remux/transcode playback
Add video as a media kind in photos: streaming upload with per-kind limits, ffprobe metadata, poster frame reusing the image variant pipeline, and a playback object (remux when web-compatible, else h264/yuv420p/aac transcode). Video jobs run on a separate pool with a long lease. Frontend gains a video viewer, grid badge/duration and video upload.
This commit is contained in:
+71
-2
@@ -23,8 +23,11 @@ import io
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
@@ -40,6 +43,8 @@ try:
|
||||
except ImportError: # pragma: no cover - exercised only without Pillow
|
||||
HAVE_PIL = False
|
||||
|
||||
HAVE_FFMPEG = shutil.which("ffmpeg") is not None
|
||||
|
||||
DEFAULT_BASE_URL = "http://localhost:8080"
|
||||
|
||||
COMMENT_TEXTS = [
|
||||
@@ -240,6 +245,38 @@ def make_image(rng):
|
||||
return "seed-{}.png".format(uuid.uuid4().hex[:8]), "image/png", generate_png(rng)
|
||||
|
||||
|
||||
def generate_video(rng):
|
||||
"""Return (filename, content_type, data) for one generated video.
|
||||
|
||||
Uses ffmpeg's synthetic source so the fixture is small and deterministic.
|
||||
Returns None when ffmpeg is unavailable.
|
||||
"""
|
||||
if not HAVE_FFMPEG:
|
||||
return None
|
||||
seconds = rng.randint(1, 3)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = os.path.join(tmp, "seed.mp4")
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-v", "error",
|
||||
"-y",
|
||||
"-f", "lavfi",
|
||||
"-i", "testsrc=size=320x240:rate=15",
|
||||
"-t", str(seconds),
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-c:v", "libx264",
|
||||
"-preset", "veryfast",
|
||||
path,
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
except (subprocess.CalledProcessError, OSError):
|
||||
return None
|
||||
with open(path, "rb") as fh:
|
||||
data = fh.read()
|
||||
return "seed-{}.mp4".format(uuid.uuid4().hex[:8]), "video/mp4", data
|
||||
|
||||
|
||||
# --- seeding ----------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -247,6 +284,7 @@ class Stats:
|
||||
def __init__(self):
|
||||
self.albums = 0
|
||||
self.photos = 0
|
||||
self.videos = 0
|
||||
self.tags = 0
|
||||
self.comments = 0
|
||||
self.likes = 0
|
||||
@@ -255,8 +293,8 @@ class Stats:
|
||||
|
||||
def report(self):
|
||||
return (
|
||||
"создано: альбомов={albums}, фото={photos}, тегов={tags}, "
|
||||
"комментариев={comments}, лайков={likes}, "
|
||||
"создано: альбомов={albums}, фото={photos}, видео={videos}, "
|
||||
"тегов={tags}, комментариев={comments}, лайков={likes}, "
|
||||
"share-ссылок={share_links}, инвайтов={invites}".format(
|
||||
**self.__dict__
|
||||
)
|
||||
@@ -289,6 +327,20 @@ def upload_photo(client, album_id, rng):
|
||||
return resp["photos"][0]["id"]
|
||||
|
||||
|
||||
def upload_video(client, album_id, rng):
|
||||
"""Upload one video fixture. Returns the photo id, or None when skipped."""
|
||||
fixture = generate_video(rng)
|
||||
if fixture is None:
|
||||
return None
|
||||
filename, content_type, data = fixture
|
||||
resp = client.request(
|
||||
"POST",
|
||||
"/api/albums/{}/photos".format(album_id),
|
||||
files=[("files", filename, content_type, data)],
|
||||
)
|
||||
return resp["photos"][0]["id"]
|
||||
|
||||
|
||||
def wait_for_variants(client, photo_id, timeout, poll=1.0):
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
@@ -349,6 +401,10 @@ def build_plan(args):
|
||||
extras.append("инвайты")
|
||||
lines.append(" сопутствующие сущности: {}".format(", ".join(extras) or "нет"))
|
||||
lines.append(" ожидание вариантов: {}".format("да" if args.wait_variants else "нет"))
|
||||
video_state = "нет"
|
||||
if args.video:
|
||||
video_state = "да" if HAVE_FFMPEG else "да, но ffmpeg не найден (пропуск)"
|
||||
lines.append(" видео-фикстуры: {}".format(video_state))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -365,6 +421,12 @@ def run(args, base_url, email, password):
|
||||
login(client, email, password)
|
||||
print("Вошел как {} @ {}".format(email, base_url))
|
||||
|
||||
if args.video and not HAVE_FFMPEG:
|
||||
print(
|
||||
" ! ffmpeg не найден: видео-фикстуры пропускаются",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
for album_index in range(1, args.albums + 1):
|
||||
album_id = create_album(
|
||||
client, "{} #{}".format(run_tag, album_index)
|
||||
@@ -402,6 +464,8 @@ def run(args, base_url, email, password):
|
||||
if args.invites:
|
||||
create_invite(client, album_id, "member")
|
||||
stats.invites += 1
|
||||
if args.video and upload_video(client, album_id, rng) is not None:
|
||||
stats.videos += 1
|
||||
|
||||
print(stats.report())
|
||||
return 0
|
||||
@@ -424,6 +488,11 @@ def parse_args(argv=None):
|
||||
parser.add_argument("--likes", action="store_true", help="добавлять лайки")
|
||||
parser.add_argument("--share-links", action="store_true", help="создавать share-ссылки")
|
||||
parser.add_argument("--invites", action="store_true", help="создавать инвайты (роль member)")
|
||||
parser.add_argument(
|
||||
"--video",
|
||||
action="store_true",
|
||||
help="загружать видео-фикстуры (требует ffmpeg; иначе пропускаются)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--wait-variants",
|
||||
action="store_true",
|
||||
|
||||
Reference in New Issue
Block a user