feat(downloads): a clip or an edit can be a GIF, shared as a picture

A clip and an editor range can come out as a silent GIF (long side
320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB
is not kept. A shared picture is served inline under the preview's
sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still
for Facebook's crawler.
This commit is contained in:
2026-11-11 01:50:55 +01:00
parent fe38c07e7e
commit c221452a00
24 changed files with 885 additions and 58 deletions
+6 -2
View File
@@ -318,7 +318,9 @@ def enqueue_edit(
except quota.QuotaExceeded as e:
raise HTTPException(status_code=422, detail=quota_detail(e)) from e
except service.EditError as e:
raise HTTPException(status_code=400, detail={"code": "edit", "reason": e.reason}) from e
raise HTTPException(
status_code=400, detail={**e.params, "code": "edit", "reason": e.reason}
) from e
db.commit()
return serialize_job(db, job)
@@ -341,6 +343,8 @@ class EnqueueClipIn(BaseModel):
audio_track: int | None = None
# A crop frame (downloads/clip): fractions of the box it was drawn in, and that box's aspect.
crop: ClipCropIn | None = None
# A GIF of this long side (downloads/gif) instead of a video; None is the video clip.
gif_side: int | None = None
display_name: str | None = None
@@ -353,7 +357,7 @@ def enqueue_clip(
try:
crop = body.crop.model_dump() if body.crop else None
spec = clipmod.normalize(
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop
body.source, body.ref, body.start_s, body.end_s, body.audio_track, crop, body.gif_side
)
display_name = display_text(body.display_name)
job = service.enqueue_clip(db, user.id, spec, display_name=display_name)
+2 -7
View File
@@ -715,13 +715,8 @@ def get_file(
return response
# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the
# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other
# answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old engines.
PREVIEW_HEADERS = {
"Content-Security-Policy": "sandbox; frame-ancestors 'self'",
"X-Frame-Options": "SAMEORIGIN",
}
# A preview's own headers, shared with a share link's picture (`preview.SANDBOX_HEADERS`).
PREVIEW_HEADERS = preview.SANDBOX_HEADERS
# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start.
TRUNCATED_HEADER = "X-Preview-Truncated"
+15 -4
View File
@@ -16,7 +16,7 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.db import get_db
from app.downloads import links as linksmod
from app.downloads import queries, storage
from app.downloads import preview, queries, storage
from app.downloads.status import JobStatus
from app.ratelimit import RateLimiter, client_key
from app.security import verify_password
@@ -226,8 +226,9 @@ def _granted(link: "DownloadLink", token: str, g: str | None) -> None:
def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
"""Range-aware stream of a shared file. Inline by default (plays in the page); an
`allow_download` link serves it as an attachment."""
"""Range-aware stream of a shared file. Inline by default (a video plays and a picture shows in
the page, and a chat's link preview can fetch the picture); an `allow_download` link serves it
as an attachment."""
path = storage.safe_abs_path(settings.files_root, asset.rel_path)
if path is None:
raise refused(404, "video_gone")
@@ -241,6 +242,17 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
# served with its real audio/video type, inline unless the link forces a download so it can play
# in the watch page. Both are range-aware (FileResponse → 206) for seeking/resume.
media_type = storage.served_media_type(asset.media_kind, asset.container)
disposition = "attachment" if link.allow_download else "inline"
if media_type is None and preview.kind(asset.media_kind, asset.container) == "image":
# A picture (a GIF made from a clip, an uploaded photo) is shown as itself: its type from the
# preview allow-list, under the preview's sandbox, so a mislabelled one still runs nothing.
return FileResponse(
path,
filename=filename,
media_type=preview.media_type("image", asset.container),
content_disposition_type=disposition,
headers=preview.SANDBOX_HEADERS,
)
if media_type is None:
return FileResponse(
path,
@@ -248,7 +260,6 @@ def _serve(link: "DownloadLink", asset: "MediaAsset") -> FileResponse:
media_type="application/octet-stream",
content_disposition_type="attachment",
)
disposition = "attachment" if link.allow_download else "inline"
return FileResponse(
path, filename=filename, media_type=media_type, content_disposition_type=disposition
)