Huggingface Image Downloader

Add buttons to quickly download images from Stable Diffusion models

  1. // ==UserScript==
  2. // @name Huggingface Image Downloader
  3. // @description Add buttons to quickly download images from Stable Diffusion models
  4. // @author Isaiah Odhner
  5. // @namespace https://isaiahodhner.io
  6. // @version 1.4
  7. // @license MIT
  8. // @match https://*.hf.space/*
  9. // @match https://*.huggingface.co/*
  10. // @icon https://www.google.com/s2/favicons?sz=64&domain=huggingface.co
  11. // @grant none
  12. // ==/UserScript==
  13.  
  14. // v1.1 adds support for Stable Diffusion 2. It expands the domain scope to match other applications on HuggingFace, and includes negative prompts in the filename, with format: "'<positive>' (anti '<negative>')".
  15. // v1.2 handles updated DOM structure of the site
  16. // v1.3 adds support for more Huggingface spaces, including https://huggingface.co/nerijs/pixel-art-xl and https://huggingface.co/spaces/songweig/rich-text-to-image and https://huggingface.co/spaces/Yntec/ToyWorldXL
  17. // These changes may result in false positives, i.e. unnecessary download buttons showing up. Let me know if this happens.
  18. // Show-on-hover code is simplified (at some negligible performance cost), and this may fix some interaction issues.
  19. // v.14 fixes detection of the prompt input (used for the download filename) for https://huggingface.co/spaces/songweig/rich-text-to-image which uses nested iframes,
  20. // as well as some other spaces.
  21. // It also fixes an unnecessary download button for the user-uploaded input image in https://huggingface.co/spaces/editing-images/ledits
  22.  
  23. setInterval(() => {
  24. // assuming prompt comes before negative prompt in DOM for some of these fallbacks
  25. // TODO: look for "prompt"/"negative" in surrounding text (often not in a <label>) before looking for any input
  26. const genericInputSelector = 'input[type="text"][required], [contenteditable="true"], textarea, input[type="text"]';
  27. const promptInputSelector = '#prompt-text-input input, [name=prompt], [placeholder*="prompt"], [placeholder*="sentence here"], .ql-editor';
  28. const negativeInputSelector = '#negative-prompt-text-input input, [name=negative-prompt], [placeholder*="negative prompt"], [placeholder*="blurry"], [placeholder*="worst quality"], [placeholder*="low quality"]';
  29. const imageSelector = ".grid img, #gallery img, .grid-container img, .thumbnail-item img, img[src^='blob:'], img[src^='data:']";
  30. // #input_image is for https://huggingface.co/spaces/editing-images/ledits
  31. // Other spaces probably have other IDs for similar purposes.
  32. const excludeImageParentSelector = "#input_image";
  33.  
  34. let input = querySelector(promptInputSelector) || querySelector(genericInputSelector);
  35. let negativeInput = querySelector(negativeInputSelector) || querySelector(genericInputSelector.split(",").map((selector) => `[id*="negative"] ${selector}`).join(","));
  36. // console.log(input, negativeInput);
  37.  
  38. if (negativeInput === input) {
  39. negativeInput = null;
  40. }
  41.  
  42. // const dlButtons = [];
  43. for (const img of document.querySelectorAll(imageSelector)) {
  44. if (img.closest(excludeImageParentSelector)) {
  45. continue; // don't add a download button for the input image
  46. }
  47. const existingA = img.parentElement.querySelector("a");
  48. if (existingA) {
  49. if (existingA._imgSrc !== img.src) {
  50. existingA.remove();
  51. // const index = dlButtons.indexOf(existingA);
  52. // if (index > -1) {
  53. // dlButtons.splice(index);
  54. // }
  55. } else {
  56. continue; // don't add a duplicate <a> or change the supposed prompt it was generated with
  57. }
  58. }
  59.  
  60. const a = document.createElement("a");
  61. a.style.position = "absolute";
  62. a.style.opacity = "0";
  63. a.style.top = "0";
  64. a.style.left = "0";
  65. a.style.background = "black";
  66. a.style.color = "white";
  67. a.style.borderRadius = "5px";
  68. a.style.padding = "5px";
  69. a.style.margin = "5px";
  70. a.style.fontSize = "50px";
  71. a.style.lineHeight = "50px";
  72. a.textContent = "Download";
  73. a._imgSrc = img.src;
  74.  
  75. let filename = sanitizeFilename(location.pathname.replace(/^spaces\//, ""));
  76. if (input) {
  77. filename = `'${sanitizeFilename(input.value || input.textContent)}'`;
  78. if (negativeInput) {
  79. filename += ` (anti '${sanitizeFilename(negativeInput.value || negativeInput.textContent)}')`;
  80. }
  81. }
  82. filename += ".jpeg";
  83. a.download = filename;
  84.  
  85. a.href = img.src;
  86. img.parentElement.append(a);
  87. if (getComputedStyle(img.parentElement).position == "static") {
  88. img.parentElement.style.position = "relative";
  89. }
  90. // dlButtons.push(a);
  91.  
  92. // Can't be delegated because it needs to stop the click event from bubbling up to the handler that zooms in
  93. a.addEventListener("click", (event) => {
  94. // Prevent also zooming into the image when clicking Download
  95. event.stopImmediatePropagation();
  96. });
  97.  
  98. showOnHover(a, img.closest(".gallery-item, .thumbnail-item") || img.parentElement);
  99. }
  100. }, 300);
  101.  
  102. function querySelector(selector, doc = document) {
  103. // Look in the current document, then in any iframes
  104. // because for example https://huggingface.co/spaces/songweig/rich-text-to-image
  105. // uses a contenteditable div in an iframe (file=rich-text-to-json-iframe.html),
  106. // within an iframe (https://songweig-rich-text-to-image.hf.space/?__theme=light).
  107. // This might not need to be recursive, since the images will be within the top-level iframe,
  108. // and thus this script will work by injection into that iframe. Might as well though.
  109. const el = doc.querySelector(selector);
  110. if (el) {
  111. return el;
  112. }
  113. for (const iframe of doc.querySelectorAll("iframe")) {
  114. try {
  115. const el = querySelector(selector, iframe.contentDocument);
  116. if (el) {
  117. return el;
  118. }
  119. } catch (e) { }
  120. }
  121. return null;
  122. }
  123.  
  124. function showOnHover(revealElement, container) {
  125. // Show the revealElement when hovering over the container
  126. container.addEventListener("mouseenter", (event) => {
  127. revealElement.style.opacity = "1";
  128. });
  129. container.addEventListener("mouseleave", (event) => {
  130. revealElement.style.opacity = "0";
  131. });
  132. // Hide the revealElement when the mouse leaves the document,
  133. // since the mouseleave event might not fire if the mouse moves fast enough outside the iframe or window
  134. document.addEventListener("mouseleave", (event) => {
  135. revealElement.style.opacity = "0";
  136. });
  137. }
  138.  
  139. function sanitizeFilename(str) {
  140. // Sanitize for file name, replacing symbols rather than removing them
  141. str = str.replace(/\//g, "⧸");
  142. str = str.replace(/\\/g, "⧹");
  143. str = str.replace(/</g, "ᐸ");
  144. str = str.replace(/>/g, "ᐳ");
  145. str = str.replace(/:/g, "꞉");
  146. str = str.replace(/\|/g, "∣");
  147. str = str.replace(/\?/g, "?");
  148. str = str.replace(/\*/g, "∗");
  149. str = str.replace(/(^|[-—\s(\["])'/g, "$1\u2018"); // opening singles
  150. str = str.replace(/'/g, "\u2019"); // closing singles & apostrophes
  151. str = str.replace(/(^|[-—/\[(‘\s])"/g, "$1\u201c"); // opening doubles
  152. str = str.replace(/"/g, "\u201d"); // closing doubles
  153. str = str.replace(/--/g, "\u2014"); // em-dashes
  154. str = str.replace(/\.\.\./g, "…"); // ellipses
  155. str = str.replace(/~/g, "\u301C"); // Chrome at least doesn't like tildes
  156. str = str.trim();
  157. return str;
  158. }