From 27ccf253f5d32ddb964a0b48adaee6a39a9ed07c Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 11 Nov 2026 02:10:07 +0100 Subject: [PATCH] feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB). A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF per kept segment; Join says why it is unavailable instead of going dead. The share page shows a picture as itself with its Download, and a picture in a folder opens above the list. --- .../features/player/ClipPanel.dom.test.tsx | 68 +++++++- frontend/src/features/player/ClipPanel.tsx | 104 +++++++++++- frontend/src/i18n/locales/en/clip.json | 15 ++ frontend/src/i18n/locales/en/editor.json | 18 +- frontend/src/i18n/locales/hu/clip.json | 15 ++ frontend/src/i18n/locales/hu/editor.json | 18 +- frontend/src/lib/api/downloads.ts | 5 + frontend/src/lib/gif.contract.test.ts | 52 ++++++ frontend/src/lib/gif.test.ts | 42 +++++ frontend/src/lib/gif.ts | 60 +++++++ .../files/VideoEditor.gif.dom.test.tsx | 126 ++++++++++++++ frontend/src/modules/files/VideoEditor.tsx | 160 +++++++++++++++--- frontend/src/watch/WatchPage.dom.test.tsx | 64 +++++++ frontend/src/watch/WatchPage.tsx | 61 ++++++- frontend/src/watch/watchStrings.ts | 4 + 15 files changed, 775 insertions(+), 37 deletions(-) create mode 100644 frontend/src/lib/gif.contract.test.ts create mode 100644 frontend/src/lib/gif.test.ts create mode 100644 frontend/src/lib/gif.ts create mode 100644 frontend/src/modules/files/VideoEditor.gif.dom.test.tsx diff --git a/frontend/src/features/player/ClipPanel.dom.test.tsx b/frontend/src/features/player/ClipPanel.dom.test.tsx index 650cfcbc..67db855c 100644 --- a/frontend/src/features/player/ClipPanel.dom.test.tsx +++ b/frontend/src/features/player/ClipPanel.dom.test.tsx @@ -12,7 +12,10 @@ import type { ClipRange } from "../../lib/clipRange"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); - return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; + return { + ...actual, + useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }), + }; }); const enqueueClip = vi.fn<(body: ClipRequest) => Promise>(); @@ -271,3 +274,66 @@ describe("ClipPanel", () => { expect(screen.getByTestId("crop").textContent).toBe("none"); }); }); + +describe("ClipPanel GIF", () => { + it("makes a GIF at the size picked, and says a GIF is being made", async () => { + const onClose = vi.fn(); + render(); + expect(screen.queryByRole("group", { name: "clip.gif.size" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" })); + fireEvent.click(screen.getByRole("button", { name: "320" })); + expect(enqueueClip).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "clip.gif.make" })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(enqueueClip.mock.calls[0]![0]).toMatchObject({ + start_s: 100, + end_s: 108, + gif_side: 320, + }); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ level: "success", message: msg("clip.gif.queued") }), + ); + }); + + it("starts at the default size, and a video again sends no GIF", async () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" })); + expect(screen.getByRole("button", { name: "480" }).getAttribute("aria-pressed")).toBe("true"); + fireEvent.click(screen.getByRole("button", { name: "clip.formatVideo" })); + fireEvent.click(screen.getByRole("button", { name: "clip.cut" })); + await waitFor(() => expect(enqueueClip).toHaveBeenCalled()); + expect(enqueueClip.mock.calls[0]![0]).not.toHaveProperty("gif_side"); + }); + + it("a range longer than a GIF may be leaves Make unavailable, with its reason", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" })); + const make = screen.getByRole("button", { name: "clip.gif.make" }); + expect(make.getAttribute("aria-disabled")).toBe("true"); + const note = document.getElementById(make.getAttribute("aria-describedby")!)!; + expect(note.textContent).toBe("clip.gif.tooLong"); + fireEvent.click(make); + expect(enqueueClip).not.toHaveBeenCalled(); + }); + + it("says what the expected size means: fine, no moving preview, or past the cap", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "clip.formatGif" })); + const note = () => + document.getElementById( + screen.getByRole("button", { name: "clip.gif.make" }).getAttribute("aria-describedby")!, + )!.textContent; + // 10 s at 480: about 4–10 MB. + expect(note()).toBe("clip.gif.hint"); + // 20 s at 480: up to about 20 MB, past a link preview's 15. + fireEvent.change(screen.getByRole("textbox", { name: "clip.out" }), { + target: { value: "2:00" }, + }); + fireEvent.keyDown(screen.getByRole("textbox", { name: "clip.out" }), { key: "Enter" }); + expect(range()).toBe("100-120"); + expect(note()).toBe("clip.gif.noPreview"); + // 20 s at 640: up to about 32 MB, past the server's 30. + fireEvent.click(screen.getByRole("button", { name: "640" })); + expect(note()).toBe("clip.gif.mayFail"); + }); +}); diff --git a/frontend/src/features/player/ClipPanel.tsx b/frontend/src/features/player/ClipPanel.tsx index 24ef24ae..deb60b6d 100644 --- a/frontend/src/features/player/ClipPanel.tsx +++ b/frontend/src/features/player/ClipPanel.tsx @@ -10,6 +10,15 @@ import { notifyActionError } from "../../lib/actionErrors"; import { showInTray } from "../../lib/downloadQueue"; import { log } from "../../lib/log"; import { formatTimecode } from "../../lib/timecode"; +import { + estimateGif, + gifEstimateText, + gifVerdict, + GIF_DEFAULT_SIDE, + GIF_MAX_S, + GIF_SIDES, + type GifSide, +} from "../../lib/gif"; import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange"; import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry"; import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack"; @@ -38,6 +47,11 @@ import { useTimecodeField, type TimecodeDraft } from "../../components/ui/useTim // (`ClipCropFrame`, which the player places over its picture) marks the part to keep, free or held // to an output shape. The crop is the caller's state too, so the player's Escape can turn it off // before it closes the panel. +// +// The clip is an mp4 unless the user picks GIF: then the size (the long side) and the size the GIF +// is expected to come out at show before it is made, with what that size means — over 15 MB a Teams +// link preview shows it still, over 30 MB the server refuses it. A range longer than a GIF may be +// leaves Cut unavailable, with the reason beside it. /** The output shapes a crop frame can be held to (width / height of the clip), or a free frame. */ type ClipShape = "free" | "16:9" | "9:16" | "1:1"; @@ -110,10 +124,13 @@ export default function ClipPanel({ onPlayFrom, onClose, }: Props) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const rootRef = useRef(null); const [name, setName] = useState(""); + const [format, setFormat] = useState<"video" | "gif">("video"); + const [side, setSide] = useState(GIF_DEFAULT_SIDE); + const gifNoteId = useId(); const [busy, setBusy] = useState(false); const [looping, setLooping] = useState(false); // The strip's window while a handle drags (null at rest: it follows the selection). @@ -123,6 +140,13 @@ export default function ClipPanel({ const view = frozen ?? zoomWindow(clip, duration); const bounds = handleBounds(clip, duration); const length = clip.end - clip.start; + const gif = format === "gif"; + // The GIF's shape is the crop's when there is one: the long side is capped, so the shape decides + // how many pixels it holds. + const shape = crop ? (crop.rect.w * picture) / crop.rect.h : picture; + const estimate = estimateGif(length, side, shape); + const verdict = gif ? gifVerdict(length, estimate) : "ok"; + const blocked = verdict === "too_long"; // Opened from a button or a key, the panel takes the focus to its In handle: the button that // opened it is gone (its row is this panel now), and the arrows then move the mark at once. Without @@ -185,26 +209,34 @@ export default function ClipPanel({ }, } : {}), + ...(gif ? { gif_side: side } : {}), ...(name.trim() ? { display_name: name.trim() } : {}), }; const submit = async (e: SubmitEvent) => { e.preventDefault(); - if (busy) return; + if (busy || blocked) return; setBusy(true); + // Before the try: a `?:` inside one makes the React Compiler skip the whole panel. + const queued = msg(gif ? "clip.gif.queued" : "clip.queued"); + const failed = msg(gif ? "clip.gif.failed" : "clip.failed"); try { const job = await api.enqueueClip(body); invalidateDownloads(qc); - log.info("clip.queued", "clip queued", { source: src.source, seconds: Math.round(length) }); + log.info("clip.queued", "clip queued", { + source: src.source, + seconds: Math.round(length), + format, + }); notify({ level: "success", - message: msg("clip.queued"), + message: queued, action: { label: msg("clip.view"), onClick: () => showInTray(job.id) }, }); onClose(); } catch (err) { // A refusal the server explains (too long, quota) is already in the error dialog; a lost // connection or a 404 is not, and must not leave the panel looking as if nothing happened. - notifyActionError(err, msg("clip.failed")); + notifyActionError(err, failed); setBusy(false); } }; @@ -370,6 +402,55 @@ export default function ClipPanel({ )} +
+ + {gif && ( + <> + + setSide(Number(v) as GifSide)} + options={GIF_SIDES.map((s) => ({ value: String(s), label: String(s) }))} + /> + + {t("clip.gif.estimate", gifEstimateText(estimate, i18n.language))} + + + )} +
+ {gif && ( +

+ {verdict === "too_long" + ? t("clip.gif.tooLong", { seconds: GIF_MAX_S }) + : verdict === "may_fail" + ? t("clip.gif.mayFail") + : verdict === "no_preview" + ? t("clip.gif.noPreview") + : t("clip.gif.hint")} +

+ )} +
{t("common.cancel")} -
diff --git a/frontend/src/i18n/locales/en/clip.json b/frontend/src/i18n/locales/en/clip.json index 8e03be99..cc464421 100644 --- a/frontend/src/i18n/locales/en/clip.json +++ b/frontend/src/i18n/locales/en/clip.json @@ -22,6 +22,21 @@ "shape": "Frame shape", "shapeFree": "Free", "whole": "Whole picture", + "format": "Format", + "formatVideo": "Video", + "formatGif": "GIF", + "gif": { + "size": "GIF size, the longer side in pixels", + "sizeLabel": "Size", + "estimate": "about {{lo}}–{{hi}} MB", + "hint": "No sound, 12 frames a second", + "noPreview": "It may pass 15 MB, and then a Teams link preview shows it still. The downloaded file moves everywhere", + "mayFail": "It may pass 30 MB, and then it isn’t saved. A shorter range or a smaller size helps", + "tooLong": "A GIF is at most {{seconds}} seconds long. Shorten the range", + "make": "Make GIF", + "queued": "The GIF is being made — it will be in your downloads", + "failed": "Couldn’t make the GIF" + }, "errors": { "bad_source": "This video can’t be clipped.", "bad_range": "The clip’s start or end isn’t valid.", diff --git a/frontend/src/i18n/locales/en/editor.json b/frontend/src/i18n/locales/en/editor.json index 390e725b..34cb6bbd 100644 --- a/frontend/src/i18n/locales/en/editor.json +++ b/frontend/src/i18n/locales/en/editor.json @@ -28,8 +28,24 @@ "output": { "label": "Output", "separate": "Separate files", - "join": "Join into one" + "join": "Join into one", + "joinNeedsTwo": "Keep at least two segments to join them", + "joinGif": "A GIF can’t be joined: each kept segment becomes a GIF of its own" }, + "format": { + "label": "Format", + "video": "Video", + "gif": "GIF", + "estimate": "about {{lo}}–{{hi}} MB in all", + "reencode": "A GIF is always re-encoded: no sound, 12 frames a second", + "segTooLong": "Segment {{n}} is {{length}} long: a GIF is at most {{seconds}} seconds", + "segMayFail": "Segment {{n}} may pass 30 MB as a GIF, and then it isn’t saved", + "segNoPreview": "Segment {{n}} may pass 15 MB as a GIF, and then a Teams link preview shows it still" + }, + "createGif_one": "Create {{count}} GIF", + "createGif_other": "Create {{count}} GIFs", + "createdGif_one": "{{count}} GIF added to your downloads", + "createdGif_other": "{{count}} GIFs added to your downloads", "segment": "Segment {{n}}", "keep": "Keep", "drop": "Drop", diff --git a/frontend/src/i18n/locales/hu/clip.json b/frontend/src/i18n/locales/hu/clip.json index 2a75d4f5..99c6177d 100644 --- a/frontend/src/i18n/locales/hu/clip.json +++ b/frontend/src/i18n/locales/hu/clip.json @@ -22,6 +22,21 @@ "shape": "Keret aránya", "shapeFree": "Szabad", "whole": "Teljes kép", + "format": "Formátum", + "formatVideo": "Videó", + "formatGif": "GIF", + "gif": { + "size": "A GIF mérete, a hosszabbik oldal pixelben", + "sizeLabel": "Méret", + "estimate": "kb. {{lo}}–{{hi}} MB", + "hint": "Hang nélkül, 12 kép másodpercenként", + "noPreview": "15 MB fölé mehet, és akkor a Teams link-előnézetben állókép lesz. A letöltött fájl mindenhol mozog", + "mayFail": "30 MB fölé mehet, és akkor nem készül el. Rövidebb rész vagy kisebb méret segít", + "tooLong": "A GIF legfeljebb {{seconds}} másodperc lehet. Rövidítsd a kijelölést", + "make": "GIF készítése", + "queued": "Készül a GIF — a letöltéseid közé kerül", + "failed": "Nem sikerült elkészíteni a GIF-et" + }, "errors": { "bad_source": "Ebből a videóból nem lehet klipet vágni.", "bad_range": "A klip eleje vagy vége érvénytelen.", diff --git a/frontend/src/i18n/locales/hu/editor.json b/frontend/src/i18n/locales/hu/editor.json index 685617b5..e5df8783 100644 --- a/frontend/src/i18n/locales/hu/editor.json +++ b/frontend/src/i18n/locales/hu/editor.json @@ -28,8 +28,24 @@ "output": { "label": "Kimenet", "separate": "Külön fájlok", - "join": "Egy fájlba fűzve" + "join": "Egy fájlba fűzve", + "joinNeedsTwo": "Az összefűzéshez legalább két szegmenst tarts meg", + "joinGif": "GIF-et nem lehet összefűzni: minden megtartott szegmensből külön GIF lesz" }, + "format": { + "label": "Formátum", + "video": "Videó", + "gif": "GIF", + "estimate": "összesen kb. {{lo}}–{{hi}} MB", + "reencode": "A GIF mindig újrakódol: hang nélkül, 12 kép másodpercenként", + "segTooLong": "{{n}}. szegmens: {{length}} hosszú, de a GIF legfeljebb {{seconds}} másodperc lehet", + "segMayFail": "{{n}}. szegmens: GIF-ként 30 MB fölé mehet, és akkor nem készül el", + "segNoPreview": "{{n}}. szegmens: GIF-ként 15 MB fölé mehet, és akkor a Teams link-előnézetben állókép lesz" + }, + "createGif_one": "{{count}} GIF létrehozása", + "createGif_other": "{{count}} GIF létrehozása", + "createdGif_one": "{{count}} GIF a letöltésekhez adva", + "createdGif_other": "{{count}} GIF a letöltésekhez adva", "segment": "{{n}}. szegmens", "keep": "Megtart", "drop": "Eldob", diff --git a/frontend/src/lib/api/downloads.ts b/frontend/src/lib/api/downloads.ts index 93d4ee68..ae77d5e1 100644 --- a/frontend/src/lib/api/downloads.ts +++ b/frontend/src/lib/api/downloads.ts @@ -62,6 +62,8 @@ export interface ClipRequest { audio_track?: number; /** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */ crop?: { x: number; y: number; w: number; h: number; aspect: number }; + /** Make it a GIF, this long side in pixels (`lib/gif` GIF_SIDES); left out, an mp4. */ + gif_side?: number; display_name?: string; } @@ -74,6 +76,9 @@ export interface EditSpec { // scales them to the file's own pixels (a video's stored and shown sizes can differ). crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number }; accurate?: boolean; + /** Make it a GIF (a single trim with an end, `lib/gif` GIF_MAX_S long at most): a GIF is always + * re-encoded, so `accurate` does not apply. */ + gif?: { side: number }; } export interface StoryboardMeta { diff --git a/frontend/src/lib/gif.contract.test.ts b/frontend/src/lib/gif.contract.test.ts new file mode 100644 index 00000000..90d78156 --- /dev/null +++ b/frontend/src/lib/gif.contract.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +// The GIF's sizes and limits, held in two languages: the panels offer the sizes the server takes and +// stop a range it would refuse. Neither side can see the other's copy, so the gate reads both (as +// `clipLimits.contract.test.ts` does for a clip's). Read as TEXT, so a renamed constant fails here +// instead of comparing two undefineds. +const src = (u: string) => readFileSync(new URL(u, import.meta.url), "utf-8"); +const backend = src("../../../backend/app/downloads/gif.py"); +const frontend = src("./gif.ts"); + +// A product of integers ("15 * 1024 * 1024"), as both files write a byte cap. +const product = (expr: string) => + expr + .split("*") + .map((n) => Number(n.trim())) + .reduce((a, b) => a * b, 1); + +function pyConst(name: string): string { + const m = backend.match(new RegExp(`^${name}\\s*=\\s*(.+)$`, "m")); + expect(m, `backend/app/downloads/gif.py no longer defines ${name}`).toBeTruthy(); + return m![1]!.trim(); +} + +function tsConst(name: string): string { + const m = frontend.match( + new RegExp(`^export const ${name}(?:: \\w+)? = (.+?)(?: as const)?;$`, "m"), + ); + expect(m, `lib/gif.ts no longer defines ${name}`).toBeTruthy(); + return m![1]!.trim(); +} + +const list = (s: string) => + s + .replace(/[[\]()]/g, "") + .split(",") + .map((n) => Number(n.trim())); + +describe("GIF limits", () => { + it("offers the sizes the server takes", () => { + expect(list(tsConst("GIF_SIDES"))).toEqual(list(pyConst("SIDES"))); + expect(Number(tsConst("GIF_DEFAULT_SIDE"))).toBe(Number(pyConst("DEFAULT_SIDE"))); + }); + + it.each([ + ["GIF_MAX_S", "MAX_S"], + ["GIF_PREVIEW_MAX_BYTES", "PREVIEW_MAX_BYTES"], + ["GIF_MAX_BYTES", "MAX_BYTES"], + ])("%s is the server's %s", (ts, py) => { + expect(product(tsConst(ts))).toBe(product(pyConst(py))); + }); +}); diff --git a/frontend/src/lib/gif.test.ts b/frontend/src/lib/gif.test.ts new file mode 100644 index 00000000..724c9e0c --- /dev/null +++ b/frontend/src/lib/gif.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { estimateGif, GIF_MAX_BYTES, GIF_PREVIEW_MAX_BYTES, gifVerdict } from "./gif"; + +const MB = 1024 * 1024; + +describe("estimateGif", () => { + it("gives the measured range for a 16:9 picture", () => { + const e = estimateGif(30, 480, 16 / 9); + expect(e.lo / MB).toBeCloseTo(12, 5); + expect(e.hi / MB).toBeCloseTo(30, 5); + }); + + it("grows with the length and the size", () => { + expect(estimateGif(20, 480, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi); + expect(estimateGif(10, 640, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi); + }); + + it("counts the pixels of the shape: a square holds more than 16:9, a tall one as many", () => { + const wide = estimateGif(10, 480, 16 / 9).hi; + expect(estimateGif(10, 480, 1).hi).toBeCloseTo(wide * (16 / 9), 3); + expect(estimateGif(10, 480, 9 / 16).hi).toBeCloseTo(wide, 3); + }); + + it("falls back to 16:9 for a shape it cannot read", () => { + expect(estimateGif(10, 480, 0).hi).toBe(estimateGif(10, 480, 16 / 9).hi); + expect(estimateGif(10, 480, Number.NaN).hi).toBe(estimateGif(10, 480, 16 / 9).hi); + }); +}); + +describe("gifVerdict", () => { + it("refuses past the length cap, whatever the size", () => { + expect(gifVerdict(30.2, { hi: 1 })).toBe("too_long"); + expect(gifVerdict(30, { hi: 1 })).toBe("ok"); + }); + + it("warns past the server's cap, then past the link preview's", () => { + expect(gifVerdict(10, { hi: GIF_MAX_BYTES + 1 })).toBe("may_fail"); + expect(gifVerdict(10, { hi: GIF_MAX_BYTES })).toBe("no_preview"); + expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES + 1 })).toBe("no_preview"); + expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok"); + }); +}); diff --git a/frontend/src/lib/gif.ts b/frontend/src/lib/gif.ts new file mode 100644 index 00000000..e7a25d4a --- /dev/null +++ b/frontend/src/lib/gif.ts @@ -0,0 +1,60 @@ +// A GIF cut from a clip or an edit: the sizes it may be made at and the limits it is held to, the +// server's (backend downloads/gif.py; `gif.contract.test.ts` holds the two copies together), and the +// size it is expected to come out at, which the panels show before anything is made. +import { formatNumber } from "./format"; + +/** The longest side of the GIF, in pixels: the choices the server takes. */ +export const GIF_SIDES = [320, 480, 640] as const; +export type GifSide = (typeof GIF_SIDES)[number]; +export const GIF_DEFAULT_SIDE: GifSide = 480; +/** The longest GIF the server makes, in seconds. */ +export const GIF_MAX_S = 30; +/** Over this a chat's link preview (Teams) shows no moving picture: the share page sends a still. */ +export const GIF_PREVIEW_MAX_BYTES = 15 * 1024 * 1024; +/** Over this the server refuses the finished GIF. */ +export const GIF_MAX_BYTES = 30 * 1024 * 1024; + +// Bytes a second, measured on real films at 12 frames a second, at each size of a 16:9 picture: the +// low and the high end of what four sources gave. The same settings spread 2.5× by what is in the +// picture, so only a range can be said before the GIF is made. +const MB = 1024 * 1024; +const RATE: Record = { + 320: [0.23 * MB, 0.47 * MB], + 480: [0.4 * MB, 1.0 * MB], + 640: [0.7 * MB, 1.6 * MB], +}; + +/** The size a GIF is expected to come out at, low and high, in bytes. `aspect` is the picture's + * width / height after any crop: the long side is capped, so a square or tall picture holds more + * or fewer pixels than the 16:9 one the rates were measured on. */ +export function estimateGif( + seconds: number, + side: GifSide, + aspect: number, +): { lo: number; hi: number } { + const shape = aspect > 0 && Number.isFinite(aspect) ? aspect : 16 / 9; + const area = shape >= 1 ? 1 / shape : shape; + const scale = area / (9 / 16); + const [lo, hi] = RATE[side]; + const s = Math.max(0, seconds); + return { lo: lo * s * scale, hi: hi * s * scale }; +} + +/** An estimate's ends as whole megabytes in the reader's number format (at least 1, as a GIF of a + * second or two still takes one in the reader's eyes). */ +export function gifEstimateText(est: { lo: number; hi: number }, lang: string) { + const lo = Math.max(1, Math.round(est.lo / MB)); + const hi = Math.max(lo, Math.round(est.hi / MB)); + return { lo: formatNumber(lo, lang), hi: formatNumber(hi, lang) }; +} + +/** What the panels say about a GIF before it is made: too long (refused, it does not start), it may + * pass the cap (refused when it does), it may be too big to move in a link preview, or fine. */ +export type GifVerdict = "too_long" | "may_fail" | "no_preview" | "ok"; + +export function gifVerdict(seconds: number, est: { hi: number }): GifVerdict { + if (seconds > GIF_MAX_S + 0.0005) return "too_long"; + if (est.hi > GIF_MAX_BYTES) return "may_fail"; + if (est.hi > GIF_PREVIEW_MAX_BYTES) return "no_preview"; + return "ok"; +} diff --git a/frontend/src/modules/files/VideoEditor.gif.dom.test.tsx b/frontend/src/modules/files/VideoEditor.gif.dom.test.tsx new file mode 100644 index 00000000..bd158db5 --- /dev/null +++ b/frontend/src/modules/files/VideoEditor.gif.dom.test.tsx @@ -0,0 +1,126 @@ +// @vitest-environment jsdom +// +// The editor's GIF: each kept segment becomes a GIF of its own at the size picked (never a joined +// cut-list, and never with the precise/fast choice, which a GIF does not have), a whole short video +// is a GIF too, and a segment longer than a GIF may be leaves Create unavailable with its reason. +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +const { notify } = vi.hoisted(() => ({ notify: vi.fn() })); +vi.mock("../../lib/notifications", async (importOriginal) => ({ + ...(await importOriginal()), + notify, +})); + +import VideoEditor from "./VideoEditor"; +import { api, type DownloadJob } from "../../lib/api"; +import { msg } from "../../lib/notifications"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + notify.mockReset(); +}); + +const jobOf = (duration: number) => + ({ + id: 7, + display_name: "Talk", + asset: { duration_s: duration, width: 1920, height: 1080, title: "Talk" }, + }) as unknown as DownloadJob; + +function open(duration: number) { + vi.spyOn(api, "downloadStoryboard").mockResolvedValue({ available: false } as never); + const enqueue = vi.spyOn(api, "enqueueEdit").mockResolvedValue({} as never); + const onClose = vi.fn(); + render( + + + , + ); + const video = document.querySelector("video")!; + const playhead = (s: number) => { + Object.defineProperty(video, "currentTime", { value: s, configurable: true, writable: true }); + fireEvent.timeUpdate(video); + }; + return { enqueue, onClose, playhead }; +} + +describe("VideoEditor — GIF", () => { + it("makes a whole short video a GIF at the size picked, with no precise/fast choice", async () => { + const { enqueue, onClose } = open(20); + fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" })); + fireEvent.click(screen.getByRole("button", { name: "640" })); + expect(screen.queryByText("editor.accurate.label")).toBeNull(); + expect(screen.getByText("editor.format.reencode")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "editor.createGif" })); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0]![0].edit_spec).toEqual({ + trim: { start_s: 0, end_s: 20 }, + gif: { side: 640 }, + }); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ message: msg("editor.createdGif", { count: 1 }) }), + ); + }); + + it("cuts each kept segment into a GIF of its own, even with Join picked before", async () => { + const { enqueue, playhead } = open(20); + playhead(8); + fireEvent.click(screen.getByText("editor.splitHere")); + fireEvent.click(screen.getByRole("button", { name: "editor.output.join" })); + fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" })); + const join = screen.getByRole("button", { name: "editor.output.join" }); + expect(join.getAttribute("aria-disabled")).toBe("true"); + expect(join.getAttribute("title")).toBe("editor.output.joinGif"); + expect(join.getAttribute("aria-pressed")).toBe("false"); + fireEvent.click(screen.getByRole("button", { name: "editor.createGif" })); + await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(2)); + expect(enqueue.mock.calls.map((c) => c[0].edit_spec)).toEqual([ + { trim: { start_s: 0, end_s: 8 }, gif: { side: 480 } }, + { trim: { start_s: 8, end_s: 20 }, gif: { side: 480 } }, + ]); + }); + + it("a segment longer than a GIF may be leaves Create unavailable, with its reason", () => { + const { enqueue } = open(100); + fireEvent.click(screen.getByRole("button", { name: "editor.format.gif" })); + const create = screen.getByRole("button", { name: "editor.createGif" }); + expect(create.getAttribute("aria-disabled")).toBe("true"); + const notes = document.getElementById(create.getAttribute("aria-describedby")!)!; + expect(notes.textContent).toBe("editor.format.segTooLong"); + fireEvent.click(create); + expect(enqueue).not.toHaveBeenCalled(); + // A video of the same range has no such limit. + fireEvent.click(screen.getByRole("button", { name: "editor.format.video" })); + expect( + screen.getByRole("button", { name: "editor.create" }).getAttribute("aria-disabled"), + ).toBe(null); + }); + + it("Join needs two kept segments, and says so instead of going dead", () => { + open(20); + const join = screen.getByRole("button", { name: "editor.output.join" }); + expect(join.hasAttribute("disabled")).toBe(false); + expect(join.getAttribute("aria-disabled")).toBe("true"); + expect(join.getAttribute("title")).toBe("editor.output.joinNeedsTwo"); + }); +}); diff --git a/frontend/src/modules/files/VideoEditor.tsx b/frontend/src/modules/files/VideoEditor.tsx index f2a2e016..bc13b438 100644 --- a/frontend/src/modules/files/VideoEditor.tsx +++ b/frontend/src/modules/files/VideoEditor.tsx @@ -10,6 +10,7 @@ import { msg, notify } from "../../lib/notifications"; import { api, type DownloadJob, type EditSpec } from "../../lib/api"; import { cropToSourcePx, type Frac } from "../../lib/cropGeometry"; import { CropFrame } from "../../components/ui/CropFrame"; +import SegmentedToggle from "../../components/ui/SegmentedToggle"; import { Button } from "../../components/ui/Button"; import { failureReason, notifyActionError } from "../../lib/actionErrors"; import { markReported } from "../../lib/api/core"; @@ -22,6 +23,16 @@ import { SURFACE } from "../../components/ui/surfaces"; import { RangeTrack } from "../../components/ui/RangeTrack"; import { trackLeft } from "../../components/ui/trackPosition"; import { formatTimecode } from "../../lib/timecode"; +import { + estimateGif, + gifEstimateText, + gifVerdict, + GIF_DEFAULT_SIDE, + GIF_MAX_S, + GIF_SIDES, + type GifSide, + type GifVerdict, +} from "../../lib/gif"; type Seg = { id: number; start: number; end: number; keep: boolean }; @@ -40,8 +51,18 @@ async function enqueueClips( } } +// A GIF is cut per kept segment (the server makes one GIF of one range, never a joined cut-list), so +// what the editor says before Create is said per segment: the worst first — one too long stops +// Create — then one that may pass the server's cap, then one too big to move in a link preview. +const GIF_NOTE_ORDER: GifVerdict[] = ["too_long", "may_fail", "no_preview"]; +const GIF_NOTE_KEY = { + too_long: "editor.format.segTooLong", + may_fail: "editor.format.segMayFail", + no_preview: "editor.format.segNoPreview", +} as const; + export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); const videoRef = useRef(null); const trackRef = useRef(null); @@ -72,6 +93,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos const [cropOn, setCropOn] = useState(false); const [crop, setCrop] = useState({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 }); const [accurate, setAccurate] = useState(true); + const [format, setFormat] = useState<"video" | "gif">("video"); + const [side, setSide] = useState(GIF_DEFAULT_SIDE); + const gif = format === "gif"; + const gifNoteId = useId(); const [name, setName] = useState(""); const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]); @@ -222,9 +247,34 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos kept.length === 1 && kept[0]!.start <= 0.01 && kept[0]!.end >= duration - 0.01; - const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle); - const willJoin = output === "join" && kept.length > 1; - const reencode = cropOn || accurate; + // A GIF of the whole video is no no-op: the format changes. + const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle); + const willJoin = !gif && output === "join" && kept.length > 1; + const reencode = cropOn || accurate || gif; + + // The GIF's shape is the crop's when there is one (the long side is capped, so the shape decides + // how many pixels it holds). + const gifShape = cropOn ? (crop.w * va) / crop.h : va; + const gifSegs = kept.map((s) => { + const est = estimateGif(s.end - s.start, side, gifShape); + return { + n: segments.indexOf(s) + 1, + length: s.end - s.start, + est, + verdict: gifVerdict(s.end - s.start, est), + }; + }); + const gifTotal = gifSegs.reduce((a, g) => ({ lo: a.lo + g.est.lo, hi: a.hi + g.est.hi }), { + lo: 0, + hi: 0, + }); + const gifNotes = GIF_NOTE_ORDER.flatMap((v) => gifSegs.filter((g) => g.verdict === v)); + const gifBlocked = gif && gifSegs.some((g) => g.verdict === "too_long"); + const joinReason = gif + ? t("editor.output.joinGif") + : kept.length < 2 + ? t("editor.output.joinNeedsTwo") + : undefined; const cropPx = (): EditSpec["crop"] => { const v = videoRef.current; @@ -270,7 +320,8 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) }, }; if (cp) spec.crop = cp; - else spec.accurate = accurate; + if (gif) spec.gif = { side }; + else if (!cp) spec.accurate = accurate; const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined; return { id: s.id, spec, dn }; }); @@ -302,7 +353,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos onSuccess: ({ files }) => { // The whole library moves with it: an edit the user already made is done at once, and lists. invalidateDownloads(qc); - notify({ level: "success", message: msg("editor.created", { count: files }) }); + notify({ + level: "success", + message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }), + }); onClose(); }, onError: (e) => notifyActionError(e, msg("editor.failed")), @@ -546,24 +600,73 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos {/* output mode + summary */}
{t("editor.output.label")}: - {(["separate", "join"] as const).map((m) => ( - - ))} + {(["separate", "join"] as const).map((m) => { + // Join is unavailable with its reason in its tooltip (a GIF is never joined; a join + // needs two kept segments), and stays focusable so the reason reaches the keyboard. + const unavailable = m === "join" ? joinReason : undefined; + return ( + + ); + })}
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
+ {/* format: a video, or each kept segment a GIF */} +
+ {t("editor.format.label")}: + + {gif && ( + <> + + setSide(Number(v) as GifSide)} + options={GIF_SIDES.map((s) => ({ value: String(s), label: String(s) }))} + /> + + {t("editor.format.estimate", gifEstimateText(gifTotal, i18n.language))} + + + )} +
+ {gif && gifNotes.length > 0 && ( +
    + {gifNotes.map((g) => ( +
  • + {t(GIF_NOTE_KEY[g.verdict as keyof typeof GIF_NOTE_KEY], { + n: g.n, + length: tc(g.length), + seconds: GIF_MAX_S, + })} +
  • + ))} +
+ )} + {/* crop + precision */}
- {!cropOn ? ( + {gif ? ( +
+ {t("editor.format.reencode")} +
+ ) : !cropOn ? (
{(["accurate", "fast"] as const).map((m) => { const on = (m === "accurate") === accurate; @@ -623,14 +730,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos size="sm" onClick={() => create.mutate()} disabled={!valid} + // A GIF segment too long: unavailable with its reason (the notes above), focusable. + aria-disabled={gifBlocked || undefined} + aria-describedby={gifBlocked ? gifNoteId : undefined} loading={create.isPending} icon={} > - {willJoin - ? t("editor.createJoin") - : kept.length > 1 - ? t("editor.createN", { count: kept.length }) - : t("editor.create")} + {gif + ? t("editor.createGif", { count: kept.length }) + : willJoin + ? t("editor.createJoin") + : kept.length > 1 + ? t("editor.createN", { count: kept.length }) + : t("editor.create")}
diff --git a/frontend/src/watch/WatchPage.dom.test.tsx b/frontend/src/watch/WatchPage.dom.test.tsx index 32d444f9..847844a9 100644 --- a/frontend/src/watch/WatchPage.dom.test.tsx +++ b/frontend/src/watch/WatchPage.dom.test.tsx @@ -161,6 +161,46 @@ describe("WatchPage media element", () => { expect(dl!.className).toContain("state-filled"); }); + it("shows a shared picture as itself, no player, with its kind, size and the chat tip", async () => { + window.history.replaceState({}, "", "/watch/tok-gif"); + mockMeta({ + needs_password: false, + media_kind: "file", + preview: "image", + title: "Evening clip", + container: "gif", + width: 480, + height: 270, + size_bytes: 6_500_000, + allow_download: true, + file_url: "/f.gif", + }); + const page = render(); + const img = await page.findByRole("img", { name: "Evening clip" }); + expect(img.getAttribute("src")).toBe("/f.gif"); + expect(page.container.querySelector("video")).toBeNull(); + expect(page.getByText(/GIF · 480×270 · 6\.2 MB/)).toBeTruthy(); + expect(page.getByRole("link", { name: "Download" }).getAttribute("href")).toBe("/f.gif"); + expect(page.getByText(/drag the downloaded file in/)).toBeTruthy(); + }); + + it("a view-only picture link shows the picture, without Download or the chat tip", async () => { + window.history.replaceState({}, "", "/watch/tok-gif-view"); + mockMeta({ + needs_password: false, + media_kind: "file", + preview: "image", + title: "Still", + container: "gif", + allow_download: false, + file_url: "/f.gif", + }); + const page = render(); + await page.findByRole("img", { name: "Still" }); + expect(page.queryByRole("link", { name: "Download" })).toBeNull(); + expect(page.queryByText(/drag the downloaded file in/)).toBeNull(); + }); + it("omits the Download for a view-only file link", async () => { window.history.replaceState({}, "", "/watch/tok-file-noview"); mockMeta({ @@ -418,6 +458,30 @@ describe("WatchPage folder", () => { ); }); + it("opens a picture above the list, shown rather than played", async () => { + window.history.replaceState({}, "", "/watch/tok-folder"); + mockMeta( + folder({ + files: [ + file(1), + file(4, { + title: "Wave.gif", + media_kind: "file", + preview: "image", + container: "gif", + duration_s: null, + }), + ], + }), + ); + const page = render(); + fireEvent.click(await page.findByRole("button", { name: "Open: Wave.gif" })); + const img = await page.findByRole("img", { name: "Wave.gif" }); + expect(img.getAttribute("src")).toBe("/api/public/watch/tok-folder/files/4/file"); + expect(page.container.querySelector("video")).toBeNull(); + expect(page.getByRole("heading", { level: 2 }).textContent).toBe("Wave.gif"); + }); + it("offers a document for download only, and Download only where the link allows it", async () => { window.history.replaceState({}, "", "/watch/tok-folder"); mockMeta(folder()); diff --git a/frontend/src/watch/WatchPage.tsx b/frontend/src/watch/WatchPage.tsx index 848a516f..5a5f2f0f 100644 --- a/frontend/src/watch/WatchPage.tsx +++ b/frontend/src/watch/WatchPage.tsx @@ -44,6 +44,10 @@ type FileMeta = { duration_s?: number | null; audio?: boolean; media_kind?: "video" | "audio" | "file" | null; + /** "image": a picture (a GIF cut from a clip, an uploaded photo), which the server serves as itself. */ + preview?: "image" | null; + width?: number | null; + height?: number | null; size_bytes?: number | null; container?: string | null; allow_download?: boolean; @@ -335,6 +339,8 @@ function FileView({ writeJSON(LS.watchRepeat, next); }; + if (meta.media_kind === "file" && meta.preview === "image") + return ; if (meta.media_kind === "file") /* A generic uploaded file has no player — just an info card (name/type/size) and, if the link allows it, a Download. The backend serves the file force-download regardless, so @@ -465,6 +471,52 @@ function FileView({ ); } +/** A shared picture: the picture itself, no player (a GIF plays and repeats on its own, and nobody + * pauses one), its name and kind under it, and the Download the link allows. A GIF says how to get + * it moving in a chat: a link's preview is the chat's to draw, the dragged-in file moves everywhere. */ +function SharedPicture({ meta, nested }: { meta: FileMeta; nested: boolean }) { + // In a folder the page's own heading is the folder's. + const Heading = nested ? "h2" : "h1"; + const gif = meta.container?.toLowerCase() === "gif"; + const dims = meta.width && meta.height ? `${meta.width}×${meta.height}` : null; + return ( +
+
+ } + className={clsx( + "max-w-full h-auto w-auto mx-auto block", + nested ? "max-h-[60dvh]" : "max-h-[80dvh]", + )} + /> +
+
+
+ + {meta.title || T.file} + +
+ {[meta.container?.toUpperCase(), dims, formatBytes(meta.size_bytes, lang)] + .filter(Boolean) + .join(" · ")} +
+
+ {meta.allow_download && ( + } size="sm" variant="glass"> + {T.download} + + )} +
+ {gif && meta.allow_download &&

{T.gifTip}

} +
+ ); +} + /** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a * song carries its length. */ function RowThumb({ file }: { file: FileMeta }) { @@ -536,6 +588,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) { ); + // A picture opens above the list as a video does (it is shown, not played); any other + // file only downloads. + const picture = f.media_kind === "file" && f.preview === "image"; return (
  • - {f.media_kind === "file" ? ( + {f.media_kind === "file" && !picture ? (
    {body}
    ) : ( setPlaying(f.id)} className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5" > {body} - + {!picture && } )} {folder.allow_download && ( diff --git a/frontend/src/watch/watchStrings.ts b/frontend/src/watch/watchStrings.ts index 1dc90de3..b4988aa6 100644 --- a/frontend/src/watch/watchStrings.ts +++ b/frontend/src/watch/watchStrings.ts @@ -31,6 +31,7 @@ const STR = { open: "Open", folderEmpty: "This folder is empty.", play: "Play", + gifTip: "To share it in a chat, drag the downloaded file in: it moves everywhere.", files: (n: number) => (n === 1 ? "1 file" : `${n} files`), }, hu: { @@ -61,6 +62,7 @@ const STR = { open: "Megnyitás", folderEmpty: "Ez a mappa üres.", play: "Lejátszás", + gifTip: "Csetbe a letöltött fájlt húzd be: az mindenhol mozog.", files: (n: number) => `${n} fájl`, }, de: { @@ -91,6 +93,8 @@ const STR = { open: "Öffnen", folderEmpty: "Dieser Ordner ist leer.", play: "Abspielen", + gifTip: + "Zum Teilen in einem Chat die heruntergeladene Datei hineinziehen: Sie bewegt sich überall.", files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`), }, } as const;