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.
This commit is contained in:
2026-11-11 02:10:07 +01:00
parent c221452a00
commit 27ccf253f5
15 changed files with 775 additions and 37 deletions
@@ -12,7 +12,10 @@ import type { ClipRange } from "../../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) }; return {
...actual,
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
};
}); });
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>(); const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
@@ -271,3 +274,66 @@ describe("ClipPanel", () => {
expect(screen.getByTestId("crop").textContent).toBe("none"); 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(<Harness onClose={onClose} start={{ start: 100, end: 108 }} />);
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(<Harness start={{ start: 100, end: 108 }} />);
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(<Harness start={{ start: 100, end: 131 }} />);
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(<Harness start={{ start: 100, end: 110 }} />);
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");
});
});
+97 -7
View File
@@ -10,6 +10,15 @@ import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue"; import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log"; import { log } from "../../lib/log";
import { formatTimecode } from "../../lib/timecode"; 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 { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry"; import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack"; 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 // (`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 // 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. // 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. */ /** 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"; type ClipShape = "free" | "16:9" | "9:16" | "1:1";
@@ -110,10 +124,13 @@ export default function ClipPanel({
onPlayFrom, onPlayFrom,
onClose, onClose,
}: Props) { }: Props) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const rootRef = useRef<HTMLFormElement>(null); const rootRef = useRef<HTMLFormElement>(null);
const [name, setName] = useState(""); const [name, setName] = useState("");
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
const gifNoteId = useId();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [looping, setLooping] = useState(false); const [looping, setLooping] = useState(false);
// The strip's window while a handle drags (null at rest: it follows the selection). // 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 view = frozen ?? zoomWindow(clip, duration);
const bounds = handleBounds(clip, duration); const bounds = handleBounds(clip, duration);
const length = clip.end - clip.start; 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 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 // 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() } : {}), ...(name.trim() ? { display_name: name.trim() } : {}),
}; };
const submit = async (e: SubmitEvent<HTMLFormElement>) => { const submit = async (e: SubmitEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
if (busy) return; if (busy || blocked) return;
setBusy(true); 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 { try {
const job = await api.enqueueClip(body); const job = await api.enqueueClip(body);
invalidateDownloads(qc); 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({ notify({
level: "success", level: "success",
message: msg("clip.queued"), message: queued,
action: { label: msg("clip.view"), onClick: () => showInTray(job.id) }, action: { label: msg("clip.view"), onClick: () => showInTray(job.id) },
}); });
onClose(); onClose();
} catch (err) { } catch (err) {
// A refusal the server explains (too long, quota) is already in the error dialog; a lost // 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. // 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); setBusy(false);
} }
}; };
@@ -370,6 +402,55 @@ export default function ClipPanel({
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-2">
<SegmentedToggle
ariaLabel={t("clip.format")}
value={format}
onChange={setFormat}
options={[
{ value: "video", label: t("clip.formatVideo") },
{ value: "gif", label: t("clip.formatGif") },
]}
/>
{gif && (
<>
<span className="ml-2 text-muted" aria-hidden="true">
{t("clip.gif.sizeLabel")}
</span>
<SegmentedToggle
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({ value: String(s), label: String(s) }))}
/>
<span className="tabular-nums text-muted" aria-live="polite">
{t("clip.gif.estimate", gifEstimateText(estimate, i18n.language))}
</span>
</>
)}
</div>
{gif && (
<p
id={gifNoteId}
className={`text-xs ${
verdict === "too_long"
? "text-danger"
: verdict === "ok"
? "text-muted"
: "text-media-mark"
}`}
aria-live="polite"
>
{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")}
</p>
)}
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Input <Input
size="sm" size="sm"
@@ -383,8 +464,17 @@ export default function ClipPanel({
<Button variant="overlay" size="sm" onClick={onClose}> <Button variant="overlay" size="sm" onClick={onClose}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}> <Button
{t("clip.cut")} type="submit"
variant="primary"
size="sm"
loading={busy}
icon={<Scissors />}
// Unavailable with its reason (the note above), and still focusable: not native disabled.
aria-disabled={blocked || undefined}
aria-describedby={gif ? gifNoteId : undefined}
>
{gif ? t("clip.gif.make") : t("clip.cut")}
</Button> </Button>
</div> </div>
</form> </form>
+15
View File
@@ -22,6 +22,21 @@
"shape": "Frame shape", "shape": "Frame shape",
"shapeFree": "Free", "shapeFree": "Free",
"whole": "Whole picture", "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": { "errors": {
"bad_source": "This video can’t be clipped.", "bad_source": "This video can’t be clipped.",
"bad_range": "The clip’s start or end isn’t valid.", "bad_range": "The clip’s start or end isn’t valid.",
+17 -1
View File
@@ -28,8 +28,24 @@
"output": { "output": {
"label": "Output", "label": "Output",
"separate": "Separate files", "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}}", "segment": "Segment {{n}}",
"keep": "Keep", "keep": "Keep",
"drop": "Drop", "drop": "Drop",
+15
View File
@@ -22,6 +22,21 @@
"shape": "Keret aránya", "shape": "Keret aránya",
"shapeFree": "Szabad", "shapeFree": "Szabad",
"whole": "Teljes kép", "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": { "errors": {
"bad_source": "Ebből a videóból nem lehet klipet vágni.", "bad_source": "Ebből a videóból nem lehet klipet vágni.",
"bad_range": "A klip eleje vagy vége érvénytelen.", "bad_range": "A klip eleje vagy vége érvénytelen.",
+17 -1
View File
@@ -28,8 +28,24 @@
"output": { "output": {
"label": "Kimenet", "label": "Kimenet",
"separate": "Külön fájlok", "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", "segment": "{{n}}. szegmens",
"keep": "Megtart", "keep": "Megtart",
"drop": "Eldob", "drop": "Eldob",
+5
View File
@@ -62,6 +62,8 @@ export interface ClipRequest {
audio_track?: number; audio_track?: number;
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */ /** 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 }; 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; 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). // 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 }; crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean; 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 { export interface StoryboardMeta {
+52
View File
@@ -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)));
});
});
+42
View File
@@ -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");
});
});
+60
View File
@@ -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<GifSide, readonly [number, number]> = {
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";
}
@@ -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<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
}));
const { notify } = vi.hoisted(() => ({ notify: vi.fn() }));
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
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(
<QueryClientProvider client={new QueryClient()}>
<VideoEditor job={jobOf(duration)} onClose={onClose} />
</QueryClientProvider>,
);
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");
});
});
+136 -24
View File
@@ -10,6 +10,7 @@ import { msg, notify } from "../../lib/notifications";
import { api, type DownloadJob, type EditSpec } from "../../lib/api"; import { api, type DownloadJob, type EditSpec } from "../../lib/api";
import { cropToSourcePx, type Frac } from "../../lib/cropGeometry"; import { cropToSourcePx, type Frac } from "../../lib/cropGeometry";
import { CropFrame } from "../../components/ui/CropFrame"; import { CropFrame } from "../../components/ui/CropFrame";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { failureReason, notifyActionError } from "../../lib/actionErrors"; import { failureReason, notifyActionError } from "../../lib/actionErrors";
import { markReported } from "../../lib/api/core"; import { markReported } from "../../lib/api/core";
@@ -22,6 +23,16 @@ import { SURFACE } from "../../components/ui/surfaces";
import { RangeTrack } from "../../components/ui/RangeTrack"; import { RangeTrack } from "../../components/ui/RangeTrack";
import { trackLeft } from "../../components/ui/trackPosition"; import { trackLeft } from "../../components/ui/trackPosition";
import { formatTimecode } from "../../lib/timecode"; 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 }; 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 }) { export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClose: () => void }) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const qc = useQueryClient(); const qc = useQueryClient();
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const trackRef = useRef<HTMLDivElement>(null); const trackRef = useRef<HTMLDivElement>(null);
@@ -72,6 +93,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
const [cropOn, setCropOn] = useState(false); const [cropOn, setCropOn] = useState(false);
const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 }); const [crop, setCrop] = useState<Frac>({ x: 0.1, y: 0.1, w: 0.8, h: 0.8 });
const [accurate, setAccurate] = useState(true); const [accurate, setAccurate] = useState(true);
const [format, setFormat] = useState<"video" | "gif">("video");
const [side, setSide] = useState<GifSide>(GIF_DEFAULT_SIDE);
const gif = format === "gif";
const gifNoteId = useId();
const [name, setName] = useState(""); const [name, setName] = useState("");
const fileUrl = useMemo(() => api.fileUrl(job.id), [job.id]); 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.length === 1 &&
kept[0]!.start <= 0.01 && kept[0]!.start <= 0.01 &&
kept[0]!.end >= duration - 0.01; kept[0]!.end >= duration - 0.01;
const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || !isFullSingle); // A GIF of the whole video is no no-op: the format changes.
const willJoin = output === "join" && kept.length > 1; const valid = kept.length >= 1 && keptDur >= MIN_SEG && (cropOn || gif || !isFullSingle);
const reencode = cropOn || accurate; 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 cropPx = (): EditSpec["crop"] => {
const v = videoRef.current; 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) }, trim: { start_s: +s.start.toFixed(3), end_s: +s.end.toFixed(3) },
}; };
if (cp) spec.crop = cp; 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; const dn = suffix.get(s.id) ? `${base}${suffix.get(s.id)}` : name.trim() || undefined;
return { id: s.id, spec, dn }; return { id: s.id, spec, dn };
}); });
@@ -302,7 +353,10 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
onSuccess: ({ files }) => { onSuccess: ({ files }) => {
// The whole library moves with it: an edit the user already made is done at once, and lists. // The whole library moves with it: an edit the user already made is done at once, and lists.
invalidateDownloads(qc); invalidateDownloads(qc);
notify({ level: "success", message: msg("editor.created", { count: files }) }); notify({
level: "success",
message: msg(gif ? "editor.createdGif" : "editor.created", { count: files }),
});
onClose(); onClose();
}, },
onError: (e) => notifyActionError(e, msg("editor.failed")), onError: (e) => notifyActionError(e, msg("editor.failed")),
@@ -546,24 +600,73 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
{/* output mode + summary */} {/* output mode + summary */}
<div className="flex flex-wrap items-center gap-2 text-sm"> <div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.output.label")}:</span> <span className="text-muted">{t("editor.output.label")}:</span>
{(["separate", "join"] as const).map((m) => ( {(["separate", "join"] as const).map((m) => {
<Button // Join is unavailable with its reason in its tooltip (a GIF is never joined; a join
key={m} // needs two kept segments), and stays focusable so the reason reaches the keyboard.
variant="outline" const unavailable = m === "join" ? joinReason : undefined;
size="sm" return (
pressed={output === m} <Button
onClick={() => setOutput(m)} key={m}
disabled={m === "join" && kept.length < 2} variant="outline"
> size="sm"
{t(`editor.output.${m}`)} pressed={(willJoin ? "join" : "separate") === m}
</Button> onClick={() => setOutput(m)}
))} aria-disabled={unavailable ? true : undefined}
title={unavailable}
>
{t(`editor.output.${m}`)}
</Button>
);
})}
<div className="flex-1" /> <div className="flex-1" />
<span className="text-xs text-muted"> <span className="text-xs text-muted">
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })} {t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
</span> </span>
</div> </div>
{/* format: a video, or each kept segment a GIF */}
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="text-muted">{t("editor.format.label")}:</span>
<SegmentedToggle
ariaLabel={t("editor.format.label")}
value={format}
onChange={setFormat}
options={[
{ value: "video", label: t("editor.format.video") },
{ value: "gif", label: t("editor.format.gif") },
]}
/>
{gif && (
<>
<span className="ml-2 text-muted" aria-hidden="true">
{t("clip.gif.sizeLabel")}
</span>
<SegmentedToggle
ariaLabel={t("clip.gif.size")}
value={String(side)}
onChange={(v) => setSide(Number(v) as GifSide)}
options={GIF_SIDES.map((s) => ({ value: String(s), label: String(s) }))}
/>
<span className="text-xs text-muted tabular-nums" aria-live="polite">
{t("editor.format.estimate", gifEstimateText(gifTotal, i18n.language))}
</span>
</>
)}
</div>
{gif && gifNotes.length > 0 && (
<ul id={gifNoteId} className="space-y-0.5 text-xs" aria-live="polite">
{gifNotes.map((g) => (
<li key={g.n} className={g.verdict === "too_long" ? "text-danger" : "text-warn"}>
{t(GIF_NOTE_KEY[g.verdict as keyof typeof GIF_NOTE_KEY], {
n: g.n,
length: tc(g.length),
seconds: GIF_MAX_S,
})}
</li>
))}
</ul>
)}
{/* crop + precision */} {/* crop + precision */}
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
<Button <Button
@@ -575,7 +678,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
> >
{cropOn ? t("editor.cropOn") : t("editor.cropOff")} {cropOn ? t("editor.cropOn") : t("editor.cropOff")}
</Button> </Button>
{!cropOn ? ( {gif ? (
<div className="flex items-center px-3 text-xs text-muted">
{t("editor.format.reencode")}
</div>
) : !cropOn ? (
<div className="flex gap-2"> <div className="flex gap-2">
{(["accurate", "fast"] as const).map((m) => { {(["accurate", "fast"] as const).map((m) => {
const on = (m === "accurate") === accurate; const on = (m === "accurate") === accurate;
@@ -623,14 +730,19 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
size="sm" size="sm"
onClick={() => create.mutate()} onClick={() => create.mutate()}
disabled={!valid} 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} loading={create.isPending}
icon={<Scissors className="w-4 h-4" />} icon={<Scissors className="w-4 h-4" />}
> >
{willJoin {gif
? t("editor.createJoin") ? t("editor.createGif", { count: kept.length })
: kept.length > 1 : willJoin
? t("editor.createN", { count: kept.length }) ? t("editor.createJoin")
: t("editor.create")} : kept.length > 1
? t("editor.createN", { count: kept.length })
: t("editor.create")}
</Button> </Button>
</div> </div>
</ModalFooter> </ModalFooter>
+64
View File
@@ -161,6 +161,46 @@ describe("WatchPage media element", () => {
expect(dl!.className).toContain("state-filled"); 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(<WatchPage />);
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(<WatchPage />);
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 () => { it("omits the Download for a view-only file link", async () => {
window.history.replaceState({}, "", "/watch/tok-file-noview"); window.history.replaceState({}, "", "/watch/tok-file-noview");
mockMeta({ 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(<WatchPage />);
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 () => { it("offers a document for download only, and Download only where the link allows it", async () => {
window.history.replaceState({}, "", "/watch/tok-folder"); window.history.replaceState({}, "", "/watch/tok-folder");
mockMeta(folder()); mockMeta(folder());
+58 -3
View File
@@ -44,6 +44,10 @@ type FileMeta = {
duration_s?: number | null; duration_s?: number | null;
audio?: boolean; audio?: boolean;
media_kind?: "video" | "audio" | "file" | null; 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; size_bytes?: number | null;
container?: string | null; container?: string | null;
allow_download?: boolean; allow_download?: boolean;
@@ -335,6 +339,8 @@ function FileView({
writeJSON(LS.watchRepeat, next); writeJSON(LS.watchRepeat, next);
}; };
if (meta.media_kind === "file" && meta.preview === "image")
return <SharedPicture meta={meta} nested={nested} />;
if (meta.media_kind === "file") if (meta.media_kind === "file")
/* A generic uploaded file has no player — just an info card (name/type/size) and, if the /* 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 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 (
<div>
<div className="rounded-xl overflow-hidden bg-black border border-border w-fit max-w-full mx-auto">
<RemoteImage
src={meta.file_url}
alt={meta.title || T.file}
width={meta.width ?? undefined}
height={meta.height ?? undefined}
retries={1}
fallback={<FileIcon className="m-12 w-12 h-12 text-muted" aria-hidden />}
className={clsx(
"max-w-full h-auto w-auto mx-auto block",
nested ? "max-h-[60dvh]" : "max-h-[80dvh]",
)}
/>
</div>
<div className={`${SURFACE.panel} mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 p-3`}>
<div className="min-w-0 flex-1 basis-60">
<Heading className="text-lg font-semibold leading-snug break-words">
{meta.title || T.file}
</Heading>
<div className="text-sm text-muted">
{[meta.container?.toUpperCase(), dims, formatBytes(meta.size_bytes, lang)]
.filter(Boolean)
.join(" · ")}
</div>
</div>
{meta.allow_download && (
<LinkButton href={meta.file_url} icon={<Download />} size="sm" variant="glass">
{T.download}
</LinkButton>
)}
</div>
{gif && meta.allow_download && <p className="mt-3 text-sm text-muted">{T.gifTip}</p>}
</div>
);
}
/** A file's picture in the folder list: its poster frame, else a glyph of its kind; a video or a /** 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. */ * song carries its length. */
function RowThumb({ file }: { file: FileMeta }) { function RowThumb({ file }: { file: FileMeta }) {
@@ -536,6 +588,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
</span> </span>
</> </>
); );
// 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 ( return (
<li <li
key={f.id} key={f.id}
@@ -544,19 +599,19 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
f.id === playing && "bg-accent/10", f.id === playing && "bg-accent/10",
)} )}
> >
{f.media_kind === "file" ? ( {f.media_kind === "file" && !picture ? (
<div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div> <div className="flex min-w-0 flex-1 items-center gap-3 px-1.5 py-1.5">{body}</div>
) : ( ) : (
<Pressable <Pressable
layer layer
selectable selectable
aria-current={f.id === playing ? "true" : undefined} aria-current={f.id === playing ? "true" : undefined}
aria-label={`${T.play}: ${title}`} aria-label={`${picture ? T.open : T.play}: ${title}`}
onClick={() => setPlaying(f.id)} onClick={() => setPlaying(f.id)}
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5" className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
> >
{body} {body}
<Play aria-hidden className="size-4 shrink-0 text-muted" /> {!picture && <Play aria-hidden className="size-4 shrink-0 text-muted" />}
</Pressable> </Pressable>
)} )}
{folder.allow_download && ( {folder.allow_download && (
+4
View File
@@ -31,6 +31,7 @@ const STR = {
open: "Open", open: "Open",
folderEmpty: "This folder is empty.", folderEmpty: "This folder is empty.",
play: "Play", 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`), files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
}, },
hu: { hu: {
@@ -61,6 +62,7 @@ const STR = {
open: "Megnyitás", open: "Megnyitás",
folderEmpty: "Ez a mappa üres.", folderEmpty: "Ez a mappa üres.",
play: "Lejátszás", play: "Lejátszás",
gifTip: "Csetbe a letöltött fájlt húzd be: az mindenhol mozog.",
files: (n: number) => `${n} fájl`, files: (n: number) => `${n} fájl`,
}, },
de: { de: {
@@ -91,6 +93,8 @@ const STR = {
open: "Öffnen", open: "Öffnen",
folderEmpty: "Dieser Ordner ist leer.", folderEmpty: "Dieser Ordner ist leer.",
play: "Abspielen", 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`), files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
}, },
} as const; } as const;