Spaces:
Running
Running
Initial release
Browse files- .gitattributes +4 -0
- LICENSE +28 -0
- README.md +64 -4
- assets/fonts/OFL.txt +102 -0
- assets/fonts/mp-sans-bold.woff2 +0 -0
- assets/fonts/mp-sans-regular.woff2 +0 -0
- core.js +729 -0
- examples/examples.json +102 -0
- examples/fairground_1941.png +3 -0
- examples/fairground_1941_x1.png +3 -0
- examples/street_stall.png +3 -0
- examples/street_stall_x1.png +3 -0
- examples/thumbs/fairground_1941.webp +0 -0
- examples/thumbs/street_stall.webp +0 -0
- examples/thumbs/visitors_1930s.webp +0 -0
- examples/thumbs/waiting_room.webp +0 -0
- examples/thumbs/washington_1910s.webp +0 -0
- examples/visitors_1930s.png +3 -0
- examples/visitors_1930s_x1.png +3 -0
- examples/waiting_room.png +3 -0
- examples/waiting_room_x2.png +3 -0
- examples/washington_1910s.png +3 -0
- examples/washington_1910s_x1.png +3 -0
- imageinfo.js +90 -0
- index.html +118 -17
- style.css +796 -17
- theme.js +5 -0
- ui.js +572 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
*.webp filter=lfs diff=lfs merge=lfs -text
|
| 38 |
+
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 39 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 MegaPotato
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
| 23 |
+
This license covers the code of this demo. It does not cover the example
|
| 24 |
+
images (public-domain or no-known-restrictions archive photographs, see
|
| 25 |
+
examples/examples.json; the results are MegaPotato outputs), the fonts in
|
| 26 |
+
assets/fonts (MP Sans, a subset of Liberation Sans under the SIL Open Font
|
| 27 |
+
License 1.1, see assets/fonts/OFL.txt), the MegaPotato name, or the MegaPotato
|
| 28 |
+
model and API.
|
README.md
CHANGED
|
@@ -1,10 +1,70 @@
|
|
| 1 |
---
|
| 2 |
title: Photo Enhancer
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Photo Enhancer
|
| 3 |
+
emoji: 🖼️
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: "Refine grainy, soft and compressed photos with AI"
|
| 11 |
+
tags:
|
| 12 |
+
- image-enhancement
|
| 13 |
+
- photo-enhancement
|
| 14 |
+
- image-restoration
|
| 15 |
+
- image-denoising
|
| 16 |
+
- super-resolution
|
| 17 |
+
- image-to-image
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# Photo Enhancer by the MegaPotato AI upscaler (megapotato.app)
|
| 21 |
+
|
| 22 |
+
A free online photo enhancer from the [MegaPotato AI upscaler](https://megapotato.app/?s=hf-photo-enhancer-readme).
|
| 23 |
+
It restores detail in noisy, soft or heavily compressed photos, at the same size or 2× larger, and works with what is
|
| 24 |
+
in the photo instead of drawing new faces or text.
|
| 25 |
+
|
| 26 |
+
## How to enhance a photo
|
| 27 |
+
|
| 28 |
+
1. Choose or drop a PNG, JPEG or WebP photo.
|
| 29 |
+
2. Pick **Original size** or **2×** and press **Enhance**.
|
| 30 |
+
3. Compare Before and After side by side; zoom to 100% or 200%, or use **Inspect** on a phone.
|
| 31 |
+
4. To download the full-size result, upload the photo on [megapotato.app](https://megapotato.app/tool?s=hf-photo-enhancer-readme)
|
| 32 |
+
(new accounts get free chips).
|
| 33 |
+
|
| 34 |
+
The examples are real results.
|
| 35 |
+
|
| 36 |
+
## Free demo limits
|
| 37 |
+
|
| 38 |
+
Log in to Hugging Face to enhance your own photos: **2 runs a day** in this Space (per network address, rolling
|
| 39 |
+
24 hours), with the same model and size limits as the product. The examples work for everyone.
|
| 40 |
+
|
| 41 |
+
## Benchmark
|
| 42 |
+
|
| 43 |
+
In MegaPotato's [blind test of 18 AI upscalers](https://megapotato.app/blog/best-ai-image-upscalers-2026?s=hf-photo-enhancer-readme)
|
| 44 |
+
the model turned none of 67 faces into a different person; creative upscalers changed up to three in four. It is
|
| 45 |
+
MegaPotato's own evaluation and MegaPotato is one of the products compared; the article gives the method, the results
|
| 46 |
+
and open data under CC BY 4.0.
|
| 47 |
+
|
| 48 |
+
## How this Space works
|
| 49 |
+
|
| 50 |
+
This Space is a static HTML page. Uploads go to a MegaPotato service on Cloudflare that holds the API key, applies
|
| 51 |
+
the demo limits and passes the image to the MegaPotato API. The proprietary model runs on MegaPotato's GPUs; the
|
| 52 |
+
model weights are not published. Images are handled under the MegaPotato
|
| 53 |
+
[Privacy Policy](https://megapotato.app/privacy) and [Terms](https://megapotato.app/terms). Every image is reviewed
|
| 54 |
+
automatically before processing, and images that may not meet our content rules are refused. The original is deleted
|
| 55 |
+
once the run finishes and the result about an hour after the upload.
|
| 56 |
+
|
| 57 |
+
The same model is available through the [MegaPotato REST API](https://megapotato.app/developers?s=hf-photo-enhancer-readme).
|
| 58 |
+
|
| 59 |
+
## Feedback
|
| 60 |
+
|
| 61 |
+
Use this Space's Community tab to report errors or share feedback. Only share images you are comfortable making
|
| 62 |
+
public.
|
| 63 |
+
|
| 64 |
+
## Credits and licensing
|
| 65 |
+
|
| 66 |
+
Example photos, from Wikimedia Commons: Wil Blanche and Jack Corn (Documerica, U.S. National Archives) and Jack
|
| 67 |
+
Delano (FSA/OWI, Library of Congress) are U.S. federal government works in the public domain; the two Harris & Ewing
|
| 68 |
+
photographs (Library of Congress) are marked "no known restrictions on publication". Sources are listed in `examples/examples.json`. Font: MP Sans, a subset of Liberation
|
| 69 |
+
Sans under the SIL Open Font License (`assets/fonts/OFL.txt`). Demo code: MIT license; access to the proprietary
|
| 70 |
+
model and API is governed by the MegaPotato service terms.
|
assets/fonts/OFL.txt
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MP Sans is a subset of Liberation Sans 2.1.5 (Regular, Bold), renamed as the SIL Open Font License requires for
|
| 2 |
+
a Modified Version (Reserved Font Names: Liberation; Arimo).
|
| 3 |
+
|
| 4 |
+
Digitized data copyright (c) 2010 Google Corporation with Reserved Font Arimo, Tinos and Cousine.
|
| 5 |
+
Copyright (c) 2012 Red Hat, Inc. with Reserved Font Name Liberation.
|
| 6 |
+
|
| 7 |
+
This Font Software is licensed under the SIL Open Font License,
|
| 8 |
+
Version 1.1.
|
| 9 |
+
|
| 10 |
+
This license is copied below, and is also available with a FAQ at:
|
| 11 |
+
http://scripts.sil.org/OFL
|
| 12 |
+
|
| 13 |
+
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
| 14 |
+
|
| 15 |
+
PREAMBLE The goals of the Open Font License (OFL) are to stimulate
|
| 16 |
+
worldwide development of collaborative font projects, to support the font
|
| 17 |
+
creation efforts of academic and linguistic communities, and to provide
|
| 18 |
+
a free and open framework in which fonts may be shared and improved in
|
| 19 |
+
partnership with others.
|
| 20 |
+
|
| 21 |
+
The OFL allows the licensed fonts to be used, studied, modified and
|
| 22 |
+
redistributed freely as long as they are not sold by themselves.
|
| 23 |
+
The fonts, including any derivative works, can be bundled, embedded,
|
| 24 |
+
redistributed and/or sold with any software provided that any reserved
|
| 25 |
+
names are not used by derivative works. The fonts and derivatives,
|
| 26 |
+
however, cannot be released under any other type of license. The
|
| 27 |
+
requirement for fonts to remain under this license does not apply to
|
| 28 |
+
any document created using the fonts or their derivatives.
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
DEFINITIONS
|
| 33 |
+
"Font Software" refers to the set of files released by the Copyright
|
| 34 |
+
Holder(s) under this license and clearly marked as such.
|
| 35 |
+
This may include source files, build scripts and documentation.
|
| 36 |
+
|
| 37 |
+
"Reserved Font Name" refers to any names specified as such after the
|
| 38 |
+
copyright statement(s).
|
| 39 |
+
|
| 40 |
+
"Original Version" refers to the collection of Font Software components
|
| 41 |
+
as distributed by the Copyright Holder(s).
|
| 42 |
+
|
| 43 |
+
"Modified Version" refers to any derivative made by adding to, deleting,
|
| 44 |
+
or substituting ? in part or in whole ?
|
| 45 |
+
any of the components of the Original Version, by changing formats or
|
| 46 |
+
by porting the Font Software to a new environment.
|
| 47 |
+
|
| 48 |
+
"Author" refers to any designer, engineer, programmer, technical writer
|
| 49 |
+
or other person who contributed to the Font Software.
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
PERMISSION & CONDITIONS
|
| 53 |
+
|
| 54 |
+
Permission is hereby granted, free of charge, to any person obtaining a
|
| 55 |
+
copy of the Font Software, to use, study, copy, merge, embed, modify,
|
| 56 |
+
redistribute, and sell modified and unmodified copies of the Font
|
| 57 |
+
Software, subject to the following conditions:
|
| 58 |
+
|
| 59 |
+
1) Neither the Font Software nor any of its individual components,in
|
| 60 |
+
Original or Modified Versions, may be sold by itself.
|
| 61 |
+
|
| 62 |
+
2) Original or Modified Versions of the Font Software may be bundled,
|
| 63 |
+
redistributed and/or sold with any software, provided that each copy
|
| 64 |
+
contains the above copyright notice and this license. These can be
|
| 65 |
+
included either as stand-alone text files, human-readable headers or
|
| 66 |
+
in the appropriate machine-readable metadata fields within text or
|
| 67 |
+
binary files as long as those fields can be easily viewed by the user.
|
| 68 |
+
|
| 69 |
+
3) No Modified Version of the Font Software may use the Reserved Font
|
| 70 |
+
Name(s) unless explicit written permission is granted by the
|
| 71 |
+
corresponding Copyright Holder. This restriction only applies to the
|
| 72 |
+
primary font name as presented to the users.
|
| 73 |
+
|
| 74 |
+
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
| 75 |
+
Software shall not be used to promote, endorse or advertise any
|
| 76 |
+
Modified Version, except to acknowledge the contribution(s) of the
|
| 77 |
+
Copyright Holder(s) and the Author(s) or with their explicit written
|
| 78 |
+
permission.
|
| 79 |
+
|
| 80 |
+
5) The Font Software, modified or unmodified, in part or in whole, must
|
| 81 |
+
be distributed entirely under this license, and must not be distributed
|
| 82 |
+
under any other license. The requirement for fonts to remain under
|
| 83 |
+
this license does not apply to any document created using the Font
|
| 84 |
+
Software.
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
TERMINATION
|
| 89 |
+
This license becomes null and void if any of the above conditions are not met.
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
DISCLAIMER
|
| 94 |
+
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
| 95 |
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
| 96 |
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
| 97 |
+
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
| 98 |
+
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
| 99 |
+
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
| 100 |
+
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
| 101 |
+
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
|
| 102 |
+
DEALINGS IN THE FONT SOFTWARE.
|
assets/fonts/mp-sans-bold.woff2
ADDED
|
Binary file (20.2 kB). View file
|
|
|
assets/fonts/mp-sans-regular.woff2
ADDED
|
Binary file (20.8 kB). View file
|
|
|
core.js
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared client of the MegaPotato demo Spaces: the demo API (config, quote, upload, poll, result), the Cloudflare
|
| 2 |
+
// human check, the Hub's login state and the upload preparation. No page markup here: each Space draws its own UI.
|
| 3 |
+
|
| 4 |
+
import { readImageInfo } from "./imageinfo.js";
|
| 5 |
+
|
| 6 |
+
export { readImageInfo };
|
| 7 |
+
|
| 8 |
+
export const API = "https://demo-api.megapotato.app";
|
| 9 |
+
const HF_ORIGIN = "https://huggingface.co";
|
| 10 |
+
export const MAX_FILE_BYTES = 50 * 1024 * 1024;
|
| 11 |
+
export const MAX_SOURCE_PIXELS = 60_000_000;
|
| 12 |
+
const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; // the site's and the API's upload limit
|
| 13 |
+
// Browsers cap a canvas (Safari: 16.7 MP); a photo larger than that is drawn at that size instead of failing.
|
| 14 |
+
const CANVAS_MAX_PIXELS = 16_777_216;
|
| 15 |
+
const JOB_DEADLINE_MS = 30 * 60_000;
|
| 16 |
+
const MIN_POLL_MS = 2000;
|
| 17 |
+
export const NBSP = " ";
|
| 18 |
+
|
| 19 |
+
export const fmt = (n) => Math.round(n).toLocaleString("en-US").replace(/,/g, NBSP);
|
| 20 |
+
export const dims = (w, h) => `${fmt(w)}${NBSP}×${NBSP}${fmt(h)}`;
|
| 21 |
+
export const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
| 22 |
+
export const escapeHtml = (s) =>
|
| 23 |
+
String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
| 24 |
+
export const usd = (x) => (x >= 0.1 ? `$${x.toFixed(2)}` : `$${x.toFixed(3).replace(/0$/, "")}`);
|
| 25 |
+
|
| 26 |
+
export function untilText(ms) {
|
| 27 |
+
if (!ms) return "later";
|
| 28 |
+
const d = new Date(ms);
|
| 29 |
+
const hhmm = `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}${NBSP}UTC`;
|
| 30 |
+
return d.toISOString().slice(0, 10) === new Date().toISOString().slice(0, 10) ? `at ${hhmm}` : `tomorrow at ${hhmm}`;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
export class DemoError extends Error {
|
| 34 |
+
constructor(kind, extra = {}) {
|
| 35 |
+
super(kind);
|
| 36 |
+
this.kind = kind;
|
| 37 |
+
Object.assign(this, extra);
|
| 38 |
+
}
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const RESUMABLE = new Set(["network", "server", "timeout"]);
|
| 42 |
+
export const isResumable = (err) => RESUMABLE.has(err.kind);
|
| 43 |
+
export const INFO_KINDS = new Set(["hourly", "daily", "budget", "spend_limit", "auth", "credits", "busy", "crowded", "login", "reduced"]);
|
| 44 |
+
|
| 45 |
+
function randomHex(bytes = 16) {
|
| 46 |
+
return Array.from(crypto.getRandomValues(new Uint8Array(bytes)), (b) => b.toString(16).padStart(2, "0")).join("");
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function withTimeout(promise, ms, kind) {
|
| 50 |
+
return Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new DemoError(kind)), ms))]);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
async function api(path, init = {}, timeoutMs = 30_000) {
|
| 54 |
+
let resp;
|
| 55 |
+
try {
|
| 56 |
+
resp = await fetch(API + path, { ...init, signal: AbortSignal.timeout(timeoutMs) });
|
| 57 |
+
} catch {
|
| 58 |
+
throw new DemoError("network");
|
| 59 |
+
}
|
| 60 |
+
let data = {};
|
| 61 |
+
try {
|
| 62 |
+
data = await resp.json();
|
| 63 |
+
} catch {
|
| 64 |
+
data = {};
|
| 65 |
+
}
|
| 66 |
+
if (!resp.ok) throw new DemoError(data.error || (resp.status >= 500 ? "server" : "network"), data);
|
| 67 |
+
return data;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* One demo page's connection to the service.
|
| 72 |
+
* `slug`: the Space ("photo-enhancer", "4k-upscaler"), used for storage keys and site attribution.
|
| 73 |
+
* `onPhase(phase, info)`: "preparing" | "uploading" | "queued" | "running" | "loading" (info: { elapsed, eta, cold }).
|
| 74 |
+
* `onQuota()`: the visitor's allowance changed (re-read after a run or when a limit ends).
|
| 75 |
+
*/
|
| 76 |
+
export class Demo {
|
| 77 |
+
constructor({ slug, turnstileEl, turnstileTheme = "auto", onPhase = () => {}, onQuota = () => {} }) {
|
| 78 |
+
this.slug = slug;
|
| 79 |
+
this.sessionKey = `mp-${slug}-active`;
|
| 80 |
+
this.passKey = "mp-demo-pass";
|
| 81 |
+
this.turnstileEl = turnstileEl;
|
| 82 |
+
this.turnstileTheme = turnstileTheme;
|
| 83 |
+
this.onPhase = onPhase;
|
| 84 |
+
this.onQuota = onQuota;
|
| 85 |
+
this.config = null;
|
| 86 |
+
this.configFailed = false;
|
| 87 |
+
this.hfUser = null;
|
| 88 |
+
this.active = null;
|
| 89 |
+
this.busy = false;
|
| 90 |
+
this.limitedUntil = 0;
|
| 91 |
+
this.turnstile = { widget: null, pending: null, ready: null };
|
| 92 |
+
this.loginWallNoted = false;
|
| 93 |
+
this.turnstileFailNoted = false;
|
| 94 |
+
this.prepared = null; // an upload prepared smaller than quoted, kept for the confirming click
|
| 95 |
+
this.quotaTimer = 0;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
// ------------------------------------------------------------------ links
|
| 99 |
+
|
| 100 |
+
/** A megapotato.app link with this Space's attribution slug (the site reads ?s=, not UTM). */
|
| 101 |
+
site(path = "/tool", content = "") {
|
| 102 |
+
const s = content ? `hf-${this.slug}-${content}` : `hf-${this.slug}`;
|
| 103 |
+
return `https://megapotato.app${path}?s=${s.slice(0, 40)}`;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
// ------------------------------------------------------------------ start
|
| 107 |
+
|
| 108 |
+
async init() {
|
| 109 |
+
const login = this.hfLoginState();
|
| 110 |
+
this.hfUser = await login;
|
| 111 |
+
try {
|
| 112 |
+
this.config = await api("/config", { headers: this.hfHeaders() }, 10_000);
|
| 113 |
+
} catch {
|
| 114 |
+
this.configFailed = true;
|
| 115 |
+
return null;
|
| 116 |
+
}
|
| 117 |
+
if (this.config.turnstile_site_key) this.loadTurnstile(this.config.turnstile_site_key);
|
| 118 |
+
this.watchQuota();
|
| 119 |
+
return this.config;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
async refresh() {
|
| 123 |
+
try {
|
| 124 |
+
this.config = await api("/config", { headers: this.hfHeaders() }, 10_000);
|
| 125 |
+
this.configFailed = false;
|
| 126 |
+
} catch {
|
| 127 |
+
// keep the last config
|
| 128 |
+
}
|
| 129 |
+
this.watchQuota();
|
| 130 |
+
return this.config;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
/** When a limit ends (a refused run's retry time, or the allowance's reset), read the allowance again. */
|
| 134 |
+
watchQuota() {
|
| 135 |
+
clearTimeout(this.quotaTimer);
|
| 136 |
+
const now = Date.now();
|
| 137 |
+
const me = this.config?.me;
|
| 138 |
+
const ends = [this.limitedUntil, me && me.left <= 0 ? me.retry_at : 0].filter((t) => Number.isFinite(t) && t > now);
|
| 139 |
+
if (!ends.length) return;
|
| 140 |
+
this.quotaTimer = setTimeout(
|
| 141 |
+
async () => {
|
| 142 |
+
await this.refresh();
|
| 143 |
+
this.onQuota();
|
| 144 |
+
},
|
| 145 |
+
Math.min(Math.min(...ends) - now + 1500, 6 * 3600_000),
|
| 146 |
+
);
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
hfHeaders() {
|
| 150 |
+
return this.hfUser ? { "X-HF-User": this.hfUser } : {};
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/** Ask the Hugging Face page whether the visitor is logged in ("anonymous", "free" or "pro"); only the Hub answers. */
|
| 154 |
+
hfLoginState() {
|
| 155 |
+
if (window.self === window.top) return Promise.resolve("anonymous");
|
| 156 |
+
return new Promise((resolve) => {
|
| 157 |
+
const done = (user) => {
|
| 158 |
+
clearTimeout(timer);
|
| 159 |
+
window.removeEventListener("message", onMessage);
|
| 160 |
+
resolve(user);
|
| 161 |
+
};
|
| 162 |
+
const onMessage = (e) => {
|
| 163 |
+
if (e.origin !== HF_ORIGIN || e.data?.type !== "USER_PLAN") return;
|
| 164 |
+
const user = e.data.plan?.user;
|
| 165 |
+
done(user === "free" || user === "pro" ? user : "anonymous");
|
| 166 |
+
};
|
| 167 |
+
const timer = setTimeout(() => done("anonymous"), 2000);
|
| 168 |
+
window.addEventListener("message", onMessage);
|
| 169 |
+
try {
|
| 170 |
+
window.parent.postMessage({ type: "USER_PLAN_REQUEST" }, HF_ORIGIN);
|
| 171 |
+
} catch {
|
| 172 |
+
done("anonymous");
|
| 173 |
+
}
|
| 174 |
+
});
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
loginNeeded() {
|
| 178 |
+
const c = this.config;
|
| 179 |
+
const me = c?.me;
|
| 180 |
+
return (!!c?.anon_jobs || !!c?.login_required) && !!me && !me.logged_in && me.left <= 0;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
/** No runs left in the current allowance (logged in or not), until its reset time when the service gave one. */
|
| 184 |
+
quotaOut() {
|
| 185 |
+
const me = this.config?.me;
|
| 186 |
+
return !!me && me.left <= 0 && !(me.retry_at && Date.now() >= me.retry_at);
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
limited() {
|
| 190 |
+
return Date.now() < this.limitedUntil || this.loginNeeded() || this.quotaOut();
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
runsLeft() {
|
| 194 |
+
return this.config?.me?.left ?? null;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
/** Once per visit: the login wall stopped someone who wanted to run (the page blocks the button, nothing is sent). */
|
| 198 |
+
noteLoginWall() {
|
| 199 |
+
if (this.loginWallNoted) return;
|
| 200 |
+
this.loginWallNoted = true;
|
| 201 |
+
this.event({ kind: "login_wall" });
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
event(body) {
|
| 205 |
+
fetch(`${API}/event`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }).catch(() => {});
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
// ------------------------------------------------------------------ human check
|
| 209 |
+
|
| 210 |
+
loadTurnstile(siteKey) {
|
| 211 |
+
if (this.turnstile.ready) return;
|
| 212 |
+
this.turnstile.ready = new Promise((resolve, reject) => {
|
| 213 |
+
window.mpTurnstileReady = () => {
|
| 214 |
+
try {
|
| 215 |
+
this.turnstile.widget = window.turnstile.render(this.turnstileEl, {
|
| 216 |
+
sitekey: siteKey,
|
| 217 |
+
action: "upscale",
|
| 218 |
+
appearance: "interaction-only",
|
| 219 |
+
execution: "execute",
|
| 220 |
+
theme: typeof this.turnstileTheme === "function" ? this.turnstileTheme() : this.turnstileTheme,
|
| 221 |
+
// Retries are ours (counted), not Cloudflare's timer: a generic failure often passes on a later run.
|
| 222 |
+
retry: "never",
|
| 223 |
+
callback: (token) => this.turnstile.pending?.resolve(token),
|
| 224 |
+
"error-callback": (code) => {
|
| 225 |
+
this.retryTurnstile(`error:${String(code ?? "unknown").slice(0, 12)}`);
|
| 226 |
+
return true;
|
| 227 |
+
},
|
| 228 |
+
"timeout-callback": () => this.retryTurnstile("interaction-timeout"),
|
| 229 |
+
"expired-callback": () => window.turnstile.reset(this.turnstile.widget),
|
| 230 |
+
});
|
| 231 |
+
resolve();
|
| 232 |
+
} catch {
|
| 233 |
+
reject(new DemoError("turnstile"));
|
| 234 |
+
}
|
| 235 |
+
};
|
| 236 |
+
const s = document.createElement("script");
|
| 237 |
+
s.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit&onload=mpTurnstileReady";
|
| 238 |
+
s.async = true;
|
| 239 |
+
s.onerror = () => {
|
| 240 |
+
this.noteTurnstileFail("script");
|
| 241 |
+
reject(new DemoError("turnstile"));
|
| 242 |
+
};
|
| 243 |
+
document.head.appendChild(s);
|
| 244 |
+
});
|
| 245 |
+
this.turnstile.ready.catch(() => undefined);
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
retryTurnstile(reason) {
|
| 249 |
+
const p = this.turnstile.pending;
|
| 250 |
+
if (!p) return;
|
| 251 |
+
if (p.attempts < 5) {
|
| 252 |
+
p.attempts += 1;
|
| 253 |
+
try {
|
| 254 |
+
window.turnstile.reset(this.turnstile.widget);
|
| 255 |
+
window.turnstile.execute(this.turnstile.widget);
|
| 256 |
+
return;
|
| 257 |
+
} catch {
|
| 258 |
+
// fall through: give up
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
this.noteTurnstileFail(reason);
|
| 262 |
+
p.reject(new DemoError("challenge"));
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
noteTurnstileFail(code) {
|
| 266 |
+
if (this.turnstileFailNoted) return;
|
| 267 |
+
this.turnstileFailNoted = true;
|
| 268 |
+
this.event({ kind: "turnstile_fail", code });
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
async turnstileToken() {
|
| 272 |
+
if (!this.turnstile.ready) return "";
|
| 273 |
+
await withTimeout(this.turnstile.ready, 20_000, "turnstile");
|
| 274 |
+
const t = window.turnstile;
|
| 275 |
+
t.reset(this.turnstile.widget);
|
| 276 |
+
try {
|
| 277 |
+
return await withTimeout(
|
| 278 |
+
new Promise((resolve, reject) => {
|
| 279 |
+
this.turnstile.pending = { resolve, reject, attempts: 1 };
|
| 280 |
+
t.execute(this.turnstile.widget);
|
| 281 |
+
}),
|
| 282 |
+
90_000,
|
| 283 |
+
"turnstile",
|
| 284 |
+
);
|
| 285 |
+
} finally {
|
| 286 |
+
this.turnstile.pending = null;
|
| 287 |
+
}
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
// A passed check is good for an hour (signed by the service, bound to this visitor). Kept for the tab only.
|
| 291 |
+
currentPass() {
|
| 292 |
+
try {
|
| 293 |
+
const p = JSON.parse(sessionStorage.getItem(this.passKey) || "null");
|
| 294 |
+
return p && typeof p.t === "string" && p.until - Date.now() > 120_000 ? p.t : null;
|
| 295 |
+
} catch {
|
| 296 |
+
return null;
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
savePass(t, ttl) {
|
| 301 |
+
try {
|
| 302 |
+
if (typeof t === "string" && Number.isFinite(ttl)) sessionStorage.setItem(this.passKey, JSON.stringify({ t, until: Date.now() + ttl }));
|
| 303 |
+
} catch {
|
| 304 |
+
// no storage: the next upload runs the check again
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
clearPass() {
|
| 309 |
+
try {
|
| 310 |
+
sessionStorage.removeItem(this.passKey);
|
| 311 |
+
} catch {
|
| 312 |
+
// ignore
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// ------------------------------------------------------------------ file and plan
|
| 317 |
+
|
| 318 |
+
/** Header facts of a chosen file, or a DemoError to show. */
|
| 319 |
+
async inspect(file) {
|
| 320 |
+
if (file.size > MAX_FILE_BYTES) throw new DemoError("too_large");
|
| 321 |
+
let info = null;
|
| 322 |
+
try {
|
| 323 |
+
info = await readImageInfo(file);
|
| 324 |
+
} catch {
|
| 325 |
+
info = null;
|
| 326 |
+
}
|
| 327 |
+
if (!info) throw new DemoError(/hei[cf]|avif|gif/i.test(file.type + file.name) ? "unsupported" : "unreadable");
|
| 328 |
+
if (info.animated) throw new DemoError("animated");
|
| 329 |
+
if (info.width * info.height > MAX_SOURCE_PIXELS) throw new DemoError("huge");
|
| 330 |
+
return info;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
async quote(width, height, factor) {
|
| 334 |
+
const plan = await api(
|
| 335 |
+
"/quote",
|
| 336 |
+
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ width, height, factor }) },
|
| 337 |
+
20_000,
|
| 338 |
+
);
|
| 339 |
+
return { ...plan, factor, source: { width, height } };
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// ------------------------------------------------------------------ run
|
| 343 |
+
|
| 344 |
+
/**
|
| 345 |
+
* Prepare the upload exactly as sent (PNG, or JPEG when the PNG is over the upload limit) and start the run. When
|
| 346 |
+
* the browser can only prepare a smaller image than quoted, nothing is sent: the smaller size is quoted and thrown
|
| 347 |
+
* as DemoError("reduced", { plan, from, file }) for the visitor to confirm with the next start (which reuses it).
|
| 348 |
+
*/
|
| 349 |
+
async start(file, plan) {
|
| 350 |
+
if (this.busy) throw new DemoError("busy");
|
| 351 |
+
this.busy = true;
|
| 352 |
+
let prepared;
|
| 353 |
+
try {
|
| 354 |
+
this.onPhase("preparing", {});
|
| 355 |
+
const kept = this.prepared;
|
| 356 |
+
this.prepared = null;
|
| 357 |
+
prepared = kept && kept.file === file && kept.w === plan.in_w && kept.h === plan.in_h ? kept : await normalize(file, plan);
|
| 358 |
+
const { width: w, height: h } = prepared.canvas;
|
| 359 |
+
if (w !== plan.in_w || h !== plan.in_h) {
|
| 360 |
+
const smaller = await this.quote(w, h, plan.factor);
|
| 361 |
+
if (smaller.in_w === w && smaller.in_h === h) this.prepared = { file, w, h, blob: prepared.blob, canvas: prepared.canvas };
|
| 362 |
+
throw new DemoError("reduced", { plan: { ...smaller, source: plan.source, resized: true, browserLimited: true }, from: plan, file });
|
| 363 |
+
}
|
| 364 |
+
} catch (err) {
|
| 365 |
+
this.busy = false;
|
| 366 |
+
throw err instanceof DemoError ? err : new DemoError("unreadable");
|
| 367 |
+
}
|
| 368 |
+
this.forget();
|
| 369 |
+
this.active = {
|
| 370 |
+
requestId: randomHex(),
|
| 371 |
+
factor: plan.factor,
|
| 372 |
+
in_w: prepared.canvas.width,
|
| 373 |
+
in_h: prepared.canvas.height,
|
| 374 |
+
blob: prepared.blob,
|
| 375 |
+
beforeUrl: URL.createObjectURL(prepared.blob),
|
| 376 |
+
canvas: prepared.canvas,
|
| 377 |
+
op: null,
|
| 378 |
+
at: Date.now(),
|
| 379 |
+
};
|
| 380 |
+
this.save();
|
| 381 |
+
this.busy = false;
|
| 382 |
+
return this.continue(true);
|
| 383 |
+
}
|
| 384 |
+
|
| 385 |
+
/** Resume a saved run after a reload; null when there is nothing to resume. */
|
| 386 |
+
async resume() {
|
| 387 |
+
let saved = null;
|
| 388 |
+
try {
|
| 389 |
+
saved = JSON.parse(sessionStorage.getItem(this.sessionKey) ?? "null");
|
| 390 |
+
} catch {
|
| 391 |
+
saved = null;
|
| 392 |
+
}
|
| 393 |
+
if (!saved?.requestId || !/^[0-9a-f]{32}$/.test(saved.requestId) || Date.now() - saved.at > 24 * 3600_000) return null;
|
| 394 |
+
this.active = {
|
| 395 |
+
requestId: saved.requestId,
|
| 396 |
+
factor: saved.factor,
|
| 397 |
+
in_w: saved.in_w,
|
| 398 |
+
in_h: saved.in_h,
|
| 399 |
+
blob: null,
|
| 400 |
+
beforeUrl: null,
|
| 401 |
+
canvas: null,
|
| 402 |
+
op: saved.op ? { op: saved.op, status: "queued" } : null,
|
| 403 |
+
at: saved.at,
|
| 404 |
+
};
|
| 405 |
+
return this.continue();
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/** Drop an upload kept from a reduced preparation (another photo was chosen). */
|
| 409 |
+
dropPrepared() {
|
| 410 |
+
this.prepared = null;
|
| 411 |
+
}
|
| 412 |
+
|
| 413 |
+
hasSaved() {
|
| 414 |
+
try {
|
| 415 |
+
return !!sessionStorage.getItem(this.sessionKey);
|
| 416 |
+
} catch {
|
| 417 |
+
return false;
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
/**
|
| 422 |
+
* Upload (if needed), wait and load the result: { op, beforeUrl, afterUrl, canvas }. Throws DemoError. The run is
|
| 423 |
+
* kept (also across a reload) until the page reports it shown with shown(), so a failed display can load it again.
|
| 424 |
+
*/
|
| 425 |
+
async continue(fresh = false) {
|
| 426 |
+
const a = this.active;
|
| 427 |
+
if (!a) throw new DemoError("unconfirmed");
|
| 428 |
+
if (this.busy) throw new DemoError("busy");
|
| 429 |
+
this.busy = true;
|
| 430 |
+
try {
|
| 431 |
+
if (!a.op && !fresh) {
|
| 432 |
+
try {
|
| 433 |
+
a.op = await api(`/requests/${a.requestId}`, {}, 20_000);
|
| 434 |
+
} catch (err) {
|
| 435 |
+
if (err.kind !== "not_found") throw err;
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
if (!a.op && !a.blob) {
|
| 439 |
+
this.forget();
|
| 440 |
+
throw new DemoError("unconfirmed");
|
| 441 |
+
}
|
| 442 |
+
if (a.op?.status === "unknown" && !this.canResend(a)) throw new DemoError("unconfirmed");
|
| 443 |
+
if (!a.op || a.op.status === "unknown") {
|
| 444 |
+
this.onPhase("uploading", {});
|
| 445 |
+
const token = a.op || this.currentPass() ? "" : await this.turnstileToken();
|
| 446 |
+
a.op = await this.submit(a, token);
|
| 447 |
+
this.save();
|
| 448 |
+
}
|
| 449 |
+
a.op = await this.follow(a);
|
| 450 |
+
this.save();
|
| 451 |
+
this.onPhase("loading", {});
|
| 452 |
+
const afterUrl = await loadResult(a.op.result);
|
| 453 |
+
return { op: a.op, beforeUrl: a.beforeUrl, afterUrl, canvas: a.canvas };
|
| 454 |
+
} catch (err) {
|
| 455 |
+
const e = err instanceof DemoError ? err : new DemoError("server");
|
| 456 |
+
if (e.op && !a.op) a.op = { op: e.op, status: "unknown" };
|
| 457 |
+
// A refusal without an operation id means nothing was created: no resume, a retry is a fresh attempt.
|
| 458 |
+
const settled =
|
| 459 |
+
["failed", "stale", "expired", "not_found", "unconfirmed"].includes(e.kind) ||
|
| 460 |
+
(["content", "check_unavailable"].includes(e.kind) && !e.op);
|
| 461 |
+
const refusedBeforeCreation = !a.op && !e.op && !RESUMABLE.has(e.kind) && e.kind !== "download";
|
| 462 |
+
if (settled || refusedBeforeCreation) this.forget();
|
| 463 |
+
if (e.kind === "login" && this.config?.me) this.config.me = { logged_in: false, left: 0, retry_at: null };
|
| 464 |
+
if (["daily", "hourly", "budget", "spend_limit"].includes(e.kind)) {
|
| 465 |
+
this.limitedUntil = e.retry_at ?? Date.now() + 3600_000;
|
| 466 |
+
this.watchQuota();
|
| 467 |
+
}
|
| 468 |
+
throw e;
|
| 469 |
+
} finally {
|
| 470 |
+
this.busy = false;
|
| 471 |
+
}
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/**
|
| 475 |
+
* The page has shown the result of `done`: the run is over. Counts it once against the shown allowance (only a run
|
| 476 |
+
* started in this tab; after a reload the allowance read at start already includes it), then re-reads it.
|
| 477 |
+
*/
|
| 478 |
+
shown(done) {
|
| 479 |
+
const a = this.active;
|
| 480 |
+
if (!a || a.op?.op !== done.op.op) return;
|
| 481 |
+
const me = this.config?.me;
|
| 482 |
+
if (a.beforeUrl && me) me.left = Math.max(0, me.left - 1);
|
| 483 |
+
this.active = null; // its before image now belongs to the page
|
| 484 |
+
try {
|
| 485 |
+
sessionStorage.removeItem(this.sessionKey);
|
| 486 |
+
} catch {
|
| 487 |
+
// ignore
|
| 488 |
+
}
|
| 489 |
+
this.refresh().then(() => this.onQuota());
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
canResend(a) {
|
| 493 |
+
return !!a.blob && (!a.op?.recoverable_until || Date.now() < a.op.recoverable_until);
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
async submit(a, token) {
|
| 497 |
+
let lastErr = null;
|
| 498 |
+
for (let attempt = 0; attempt < 3; attempt++) {
|
| 499 |
+
const headers = { "Content-Type": a.blob.type || "image/png", "X-Factor": String(a.factor), "X-Request-Id": a.requestId, ...this.hfHeaders() };
|
| 500 |
+
const pass = token ? null : this.currentPass();
|
| 501 |
+
if (token) headers["X-Turnstile"] = token;
|
| 502 |
+
else if (pass) headers["X-Pass"] = pass;
|
| 503 |
+
try {
|
| 504 |
+
const op = await api("/ops", { method: "POST", headers, body: a.blob }, 120_000);
|
| 505 |
+
if (op?.pass) this.savePass(op.pass, op.pass_ttl_ms);
|
| 506 |
+
return op;
|
| 507 |
+
} catch (err) {
|
| 508 |
+
lastErr = err;
|
| 509 |
+
if (err.kind === "challenge" && attempt === 0 && this.turnstile.ready) {
|
| 510 |
+
this.clearPass(); // an expired or refused pass: run the check after all
|
| 511 |
+
token = await this.turnstileToken();
|
| 512 |
+
continue;
|
| 513 |
+
}
|
| 514 |
+
const unclear = err.kind === "network" || (err.kind === "server" && err.op);
|
| 515 |
+
if (!unclear) throw err;
|
| 516 |
+
await sleep(2000 * (attempt + 1));
|
| 517 |
+
}
|
| 518 |
+
}
|
| 519 |
+
throw lastErr ?? new DemoError("server");
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
async follow(a) {
|
| 523 |
+
const started = Date.now();
|
| 524 |
+
let backoff = 3000;
|
| 525 |
+
let op = a.op;
|
| 526 |
+
while (true) {
|
| 527 |
+
if (op.status === "completed") return op;
|
| 528 |
+
if (op.status === "failed") throw new DemoError("failed");
|
| 529 |
+
if (op.status === "stale") throw new DemoError("stale");
|
| 530 |
+
if (Date.now() - started > JOB_DEADLINE_MS) throw new DemoError("timeout");
|
| 531 |
+
if (op.status === "unknown") {
|
| 532 |
+
if (!this.canResend(a)) throw new DemoError("unconfirmed");
|
| 533 |
+
await sleep(backoff);
|
| 534 |
+
backoff = Math.min(backoff * 2, 20_000);
|
| 535 |
+
op = await this.submit(a, "");
|
| 536 |
+
a.op = op;
|
| 537 |
+
continue;
|
| 538 |
+
}
|
| 539 |
+
const running = op.status === "running";
|
| 540 |
+
this.onPhase(running ? "running" : "queued", {
|
| 541 |
+
at: a.at,
|
| 542 |
+
eta: op.eta ?? null,
|
| 543 |
+
cold: !running && Date.now() - a.at > 20_000,
|
| 544 |
+
});
|
| 545 |
+
const t0 = Date.now();
|
| 546 |
+
try {
|
| 547 |
+
op = await api(`/ops/${op.op}`, {}, 45_000);
|
| 548 |
+
a.op = op;
|
| 549 |
+
backoff = 3000;
|
| 550 |
+
const spent = Date.now() - t0;
|
| 551 |
+
if (spent < MIN_POLL_MS && op.status !== "completed") await sleep(MIN_POLL_MS - spent);
|
| 552 |
+
} catch (err) {
|
| 553 |
+
if (["expired", "not_found"].includes(err.kind)) throw err;
|
| 554 |
+
await sleep(err.kind === "slow_down" ? 10_000 : backoff);
|
| 555 |
+
backoff = Math.min(backoff * 2, 20_000);
|
| 556 |
+
}
|
| 557 |
+
}
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
save() {
|
| 561 |
+
const a = this.active;
|
| 562 |
+
if (!a) return;
|
| 563 |
+
try {
|
| 564 |
+
sessionStorage.setItem(
|
| 565 |
+
this.sessionKey,
|
| 566 |
+
JSON.stringify({ requestId: a.requestId, op: a.op?.op ?? null, factor: a.factor, in_w: a.in_w, in_h: a.in_h, at: a.at }),
|
| 567 |
+
);
|
| 568 |
+
} catch {
|
| 569 |
+
// ignore
|
| 570 |
+
}
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
forget() {
|
| 574 |
+
if (this.active?.beforeUrl) URL.revokeObjectURL(this.active.beforeUrl);
|
| 575 |
+
this.active = null;
|
| 576 |
+
try {
|
| 577 |
+
sessionStorage.removeItem(this.sessionKey);
|
| 578 |
+
} catch {
|
| 579 |
+
// ignore
|
| 580 |
+
}
|
| 581 |
+
}
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
// ---------------------------------------------------------------------- upload preparation
|
| 585 |
+
|
| 586 |
+
async function normalize(file, plan) {
|
| 587 |
+
const { in_w: w, in_h: h } = plan;
|
| 588 |
+
try {
|
| 589 |
+
return await encodeUpload(file, w, h);
|
| 590 |
+
} catch (err) {
|
| 591 |
+
if (w * h <= CANVAS_MAX_PIXELS || (err instanceof DemoError && err.kind === "too_big_for_demo")) throw err;
|
| 592 |
+
}
|
| 593 |
+
const k = Math.sqrt(CANVAS_MAX_PIXELS / (w * h));
|
| 594 |
+
return encodeUpload(file, Math.max(1, Math.floor(w * k)), Math.max(1, Math.floor(h * k)));
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
// PNG, as the site sends; a photo whose PNG is over the upload limit goes as a high-quality JPEG instead.
|
| 598 |
+
async function encodeUpload(file, w, h) {
|
| 599 |
+
let source;
|
| 600 |
+
try {
|
| 601 |
+
source = await createImageBitmap(file, { imageOrientation: "from-image", resizeWidth: w, resizeHeight: h, resizeQuality: "high" });
|
| 602 |
+
} catch {
|
| 603 |
+
throw new DemoError("unreadable");
|
| 604 |
+
}
|
| 605 |
+
const canvas = document.createElement("canvas");
|
| 606 |
+
canvas.width = w;
|
| 607 |
+
canvas.height = h;
|
| 608 |
+
const ctx = canvas.getContext("2d", { alpha: false, colorSpace: "srgb" });
|
| 609 |
+
if (!ctx) throw new DemoError("unreadable");
|
| 610 |
+
ctx.fillStyle = "#ffffff";
|
| 611 |
+
ctx.fillRect(0, 0, w, h);
|
| 612 |
+
ctx.drawImage(source, 0, 0, w, h);
|
| 613 |
+
source.close();
|
| 614 |
+
let blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/png"));
|
| 615 |
+
if (!blob || blob.type !== "image/png") throw new DemoError("unreadable");
|
| 616 |
+
if (blob.size > MAX_UPLOAD_BYTES) {
|
| 617 |
+
blob = await new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", 0.97));
|
| 618 |
+
if (!blob || blob.type !== "image/jpeg") throw new DemoError("unreadable");
|
| 619 |
+
}
|
| 620 |
+
if (blob.size > MAX_UPLOAD_BYTES) throw new DemoError("too_big_for_demo");
|
| 621 |
+
return { blob, canvas };
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
async function loadResult(path) {
|
| 625 |
+
let resp;
|
| 626 |
+
try {
|
| 627 |
+
// The full-size JPEG (~20 MB at 16K); the demo API sends the PNG instead when there is no JPEG for this run.
|
| 628 |
+
const url = API + path + (path.includes("?") ? "&" : "?") + "format=jpeg";
|
| 629 |
+
resp = await fetch(url, { signal: AbortSignal.timeout(30 * 60_000) }); // a full-size PNG can be hundreds of MB
|
| 630 |
+
} catch {
|
| 631 |
+
throw new DemoError("download");
|
| 632 |
+
}
|
| 633 |
+
const type = resp.headers.get("Content-Type");
|
| 634 |
+
if (!resp.ok || (type !== "image/png" && type !== "image/jpeg")) throw new DemoError("download");
|
| 635 |
+
try {
|
| 636 |
+
return URL.createObjectURL(await resp.blob());
|
| 637 |
+
} catch {
|
| 638 |
+
throw new DemoError("download");
|
| 639 |
+
}
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
/** A small preview of a chosen file (object URL), or null. */
|
| 643 |
+
export async function thumbnail(file, info, edge = 160) {
|
| 644 |
+
try {
|
| 645 |
+
const k = Math.min(1, edge / Math.max(info.width, info.height));
|
| 646 |
+
const bmp = await createImageBitmap(file, {
|
| 647 |
+
imageOrientation: "from-image",
|
| 648 |
+
resizeQuality: "medium",
|
| 649 |
+
resizeWidth: Math.max(1, Math.round(info.width * k)),
|
| 650 |
+
resizeHeight: Math.max(1, Math.round(info.height * k)),
|
| 651 |
+
});
|
| 652 |
+
const c = document.createElement("canvas");
|
| 653 |
+
c.width = bmp.width;
|
| 654 |
+
c.height = bmp.height;
|
| 655 |
+
c.getContext("2d").drawImage(bmp, 0, 0);
|
| 656 |
+
bmp.close();
|
| 657 |
+
const blob = await new Promise((r) => c.toBlob(r, "image/jpeg", 0.85));
|
| 658 |
+
return blob ? URL.createObjectURL(blob) : null;
|
| 659 |
+
} catch {
|
| 660 |
+
return null;
|
| 661 |
+
}
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
// ---------------------------------------------------------------------- copy
|
| 665 |
+
|
| 666 |
+
/**
|
| 667 |
+
* Title and note for an error. `t`: { verb: "enhance", runs: "enhancements", site: (content) => url, inHub: bool }.
|
| 668 |
+
* Returns [title, noteHtml].
|
| 669 |
+
*/
|
| 670 |
+
export function errorCopy(err, demo, t) {
|
| 671 |
+
const link = (content, text = "megapotato.app") => `<a href="${demo.site("/tool", content)}" target="_blank" rel="noopener">${text}</a>`;
|
| 672 |
+
const perDay = `${demo.config?.jobs_per_day ?? 2}${NBSP}${t.runs}`;
|
| 673 |
+
const loginHint =
|
| 674 |
+
window.self !== window.top
|
| 675 |
+
? `Log in to Hugging Face (top right) for ${perDay} a day.`
|
| 676 |
+
: `Open this demo on <a href="${t.spaceUrl}" target="_blank" rel="noopener">Hugging Face</a> and log in for ${perDay} a day.`;
|
| 677 |
+
const more = `Want more? ${link("limit", "Sign up on megapotato.app")} for downloads and full access.`;
|
| 678 |
+
const still = "You can still explore the examples.";
|
| 679 |
+
const table = {
|
| 680 |
+
network: ["Could not reach the service.", "Check your connection, then press Resume."],
|
| 681 |
+
server: ["The service had a hiccup.", "Press Resume: the same upload is picked up, not charged again."],
|
| 682 |
+
download: ["The result could not be loaded.", "Press Load again."],
|
| 683 |
+
auth: ["The live demo is paused right now.", still],
|
| 684 |
+
credits: ["The live demo is paused right now.", still],
|
| 685 |
+
spend_limit: ["The live demo is paused right now.", still],
|
| 686 |
+
budget: ["The live demo is paused right now.", still],
|
| 687 |
+
login: [`Log in to Hugging Face to ${t.verb} your own photo.`, `${loginHint} ${still}`],
|
| 688 |
+
content: [
|
| 689 |
+
"This image can't be processed.",
|
| 690 |
+
"Our automated review found that it may not meet our content rules. If you believe this is a mistake, contact support@megapotato.app.",
|
| 691 |
+
],
|
| 692 |
+
check_unavailable: ["We can't accept images right now.", "Please try again in a minute."],
|
| 693 |
+
hourly: ["You've used today's free runs here.", `Another run becomes available ${untilText(err.retry_at)}. ${more}`],
|
| 694 |
+
daily: ["You've used today's free runs here.", `Another run becomes available ${untilText(err.retry_at)}. ${more}`],
|
| 695 |
+
busy: ["Your previous run is still going.", "Wait for it to finish, then start the next one."],
|
| 696 |
+
crowded: ["The demo is busy right now.", "Please try again in a minute."],
|
| 697 |
+
rate_limit: ["The demo is busy right now.", "Please try again in a minute."],
|
| 698 |
+
slow_down: ["Too many requests in a short time.", "Please wait a moment and try again."],
|
| 699 |
+
challenge: [
|
| 700 |
+
"Cloudflare's quick human check didn't pass.",
|
| 701 |
+
"This usually happens with an ad or privacy blocker, or a VPN. Allow challenges.cloudflare.com, turn the VPN off or try another browser, then try again.",
|
| 702 |
+
],
|
| 703 |
+
turnstile: [
|
| 704 |
+
"Cloudflare's quick human check couldn't load.",
|
| 705 |
+
"An ad or privacy blocker may be blocking challenges.cloudflare.com. Allow it, turn the VPN off or try another browser, then reload.",
|
| 706 |
+
],
|
| 707 |
+
too_small: ["This image is too small.", "Please use an image at least 16 pixels on each side."],
|
| 708 |
+
too_big_for_demo: ["This image is too large to process here.", `Upload the original file on ${link("too-big")}.`],
|
| 709 |
+
too_large: ["This file is too large.", "Please use an image under 50 MB."],
|
| 710 |
+
unsupported: ["This file type is not supported here.", "Use a PNG, JPEG or WebP image."],
|
| 711 |
+
unreadable: ["This file could not be read as an image.", "Use a PNG, JPEG or WebP image."],
|
| 712 |
+
animated: ["Animated images are not supported.", "Export a single frame as PNG or JPEG and try again."],
|
| 713 |
+
huge: ["This image is too large to open in the browser.", "Please use an image under 60 megapixels."],
|
| 714 |
+
failed: ["That run did not succeed.", "Nothing was charged. Please try again, or try another image."],
|
| 715 |
+
stale: ["This is taking much longer than usual.", "Please try again later."],
|
| 716 |
+
timeout: ["This is taking longer than usual.", "The job keeps running. Press Resume to check again."],
|
| 717 |
+
unconfirmed: ["That upload could not be confirmed.", "Nothing more will happen with it. Please start again."],
|
| 718 |
+
expired: ["This result has expired.", "Results stay available here for about an hour after the upload."],
|
| 719 |
+
not_found: ["This result is no longer available.", "Results stay available here for about an hour after the upload."],
|
| 720 |
+
invalid: ["The image could not be processed.", "Try another image."],
|
| 721 |
+
reduced: [
|
| 722 |
+
"This browser can't prepare the image at full size.",
|
| 723 |
+
err.plan
|
| 724 |
+
? `It would go at ${dims(err.plan.in_w, err.plan.in_h)}, for an output of ${dims(err.plan.out_w, err.plan.out_h)}. Press the button again to continue.`
|
| 725 |
+
: "Press the button again to continue at a smaller size.",
|
| 726 |
+
],
|
| 727 |
+
};
|
| 728 |
+
return table[err.kind] ?? table.server;
|
| 729 |
+
}
|
examples/examples.json
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[
|
| 2 |
+
{
|
| 3 |
+
"id": "street_stall",
|
| 4 |
+
"title": "Street stall, 1970s",
|
| 5 |
+
"caption": "Phone photo at night",
|
| 6 |
+
"factor": 1,
|
| 7 |
+
"input": "examples/street_stall.png",
|
| 8 |
+
"result": "examples/street_stall_x1.png",
|
| 9 |
+
"thumb": "examples/thumbs/street_stall.webp",
|
| 10 |
+
"in": [
|
| 11 |
+
1500,
|
| 12 |
+
1018
|
| 13 |
+
],
|
| 14 |
+
"out": [
|
| 15 |
+
1500,
|
| 16 |
+
1018
|
| 17 |
+
],
|
| 18 |
+
"credit": "Memorial Day at Battery Park, Lower Manhattan, by Wil Blanche (Documerica, U.S. National Archives), Wikimedia Commons. Reduced to 1 500 × 1 018. The demo input was damaged on purpose to show what the enhancer restores: simulated low-light sensor noise and JPEG.",
|
| 19 |
+
"source": "https://commons.wikimedia.org/wiki/File:PEOPLE_ENJOYING_MEMORIAL_DAY_HOLIDAY_AT_BATTERY_PARK_IN_LOWER_MANHATTAN_-_NARA_-_549315.jpg",
|
| 20 |
+
"licence": "Public domain (U.S. federal government work)."
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"id": "visitors_1930s",
|
| 24 |
+
"title": "Visitors, 1930s",
|
| 25 |
+
"caption": "Sent through a messenger",
|
| 26 |
+
"factor": 1,
|
| 27 |
+
"input": "examples/visitors_1930s.png",
|
| 28 |
+
"result": "examples/visitors_1930s_x1.png",
|
| 29 |
+
"thumb": "examples/thumbs/visitors_1930s.webp",
|
| 30 |
+
"in": [
|
| 31 |
+
1162,
|
| 32 |
+
1500
|
| 33 |
+
],
|
| 34 |
+
"out": [
|
| 35 |
+
1162,
|
| 36 |
+
1500
|
| 37 |
+
],
|
| 38 |
+
"credit": "Albert Sarraut leaving the White House, Harris & Ewing (Library of Congress), Wikimedia Commons. Reduced to 1 162 × 1 500. The demo input was damaged on purpose to show what the enhancer restores: shrunk and re-saved as a low-quality JPEG three times, then enlarged back.",
|
| 39 |
+
"source": "https://commons.wikimedia.org/wiki/File:France%27s_ex-prime_minister_visits_President_Roosevelt,_Washington,_D.C.,_September_8._Albert_Sarraut,_former_Prime_Minister_of_France_%3F_leaving_the_White_House_after_having_paid_a_visit_to_LCCN2016878422.tif",
|
| 40 |
+
"licence": "No known restrictions on publication (Library of Congress)."
|
| 41 |
+
},
|
| 42 |
+
{
|
| 43 |
+
"id": "washington_1910s",
|
| 44 |
+
"title": "Washington, 1910s",
|
| 45 |
+
"caption": "Old digital camera",
|
| 46 |
+
"factor": 1,
|
| 47 |
+
"input": "examples/washington_1910s.png",
|
| 48 |
+
"result": "examples/washington_1910s_x1.png",
|
| 49 |
+
"thumb": "examples/thumbs/washington_1910s.webp",
|
| 50 |
+
"in": [
|
| 51 |
+
1120,
|
| 52 |
+
1500
|
| 53 |
+
],
|
| 54 |
+
"out": [
|
| 55 |
+
1120,
|
| 56 |
+
1500
|
| 57 |
+
],
|
| 58 |
+
"credit": "Roger W. Babson with Secretary of Labor W. B. Wilson, Harris & Ewing (Library of Congress), Wikimedia Commons. Reduced to 1 120 × 1 500. The demo input was damaged on purpose to show what the enhancer restores: simulated soft lens, blotchy colour noise and JPEG.",
|
| 59 |
+
"source": "https://commons.wikimedia.org/wiki/File:BABSON,_ROGER_W._OF_LABOR_DEPARTMENT._LEFT,_WITH_SEC._W.B._WILSON_LCCN2016869781.jpg",
|
| 60 |
+
"licence": "No known restrictions on publication (Library of Congress)."
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"id": "fairground_1941",
|
| 64 |
+
"title": "Fairground, 1941",
|
| 65 |
+
"caption": "Phone photo at dusk",
|
| 66 |
+
"factor": 1,
|
| 67 |
+
"input": "examples/fairground_1941.png",
|
| 68 |
+
"result": "examples/fairground_1941_x1.png",
|
| 69 |
+
"thumb": "examples/thumbs/fairground_1941.webp",
|
| 70 |
+
"in": [
|
| 71 |
+
1600,
|
| 72 |
+
1051
|
| 73 |
+
],
|
| 74 |
+
"out": [
|
| 75 |
+
1600,
|
| 76 |
+
1051
|
| 77 |
+
],
|
| 78 |
+
"credit": "At the Vermont State Fair, Rutland, by Jack Delano (FSA/OWI, Library of Congress), Wikimedia Commons. Reduced to 1 600 × 1 051. The demo input was damaged on purpose to show what the enhancer restores: simulated low-light sensor noise and JPEG.",
|
| 79 |
+
"source": "https://commons.wikimedia.org/wiki/File:At_the_Vermont_State_Fair,_Rutland_(LOC_fsac.1a33910).jpg",
|
| 80 |
+
"licence": "Public domain (U.S. federal government work)."
|
| 81 |
+
},
|
| 82 |
+
{
|
| 83 |
+
"id": "waiting_room",
|
| 84 |
+
"title": "Waiting room, 1970s",
|
| 85 |
+
"caption": "Small, shared copy",
|
| 86 |
+
"factor": 2,
|
| 87 |
+
"input": "examples/waiting_room.png",
|
| 88 |
+
"result": "examples/waiting_room_x2.png",
|
| 89 |
+
"thumb": "examples/thumbs/waiting_room.webp",
|
| 90 |
+
"in": [
|
| 91 |
+
625,
|
| 92 |
+
422
|
| 93 |
+
],
|
| 94 |
+
"out": [
|
| 95 |
+
1250,
|
| 96 |
+
844
|
| 97 |
+
],
|
| 98 |
+
"credit": "People waiting for a Black Lung Association meeting, Beckley, West Virginia, by Jack Corn (Documerica, U.S. National Archives), Wikimedia Commons. The demo input was damaged on purpose to show what the enhancer restores: shrunk to 625 px, blurred, with noise, saved as a JPEG twice.",
|
| 99 |
+
"source": "https://commons.wikimedia.org/wiki/File:PICTURE_OF_PEOPLE_SEATED_AND_WAITING_FOR_THE_BECKLEY_(WEST_VIRGINIA)_BLACK_LUNG_ASSOCIATION_MEETING_TO_BEGIN._THIS..._-_NARA_-_556450.jpg",
|
| 100 |
+
"licence": "Public domain (U.S. federal government work)."
|
| 101 |
+
}
|
| 102 |
+
]
|
examples/fairground_1941.png
ADDED
|
Git LFS Details
|
examples/fairground_1941_x1.png
ADDED
|
Git LFS Details
|
examples/street_stall.png
ADDED
|
Git LFS Details
|
examples/street_stall_x1.png
ADDED
|
Git LFS Details
|
examples/thumbs/fairground_1941.webp
ADDED
|
|
examples/thumbs/street_stall.webp
ADDED
|
|
examples/thumbs/visitors_1930s.webp
ADDED
|
|
examples/thumbs/waiting_room.webp
ADDED
|
|
examples/thumbs/washington_1910s.webp
ADDED
|
|
examples/visitors_1930s.png
ADDED
|
Git LFS Details
|
examples/visitors_1930s_x1.png
ADDED
|
Git LFS Details
|
examples/waiting_room.png
ADDED
|
Git LFS Details
|
examples/waiting_room_x2.png
ADDED
|
Git LFS Details
|
examples/washington_1910s.png
ADDED
|
Git LFS Details
|
examples/washington_1910s_x1.png
ADDED
|
Git LFS Details
|
imageinfo.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Image size from the file header, without decoding: PNG, JPEG (with EXIF orientation) and WebP.
|
| 2 |
+
// Anything else (GIF, BMP, HEIC, AVIF, …) returns null and is refused before any decoding.
|
| 3 |
+
// Returns { width, height, format, rotated } with width/height as displayed (after EXIF rotation), or null.
|
| 4 |
+
|
| 5 |
+
const HEAD_BYTES = 512 * 1024;
|
| 6 |
+
|
| 7 |
+
export async function readImageInfo(file) {
|
| 8 |
+
const buf = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer());
|
| 9 |
+
return png(buf) ?? jpeg(buf) ?? webp(buf);
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
const u16be = (b, i) => (b[i] << 8) | b[i + 1];
|
| 13 |
+
const u16le = (b, i) => b[i] | (b[i + 1] << 8);
|
| 14 |
+
const u32be = (b, i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
| 15 |
+
const u32le = (b, i) => (b[i] | (b[i + 1] << 8) | (b[i + 2] << 16) | (b[i + 3] << 24)) >>> 0;
|
| 16 |
+
const ascii = (b, i, n) => String.fromCharCode(...b.subarray(i, i + n));
|
| 17 |
+
|
| 18 |
+
function result(width, height, format, orientation = 1) {
|
| 19 |
+
if (!width || !height) return null;
|
| 20 |
+
const rotated = orientation >= 5 && orientation <= 8;
|
| 21 |
+
return { width: rotated ? height : width, height: rotated ? width : height, format, rotated };
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function png(b) {
|
| 25 |
+
if (b.length < 24 || u32be(b, 0) !== 0x89504e47 || ascii(b, 12, 4) !== "IHDR") return null;
|
| 26 |
+
let at = 8;
|
| 27 |
+
while (at + 8 <= b.length) {
|
| 28 |
+
const len = u32be(b, at);
|
| 29 |
+
const type = ascii(b, at + 4, 4);
|
| 30 |
+
if (type === "acTL") return { ...result(u32be(b, 16), u32be(b, 20), "png"), animated: true };
|
| 31 |
+
if (type === "IDAT") return result(u32be(b, 16), u32be(b, 20), "png");
|
| 32 |
+
at += 12 + len;
|
| 33 |
+
}
|
| 34 |
+
return null;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function exifOrientation(b, start, len) {
|
| 38 |
+
if (ascii(b, start, 4) !== "Exif") return 1;
|
| 39 |
+
const tiff = start + 6;
|
| 40 |
+
if (tiff + 8 > b.length) return 1;
|
| 41 |
+
const little = ascii(b, tiff, 2) === "II";
|
| 42 |
+
const r16 = (i) => (little ? u16le(b, i) : u16be(b, i));
|
| 43 |
+
const r32 = (i) => (little ? u32le(b, i) : u32be(b, i));
|
| 44 |
+
const ifd = tiff + r32(tiff + 4);
|
| 45 |
+
if (ifd + 2 > Math.min(b.length, start + len)) return 1;
|
| 46 |
+
const count = r16(ifd);
|
| 47 |
+
for (let k = 0; k < count; k++) {
|
| 48 |
+
const e = ifd + 2 + k * 12;
|
| 49 |
+
if (e + 12 > b.length) break;
|
| 50 |
+
if (r16(e) === 0x0112) return r16(e + 8) || 1;
|
| 51 |
+
}
|
| 52 |
+
return 1;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function jpeg(b) {
|
| 56 |
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return null;
|
| 57 |
+
let at = 2;
|
| 58 |
+
let orientation = 1;
|
| 59 |
+
while (at + 4 <= b.length) {
|
| 60 |
+
if (b[at] !== 0xff) return null;
|
| 61 |
+
const marker = b[at + 1];
|
| 62 |
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
|
| 63 |
+
at += 2;
|
| 64 |
+
continue;
|
| 65 |
+
}
|
| 66 |
+
const len = u16be(b, at + 2);
|
| 67 |
+
if (marker === 0xe1) orientation = exifOrientation(b, at + 4, len - 2);
|
| 68 |
+
const isSof = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
| 69 |
+
if (isSof && at + 9 <= b.length) return result(u16be(b, at + 7), u16be(b, at + 5), "jpeg", orientation);
|
| 70 |
+
at += 2 + len;
|
| 71 |
+
}
|
| 72 |
+
return null;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function webp(b) {
|
| 76 |
+
if (b.length < 30 || ascii(b, 0, 4) !== "RIFF" || ascii(b, 8, 4) !== "WEBP") return null;
|
| 77 |
+
const kind = ascii(b, 12, 4);
|
| 78 |
+
if (kind === "VP8 ") return result(u16le(b, 26) & 0x3fff, u16le(b, 28) & 0x3fff, "webp");
|
| 79 |
+
if (kind === "VP8L") {
|
| 80 |
+
const bits = u32le(b, 21);
|
| 81 |
+
return result((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1, "webp");
|
| 82 |
+
}
|
| 83 |
+
if (kind === "VP8X") {
|
| 84 |
+
const animated = (b[20] & 0x02) !== 0;
|
| 85 |
+
const w = 1 + (b[24] | (b[25] << 8) | (b[26] << 16));
|
| 86 |
+
const h = 1 + (b[27] | (b[28] << 8) | (b[29] << 16));
|
| 87 |
+
return { ...result(w, h, "webp"), animated };
|
| 88 |
+
}
|
| 89 |
+
return null;
|
| 90 |
+
}
|
index.html
CHANGED
|
@@ -1,19 +1,120 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>Photo Enhancer: clean up noisy, soft and compressed photos · MegaPotato AI upscaler (megapotato.app)</title>
|
| 7 |
+
<meta name="description" content="Photo enhancer demo of the MegaPotato AI upscaler (megapotato.app). Restore detail in noisy, soft or compressed photos at the original size or 2× larger, and compare before and after side by side.">
|
| 8 |
+
<meta name="referrer" content="strict-origin">
|
| 9 |
+
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; connect-src 'self' https://demo-api.megapotato.app; img-src 'self' data: blob: https://demo-api.megapotato.app https://*.hf.co https://*.huggingface.co; style-src 'self' 'unsafe-inline'; font-src 'self' https://*.hf.co; base-uri 'none'; form-action 'none'; object-src 'none'">
|
| 10 |
+
<meta name="color-scheme" content="light dark">
|
| 11 |
+
<meta property="og:title" content="Photo Enhancer · MegaPotato AI upscaler (megapotato.app)">
|
| 12 |
+
<meta property="og:description" content="Restore detail in noisy, soft or compressed photos. Same size, or 2× larger.">
|
| 13 |
+
<link rel="preload" href="assets/fonts/mp-sans-regular.woff2" as="font" type="font/woff2" crossorigin>
|
| 14 |
+
<link rel="preload" href="assets/fonts/mp-sans-bold.woff2" as="font" type="font/woff2" crossorigin>
|
| 15 |
+
<script src="theme.js"></script>
|
| 16 |
+
<link rel="stylesheet" href="style.css">
|
| 17 |
+
<script type="module" src="ui.js"></script>
|
| 18 |
+
</head>
|
| 19 |
+
<body>
|
| 20 |
+
<div class="page">
|
| 21 |
+
|
| 22 |
+
<header class="masthead">
|
| 23 |
+
<div class="masthead-row">
|
| 24 |
+
<h1>Photo Enhancer</h1>
|
| 25 |
+
<a class="site" href="https://megapotato.app/?s=hf-photo-enhancer" target="_blank" rel="noopener">megapotato.app<span class="arrow" aria-hidden="true">↗</span></a>
|
| 26 |
+
</div>
|
| 27 |
+
<p class="lead">Restores detail in noisy, soft or compressed photos. Same size, or 2× larger. It works with what is in the photo instead of drawing new faces or text.</p>
|
| 28 |
+
<p class="proof">In <a href="https://megapotato.app/blog/best-ai-image-upscalers-2026?s=hf-photo-enhancer#scoreboard" target="_blank" rel="noopener">our blind test of 18 AI upscalers</a> it turned none of 67 faces into a different person. Creative upscalers changed up to three in four.</p>
|
| 29 |
+
<p class="brand">A demo of the <a href="https://megapotato.app/?s=hf-photo-enhancer" target="_blank" rel="noopener">MegaPotato AI upscaler</a> · megapotato.app</p>
|
| 30 |
+
</header>
|
| 31 |
+
|
| 32 |
+
<div id="notice" class="notice" role="status" hidden></div>
|
| 33 |
+
|
| 34 |
+
<section class="bench" aria-label="Enhance your photo">
|
| 35 |
+
<div class="bench-row">
|
| 36 |
+
<div class="pick">
|
| 37 |
+
<label id="drop" class="pick-button">
|
| 38 |
+
<input id="file" type="file" accept="image/png,image/jpeg,image/webp">
|
| 39 |
+
<span id="file-label">Choose photo</span>
|
| 40 |
+
</label>
|
| 41 |
+
<span class="pick-meta">
|
| 42 |
+
<img id="drop-thumb" class="pick-thumb" alt="" hidden>
|
| 43 |
+
<span id="file-meta" class="pick-text"><span class="note-mouse">or drop it anywhere on the page · </span>PNG, JPEG, WebP up to 50 MB</span>
|
| 44 |
+
</span>
|
| 45 |
+
</div>
|
| 46 |
+
|
| 47 |
+
<fieldset class="seg" aria-label="Output size">
|
| 48 |
+
<label class="seg-option"><input type="radio" name="mode" value="1" checked><span>Original size</span></label>
|
| 49 |
+
<label class="seg-option"><input type="radio" name="mode" value="2"><span>2×</span></label>
|
| 50 |
+
</fieldset>
|
| 51 |
+
|
| 52 |
+
<button id="run" class="primary run" type="button" disabled>Enhance</button>
|
| 53 |
+
|
| 54 |
+
<p id="quota" class="quota">Preview only · 2 free runs a day</p>
|
| 55 |
+
</div>
|
| 56 |
+
|
| 57 |
+
<div class="bench-line">
|
| 58 |
+
<p id="plan" class="plan"></p>
|
| 59 |
+
<div id="status" class="status" aria-live="polite" hidden></div>
|
| 60 |
+
</div>
|
| 61 |
+
<div id="turnstile" class="turnstile"></div>
|
| 62 |
+
</section>
|
| 63 |
+
|
| 64 |
+
<section id="viewer-wrap" class="viewer" data-inspect="false" aria-label="Before and after">
|
| 65 |
+
<div class="viewer-head">
|
| 66 |
+
<span id="before-label" class="label-before">Before</span>
|
| 67 |
+
<div class="label-after">
|
| 68 |
+
<span>After</span>
|
| 69 |
+
<div class="zoom" role="group" aria-label="Zoom">
|
| 70 |
+
<button type="button" data-zoom="fit" aria-pressed="true">Fit</button>
|
| 71 |
+
<button type="button" data-zoom="1" aria-pressed="false">100%</button>
|
| 72 |
+
<button type="button" data-zoom="2" aria-pressed="false">200%</button>
|
| 73 |
+
<button id="inspect" class="inspect" type="button" aria-pressed="false">Inspect</button>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
</div>
|
| 77 |
+
|
| 78 |
+
<div id="viewer" class="diptych">
|
| 79 |
+
<figure class="pane" data-side="before">
|
| 80 |
+
<div class="pane-frame"><img alt="Before" draggable="false"><p class="pane-empty" hidden>The original is not kept after a reload.</p></div>
|
| 81 |
+
</figure>
|
| 82 |
+
<figure class="pane" data-side="after">
|
| 83 |
+
<div class="pane-frame"><img alt="After" draggable="false"></div>
|
| 84 |
+
</figure>
|
| 85 |
+
<div class="loading" hidden><span></span></div>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div class="viewer-foot">
|
| 89 |
+
<p id="caption" class="caption"></p>
|
| 90 |
+
<p class="hint"><span class="note-mouse">Drag either frame to look around. Ctrl + scroll zooms, double-click shows 100%.</span><span class="note-touch">Tap Inspect to pan and pinch both frames together.</span></p>
|
| 91 |
+
</div>
|
| 92 |
+
</section>
|
| 93 |
+
|
| 94 |
+
<section class="gallery" aria-label="Examples">
|
| 95 |
+
<p class="gallery-head">Examples, real results</p>
|
| 96 |
+
<ul id="examples" class="examples"></ul>
|
| 97 |
+
</section>
|
| 98 |
+
|
| 99 |
+
<footer class="colophon">
|
| 100 |
+
<nav class="colophon-links" aria-label="More">
|
| 101 |
+
<a href="https://megapotato.app/developers?s=hf-photo-enhancer" target="_blank" rel="noopener">API</a>
|
| 102 |
+
<a href="https://megapotato.app/blog/best-ai-image-upscalers-2026?s=hf-photo-enhancer" target="_blank" rel="noopener">Benchmark</a>
|
| 103 |
+
<a href="https://megapotato.app/pricing?s=hf-photo-enhancer" target="_blank" rel="noopener">Pricing</a>
|
| 104 |
+
</nav>
|
| 105 |
+
<details id="about" class="about">
|
| 106 |
+
<summary>About this demo, your images and privacy</summary>
|
| 107 |
+
<div class="about-body">
|
| 108 |
+
<p>A free demo of the <a href="https://megapotato.app/?s=hf-photo-enhancer" target="_blank" rel="noopener">MegaPotato AI upscaler</a>. Your image goes to the MegaPotato API, is processed on MegaPotato's GPUs and comes back to this page. The model weights are not published. Through the API the same model enhances at the original size (<code>upscale=1</code>) or enlarges ×2, ×4 and ×8.</p>
|
| 109 |
+
<h2 id="privacy">Your images and privacy</h2>
|
| 110 |
+
<p>Images you enhance are sent through a small MegaPotato service on Cloudflare to the MegaPotato API and handled under its <a href="https://megapotato.app/privacy" target="_blank" rel="noopener">Privacy Policy</a> and <a href="https://megapotato.app/terms" target="_blank" rel="noopener">Terms</a>. Every image is reviewed automatically before processing, and images that may not meet our content rules are refused. The original is deleted once the run finishes and the result about an hour after the upload. The demo asks the Hugging Face page only whether you are logged in (not who you are) and counts runs per network address, stored only as a keyed hash; Cloudflare processes it to run the service and the Turnstile check that helps limit automated abuse. Results open only inside this demo page. Please upload only images you have the right to use.</p>
|
| 111 |
+
<h2>Example images</h2>
|
| 112 |
+
<ul id="credits" class="credits"></ul>
|
| 113 |
+
<p>Demo code: MIT license. Font: MP Sans, a subset of Liberation Sans (SIL Open Font License, <a href="assets/fonts/OFL.txt">text</a>).</p>
|
| 114 |
+
</div>
|
| 115 |
+
</details>
|
| 116 |
+
</footer>
|
| 117 |
+
</div>
|
| 118 |
+
<div id="dropzone" class="dropzone" aria-hidden="true"><span>Drop the photo to enhance it</span></div>
|
| 119 |
+
</body>
|
| 120 |
</html>
|
style.css
CHANGED
|
@@ -1,28 +1,807 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
h1 {
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
|
|
|
| 1 |
+
@font-face {
|
| 2 |
+
font-family: "MP Sans";
|
| 3 |
+
font-weight: 400;
|
| 4 |
+
font-display: swap;
|
| 5 |
+
src: url(assets/fonts/mp-sans-regular.woff2) format("woff2");
|
| 6 |
+
}
|
| 7 |
+
@font-face {
|
| 8 |
+
font-family: "MP Sans";
|
| 9 |
+
font-weight: 700;
|
| 10 |
+
font-display: swap;
|
| 11 |
+
src: url(assets/fonts/mp-sans-bold.woff2) format("woff2");
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
/* A gallery wall: white paper, warm ink, the photographs carry all the colour. One typeface, two sizes that matter. */
|
| 15 |
+
:root {
|
| 16 |
+
--paper: #ffffff;
|
| 17 |
+
--ink: #171511;
|
| 18 |
+
--ink-2: #625c53;
|
| 19 |
+
--ink-3: #9d968b;
|
| 20 |
+
--ink-hint: #716a61; /* small text: >= 4.5:1 on paper, well and hover */
|
| 21 |
+
--line: #e4e0d9;
|
| 22 |
+
--line-strong: #c9c3ba;
|
| 23 |
+
--well: #f1efea;
|
| 24 |
+
--hover: #f6f4f0;
|
| 25 |
+
--focus: #171511;
|
| 26 |
+
--error: #b5361f;
|
| 27 |
+
--ui: "MP Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
|
| 28 |
+
color-scheme: light;
|
| 29 |
+
}
|
| 30 |
+
@media (prefers-color-scheme: dark) {
|
| 31 |
+
:root:not([data-theme="light"]) {
|
| 32 |
+
--paper: #1a1917;
|
| 33 |
+
--ink: #f1ede6;
|
| 34 |
+
--ink-2: #a6a097;
|
| 35 |
+
--ink-3: #6f6a62;
|
| 36 |
+
--ink-hint: #8f8a81;
|
| 37 |
+
--line: #2f2d29;
|
| 38 |
+
--line-strong: #4a4740;
|
| 39 |
+
--well: #131210;
|
| 40 |
+
--hover: #232220;
|
| 41 |
+
--focus: #f1ede6;
|
| 42 |
+
--error: #ff9a85;
|
| 43 |
+
color-scheme: dark;
|
| 44 |
+
}
|
| 45 |
+
}
|
| 46 |
+
:root[data-theme="dark"] {
|
| 47 |
+
--paper: #1a1917;
|
| 48 |
+
--ink: #f1ede6;
|
| 49 |
+
--ink-2: #a6a097;
|
| 50 |
+
--ink-3: #6f6a62;
|
| 51 |
+
--ink-hint: #8f8a81;
|
| 52 |
+
--line: #2f2d29;
|
| 53 |
+
--line-strong: #4a4740;
|
| 54 |
+
--well: #131210;
|
| 55 |
+
--hover: #232220;
|
| 56 |
+
--focus: #f1ede6;
|
| 57 |
+
--error: #ff9a85;
|
| 58 |
+
color-scheme: dark;
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
* {
|
| 62 |
+
box-sizing: border-box;
|
| 63 |
+
}
|
| 64 |
+
[hidden] {
|
| 65 |
+
display: none !important;
|
| 66 |
+
}
|
| 67 |
+
html {
|
| 68 |
+
-webkit-text-size-adjust: 100%;
|
| 69 |
+
}
|
| 70 |
body {
|
| 71 |
+
margin: 0;
|
| 72 |
+
background: var(--paper);
|
| 73 |
+
color: var(--ink);
|
| 74 |
+
font: 400 15px/1.45 var(--ui);
|
| 75 |
+
font-feature-settings: "kern";
|
| 76 |
+
}
|
| 77 |
+
button,
|
| 78 |
+
input {
|
| 79 |
+
font: inherit;
|
| 80 |
+
color: inherit;
|
| 81 |
+
}
|
| 82 |
+
figure,
|
| 83 |
+
fieldset,
|
| 84 |
+
p,
|
| 85 |
+
h1,
|
| 86 |
+
h2,
|
| 87 |
+
ul {
|
| 88 |
+
margin: 0;
|
| 89 |
+
}
|
| 90 |
+
fieldset {
|
| 91 |
+
padding: 0;
|
| 92 |
+
border: 0;
|
| 93 |
+
min-width: 0;
|
| 94 |
+
}
|
| 95 |
+
ul {
|
| 96 |
+
padding: 0;
|
| 97 |
+
list-style: none;
|
| 98 |
+
}
|
| 99 |
+
a {
|
| 100 |
+
color: inherit;
|
| 101 |
+
text-decoration-thickness: 1px;
|
| 102 |
+
text-underline-offset: 3px;
|
| 103 |
+
text-decoration-color: var(--line-strong);
|
| 104 |
+
}
|
| 105 |
+
a:hover {
|
| 106 |
+
text-decoration-color: currentColor;
|
| 107 |
+
}
|
| 108 |
+
button {
|
| 109 |
+
min-height: 44px;
|
| 110 |
+
padding: 0 16px;
|
| 111 |
+
border: 1px solid var(--line-strong);
|
| 112 |
+
border-radius: 6px;
|
| 113 |
+
background: transparent;
|
| 114 |
+
cursor: pointer;
|
| 115 |
+
transition: background-color 0.15s, border-color 0.15s, color 0.15s;
|
| 116 |
+
}
|
| 117 |
+
button:hover:not(:disabled) {
|
| 118 |
+
background: var(--hover);
|
| 119 |
+
border-color: var(--ink);
|
| 120 |
+
}
|
| 121 |
+
:where(button, a, summary):focus-visible,
|
| 122 |
+
.pick-button:focus-within,
|
| 123 |
+
.seg-option:has(input:focus-visible),
|
| 124 |
+
.example:focus-visible {
|
| 125 |
+
outline: 2px solid var(--focus);
|
| 126 |
+
outline-offset: 3px;
|
| 127 |
+
}
|
| 128 |
+
.primary {
|
| 129 |
+
border-color: var(--ink);
|
| 130 |
+
background: var(--ink);
|
| 131 |
+
color: var(--paper);
|
| 132 |
+
font-weight: 700;
|
| 133 |
+
}
|
| 134 |
+
.primary:hover:not(:disabled) {
|
| 135 |
+
background: var(--ink);
|
| 136 |
+
color: var(--paper);
|
| 137 |
+
opacity: 0.86;
|
| 138 |
+
}
|
| 139 |
+
.primary:disabled {
|
| 140 |
+
background: var(--well);
|
| 141 |
+
color: var(--ink-3);
|
| 142 |
+
border-color: var(--well);
|
| 143 |
+
cursor: default;
|
| 144 |
+
}
|
| 145 |
+
.numeric {
|
| 146 |
+
font-variant-numeric: lining-nums tabular-nums;
|
| 147 |
+
}
|
| 148 |
+
code {
|
| 149 |
+
font-family: inherit;
|
| 150 |
+
font-size: inherit;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/* ------------------------------------------------------------------ page */
|
| 154 |
+
|
| 155 |
+
.page {
|
| 156 |
+
max-width: 1320px;
|
| 157 |
+
margin: 0 auto;
|
| 158 |
+
padding: 36px 40px 28px;
|
| 159 |
}
|
| 160 |
|
| 161 |
+
/* ------------------------------------------------------------------ masthead */
|
| 162 |
+
|
| 163 |
+
.masthead {
|
| 164 |
+
display: grid;
|
| 165 |
+
gap: 6px;
|
| 166 |
+
margin-bottom: 30px;
|
| 167 |
+
}
|
| 168 |
+
.masthead-row {
|
| 169 |
+
display: flex;
|
| 170 |
+
justify-content: space-between;
|
| 171 |
+
align-items: baseline;
|
| 172 |
+
gap: 24px;
|
| 173 |
+
margin-bottom: 8px;
|
| 174 |
+
}
|
| 175 |
h1 {
|
| 176 |
+
font-size: 60px;
|
| 177 |
+
line-height: 0.96;
|
| 178 |
+
font-weight: 700;
|
| 179 |
+
letter-spacing: -0.035em;
|
| 180 |
+
}
|
| 181 |
+
.site {
|
| 182 |
+
font-size: 15px;
|
| 183 |
+
text-decoration: none;
|
| 184 |
+
white-space: nowrap;
|
| 185 |
+
color: var(--ink);
|
| 186 |
+
}
|
| 187 |
+
.site .arrow {
|
| 188 |
+
display: inline-block;
|
| 189 |
+
margin-left: 0.2em;
|
| 190 |
+
transition: transform 0.2s;
|
| 191 |
+
}
|
| 192 |
+
.site:hover .arrow {
|
| 193 |
+
transform: translate(2px, -2px);
|
| 194 |
+
}
|
| 195 |
+
.lead {
|
| 196 |
+
max-width: 800px;
|
| 197 |
+
font-size: 19px;
|
| 198 |
+
line-height: 1.35;
|
| 199 |
+
letter-spacing: -0.012em;
|
| 200 |
+
}
|
| 201 |
+
.brand {
|
| 202 |
+
font-size: 14px;
|
| 203 |
+
color: var(--ink-2);
|
| 204 |
+
}
|
| 205 |
+
.proof {
|
| 206 |
+
max-width: 800px;
|
| 207 |
+
font-size: 15px;
|
| 208 |
+
line-height: 1.45;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.notice {
|
| 212 |
+
margin-bottom: 16px;
|
| 213 |
+
padding: 10px 0;
|
| 214 |
+
border-top: 1px solid var(--line);
|
| 215 |
+
border-bottom: 1px solid var(--line);
|
| 216 |
+
font-size: 14px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
/* ------------------------------------------------------------------ bench: one row of controls */
|
| 220 |
+
|
| 221 |
+
.bench {
|
| 222 |
+
padding: 16px 0 14px;
|
| 223 |
+
border-top: 1px solid var(--ink);
|
| 224 |
+
border-bottom: 1px solid var(--line);
|
| 225 |
+
}
|
| 226 |
+
.bench-row {
|
| 227 |
+
display: flex;
|
| 228 |
+
flex-wrap: wrap;
|
| 229 |
+
align-items: center;
|
| 230 |
+
gap: 12px 28px;
|
| 231 |
+
}
|
| 232 |
+
.pick {
|
| 233 |
+
display: flex;
|
| 234 |
+
align-items: center;
|
| 235 |
+
gap: 12px;
|
| 236 |
+
min-width: 0;
|
| 237 |
+
flex: 1 1 320px;
|
| 238 |
+
}
|
| 239 |
+
.pick-button {
|
| 240 |
+
position: relative;
|
| 241 |
+
display: inline-flex;
|
| 242 |
+
align-items: center;
|
| 243 |
+
min-height: 44px;
|
| 244 |
+
padding: 0 16px;
|
| 245 |
+
border: 1px solid var(--ink);
|
| 246 |
+
border-radius: 6px;
|
| 247 |
+
font-weight: 700;
|
| 248 |
+
cursor: pointer;
|
| 249 |
+
white-space: nowrap;
|
| 250 |
+
transition: background-color 0.15s;
|
| 251 |
+
}
|
| 252 |
+
.pick-button:hover {
|
| 253 |
+
background: var(--hover);
|
| 254 |
+
}
|
| 255 |
+
.pick-button input {
|
| 256 |
+
position: absolute;
|
| 257 |
+
width: 1px;
|
| 258 |
+
height: 1px;
|
| 259 |
+
overflow: hidden;
|
| 260 |
+
clip-path: inset(50%);
|
| 261 |
+
}
|
| 262 |
+
.pick-meta {
|
| 263 |
+
display: flex;
|
| 264 |
+
align-items: center;
|
| 265 |
+
gap: 10px;
|
| 266 |
+
min-width: 0;
|
| 267 |
+
color: var(--ink-2);
|
| 268 |
+
font-size: 14px;
|
| 269 |
+
}
|
| 270 |
+
.pick-thumb {
|
| 271 |
+
width: 36px;
|
| 272 |
+
height: 36px;
|
| 273 |
+
object-fit: cover;
|
| 274 |
+
border-radius: 3px;
|
| 275 |
+
flex: none;
|
| 276 |
+
}
|
| 277 |
+
.pick-text {
|
| 278 |
+
min-width: 0;
|
| 279 |
+
overflow-wrap: anywhere;
|
| 280 |
+
}
|
| 281 |
+
.pick-text strong {
|
| 282 |
+
color: var(--ink);
|
| 283 |
+
font-weight: 400;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.seg {
|
| 287 |
+
display: inline-flex;
|
| 288 |
+
padding: 3px;
|
| 289 |
+
border: 1px solid var(--line-strong);
|
| 290 |
+
border-radius: 8px;
|
| 291 |
+
}
|
| 292 |
+
.seg-option {
|
| 293 |
+
display: inline-flex;
|
| 294 |
+
align-items: center;
|
| 295 |
+
justify-content: center;
|
| 296 |
+
min-height: 36px;
|
| 297 |
+
padding: 0 14px;
|
| 298 |
+
border-radius: 5px;
|
| 299 |
+
cursor: pointer;
|
| 300 |
+
color: var(--ink-2);
|
| 301 |
+
white-space: nowrap;
|
| 302 |
+
transition: background-color 0.15s, color 0.15s;
|
| 303 |
+
}
|
| 304 |
+
.seg-option:hover {
|
| 305 |
+
color: var(--ink);
|
| 306 |
+
}
|
| 307 |
+
.seg-option input {
|
| 308 |
+
position: absolute;
|
| 309 |
+
width: 1px;
|
| 310 |
+
height: 1px;
|
| 311 |
+
overflow: hidden;
|
| 312 |
+
clip-path: inset(50%);
|
| 313 |
+
}
|
| 314 |
+
.seg-option:has(input:checked) {
|
| 315 |
+
background: var(--ink);
|
| 316 |
+
color: var(--paper);
|
| 317 |
+
}
|
| 318 |
+
.run {
|
| 319 |
+
min-width: 128px;
|
| 320 |
+
}
|
| 321 |
+
.quota {
|
| 322 |
+
margin-left: auto;
|
| 323 |
+
max-width: 480px;
|
| 324 |
+
text-align: right;
|
| 325 |
+
font-size: 13px;
|
| 326 |
+
line-height: 1.35;
|
| 327 |
+
color: var(--ink-2);
|
| 328 |
}
|
| 329 |
|
| 330 |
+
.bench-line {
|
| 331 |
+
display: grid;
|
| 332 |
+
gap: 8px;
|
| 333 |
+
justify-items: start;
|
| 334 |
+
}
|
| 335 |
+
.bench-line:has(.plan:not(:empty)),
|
| 336 |
+
.bench-line:has(.status:not([hidden])) {
|
| 337 |
+
margin-top: 12px;
|
| 338 |
+
}
|
| 339 |
+
.plan {
|
| 340 |
+
font-size: 14px;
|
| 341 |
+
color: var(--ink-2);
|
| 342 |
+
font-variant-numeric: lining-nums tabular-nums;
|
| 343 |
+
}
|
| 344 |
+
.plan:empty {
|
| 345 |
+
display: none;
|
| 346 |
+
}
|
| 347 |
+
.plan-error {
|
| 348 |
+
color: var(--error);
|
| 349 |
+
}
|
| 350 |
+
.status {
|
| 351 |
+
display: flex;
|
| 352 |
+
flex-wrap: wrap;
|
| 353 |
+
align-items: center;
|
| 354 |
+
gap: 8px 16px;
|
| 355 |
+
font-size: 15px;
|
| 356 |
+
}
|
| 357 |
+
.status[data-state="error"] .status-text strong {
|
| 358 |
+
color: var(--error);
|
| 359 |
+
}
|
| 360 |
+
.status-text strong {
|
| 361 |
+
font-weight: 700;
|
| 362 |
+
}
|
| 363 |
+
.status-text .meta {
|
| 364 |
+
color: var(--ink-2);
|
| 365 |
+
}
|
| 366 |
+
.status .actions {
|
| 367 |
+
display: flex;
|
| 368 |
+
flex-wrap: wrap;
|
| 369 |
+
gap: 8px;
|
| 370 |
+
}
|
| 371 |
+
.status .actions a {
|
| 372 |
+
display: inline-flex;
|
| 373 |
+
align-items: center;
|
| 374 |
+
min-height: 40px;
|
| 375 |
+
padding: 0 14px;
|
| 376 |
+
border: 1px solid var(--ink);
|
| 377 |
+
border-radius: 6px;
|
| 378 |
+
text-decoration: none;
|
| 379 |
+
font-weight: 700;
|
| 380 |
+
}
|
| 381 |
+
.status .actions button {
|
| 382 |
+
min-height: 40px;
|
| 383 |
}
|
| 384 |
+
.spinner {
|
| 385 |
+
display: inline-block;
|
| 386 |
+
width: 13px;
|
| 387 |
+
height: 13px;
|
| 388 |
+
margin-right: 8px;
|
| 389 |
+
vertical-align: -1px;
|
| 390 |
+
border: 2px solid var(--line);
|
| 391 |
+
border-top-color: var(--ink);
|
| 392 |
+
border-radius: 50%;
|
| 393 |
+
animation: spin 0.8s linear infinite;
|
| 394 |
+
}
|
| 395 |
+
@keyframes spin {
|
| 396 |
+
to {
|
| 397 |
+
transform: rotate(360deg);
|
| 398 |
+
}
|
| 399 |
+
}
|
| 400 |
+
.turnstile:empty {
|
| 401 |
+
display: none;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
/* ------------------------------------------------------------------ viewer */
|
| 405 |
+
|
| 406 |
+
.viewer {
|
| 407 |
+
margin-top: 22px;
|
| 408 |
+
}
|
| 409 |
+
.viewer-head {
|
| 410 |
+
display: grid;
|
| 411 |
+
grid-template-columns: var(--fw, 1fr) var(--fw, 1fr);
|
| 412 |
+
justify-content: center;
|
| 413 |
+
align-items: end;
|
| 414 |
+
gap: var(--gap, 10px);
|
| 415 |
+
margin-bottom: 10px;
|
| 416 |
+
font-size: 14px;
|
| 417 |
+
}
|
| 418 |
+
.label-before {
|
| 419 |
+
color: var(--ink-2);
|
| 420 |
+
}
|
| 421 |
+
.label-after {
|
| 422 |
+
display: flex;
|
| 423 |
+
justify-content: space-between;
|
| 424 |
+
align-items: end;
|
| 425 |
+
gap: 16px;
|
| 426 |
+
font-weight: 700;
|
| 427 |
+
}
|
| 428 |
+
.zoom {
|
| 429 |
+
display: flex;
|
| 430 |
+
gap: 2px;
|
| 431 |
+
}
|
| 432 |
+
.zoom button {
|
| 433 |
+
min-height: 32px;
|
| 434 |
+
padding: 0 10px;
|
| 435 |
+
border-color: transparent;
|
| 436 |
+
font-size: 14px;
|
| 437 |
+
color: var(--ink-2);
|
| 438 |
+
}
|
| 439 |
+
.zoom button:hover:not(:disabled) {
|
| 440 |
+
border-color: transparent;
|
| 441 |
+
color: var(--ink);
|
| 442 |
+
}
|
| 443 |
+
.zoom [aria-pressed="true"] {
|
| 444 |
+
color: var(--ink);
|
| 445 |
+
background: var(--well);
|
| 446 |
+
}
|
| 447 |
+
.inspect {
|
| 448 |
+
display: none;
|
| 449 |
+
min-height: 32px;
|
| 450 |
+
margin-left: 6px;
|
| 451 |
+
border-color: var(--line-strong);
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.diptych {
|
| 455 |
+
position: relative;
|
| 456 |
+
display: flex;
|
| 457 |
+
justify-content: center;
|
| 458 |
+
gap: var(--gap, 10px);
|
| 459 |
+
user-select: none;
|
| 460 |
+
-webkit-user-select: none;
|
| 461 |
+
}
|
| 462 |
+
.pane {
|
| 463 |
+
min-width: 0;
|
| 464 |
+
}
|
| 465 |
+
.pane-frame {
|
| 466 |
+
position: relative;
|
| 467 |
+
width: var(--fw, 50%);
|
| 468 |
+
height: var(--fh, 480px);
|
| 469 |
+
overflow: hidden;
|
| 470 |
+
background: var(--well);
|
| 471 |
+
cursor: grab;
|
| 472 |
+
touch-action: pan-y pinch-zoom;
|
| 473 |
+
}
|
| 474 |
+
.pane-frame:focus-visible {
|
| 475 |
+
outline: 2px solid var(--focus);
|
| 476 |
+
outline-offset: 2px;
|
| 477 |
+
}
|
| 478 |
+
.pane-frame.is-dragging {
|
| 479 |
+
cursor: grabbing;
|
| 480 |
+
}
|
| 481 |
+
.viewer[data-inspect="true"] .pane-frame {
|
| 482 |
+
touch-action: none;
|
| 483 |
+
}
|
| 484 |
+
.pane-frame img {
|
| 485 |
+
position: absolute;
|
| 486 |
+
left: 0;
|
| 487 |
+
top: 0;
|
| 488 |
+
max-width: none;
|
| 489 |
+
max-height: none;
|
| 490 |
+
transform-origin: 0 0;
|
| 491 |
+
pointer-events: none;
|
| 492 |
+
}
|
| 493 |
+
.pane-empty {
|
| 494 |
+
position: absolute;
|
| 495 |
+
inset: 0;
|
| 496 |
+
display: grid;
|
| 497 |
+
place-items: center;
|
| 498 |
+
padding: 24px;
|
| 499 |
+
text-align: center;
|
| 500 |
+
color: var(--ink-2);
|
| 501 |
+
font-size: 14px;
|
| 502 |
+
}
|
| 503 |
+
.loading {
|
| 504 |
+
position: absolute;
|
| 505 |
+
inset: 0;
|
| 506 |
+
display: grid;
|
| 507 |
+
place-items: center;
|
| 508 |
+
pointer-events: none;
|
| 509 |
+
}
|
| 510 |
+
.loading span {
|
| 511 |
+
width: 26px;
|
| 512 |
+
height: 26px;
|
| 513 |
+
border: 2px solid rgba(255, 255, 255, 0.45);
|
| 514 |
+
border-top-color: var(--ink);
|
| 515 |
+
border-radius: 50%;
|
| 516 |
+
animation: spin 0.8s linear infinite;
|
| 517 |
+
}
|
| 518 |
+
.viewer-foot {
|
| 519 |
+
display: flex;
|
| 520 |
+
justify-content: space-between;
|
| 521 |
+
align-items: baseline;
|
| 522 |
+
flex-wrap: wrap;
|
| 523 |
+
gap: 4px 24px;
|
| 524 |
+
margin-top: 12px;
|
| 525 |
+
}
|
| 526 |
+
.caption {
|
| 527 |
+
font-size: 15px;
|
| 528 |
+
}
|
| 529 |
+
.caption strong {
|
| 530 |
+
font-weight: 700;
|
| 531 |
+
}
|
| 532 |
+
.caption .meta {
|
| 533 |
+
color: var(--ink-2);
|
| 534 |
+
margin-left: 8px;
|
| 535 |
+
}
|
| 536 |
+
.hint {
|
| 537 |
+
font-size: 13px;
|
| 538 |
+
color: var(--ink-hint);
|
| 539 |
+
}
|
| 540 |
+
.note-touch {
|
| 541 |
+
display: none;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
/* ------------------------------------------------------------------ gallery: the five examples in one justified row */
|
| 545 |
+
|
| 546 |
+
.gallery {
|
| 547 |
+
margin-top: 36px;
|
| 548 |
+
}
|
| 549 |
+
.gallery-head {
|
| 550 |
+
margin-bottom: 10px;
|
| 551 |
+
font-size: 14px;
|
| 552 |
+
color: var(--ink-2);
|
| 553 |
+
}
|
| 554 |
+
.examples {
|
| 555 |
+
display: flex;
|
| 556 |
+
gap: 10px;
|
| 557 |
+
}
|
| 558 |
+
.examples li {
|
| 559 |
+
display: flex;
|
| 560 |
+
flex: var(--ar) 1 0;
|
| 561 |
+
min-width: 0;
|
| 562 |
+
}
|
| 563 |
+
.example {
|
| 564 |
+
display: grid;
|
| 565 |
+
grid-template-rows: 1fr auto;
|
| 566 |
+
width: 100%;
|
| 567 |
+
min-height: 0;
|
| 568 |
+
padding: 0;
|
| 569 |
+
border: 0;
|
| 570 |
+
border-radius: 0;
|
| 571 |
+
background: transparent;
|
| 572 |
+
text-align: left;
|
| 573 |
+
cursor: pointer;
|
| 574 |
+
}
|
| 575 |
+
.example:hover:not(:disabled) {
|
| 576 |
+
background: transparent;
|
| 577 |
+
}
|
| 578 |
+
.example-image {
|
| 579 |
+
position: relative;
|
| 580 |
+
aspect-ratio: var(--ar);
|
| 581 |
+
overflow: hidden;
|
| 582 |
+
background: var(--well);
|
| 583 |
+
}
|
| 584 |
+
.example-image img {
|
| 585 |
+
display: block;
|
| 586 |
+
width: 100%;
|
| 587 |
+
height: 100%;
|
| 588 |
+
object-fit: cover;
|
| 589 |
+
transition: opacity 0.2s;
|
| 590 |
+
}
|
| 591 |
+
.example:hover .example-image img {
|
| 592 |
+
opacity: 0.85;
|
| 593 |
+
}
|
| 594 |
+
.example-image::after {
|
| 595 |
+
content: "";
|
| 596 |
+
position: absolute;
|
| 597 |
+
inset: 0;
|
| 598 |
+
box-shadow: inset 0 0 0 0 var(--ink);
|
| 599 |
+
transition: box-shadow 0.15s;
|
| 600 |
+
pointer-events: none;
|
| 601 |
+
}
|
| 602 |
+
.example[aria-pressed="true"] .example-image::after {
|
| 603 |
+
box-shadow: inset 0 0 0 3px var(--ink);
|
| 604 |
+
}
|
| 605 |
+
.example-text {
|
| 606 |
+
display: flex;
|
| 607 |
+
justify-content: space-between;
|
| 608 |
+
gap: 8px;
|
| 609 |
+
padding-top: 8px;
|
| 610 |
+
font-size: 13px;
|
| 611 |
+
line-height: 1.3;
|
| 612 |
+
color: var(--ink-2);
|
| 613 |
+
}
|
| 614 |
+
.example[aria-pressed="true"] .example-text {
|
| 615 |
+
color: var(--ink);
|
| 616 |
+
}
|
| 617 |
+
.example-text span {
|
| 618 |
+
min-width: 0;
|
| 619 |
+
}
|
| 620 |
+
.example-text small {
|
| 621 |
+
font-size: 13px;
|
| 622 |
+
white-space: nowrap;
|
| 623 |
+
color: var(--ink-hint);
|
| 624 |
+
}
|
| 625 |
+
|
| 626 |
+
/* ------------------------------------------------------------------ colophon */
|
| 627 |
+
|
| 628 |
+
.colophon {
|
| 629 |
+
margin-top: 40px;
|
| 630 |
+
padding-top: 14px;
|
| 631 |
+
border-top: 1px solid var(--line);
|
| 632 |
+
font-size: 14px;
|
| 633 |
+
color: var(--ink-2);
|
| 634 |
+
}
|
| 635 |
+
.colophon-links {
|
| 636 |
+
display: flex;
|
| 637 |
+
flex-wrap: wrap;
|
| 638 |
+
gap: 8px 24px;
|
| 639 |
+
min-height: 32px;
|
| 640 |
+
align-items: center;
|
| 641 |
+
}
|
| 642 |
+
.about summary {
|
| 643 |
+
cursor: pointer;
|
| 644 |
+
padding: 8px 0;
|
| 645 |
+
list-style: none;
|
| 646 |
+
}
|
| 647 |
+
.about summary::-webkit-details-marker {
|
| 648 |
+
display: none;
|
| 649 |
+
}
|
| 650 |
+
.about summary::before {
|
| 651 |
+
content: "+";
|
| 652 |
+
display: inline-block;
|
| 653 |
+
width: 16px;
|
| 654 |
+
font-weight: 700;
|
| 655 |
+
}
|
| 656 |
+
.about[open] summary::before {
|
| 657 |
+
content: "–";
|
| 658 |
+
}
|
| 659 |
+
.about-body {
|
| 660 |
+
max-width: 76ch;
|
| 661 |
+
display: grid;
|
| 662 |
+
gap: 10px;
|
| 663 |
+
padding: 4px 0 16px 16px;
|
| 664 |
+
}
|
| 665 |
+
.about-body h2 {
|
| 666 |
+
margin-top: 8px;
|
| 667 |
+
font-size: 14px;
|
| 668 |
+
font-weight: 700;
|
| 669 |
+
color: var(--ink);
|
| 670 |
+
}
|
| 671 |
+
.credits {
|
| 672 |
+
display: grid;
|
| 673 |
+
gap: 4px;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
/* ------------------------------------------------------------------ drop anywhere */
|
| 677 |
+
|
| 678 |
+
.dropzone {
|
| 679 |
+
position: fixed;
|
| 680 |
+
inset: 12px;
|
| 681 |
+
display: none;
|
| 682 |
+
place-items: center;
|
| 683 |
+
border: 2px dashed var(--ink);
|
| 684 |
+
background: color-mix(in srgb, var(--paper) 80%, transparent);
|
| 685 |
+
font-size: 22px;
|
| 686 |
+
font-weight: 700;
|
| 687 |
+
letter-spacing: -0.02em;
|
| 688 |
+
z-index: 10;
|
| 689 |
+
pointer-events: none;
|
| 690 |
+
}
|
| 691 |
+
body[data-dragover="true"] .dropzone {
|
| 692 |
+
display: grid;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
/* ------------------------------------------------------------------ input modes */
|
| 696 |
+
|
| 697 |
+
@media (hover: none) {
|
| 698 |
+
.note-mouse {
|
| 699 |
+
display: none;
|
| 700 |
+
}
|
| 701 |
+
.note-touch {
|
| 702 |
+
display: inline;
|
| 703 |
+
}
|
| 704 |
+
.inspect {
|
| 705 |
+
display: inline-flex;
|
| 706 |
+
}
|
| 707 |
+
}
|
| 708 |
+
@media (prefers-reduced-motion: reduce) {
|
| 709 |
+
* {
|
| 710 |
+
transition: none !important;
|
| 711 |
+
animation-duration: 0.01ms !important;
|
| 712 |
+
}
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
/* ------------------------------------------------------------------ narrow */
|
| 716 |
|
| 717 |
+
@media (max-width: 1000px) {
|
| 718 |
+
.quota {
|
| 719 |
+
margin-left: 0;
|
| 720 |
+
max-width: none;
|
| 721 |
+
text-align: left;
|
| 722 |
+
flex-basis: 100%;
|
| 723 |
+
}
|
| 724 |
}
|
| 725 |
|
| 726 |
+
@media (max-width: 720px) {
|
| 727 |
+
.page {
|
| 728 |
+
padding: 22px 16px 24px;
|
| 729 |
+
}
|
| 730 |
+
.masthead {
|
| 731 |
+
margin-bottom: 22px;
|
| 732 |
+
}
|
| 733 |
+
.masthead-row {
|
| 734 |
+
flex-wrap: wrap;
|
| 735 |
+
gap: 8px 16px;
|
| 736 |
+
}
|
| 737 |
+
h1 {
|
| 738 |
+
font-size: 38px;
|
| 739 |
+
}
|
| 740 |
+
.lead {
|
| 741 |
+
font-size: 17px;
|
| 742 |
+
}
|
| 743 |
+
.bench-row {
|
| 744 |
+
gap: 10px;
|
| 745 |
+
}
|
| 746 |
+
.pick {
|
| 747 |
+
flex-basis: 100%;
|
| 748 |
+
flex-wrap: wrap;
|
| 749 |
+
}
|
| 750 |
+
.pick-button {
|
| 751 |
+
width: 100%;
|
| 752 |
+
justify-content: center;
|
| 753 |
+
}
|
| 754 |
+
.seg {
|
| 755 |
+
width: 100%;
|
| 756 |
+
}
|
| 757 |
+
.seg-option {
|
| 758 |
+
flex: 1;
|
| 759 |
+
}
|
| 760 |
+
.run {
|
| 761 |
+
width: 100%;
|
| 762 |
+
}
|
| 763 |
+
.viewer-head {
|
| 764 |
+
display: flex;
|
| 765 |
+
justify-content: flex-end;
|
| 766 |
+
}
|
| 767 |
+
.label-before,
|
| 768 |
+
.label-after > :not(.zoom) {
|
| 769 |
+
display: none;
|
| 770 |
+
}
|
| 771 |
+
.diptych {
|
| 772 |
+
flex-direction: column;
|
| 773 |
+
gap: 8px;
|
| 774 |
+
}
|
| 775 |
+
.pane {
|
| 776 |
+
position: relative;
|
| 777 |
+
}
|
| 778 |
+
.pane::before {
|
| 779 |
+
content: attr(data-label);
|
| 780 |
+
position: absolute;
|
| 781 |
+
left: 0;
|
| 782 |
+
top: 0;
|
| 783 |
+
z-index: 1;
|
| 784 |
+
padding: 4px 8px;
|
| 785 |
+
background: var(--paper);
|
| 786 |
+
font-size: 13px;
|
| 787 |
+
font-weight: 700;
|
| 788 |
+
}
|
| 789 |
+
.pane-frame {
|
| 790 |
+
width: 100% !important;
|
| 791 |
+
}
|
| 792 |
+
.examples {
|
| 793 |
+
overflow-x: auto;
|
| 794 |
+
gap: 8px;
|
| 795 |
+
margin: 0 -16px;
|
| 796 |
+
padding: 0 16px 4px;
|
| 797 |
+
scrollbar-width: none;
|
| 798 |
+
}
|
| 799 |
+
.examples li {
|
| 800 |
+
flex: none;
|
| 801 |
+
width: calc(130px * var(--ar));
|
| 802 |
+
}
|
| 803 |
+
.example-text {
|
| 804 |
+
display: grid;
|
| 805 |
+
gap: 0;
|
| 806 |
+
}
|
| 807 |
}
|
theme.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Apply ?__theme=light|dark (set by Hugging Face) before the stylesheet paints, so the page does not flash.
|
| 2 |
+
(() => {
|
| 3 |
+
const t = new URLSearchParams(location.search).get("__theme");
|
| 4 |
+
if (t === "dark" || t === "light") document.documentElement.dataset.theme = t;
|
| 5 |
+
})();
|
ui.js
ADDED
|
@@ -0,0 +1,572 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Photo Enhancer (MegaPotato): enhance at the original size or 2×, compare two frames side by side.
|
| 2 |
+
|
| 3 |
+
import { DemoError, Demo, INFO_KINDS, NBSP, dims, errorCopy, escapeHtml, isResumable, thumbnail, usd } from "./core.js";
|
| 4 |
+
|
| 5 |
+
const SPACE_URL = "https://huggingface.co/spaces/MegaPotatoAI/Photo-Enhancer";
|
| 6 |
+
const COPY = { verb: "enhance", runs: "runs", spaceUrl: SPACE_URL };
|
| 7 |
+
|
| 8 |
+
const $ = (sel) => document.querySelector(sel);
|
| 9 |
+
const els = {
|
| 10 |
+
notice: $("#notice"),
|
| 11 |
+
file: $("#file"),
|
| 12 |
+
thumb: $("#drop-thumb"),
|
| 13 |
+
fileLabel: $("#file-label"),
|
| 14 |
+
fileMeta: $("#file-meta"),
|
| 15 |
+
plan: $("#plan"),
|
| 16 |
+
quota: $("#quota"),
|
| 17 |
+
run: $("#run"),
|
| 18 |
+
status: $("#status"),
|
| 19 |
+
caption: $("#caption"),
|
| 20 |
+
examples: $("#examples"),
|
| 21 |
+
credits: $("#credits"),
|
| 22 |
+
beforeLabel: $("#before-label"),
|
| 23 |
+
emptyNote: $(".pane-empty"),
|
| 24 |
+
viewerWrap: $("#viewer-wrap"),
|
| 25 |
+
inspect: $("#inspect"),
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
// selection: the chosen photo; quote: the plan for it (a new quote never cancels a selection still being read)
|
| 29 |
+
const state = { file: null, info: null, thumbUrl: null, factor: 1, plan: null, selection: 0, quote: 0, view: 0, presenting: false, examples: [], shown: [], tick: null };
|
| 30 |
+
const PICK = { label: els.fileLabel.textContent, meta: els.fileMeta.innerHTML };
|
| 31 |
+
const sizeName = (f) => (f === 1 ? "Original size" : `${f}×`);
|
| 32 |
+
const phone = () => matchMedia("(max-width: 720px)").matches;
|
| 33 |
+
|
| 34 |
+
// ---------------------------------------------------------------------------- the two frames share one view
|
| 35 |
+
|
| 36 |
+
class Diptych {
|
| 37 |
+
constructor(root, wrap) {
|
| 38 |
+
this.root = root;
|
| 39 |
+
this.wrap = wrap;
|
| 40 |
+
this.loading = root.querySelector(".loading");
|
| 41 |
+
this.panes = [...root.querySelectorAll(".pane-frame")].map((frame) => ({ frame, img: frame.querySelector("img") }));
|
| 42 |
+
this.W = 0;
|
| 43 |
+
this.H = 0;
|
| 44 |
+
this.s = 1;
|
| 45 |
+
this.x = 0;
|
| 46 |
+
this.y = 0;
|
| 47 |
+
this.zoom = "fit";
|
| 48 |
+
this.token = 0;
|
| 49 |
+
this.pointers = new Map();
|
| 50 |
+
this.onZoom = () => {};
|
| 51 |
+
for (const { frame } of this.panes) this.bind(frame);
|
| 52 |
+
new ResizeObserver(() => this.W && this.layout()).observe(root);
|
| 53 |
+
addEventListener("resize", () => this.W && this.layout());
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
frame() {
|
| 57 |
+
const f = this.panes[0].frame;
|
| 58 |
+
return { w: f.clientWidth, h: f.clientHeight };
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
fitScale() {
|
| 62 |
+
const { w, h } = this.frame();
|
| 63 |
+
return Math.min(w / this.W, h / this.H);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/** Frames follow the photo: two frames of its aspect ratio, as large as the row and the window allow. */
|
| 67 |
+
layout() {
|
| 68 |
+
const gap = phone() ? 8 : 10;
|
| 69 |
+
const total = this.root.clientWidth;
|
| 70 |
+
const maxH = Math.min(Math.max(320, innerHeight * (phone() ? 0.62 : 0.74)), 860);
|
| 71 |
+
let fw = phone() ? total : (total - gap) / 2;
|
| 72 |
+
let fh = (fw * this.H) / this.W;
|
| 73 |
+
if (fh > maxH) {
|
| 74 |
+
fh = maxH;
|
| 75 |
+
fw = (fh * this.W) / this.H;
|
| 76 |
+
}
|
| 77 |
+
fh = Math.max(fh, phone() ? 200 : 240);
|
| 78 |
+
this.wrap.style.setProperty("--fw", `${Math.floor(fw)}px`);
|
| 79 |
+
this.wrap.style.setProperty("--fh", `${Math.round(fh)}px`);
|
| 80 |
+
this.wrap.style.setProperty("--gap", `${gap}px`);
|
| 81 |
+
if (this.zoom === "fit") this.setZoom("fit", false);
|
| 82 |
+
else this.apply();
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async show({ before, after, width, height }) {
|
| 86 |
+
const token = ++this.token;
|
| 87 |
+
this.loading.hidden = false;
|
| 88 |
+
// Each call loads into its own images; only the newest one swaps them in, together with the size.
|
| 89 |
+
const load = (src, like) => {
|
| 90 |
+
const img = new Image();
|
| 91 |
+
img.alt = like.alt;
|
| 92 |
+
img.draggable = false;
|
| 93 |
+
if (!src) return Promise.resolve(img);
|
| 94 |
+
// A rejected decode() is a failed display (the run stays saved for Load again), not something to show.
|
| 95 |
+
return new Promise((resolve, reject) => {
|
| 96 |
+
img.onload = () => resolve();
|
| 97 |
+
img.onerror = () => reject(new Error("load"));
|
| 98 |
+
img.src = src;
|
| 99 |
+
})
|
| 100 |
+
.then(() => img.decode?.())
|
| 101 |
+
.then(() => img);
|
| 102 |
+
};
|
| 103 |
+
let pair;
|
| 104 |
+
try {
|
| 105 |
+
pair = await Promise.all([load(before, this.panes[0].img), load(after, this.panes[1].img)]);
|
| 106 |
+
} catch {
|
| 107 |
+
if (token !== this.token) return "superseded";
|
| 108 |
+
this.loading.hidden = true;
|
| 109 |
+
return "error";
|
| 110 |
+
}
|
| 111 |
+
if (token !== this.token) return "superseded";
|
| 112 |
+
pair.forEach((img, i) => {
|
| 113 |
+
this.panes[i].img.replaceWith(img);
|
| 114 |
+
this.panes[i].img = img;
|
| 115 |
+
});
|
| 116 |
+
this.loading.hidden = true;
|
| 117 |
+
this.panes[0].img.hidden = !before;
|
| 118 |
+
els.emptyNote.hidden = !!before;
|
| 119 |
+
this.W = width;
|
| 120 |
+
this.H = height;
|
| 121 |
+
this.zoom = "fit";
|
| 122 |
+
this.layout();
|
| 123 |
+
this.onZoom("fit");
|
| 124 |
+
return "shown";
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
setZoom(z, announce = true) {
|
| 128 |
+
if (!this.W) return;
|
| 129 |
+
this.zoom = z;
|
| 130 |
+
this.s = z === "fit" ? this.fitScale() : Number(z);
|
| 131 |
+
if (z === "fit") {
|
| 132 |
+
this.x = this.W / 2;
|
| 133 |
+
this.y = this.H / 2;
|
| 134 |
+
}
|
| 135 |
+
this.apply();
|
| 136 |
+
if (announce) this.onZoom(z);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
zoomAt(scale, sx, sy) {
|
| 140 |
+
const fit = this.fitScale();
|
| 141 |
+
const next = Math.min(8, Math.max(Math.min(fit, 1), scale));
|
| 142 |
+
const { w, h } = this.frame();
|
| 143 |
+
const px = this.x + (sx - w / 2) / this.s;
|
| 144 |
+
const py = this.y + (sy - h / 2) / this.s;
|
| 145 |
+
this.s = next;
|
| 146 |
+
this.x = px - (sx - w / 2) / this.s;
|
| 147 |
+
this.y = py - (sy - h / 2) / this.s;
|
| 148 |
+
this.zoom = Math.abs(next - 1) < 1e-6 ? "1" : Math.abs(next - 2) < 1e-6 ? "2" : "custom";
|
| 149 |
+
this.apply();
|
| 150 |
+
this.onZoom(this.zoom);
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
clamp() {
|
| 154 |
+
const { w, h } = this.frame();
|
| 155 |
+
const hw = w / (2 * this.s);
|
| 156 |
+
const hh = h / (2 * this.s);
|
| 157 |
+
this.x = this.W * this.s <= w ? this.W / 2 : Math.min(this.W - hw, Math.max(hw, this.x));
|
| 158 |
+
this.y = this.H * this.s <= h ? this.H / 2 : Math.min(this.H - hh, Math.max(hh, this.y));
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
apply() {
|
| 162 |
+
this.clamp();
|
| 163 |
+
const { w, h } = this.frame();
|
| 164 |
+
const tx = Math.round(w / 2 - this.x * this.s);
|
| 165 |
+
const ty = Math.round(h / 2 - this.y * this.s);
|
| 166 |
+
for (const { img } of this.panes) {
|
| 167 |
+
img.style.width = `${this.W * this.s}px`;
|
| 168 |
+
img.style.height = `${this.H * this.s}px`;
|
| 169 |
+
img.style.transform = `translate(${tx}px, ${ty}px)`;
|
| 170 |
+
img.style.imageRendering = this.s > 1.5 ? "pixelated" : "auto";
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
bind(frame) {
|
| 175 |
+
const usable = (e) => e.pointerType === "mouse" || els.viewerWrap.dataset.inspect === "true";
|
| 176 |
+
frame.addEventListener("pointerdown", (e) => {
|
| 177 |
+
if (!this.W || !usable(e)) return;
|
| 178 |
+
frame.setPointerCapture(e.pointerId);
|
| 179 |
+
this.pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
| 180 |
+
frame.classList.add("is-dragging");
|
| 181 |
+
});
|
| 182 |
+
frame.addEventListener("pointermove", (e) => {
|
| 183 |
+
const prev = this.pointers.get(e.pointerId);
|
| 184 |
+
if (!prev) return;
|
| 185 |
+
const now = { x: e.clientX, y: e.clientY };
|
| 186 |
+
if (this.pointers.size === 1) {
|
| 187 |
+
this.x -= (now.x - prev.x) / this.s;
|
| 188 |
+
this.y -= (now.y - prev.y) / this.s;
|
| 189 |
+
this.pointers.set(e.pointerId, now);
|
| 190 |
+
if (this.zoom === "fit") {
|
| 191 |
+
this.zoom = "custom";
|
| 192 |
+
this.onZoom("custom");
|
| 193 |
+
}
|
| 194 |
+
this.apply();
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
const [a, b] = [...this.pointers.values()];
|
| 198 |
+
const before = Math.hypot(a.x - b.x, a.y - b.y);
|
| 199 |
+
this.pointers.set(e.pointerId, now);
|
| 200 |
+
const [c, d] = [...this.pointers.values()];
|
| 201 |
+
const after = Math.hypot(c.x - d.x, c.y - d.y);
|
| 202 |
+
if (before > 0) {
|
| 203 |
+
const r = frame.getBoundingClientRect();
|
| 204 |
+
this.zoomAt(this.s * (after / before), (c.x + d.x) / 2 - r.left, (c.y + d.y) / 2 - r.top);
|
| 205 |
+
}
|
| 206 |
+
});
|
| 207 |
+
const end = (e) => {
|
| 208 |
+
this.pointers.delete(e.pointerId);
|
| 209 |
+
if (!this.pointers.size) frame.classList.remove("is-dragging");
|
| 210 |
+
};
|
| 211 |
+
frame.addEventListener("pointerup", end);
|
| 212 |
+
frame.addEventListener("pointercancel", end);
|
| 213 |
+
// a plain wheel scrolls the page; Ctrl/⌘ + wheel (and trackpad pinch) zooms
|
| 214 |
+
frame.addEventListener(
|
| 215 |
+
"wheel",
|
| 216 |
+
(e) => {
|
| 217 |
+
if (!this.W || !(e.ctrlKey || e.metaKey)) return;
|
| 218 |
+
e.preventDefault();
|
| 219 |
+
const r = frame.getBoundingClientRect();
|
| 220 |
+
this.zoomAt(this.s * Math.exp(-e.deltaY * 0.004), e.clientX - r.left, e.clientY - r.top);
|
| 221 |
+
},
|
| 222 |
+
{ passive: false },
|
| 223 |
+
);
|
| 224 |
+
frame.addEventListener("dblclick", (e) => {
|
| 225 |
+
const r = frame.getBoundingClientRect();
|
| 226 |
+
if (this.zoom === "fit") this.zoomAt(1, e.clientX - r.left, e.clientY - r.top);
|
| 227 |
+
else this.setZoom("fit");
|
| 228 |
+
});
|
| 229 |
+
// keyboard: arrows pan both frames (Shift: bigger steps), + and - zoom, Home fits
|
| 230 |
+
frame.tabIndex = 0;
|
| 231 |
+
frame.setAttribute("role", "group");
|
| 232 |
+
frame.setAttribute("aria-label", "Arrow keys move both frames, + and - zoom, Home fits");
|
| 233 |
+
frame.addEventListener("keydown", (e) => {
|
| 234 |
+
if (!this.W || e.altKey || e.ctrlKey || e.metaKey) return;
|
| 235 |
+
const step = (e.shiftKey ? 160 : 40) / this.s;
|
| 236 |
+
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
|
| 237 |
+
const { w, h } = this.frame();
|
| 238 |
+
if (moves[e.key]) {
|
| 239 |
+
this.x += moves[e.key][0];
|
| 240 |
+
this.y += moves[e.key][1];
|
| 241 |
+
this.apply();
|
| 242 |
+
} else if (e.key === "+" || e.key === "=") this.zoomAt(this.s * 1.5, w / 2, h / 2);
|
| 243 |
+
else if (e.key === "-" || e.key === "_") this.zoomAt(this.s / 1.5, w / 2, h / 2);
|
| 244 |
+
else if (e.key === "Home") this.setZoom("fit");
|
| 245 |
+
else return;
|
| 246 |
+
e.preventDefault();
|
| 247 |
+
});
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
const viewer = new Diptych($("#viewer"), els.viewerWrap);
|
| 252 |
+
viewer.onZoom = (z) => document.querySelectorAll("[data-zoom]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.zoom === z)));
|
| 253 |
+
|
| 254 |
+
// ---------------------------------------------------------------------------- status: one line
|
| 255 |
+
|
| 256 |
+
const demo = new Demo({
|
| 257 |
+
slug: "photo-enhancer",
|
| 258 |
+
onQuota: () => update(),
|
| 259 |
+
turnstileEl: "#turnstile",
|
| 260 |
+
turnstileTheme: () => {
|
| 261 |
+
const t = document.documentElement.dataset.theme;
|
| 262 |
+
if (t === "dark" || t === "light") return t;
|
| 263 |
+
return matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
| 264 |
+
},
|
| 265 |
+
onPhase: (phase, info) => {
|
| 266 |
+
update();
|
| 267 |
+
const text = { preparing: "Preparing…", uploading: "Uploading…", queued: "Waiting for a GPU…", running: "Enhancing…", loading: "Loading result…" }[phase] ?? "Working…";
|
| 268 |
+
status({ busy: true, text, at: info.at ?? demo.active?.at, note: info.cold ? "The first run after a quiet spell can take 2–3 minutes." : "" });
|
| 269 |
+
},
|
| 270 |
+
});
|
| 271 |
+
|
| 272 |
+
function status({ busy = false, error = false, text, note = "", actions = [], at = null }) {
|
| 273 |
+
clearInterval(state.tick);
|
| 274 |
+
els.status.hidden = false;
|
| 275 |
+
els.status.dataset.state = error ? "error" : busy ? "busy" : "idle";
|
| 276 |
+
const acts = actions.length
|
| 277 |
+
? `<span class="actions">${actions
|
| 278 |
+
.map((a, i) =>
|
| 279 |
+
a.href
|
| 280 |
+
? `<a href="${a.href}" target="_blank" rel="noopener">${a.label}</a>`
|
| 281 |
+
: `<button type="button" class="${a.primary ? "primary" : ""}" data-i="${i}">${a.label}</button>`,
|
| 282 |
+
)
|
| 283 |
+
.join("")}</span>`
|
| 284 |
+
: "";
|
| 285 |
+
els.status.innerHTML = `<span class="status-text">${busy ? '<span class="spinner" aria-hidden="true"></span>' : ""}<strong>${text}</strong>${note ? ` <span class="meta">${note}</span>` : ""}<span class="meta elapsed"></span></span>${acts}`;
|
| 286 |
+
els.status.querySelectorAll("button[data-i]").forEach((b) => b.addEventListener("click", () => actions[Number(b.dataset.i)].run()));
|
| 287 |
+
if (at) {
|
| 288 |
+
const el = els.status.querySelector(".elapsed");
|
| 289 |
+
const upd = () => (el.textContent = ` · ${Math.round((Date.now() - at) / 1000)}${NBSP}s`);
|
| 290 |
+
upd();
|
| 291 |
+
state.tick = setInterval(upd, 1000);
|
| 292 |
+
}
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
function showError(err) {
|
| 296 |
+
const [title, note] = errorCopy(err, demo, COPY);
|
| 297 |
+
if (INFO_KINDS.has(err.kind) && !demo.active?.op) return status({ text: title, note });
|
| 298 |
+
const actions = [];
|
| 299 |
+
if (isResumable(err) && demo.active) actions.push({ label: "Resume", primary: true, run: () => go(() => demo.continue()) });
|
| 300 |
+
if (err.kind === "download" && demo.active?.op) actions.push({ label: "Load again", primary: true, run: () => go(() => demo.continue()) });
|
| 301 |
+
if (demo.active && actions.length)
|
| 302 |
+
actions.push({
|
| 303 |
+
label: "Start over",
|
| 304 |
+
run: () => {
|
| 305 |
+
demo.forget();
|
| 306 |
+
els.status.hidden = true;
|
| 307 |
+
update();
|
| 308 |
+
},
|
| 309 |
+
});
|
| 310 |
+
status({ error: true, text: title, note, actions });
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
// ---------------------------------------------------------------------------- photo and plan
|
| 314 |
+
|
| 315 |
+
async function selectFile(file) {
|
| 316 |
+
if (!file || demo.busy) return;
|
| 317 |
+
const sel = ++state.selection;
|
| 318 |
+
state.quote++; // a quote still running is for the previous photo
|
| 319 |
+
state.file = null;
|
| 320 |
+
state.info = null;
|
| 321 |
+
state.plan = null;
|
| 322 |
+
demo.dropPrepared();
|
| 323 |
+
update();
|
| 324 |
+
if (!demo.active) els.status.hidden = true;
|
| 325 |
+
let info;
|
| 326 |
+
try {
|
| 327 |
+
info = await demo.inspect(file);
|
| 328 |
+
} catch (err) {
|
| 329 |
+
if (sel !== state.selection) return;
|
| 330 |
+
clearPhoto();
|
| 331 |
+
return planError(err);
|
| 332 |
+
}
|
| 333 |
+
const thumb = await thumbnail(file, info, 120);
|
| 334 |
+
if (sel !== state.selection) return thumb && URL.revokeObjectURL(thumb);
|
| 335 |
+
if (state.thumbUrl) URL.revokeObjectURL(state.thumbUrl);
|
| 336 |
+
state.file = file;
|
| 337 |
+
state.info = info;
|
| 338 |
+
state.thumbUrl = thumb;
|
| 339 |
+
els.thumb.hidden = !thumb;
|
| 340 |
+
if (thumb) els.thumb.src = thumb;
|
| 341 |
+
els.fileLabel.textContent = "Change photo";
|
| 342 |
+
els.fileMeta.innerHTML = `<strong>${escapeHtml(file.name || "Pasted photo")}</strong> · <span class="numeric">${dims(info.width, info.height)}</span>`;
|
| 343 |
+
refreshPlan();
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/** No photo: the file, its size, its plan and its preview go together. */
|
| 347 |
+
function clearPhoto() {
|
| 348 |
+
if (state.thumbUrl) URL.revokeObjectURL(state.thumbUrl);
|
| 349 |
+
Object.assign(state, { file: null, info: null, plan: null, thumbUrl: null });
|
| 350 |
+
els.thumb.hidden = true;
|
| 351 |
+
els.thumb.removeAttribute("src");
|
| 352 |
+
els.fileLabel.textContent = PICK.label;
|
| 353 |
+
els.fileMeta.innerHTML = PICK.meta;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
function planError(err, current = () => true) {
|
| 357 |
+
if (!current()) return;
|
| 358 |
+
const [title, note] = errorCopy(err, demo, COPY);
|
| 359 |
+
els.plan.innerHTML = `<span class="plan-error"><strong>${title}</strong></span> ${note}`;
|
| 360 |
+
state.plan = null;
|
| 361 |
+
update();
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
function applyPlan(plan) {
|
| 365 |
+
state.plan = plan;
|
| 366 |
+
const price = demo.config.price;
|
| 367 |
+
const api = plan.chips ? ` · via the API ${plan.chips}${NBSP}chip${plan.chips === 1 ? "" : "s"}${price ? ` (${usd(plan.chips * price.min)}–${usd(plan.chips * price.max)})` : ""}` : "";
|
| 368 |
+
const reduced = plan.resized ? ` · reduced from ${dims(plan.source.width, plan.source.height)}` : "";
|
| 369 |
+
els.plan.textContent = `Output ${dims(plan.out_w, plan.out_h)}${reduced}${api}`;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
async function refreshPlan() {
|
| 373 |
+
const { file, info } = state;
|
| 374 |
+
if (!file || !info) return;
|
| 375 |
+
if (demo.loginNeeded()) demo.noteLoginWall();
|
| 376 |
+
const sel = state.selection;
|
| 377 |
+
const q = ++state.quote;
|
| 378 |
+
const current = () => q === state.quote && sel === state.selection;
|
| 379 |
+
state.plan = null;
|
| 380 |
+
update();
|
| 381 |
+
if (!demo.config) return; // start() quotes again once the service answers
|
| 382 |
+
try {
|
| 383 |
+
const plan = await demo.quote(info.width, info.height, state.factor);
|
| 384 |
+
if (!current()) return;
|
| 385 |
+
applyPlan(plan);
|
| 386 |
+
} catch (err) {
|
| 387 |
+
planError(err, current);
|
| 388 |
+
}
|
| 389 |
+
update();
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
function update() {
|
| 393 |
+
const login = demo.loginNeeded();
|
| 394 |
+
const limited = demo.limited();
|
| 395 |
+
const working = demo.busy || state.presenting;
|
| 396 |
+
const pending = !!demo.active && !working;
|
| 397 |
+
els.run.disabled = working || (!pending && (!state.plan || demo.configFailed || limited));
|
| 398 |
+
els.run.textContent = working ? "Enhancing…" : pending ? "Resume" : login ? "Log in to enhance" : limited ? "No runs left today" : "Enhance";
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
// ---------------------------------------------------------------------------- run
|
| 402 |
+
|
| 403 |
+
async function go(step) {
|
| 404 |
+
update();
|
| 405 |
+
try {
|
| 406 |
+
const done = await step();
|
| 407 |
+
state.presenting = true;
|
| 408 |
+
update();
|
| 409 |
+
await present(done);
|
| 410 |
+
} catch (err) {
|
| 411 |
+
const e = err instanceof DemoError ? err : new DemoError("server");
|
| 412 |
+
// The browser could prepare only a smaller image: show that plan; the next press sends it.
|
| 413 |
+
if (e.kind === "reduced" && e.plan && e.file === state.file && e.from === state.plan) applyPlan(e.plan);
|
| 414 |
+
showError(e);
|
| 415 |
+
} finally {
|
| 416 |
+
state.presenting = false;
|
| 417 |
+
update();
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
function setBeforeLabel(factor) {
|
| 422 |
+
const text = factor === 1 ? "Before" : `Before · resized ${factor}×`;
|
| 423 |
+
els.beforeLabel.textContent = text;
|
| 424 |
+
viewer.panes[0].frame.closest(".pane").dataset.label = text;
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
async function present(done) {
|
| 428 |
+
const view = ++state.view;
|
| 429 |
+
const op = done.op;
|
| 430 |
+
setBeforeLabel(op.factor);
|
| 431 |
+
const outcome = await viewer.show({ before: done.beforeUrl, after: done.afterUrl, width: op.out_w, height: op.out_h });
|
| 432 |
+
if (outcome !== "shown" || view !== state.view) {
|
| 433 |
+
// Not on screen: the run stays saved, so it can be loaded again (Load again, Resume, or after a reload).
|
| 434 |
+
URL.revokeObjectURL(done.afterUrl);
|
| 435 |
+
if (outcome === "error") throw new DemoError("download");
|
| 436 |
+
if (demo.active?.op?.op === op.op)
|
| 437 |
+
status({ text: "Your result is ready.", actions: [{ label: "Show it", primary: true, run: () => go(() => demo.continue()) }] });
|
| 438 |
+
return;
|
| 439 |
+
}
|
| 440 |
+
demo.shown(done);
|
| 441 |
+
for (const u of state.shown) URL.revokeObjectURL(u);
|
| 442 |
+
state.shown = [done.afterUrl, done.beforeUrl].filter(Boolean);
|
| 443 |
+
markExample(null);
|
| 444 |
+
els.caption.innerHTML = `<strong>Your photo</strong><span class="meta numeric">${sizeName(op.factor)} · ${dims(op.out_w, op.out_h)}</span>`;
|
| 445 |
+
const left = demo.runsLeft();
|
| 446 |
+
const actions = [{ label: "Open megapotato.app ↗", href: demo.site("/tool", "get-png") }];
|
| 447 |
+
if (phone())
|
| 448 |
+
actions.push({ label: "View result", run: () => els.viewerWrap.scrollIntoView({ behavior: matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth" }) });
|
| 449 |
+
status({
|
| 450 |
+
text: `Ready${left !== null ? ` · ${left} run${left === 1 ? "" : "s"} left today` : ""}`,
|
| 451 |
+
note: "Upload your image on megapotato.app to download it.",
|
| 452 |
+
actions,
|
| 453 |
+
});
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
// ---------------------------------------------------------------------------- examples
|
| 457 |
+
|
| 458 |
+
async function loadExamples() {
|
| 459 |
+
try {
|
| 460 |
+
state.examples = await (await fetch("examples/examples.json")).json();
|
| 461 |
+
} catch {
|
| 462 |
+
state.examples = [];
|
| 463 |
+
}
|
| 464 |
+
els.examples.innerHTML = state.examples
|
| 465 |
+
.map(
|
| 466 |
+
(ex, i) => `<li style="--ar:${(ex.in[0] / ex.in[1]).toFixed(4)}"><button type="button" class="example" data-i="${i}" aria-pressed="false">
|
| 467 |
+
<span class="example-image"><img src="${escapeHtml(ex.thumb)}" alt="" loading="lazy"></span>
|
| 468 |
+
<span class="example-text"><span>${escapeHtml(ex.title)}</span><small>${sizeName(ex.factor)}</small></span>
|
| 469 |
+
</button></li>`,
|
| 470 |
+
)
|
| 471 |
+
.join("");
|
| 472 |
+
els.credits.innerHTML = state.examples
|
| 473 |
+
.map((ex) => `<li>${escapeHtml(ex.credit)} <a href="${escapeHtml(ex.source)}" target="_blank" rel="noopener">Source</a>. ${escapeHtml(ex.licence ?? "")}</li>`)
|
| 474 |
+
.join("");
|
| 475 |
+
els.examples.querySelectorAll(".example").forEach((b) => b.addEventListener("click", () => showExample(Number(b.dataset.i))));
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
function markExample(index) {
|
| 479 |
+
els.examples.querySelectorAll(".example").forEach((b, i) => b.setAttribute("aria-pressed", String(i === index)));
|
| 480 |
+
}
|
| 481 |
+
|
| 482 |
+
async function showExample(i) {
|
| 483 |
+
const ex = state.examples[i];
|
| 484 |
+
if (!ex) return;
|
| 485 |
+
const view = ++state.view;
|
| 486 |
+
markExample(i);
|
| 487 |
+
setBeforeLabel(ex.factor);
|
| 488 |
+
els.caption.innerHTML = `<strong>${escapeHtml(ex.title)}</strong><span class="meta numeric">${escapeHtml(ex.caption)} · ${sizeName(ex.factor)} · ${dims(ex.out[0], ex.out[1])}</span>`;
|
| 489 |
+
const outcome = await viewer.show({ before: ex.input, after: ex.result, width: ex.out[0], height: ex.out[1] });
|
| 490 |
+
if (view === state.view && outcome === "shown") {
|
| 491 |
+
for (const u of state.shown) URL.revokeObjectURL(u);
|
| 492 |
+
state.shown = [];
|
| 493 |
+
}
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
// ---------------------------------------------------------------------------- wiring
|
| 497 |
+
|
| 498 |
+
function wire() {
|
| 499 |
+
els.file.addEventListener("change", () => selectFile(els.file.files?.[0]));
|
| 500 |
+
// the whole page is the drop target
|
| 501 |
+
let depth = 0;
|
| 502 |
+
const hasFiles = (e) => [...(e.dataTransfer?.types ?? [])].includes("Files");
|
| 503 |
+
document.addEventListener("dragenter", (e) => {
|
| 504 |
+
if (!hasFiles(e)) return;
|
| 505 |
+
e.preventDefault();
|
| 506 |
+
depth += 1;
|
| 507 |
+
document.body.dataset.dragover = "true";
|
| 508 |
+
});
|
| 509 |
+
document.addEventListener("dragover", (e) => {
|
| 510 |
+
if (!hasFiles(e)) return;
|
| 511 |
+
e.preventDefault();
|
| 512 |
+
});
|
| 513 |
+
document.addEventListener("dragleave", (e) => {
|
| 514 |
+
if (!hasFiles(e)) return;
|
| 515 |
+
depth = Math.max(0, depth - 1);
|
| 516 |
+
if (!depth) delete document.body.dataset.dragover;
|
| 517 |
+
});
|
| 518 |
+
document.addEventListener("drop", (e) => {
|
| 519 |
+
if (!hasFiles(e)) return;
|
| 520 |
+
e.preventDefault();
|
| 521 |
+
depth = 0;
|
| 522 |
+
delete document.body.dataset.dragover;
|
| 523 |
+
selectFile(e.dataTransfer?.files?.[0]);
|
| 524 |
+
});
|
| 525 |
+
document.addEventListener("paste", (e) => {
|
| 526 |
+
const item = [...(e.clipboardData?.items ?? [])].find((it) => it.kind === "file" && it.type.startsWith("image/"));
|
| 527 |
+
if (item) selectFile(item.getAsFile());
|
| 528 |
+
});
|
| 529 |
+
document.querySelectorAll('input[name="mode"]').forEach((r) =>
|
| 530 |
+
r.addEventListener("change", () => {
|
| 531 |
+
state.factor = Number(r.value);
|
| 532 |
+
refreshPlan();
|
| 533 |
+
}),
|
| 534 |
+
);
|
| 535 |
+
document.querySelectorAll("[data-zoom]").forEach((b) => b.addEventListener("click", () => viewer.setZoom(b.dataset.zoom)));
|
| 536 |
+
els.inspect.addEventListener("click", () => {
|
| 537 |
+
const on = els.viewerWrap.dataset.inspect !== "true";
|
| 538 |
+
els.viewerWrap.dataset.inspect = String(on);
|
| 539 |
+
els.inspect.setAttribute("aria-pressed", String(on));
|
| 540 |
+
els.inspect.textContent = on ? "Done" : "Inspect";
|
| 541 |
+
});
|
| 542 |
+
els.run.addEventListener("click", () => {
|
| 543 |
+
if (demo.active) return go(() => demo.continue());
|
| 544 |
+
if (state.file && state.plan) go(() => demo.start(state.file, state.plan));
|
| 545 |
+
});
|
| 546 |
+
viewer.panes[1].frame.closest(".pane").dataset.label = "After";
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
async function start() {
|
| 550 |
+
wire();
|
| 551 |
+
const saved = demo.hasSaved();
|
| 552 |
+
await loadExamples();
|
| 553 |
+
if (state.examples.length) showExample(0);
|
| 554 |
+
const c = await demo.init();
|
| 555 |
+
if (!c) {
|
| 556 |
+
els.notice.hidden = false;
|
| 557 |
+
els.notice.textContent = "Live enhancing is unavailable right now. The examples still work.";
|
| 558 |
+
return update();
|
| 559 |
+
}
|
| 560 |
+
els.quota.textContent = c.login_required
|
| 561 |
+
? `Preview only · ${c.jobs_per_day} free runs a day · Log in to Hugging Face (top right) to run`
|
| 562 |
+
: `${c.jobs_per_day} free runs a day`;
|
| 563 |
+
update();
|
| 564 |
+
refreshPlan(); // a photo chosen while the page was still starting gets its plan now
|
| 565 |
+
if (!saved && demo.loginNeeded()) showError(new DemoError("login"));
|
| 566 |
+
if (saved) {
|
| 567 |
+
status({ busy: true, text: "Picking up your last run…" });
|
| 568 |
+
await go(() => demo.resume().then((r) => r ?? Promise.reject(new DemoError("unconfirmed"))));
|
| 569 |
+
}
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
start();
|