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;