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:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user