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:
@@ -12,7 +12,10 @@ import type { ClipRange } from "../../lib/clipRange";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
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>>();
|
||||
@@ -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(<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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<HTMLFormElement>(null);
|
||||
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 [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<HTMLFormElement>) => {
|
||||
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({
|
||||
)}
|
||||
</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">
|
||||
<Input
|
||||
size="sm"
|
||||
@@ -383,8 +464,17 @@ export default function ClipPanel({
|
||||
<Button variant="overlay" size="sm" onClick={onClose}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" size="sm" loading={busy} icon={<Scissors />}>
|
||||
{t("clip.cut")}
|
||||
<Button
|
||||
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>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<HTMLVideoElement>(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 [crop, setCrop] = useState<Frac>({ 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<GifSide>(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 */}
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-muted">{t("editor.output.label")}:</span>
|
||||
{(["separate", "join"] as const).map((m) => (
|
||||
<Button
|
||||
key={m}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={output === m}
|
||||
onClick={() => setOutput(m)}
|
||||
disabled={m === "join" && kept.length < 2}
|
||||
>
|
||||
{t(`editor.output.${m}`)}
|
||||
</Button>
|
||||
))}
|
||||
{(["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 (
|
||||
<Button
|
||||
key={m}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={(willJoin ? "join" : "separate") === m}
|
||||
onClick={() => setOutput(m)}
|
||||
aria-disabled={unavailable ? true : undefined}
|
||||
title={unavailable}
|
||||
>
|
||||
{t(`editor.output.${m}`)}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<div className="flex-1" />
|
||||
<span className="text-xs text-muted">
|
||||
{t("editor.keptSummary", { n: kept.length, dur: tc(keptDur) })}
|
||||
</span>
|
||||
</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 */}
|
||||
<div className="grid sm:grid-cols-2 gap-3">
|
||||
<Button
|
||||
@@ -575,7 +678,11 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
>
|
||||
{cropOn ? t("editor.cropOn") : t("editor.cropOff")}
|
||||
</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">
|
||||
{(["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={<Scissors className="w-4 h-4" />}
|
||||
>
|
||||
{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")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
@@ -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(<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 () => {
|
||||
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(<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 () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-folder");
|
||||
mockMeta(folder());
|
||||
|
||||
@@ -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 <SharedPicture meta={meta} nested={nested} />;
|
||||
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 (
|
||||
<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
|
||||
* song carries its length. */
|
||||
function RowThumb({ file }: { file: FileMeta }) {
|
||||
@@ -536,6 +588,9 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
|
||||
</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 (
|
||||
<li
|
||||
key={f.id}
|
||||
@@ -544,19 +599,19 @@ function FolderView({ folder, token }: { folder: FolderMeta; token: string }) {
|
||||
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>
|
||||
) : (
|
||||
<Pressable
|
||||
layer
|
||||
selectable
|
||||
aria-current={f.id === playing ? "true" : undefined}
|
||||
aria-label={`${T.play}: ${title}`}
|
||||
aria-label={`${picture ? T.open : T.play}: ${title}`}
|
||||
onClick={() => setPlaying(f.id)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 rounded-lg px-1.5 py-1.5"
|
||||
>
|
||||
{body}
|
||||
<Play aria-hidden className="size-4 shrink-0 text-muted" />
|
||||
{!picture && <Play aria-hidden className="size-4 shrink-0 text-muted" />}
|
||||
</Pressable>
|
||||
)}
|
||||
{folder.allow_download && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user