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:
iu
2026-10-11 20:37:53 +03:00
parent d059d042f0
commit d38ede0201
52 changed files with 2717 additions and 82 deletions
+71 -2
View File
@@ -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",