rc-1
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
<div class="document-section">
|
||||
<p class="document-header">API Guide</p>
|
||||
<p class="document-paragraph">Last Updated: April 13th 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Introduction ]</p>
|
||||
<p class="document-paragraph">
|
||||
The gifuu API is publicly accessible and requires no authentication
|
||||
for read operations. We recommend that you have the client make requests
|
||||
on their own instead of proxying requests for them to avoid issues with
|
||||
our rate limits.
|
||||
</p>
|
||||
|
||||
<p>Use the following URLs for HTTP requests:</p>
|
||||
<pre class="document-codeblock">
|
||||
https://api.gifuu.pancakz.net/ // Base URL for API Requests
|
||||
https://cdn.gifuu.pancakz.net/ // Base URL for CDN Requests
|
||||
|
||||
https://cdn.gifuu.pancakz.net/{id}/preview.avif // Up to 240px at 16fps
|
||||
https://cdn.gifuu.pancakz.net/{id}/standard.avif // Up to 720px at 60fps
|
||||
https://cdn.gifuu.pancakz.net/{id}/alpha.webm // See "Transparency" section below
|
||||
https://cdn.gifuu.pancakz.net/{id}/standard.ogg // See "Audio" section below
|
||||
</pre>
|
||||
|
||||
<p>Ratelimit headers are provided with each request:</p>
|
||||
<pre class="document-codeblock">
|
||||
X-Ratelimit-Category // Endpoint Category (a.k.a Bucket)
|
||||
X-Ratelimit-Reset // Seconds until reset (float string)
|
||||
X-Ratelimit-Limit // Requests allowed per period
|
||||
X-Ratelimit-Remaining // Requests left before 429 errors appear
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Transparency ]</p>
|
||||
<p class="document-paragraph">
|
||||
gifuu stores animations (also referred to as Art) as AVIF a very new and modern format.
|
||||
We sacrifice compatibility with older devices to gain massive efficiency in file size and visual quality.
|
||||
Unfortunately AVIF doesn't natively support transparency, so we use a stacked video technique to encode
|
||||
the alpha channel (transparency) alongside the color data.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
The <code>alpha.webm</code> file is a double-height video where the top half contains the
|
||||
color data and the bottom half contains the alpha channel encoded as a grayscale luma
|
||||
(brightness) map. White pixels signify opaque, black pixels signify transparent.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
To render this correctly in the browser you must use a WebGL fragment shader to composite
|
||||
the two halves together. The following shader can be used as a reference:
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
// Sample color from top half, alpha from bottom half
|
||||
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
|
||||
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
|
||||
|
||||
// Apply colors to a texture
|
||||
vec4 color = texture2D(uFrame, colorUV);
|
||||
float alpha = texture2D(uFrame, alphaUV).r;
|
||||
gl_FragColor = vec4(color.rgb, alpha);
|
||||
</pre>
|
||||
<p class="document-paragraph">
|
||||
This technique was pioneered by
|
||||
<a target="_blank" href="https://jakearchibald.com/2024/video-with-transparency/">Jake Archibald</a>.
|
||||
If you don't want to implement this yourself, you can embed our player by clicking the
|
||||
<code>EMBED</code> button while viewing any animation.
|
||||
</p>
|
||||
|
||||
<p class="document-header">[ Audio ]</p>
|
||||
<p class="document-paragraph">
|
||||
You can check for audio by reading the <code>audio</code> field on Art objects, or by
|
||||
requesting it from the CDN and checking for a <code>404 Not Found</code> response.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
Content is encoded with the <a target="_blank" href="https://en.wikipedia.org/wiki/Opus_(audio_format)">Opus</a>
|
||||
codec inside an <a target="_blank" href="https://en.wikipedia.org/wiki/Ogg">Ogg</a> container
|
||||
for compatibility with Apple devices.
|
||||
</p>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Object Types ]</p>
|
||||
<p class="document-paragraph">The following types are returned as responses across API endpoints:</p>
|
||||
|
||||
<p class="document-subheader">Object: Tag</p>
|
||||
<pre class="document-codeblock">
|
||||
{
|
||||
"id": string // Tag ID (snowflake string)
|
||||
"label": string // Tag Name
|
||||
"usage": number // Number of animations using this tag
|
||||
}
|
||||
</pre>
|
||||
|
||||
<p class="document-subheader">Object: Art</p>
|
||||
<pre class="document-codeblock">
|
||||
{
|
||||
"id": string // Animation ID (snowflake string)
|
||||
"created": string // ISO 8601 Timestamp
|
||||
"sticker": boolean // Is Static?
|
||||
"audio": boolean // Has Audio?
|
||||
"framerate": number // Approximate Framerate
|
||||
"width": number // Approximate Width
|
||||
"height": number // Approximate Height
|
||||
"rating": string // NSFW Rating (string float, range: 0.0 - 1.0)
|
||||
"title": string // Associated Title
|
||||
"tags": Tag[] // Associated Tags
|
||||
}
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Special ]</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /limits</p>
|
||||
<p class="document-paragraph">
|
||||
Returns upload constraints and validation rules for all user input fields.
|
||||
The regex patterns and normalizer rules here are authoritative.
|
||||
You must sanitize your inputs against them before submitting or the server will reject your request.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Response Body:
|
||||
{
|
||||
"upload": {
|
||||
"input_width_min": number // Minimum input width (64px)
|
||||
"input_height_min": number // Minimum input height (64px)
|
||||
"video_width_max": number // Maximum video width (3840px)
|
||||
"video_height_max": number // Maximum video height (2160px)
|
||||
"image_width_max": number // Maximum image width (7680px)
|
||||
"image_height_max": number // Maximum image height (4320px)
|
||||
"duration": number // Maximum duration in seconds (62s)
|
||||
"filesize": number // Maximum file size in bytes
|
||||
"mime_types": string[] // Accepted MIME types
|
||||
}
|
||||
"title": {
|
||||
"normalizers": NormalizerRule[] // Apply before validating
|
||||
"matcher": string // Regex pattern
|
||||
"max_length": number // 80
|
||||
"min_length": number // 1
|
||||
}
|
||||
"tag": {
|
||||
"normalizers": NormalizerRule[]
|
||||
"matcher": string // ^[\p{L}\p{N}_]{1,32}$
|
||||
"max_length": number // 32
|
||||
"min_length": number // 1
|
||||
}
|
||||
"comment": {
|
||||
"normalizers": NormalizerRule[]
|
||||
"matcher": string
|
||||
"max_length": number // 240
|
||||
"min_length": number // 10
|
||||
}
|
||||
"report": {
|
||||
"values": [ // Valid report reason types
|
||||
{ "id": number, "title": string, "description": string }
|
||||
]
|
||||
"normalizers": NormalizerRule[]
|
||||
"matcher": string
|
||||
"max_length": number // 240
|
||||
"min_length": number // 10
|
||||
}
|
||||
}
|
||||
|
||||
Object: NormalizerRule
|
||||
{
|
||||
"match": string // Regex pattern to find
|
||||
"replace": string // Replacement string
|
||||
"comment": string // Human-readable description
|
||||
}
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /challenge</p>
|
||||
<p class="document-paragraph">
|
||||
Returns a fresh Proof of Work challenge.
|
||||
You select the difficulty, the server enforces a minimum of <code>18</code>.
|
||||
Challenges expire after 5 minutes.
|
||||
They are consumed immediately upon use even if the request fails.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
Provide your completed counter and given nonce to endpoints that require PoW via the
|
||||
<code>X-Pow-Counter</code> and <code>X-Pow-Nonce</code> headers respectively.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
difficulty number // Desired difficulty (minimum: 18)
|
||||
|
||||
Response Body:
|
||||
{
|
||||
"nonce": string // Hex-encoded nonce
|
||||
"difficulty": number // Confirmed difficulty
|
||||
"expires": number // Expiry as UNIX timestamp
|
||||
}
|
||||
</pre>
|
||||
|
||||
<p class="document-paragraph">
|
||||
Some endpoints enforce a higher minimum difficulty than the global floor.
|
||||
Request at least the required difficulty for the endpoint you intend to call or it will be rejected:
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Endpoint Minimum Difficulty
|
||||
------------------------ ------------------
|
||||
POST /uploads 20
|
||||
</pre>
|
||||
|
||||
<p class="document-paragraph">Example Solver (JavaScript):</p>
|
||||
<pre class="document-codeblock">
|
||||
const { nonce, difficulty } = await fetch("/challenge?difficulty=18").then(r => r.json())
|
||||
const encoder = new TextEncoder()
|
||||
let counter = 0
|
||||
while (true) {
|
||||
const data = encoder.encode(nonce + counter)
|
||||
const hash = new Uint8Array(await crypto.subtle.digest("SHA-256", data))
|
||||
let zeroBits = 0
|
||||
for (const byte of hash) {
|
||||
if (byte === 0) { zeroBits += 8 }
|
||||
else { zeroBits += Math.clz32(byte) - 24; break }
|
||||
}
|
||||
if (zeroBits >= difficulty) break
|
||||
counter++
|
||||
}
|
||||
|
||||
// Submit nonce + counter with your upload via X-Pow-Nonce and X-Pow-Counter headers
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Tags ]</p>
|
||||
<p class="document-paragraph">
|
||||
gifuu uses tags to make its database queryable. Sanitize tag strings against the rules
|
||||
provided by <code>limits.tags</code> or the server will reject your request.
|
||||
</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /tags/popular</p>
|
||||
<p class="document-paragraph">Returns the most popular tags (highest usage) on the platform.</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
limit number // Amount of results to return (range 1-100)
|
||||
|
||||
Response Body:
|
||||
Tag[]
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /tags/autocomplete</p>
|
||||
<p class="document-paragraph">Search for tags with a similar spelling using word similarity ranking.</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
query string // Search query — must pass validation rules from 'limits.tag'
|
||||
limit number // Amount of results to return (range 1-100)
|
||||
|
||||
Response Body:
|
||||
Tag[]
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Art ]</p>
|
||||
<p class="document-paragraph">
|
||||
Content is processed and served as AVIF files for efficiency. Most modern web browsers
|
||||
and operating systems <a target="_blank" href="https://caniuse.com/avif">support this format</a>.
|
||||
</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /art/latest</p>
|
||||
<p class="document-paragraph">Returns the most recently uploaded animations, newest first.</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
limit number // Amount of results to return (range 1-100)
|
||||
after string? // Cursor for pagination — snowflake ID of last seen item (optional)
|
||||
|
||||
Response Body:
|
||||
Art[]
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /art/search</p>
|
||||
<p class="document-paragraph">
|
||||
Returns animations matching all provided tags (AND logic). At least one
|
||||
<code>tag</code> parameter is required.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
tag string // Tag ID to filter by (snowflake string) — repeat for multiple tags
|
||||
limit number // Amount of results to return (range 1-100)
|
||||
after string? // Cursor for pagination — snowflake ID of last seen item (optional)
|
||||
|
||||
// Example: /art/search?tag=123&tag=456&limit=20
|
||||
|
||||
Response Body:
|
||||
Art[]
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">GET /art/{id}</p>
|
||||
<p class="document-paragraph">Returns metadata for a single animation.</p>
|
||||
<pre class="document-codeblock">
|
||||
Response Body:
|
||||
Art
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">DELETE /art/{id}</p>
|
||||
<p class="document-paragraph">
|
||||
Deletes an animation. Requires the edit token returned at upload time,
|
||||
passed as a query parameter. Responds with <code>204 No Content</code> on success.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Query Parameters:
|
||||
token string // Edit token from upload response
|
||||
</pre>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">POST /art/{id}/reports</p>
|
||||
<p class="document-paragraph">
|
||||
Submits a moderation report for an animation.
|
||||
Valid reason type IDs are listed in <code>limits.report.values</code></code>.
|
||||
The reason text must pass the <code>report</code> validation rules from the same endpoint.
|
||||
Responds with <code>204 No Content</code> on success.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Request Body:
|
||||
{
|
||||
"type": number // Report reason ID (see 'limits.report.values')
|
||||
"reason": string // Description of the issue (10-240 characters)
|
||||
}
|
||||
</pre>
|
||||
|
||||
<div class="document-divider"></div>
|
||||
|
||||
<p class="document-header">[ Uploads ]</p>
|
||||
<p class="document-paragraph">Endpoints for creating and monitoring uploads.</p>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<p class="document-subheader">POST /uploads</p>
|
||||
<p class="document-paragraph">
|
||||
Uploads an animation to the site. To prevent spam this endpoint requires a valid
|
||||
Proof of Work challenge solved via <code>GET /challenge</code> with a minimum difficulty of <code>20</code>.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
This endpoint responds as a <strong>Server-Sent Events (SSE) stream</strong>. Events are
|
||||
emitted throughout processing to report progress. The connection closes after the final
|
||||
<code>finish</code> event or on any error.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
NOTE: Requests exceeding the <code>limits.upload.filesize</code> limit will be aborted immediately.
|
||||
</p>
|
||||
<pre class="document-codeblock">
|
||||
Request Body: [multipart/form-data]
|
||||
|
||||
field: data (text/JSON)
|
||||
{
|
||||
"title": string // Animation title (1-80 characters, see 'limits.title')
|
||||
"tags": string[] // Tag names to attach, plaintext (see 'limits.tag')
|
||||
}
|
||||
|
||||
field: file (binary)
|
||||
// Accepted MIME types may change, fetch the current list from 'limits.upload.mime_types'
|
||||
image/jpeg, image/png, image/gif, image/webp, image/heic, image/heif,
|
||||
image/avif, image/jxl, image/tiff, image/bmp,
|
||||
video/mp4, video/webm, video/quicktime, video/x-matroska,
|
||||
video/avi, video/x-ms-wmv,
|
||||
|
||||
Request Headers:
|
||||
X-Pow-Nonce // Nonce from GET /challenge
|
||||
X-Pow-Counter // Your solved counter value
|
||||
</pre>
|
||||
|
||||
<pre class="document-codeblock">
|
||||
SSE Event Stream:
|
||||
|
||||
event: id // Emitted early — the assigned snowflake ID for this upload
|
||||
{ "id": string }
|
||||
|
||||
event: step // Processing stage updates
|
||||
{ "id": string, "message": string }
|
||||
// Known step IDs: PROBE_QUEUE, PROBE_START, SERVER_FINALIZE
|
||||
|
||||
event: progress // Encoding/classification progress
|
||||
{ "percent": string } // Float string, e.g. "42.50"
|
||||
|
||||
event: finish // Final event on success — save edit_token, it is not recoverable!
|
||||
{
|
||||
"id": string // Animation snowflake ID
|
||||
"edit_token": string // Required to delete this animation later
|
||||
}
|
||||
|
||||
event: error // Emitted on client or server error, stream closes after
|
||||
{ "code": number, "message": string }
|
||||
</pre>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,51 @@
|
||||
<div class="document-section">
|
||||
<p class="document-header">Privacy Policy</p>
|
||||
<p class="document-paragraph">Last Updated: March 21st 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Data Collection ]</p>
|
||||
<p class="document-paragraph">We collect the following data in order to provide you with our services:</p>
|
||||
<div class="document-list">
|
||||
<p class="document-item">Content that you upload</p>
|
||||
<p class="document-item">Your edit tokens¹</p>
|
||||
<p class="document-item">Your IP address²</p>
|
||||
</div>
|
||||
<p class="document-paragraph">
|
||||
¹ Edit tokens are kept on your device when using our website.
|
||||
Please back them up via the settings menu, as we cannot assist in recovery.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
² Your IP address is stored in hashed form via a one-way algorithm to reduce direct identification.
|
||||
We use this data to prevent abuse on our platform and issue disciplinary actions towards bad actors.
|
||||
Decisions are made at our discretion and are final.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Third Party ]</p>
|
||||
<p class="document-paragraph">
|
||||
We use a self-hosted instance of <a target="_blank" href="https://umami.is/">Umami</a>
|
||||
for analytics to see how people arrive at and interact with our site.
|
||||
We use <a href="https://en.wikipedia.org/wiki/UTM_parameters">UTM parameters</a>,
|
||||
which can be manually removed if desired.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
All of this data is stored anonymously on our own servers and isn’t shared with any third parties.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[ Contact ]</p>
|
||||
<p class="document-paragraph">
|
||||
gifuu is a personal project operated by bakonpancakz.
|
||||
For privacy or legal concerns, please visit:
|
||||
<a target="_blank" href="https://pancakz.net/">https://pancakz.net/</a>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,69 @@
|
||||
<div class="document-section">
|
||||
<p class="document-header">Terms of Service</p>
|
||||
<p class="document-paragraph">Last Updated: March 21st 2026</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[1] Acceptance</p>
|
||||
<p class="document-paragraph">
|
||||
By using gifuu, you agree to the following terms of service.
|
||||
If you do not agree to these terms, do not use our platform.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
These terms may be updated at any time without prior notice. Continued use constitutes acceptance.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[2] Content</p>
|
||||
<p class="document-paragraph">
|
||||
You are solely responsible for any content you upload to gifuu.
|
||||
You may not upload, distribute, or store content that:
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
Violates any applicable law or regulation;
|
||||
infringes on any copyright, trademark, or other intellectual property right;
|
||||
depicts any being in a sexual or exploitative manner;
|
||||
constitutes targeted harassment, hate speech, or incitement of violence;
|
||||
depicts graphic violence, gore, or abuse;
|
||||
promotes or depicts self-harm, dangerous activity, or seizure-inducing imagery;
|
||||
constitutes spam, advertising, or unsolicited solicitation;
|
||||
or that you do not have the rights to distribute.
|
||||
</p>
|
||||
<p class="document-paragraph">
|
||||
We reserve the right to moderate, remove content, or restrict access to our platform at our discretion.
|
||||
Violations may be reported by users and are reviewed by our moderation team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[3] Data Collection</p>
|
||||
<p class="document-paragraph">
|
||||
You agree to our data collection and privacy policies.
|
||||
A description of how we collect and process your data is available <a href="/text/privacy-policy">here</a>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[4] Availability</p>
|
||||
<p class="document-paragraph">
|
||||
We are not liable for any loss of data or damages resulting from use of the platform.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="document-spacer"></div>
|
||||
|
||||
<div class="document-section">
|
||||
<p class="document-header">[5] Other</p>
|
||||
<p class="document-paragraph">
|
||||
You agree that bunnies are adorable.
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,106 @@
|
||||
:root {
|
||||
--animation-transition: 200ms;
|
||||
--border-thickness: 2px;
|
||||
|
||||
--background-tertiary: hsl(0, 0%, 0%);
|
||||
--background-secondary: hsl(0, 0%, 16%);
|
||||
--background-primary: hsl(0, 0%, 32%);
|
||||
|
||||
--font-color-accent: hsl(0, 50%, 80%);
|
||||
--font-color-primary: hsl(0, 0%, 95%);
|
||||
--font-color-secondary: hsl(0, 0%, 65%);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.effect-centered {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
a.layout-wrapper {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
a.layout-wrapper img.watermark {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
bottom: 16px;
|
||||
opacity: 0.8;
|
||||
transition: var(--animation-transition) ease-in-out opacity;
|
||||
height: 4vw;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
a.layout-wrapper:hover img.watermark,
|
||||
a.layout-wrapper:focus-visible img.watermark {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Layout: Error */
|
||||
a.layout-wrapper div.error {
|
||||
box-sizing: border-box;
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-image:
|
||||
repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
var(--background-secondary) 8px,
|
||||
var(--background-secondary) 9px
|
||||
),
|
||||
repeating-linear-gradient(
|
||||
-45deg,
|
||||
transparent,
|
||||
transparent 8px,
|
||||
var(--background-secondary) 8px,
|
||||
var(--background-secondary) 9px
|
||||
);
|
||||
background-color: var(--background-tertiary);
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
a.layout-wrapper div.error p {
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 16px 32px;
|
||||
width: fit-content;
|
||||
color: var(--font-color-primary);
|
||||
font-size: large;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Layout: Canvas */
|
||||
a.layout-wrapper video.decoder {
|
||||
position: absolute;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
a.layout-wrapper canvas.render {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* Layout: Image */
|
||||
a.layout-wrapper img.render {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
;(() => {
|
||||
let defer: (() => void)[] = []
|
||||
let running = false
|
||||
let paramQuality: 'standard' | 'transparent'
|
||||
let paramID: bigint
|
||||
|
||||
const elemHost = document.querySelector<HTMLAnchorElement>('a.layout-wrapper')
|
||||
// @ts-expect-error
|
||||
const BASE_CDN = window.__ENV__.CDN
|
||||
// @ts-expect-error
|
||||
const BASE_WEB = window.__ENV__.WEB
|
||||
|
||||
if (BASE_CDN === undefined || BASE_WEB === undefined || !elemHost) {
|
||||
console.error('[gifuu] Invalid Document')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const givenQuality = search.get('quality')
|
||||
const givenID = BigInt(search.get('id') ?? '0')
|
||||
|
||||
if (givenID < 1) {
|
||||
return exit('Invalid ID')
|
||||
}
|
||||
if (givenQuality !== 'standard' && givenQuality !== 'transparent') {
|
||||
return exit('Invalid Quality')
|
||||
}
|
||||
|
||||
paramQuality = givenQuality
|
||||
paramID = givenID
|
||||
} catch (error) {
|
||||
exit(error)
|
||||
return
|
||||
}
|
||||
|
||||
function setupGL() {
|
||||
if (!elemHost) throw 'Missing Anchor Node'
|
||||
|
||||
// Setup Elements
|
||||
const elemCanvas = document.createElement('canvas')
|
||||
elemCanvas.classList.add('render')
|
||||
elemCanvas.addEventListener('webglcontextlost', (ev) => {
|
||||
ev.preventDefault()
|
||||
console.warn('[gifuu] Failed to allocate a WebGL context for us! Using image fallback...')
|
||||
teardown()
|
||||
setupImage()
|
||||
})
|
||||
|
||||
elemHost.appendChild(elemCanvas)
|
||||
defer.push(() => elemCanvas.remove())
|
||||
|
||||
const elemVideo = document.createElement('video')
|
||||
elemVideo.classList.add('decode')
|
||||
elemVideo.crossOrigin = 'anonymous'
|
||||
elemVideo.playsInline = true
|
||||
elemVideo.autoplay = true
|
||||
elemVideo.muted = true
|
||||
elemVideo.loop = true
|
||||
|
||||
elemHost.appendChild(elemVideo)
|
||||
defer.push(() => elemVideo.remove())
|
||||
|
||||
// Setup Context
|
||||
const gl = elemCanvas.getContext('webgl', {
|
||||
powerPreference: 'low-power',
|
||||
premultipliedAlpha: false,
|
||||
antialias: false,
|
||||
alpha: true,
|
||||
depth: false,
|
||||
})
|
||||
if (!gl) {
|
||||
console.warn('[gifuu] WebGL is unsupported, using image fallback...')
|
||||
teardown()
|
||||
setupImage()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const VERT = `
|
||||
precision mediump float;
|
||||
attribute vec2 aPos;
|
||||
uniform mat3 uMatrix;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
gl_Position = vec4(uMatrix * vec3(aPos, 1.0), 1.0);
|
||||
vUV = aPos;
|
||||
}`
|
||||
|
||||
const FRAG = `
|
||||
precision mediump float;
|
||||
uniform sampler2D uFrame;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vec2 colorUV = vec2(vUV.x, vUV.y * 0.5);
|
||||
vec2 alphaUV = vec2(vUV.x, 0.5 + vUV.y * 0.5);
|
||||
vec4 color = texture2D(uFrame, colorUV);
|
||||
float alpha = texture2D(uFrame, alphaUV).r;
|
||||
gl_FragColor = vec4(color.rgb, alpha);
|
||||
}`
|
||||
|
||||
function compileShader(type: number, src: string) {
|
||||
if (!gl) throw 'Missing GL Context'
|
||||
|
||||
const s = gl.createShader(type)
|
||||
if (!s) throw 'Shader compilation failed'
|
||||
|
||||
gl.shaderSource(s, src)
|
||||
gl.compileShader(s)
|
||||
return s
|
||||
}
|
||||
|
||||
const prog = gl.createProgram()
|
||||
gl.attachShader(prog, compileShader(gl.VERTEX_SHADER, VERT))
|
||||
gl.attachShader(prog, compileShader(gl.FRAGMENT_SHADER, FRAG))
|
||||
gl.linkProgram(prog)
|
||||
gl.useProgram(prog)
|
||||
defer.push(() => gl.deleteProgram(prog))
|
||||
|
||||
// --- Quad ---
|
||||
const buf = gl.createBuffer()
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, buf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0, 0, 1, 0, 0, 1, 1, 0, 1, 1, 0, 1]), gl.STATIC_DRAW)
|
||||
defer.push(() => gl.deleteBuffer(buf))
|
||||
|
||||
const aPos = gl.getAttribLocation(prog, 'aPos')
|
||||
gl.enableVertexAttribArray(aPos)
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0)
|
||||
gl.uniformMatrix3fv(gl.getUniformLocation(prog, 'uMatrix'), false, [2, 0, 0, 0, -2, 0, -1, 1, 1])
|
||||
|
||||
// --- Texture ---
|
||||
const tex = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, tex)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.uniform1i(gl.getUniformLocation(prog, 'uFrame'), 0)
|
||||
defer.push(() => gl.deleteTexture(tex))
|
||||
} catch (error) {
|
||||
console.error('[gifuu] Failed to Initialize WebGL dependencies:', error)
|
||||
teardown()
|
||||
setupImage()
|
||||
return
|
||||
}
|
||||
|
||||
// Tick Function
|
||||
let cancel = 0
|
||||
let sized = false
|
||||
function tick() {
|
||||
cancel = requestAnimationFrame(tick)
|
||||
try {
|
||||
if (!gl) throw 'Missing GL Context'
|
||||
if (!sized && elemVideo.videoWidth > 0) {
|
||||
sized = true
|
||||
elemCanvas.width = elemVideo.videoWidth
|
||||
elemCanvas.height = Math.floor(elemVideo.videoHeight / 2)
|
||||
gl.viewport(0, 0, elemCanvas.width, elemCanvas.height)
|
||||
}
|
||||
if (!sized) return
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, elemVideo)
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6)
|
||||
} catch (error) {
|
||||
console.warn('[gifuu] Draw failed, using image fallback...', error)
|
||||
teardown()
|
||||
setupImage()
|
||||
return
|
||||
}
|
||||
}
|
||||
defer.push(() => cancelAnimationFrame(cancel))
|
||||
|
||||
// Download Video
|
||||
fetch(`${BASE_CDN}/${paramID}/alpha.webm`, { mode: 'cors', cache: 'force-cache' })
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
const content = URL.createObjectURL(blob)
|
||||
defer.push(() => URL.revokeObjectURL(content))
|
||||
|
||||
elemVideo.src = content
|
||||
elemVideo.play().catch(() => {})
|
||||
tick()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[gifuu] Video download failed, using image fallback...', error)
|
||||
teardown()
|
||||
setupImage()
|
||||
})
|
||||
}
|
||||
|
||||
function setupImage() {
|
||||
if (!elemHost) throw 'Missing Image Node'
|
||||
|
||||
// Create Element
|
||||
const elemImage = document.createElement('img')
|
||||
elemImage.classList.add('render')
|
||||
|
||||
elemHost.appendChild(elemImage)
|
||||
defer.push(() => elemImage.remove())
|
||||
|
||||
// Download Image
|
||||
fetch(`${BASE_CDN}/${paramID}/${paramQuality}.avif`, { mode: 'cors', cache: 'force-cache' })
|
||||
.then((r) => r.blob())
|
||||
.then((blob) => {
|
||||
const content = URL.createObjectURL(blob)
|
||||
defer.push(() => URL.revokeObjectURL(content))
|
||||
|
||||
elemImage.src = content
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[gifuu] Image download failed, quitting...', error)
|
||||
teardown()
|
||||
exit('Media Unavailable')
|
||||
})
|
||||
}
|
||||
|
||||
function teardown() {
|
||||
let func
|
||||
while ((func = defer.shift())) {
|
||||
try {
|
||||
func()
|
||||
} catch (error) {
|
||||
console.error('[gifuu] Teardown failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function exit(error: any) {
|
||||
console.error('[gifuu] Exiting Embed:', error)
|
||||
teardown()
|
||||
|
||||
if (!elemHost) return
|
||||
elemHost.href = BASE_WEB
|
||||
|
||||
const container = document.createElement('div')
|
||||
container.classList.add('error')
|
||||
|
||||
const message = document.createElement('p')
|
||||
message.classList.add('message', 'effect-centered')
|
||||
message.textContent = String(error)
|
||||
container.append(message)
|
||||
|
||||
elemHost.append(container)
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
elemHost.href = `${BASE_WEB}/art/${paramID}`
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && !running) {
|
||||
running = true
|
||||
console.log('[gifuu] Showtime! Setting up...')
|
||||
paramQuality === 'transparent' ? setupGL() : setupImage()
|
||||
} else if (!entries[0].isIntersecting && running) {
|
||||
running = false
|
||||
console.log('[gifuu] Out of View! Tearing down...')
|
||||
teardown()
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
)
|
||||
observer.observe(elemHost)
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
console.log('[gifuu] Goodbye!')
|
||||
observer.disconnect()
|
||||
elemHost.remove()
|
||||
teardown()
|
||||
})
|
||||
})()
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 103.11 55.18">
|
||||
<path style="fill: #f0f0f0; stroke: #242424;" d="M54.36,48.13c0,3.62,2.93,6.55,6.55,6.55h22.75c6.86,0,10.07-1.56,13.62-4.68,3.55-3.12,5.33-7.66,5.33-13.61s-1.78-10.42-5.33-13.54c-3.55-3.12-6.76-4.68-13.62-4.68h-11.65c-3.62,0-6.55-2.93-6.55-6.55v-4.58c0-3.62-.93-6.55-4.55-6.55h0c-3.62,0-6.55,2.93-6.55,6.55v41.08ZM67.46,34.02c0-3.62,2.93-6.55,6.55-6.55h7.78c2.11,0,1.82.25,3.11.76,1.3.5,2.3,1.18,3.02,2.02.72.84,1.21,1.79,1.48,2.84.26,1.06.4,2.16.4,3.31s-.13,2.27-.4,3.35c-.26,1.08-.76,2.04-1.48,2.88-.72.84-1.73,1.51-3.02,2.02-1.3.5-1,.76-3.11.76h-7.78c-3.62,0-6.55-2.93-6.55-6.55v-4.82Z" />
|
||||
<path style="fill: #f0f0f0; stroke: #242424;" d="M48.76,48.13c0,3.62-2.93,6.55-6.55,6.55h-22.75c-6.86,0-10.07-1.56-13.62-4.68-3.55-3.12-5.33-7.66-5.33-13.61s1.78-10.42,5.33-13.54c3.55-3.12,6.76-4.68,13.62-4.68h11.65c3.62,0,6.55-2.93,6.55-6.55v-6.58c0-2.51,2.04-4.55,4.55-4.55h0c3.62,0,6.55,2.93,6.55,6.55v41.08ZM35.65,34.02c0-3.62-2.93-6.55-6.55-6.55h-7.78c-2.11,0-1.82.25-3.11.76-1.3.5-2.3,1.18-3.02,2.02-.72.84-1.21,1.79-1.48,2.84-.26,1.06-.4,2.16-.4,3.31s.13,2.27.4,3.35c.26,1.08.76,2.04,1.48,2.88.72.84,1.73,1.51,3.02,2.02,1.3.5,1,.76,3.11.76h7.78c3.62,0,6.55-2.93,6.55-6.55v-4.82Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,567 @@
|
||||
;(() => {
|
||||
const elemParent = document.querySelector('div.layout-background')
|
||||
const elemSprite = document.querySelector<HTMLLinkElement>('link[rel="texture"]')
|
||||
if (!elemParent || !elemSprite) throw 'Invalid Document'
|
||||
|
||||
const FRAME_INTERVAL = 1000
|
||||
const FRAME_TIME_IDLE = 12
|
||||
const FRAME_TIME_ACTIVE = 60
|
||||
const CAM_ACCEL = 2
|
||||
const CAM_FRICTION = 0.85
|
||||
const PARTICLE_COUNT = 160
|
||||
const COLOR_PARTICLE = 0x484848
|
||||
const COLOR_FOREGROUND = 0x363636
|
||||
const COLOR_BACKGROUND = 0x000000
|
||||
|
||||
let keysHeld = new Set()
|
||||
let frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE
|
||||
let lastTime = 0
|
||||
let lastFrame = 0
|
||||
let time = 0
|
||||
let cameraMVP = mat4()
|
||||
let cameraRotX = 0
|
||||
let cameraRotY = 0
|
||||
let camVelRotX = 0
|
||||
let camVelRotY = 0
|
||||
let spriteTexture: WebGLTexture
|
||||
let spriteModel: Float32Array
|
||||
let particlePos: Float32Array
|
||||
let particleColor: Float32Array
|
||||
let particleData: {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
vx: number
|
||||
vy: number
|
||||
vz: number
|
||||
life: number
|
||||
maxLife: number
|
||||
}[] = []
|
||||
let planeW: number
|
||||
let planeH: number
|
||||
let planeSegX: number
|
||||
let planeSegY: number
|
||||
let planeVerts: Float32Array
|
||||
let planeOrig: Float32Array
|
||||
let planeIdx
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const gl = canvas.getContext('webgl')
|
||||
if (!gl) throw 'Failed to allocate WebGL Context'
|
||||
|
||||
// --- Camera Controls ---
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!e.shiftKey && !e.ctrlKey) return
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
if (e.target instanceof HTMLInputElement) return
|
||||
if ((e as any).isContentEditable) return
|
||||
keysHeld.add(e.key)
|
||||
})
|
||||
document.addEventListener('keyup', (e) => {
|
||||
keysHeld.delete(e.key)
|
||||
})
|
||||
|
||||
// --- Math Functions ---
|
||||
function mat4(): Float32Array {
|
||||
return new Float32Array(16)
|
||||
}
|
||||
function mat4Identity(m: Float32Array): Float32Array {
|
||||
m[0] = 1
|
||||
m[1] = 0
|
||||
m[2] = 0
|
||||
m[3] = 0
|
||||
m[4] = 0
|
||||
m[5] = 1
|
||||
m[6] = 0
|
||||
m[7] = 0
|
||||
m[8] = 0
|
||||
m[9] = 0
|
||||
m[10] = 1
|
||||
m[11] = 0
|
||||
m[12] = 0
|
||||
m[13] = 0
|
||||
m[14] = 0
|
||||
m[15] = 1
|
||||
return m
|
||||
}
|
||||
function mat4Multiply(out: Float32Array, a: Float32Array, b: Float32Array): Float32Array {
|
||||
for (let i = 0; i < 4; i++)
|
||||
for (let j = 0; j < 4; j++) {
|
||||
out[j * 4 + i] = 0
|
||||
for (let k = 0; k < 4; k++) out[j * 4 + i] += a[k * 4 + i] * b[j * 4 + k]
|
||||
}
|
||||
return out
|
||||
}
|
||||
function mat4Perspective(m: Float32Array, fovY: number, aspect: number, near: number, far: number): Float32Array {
|
||||
const f = 1.0 / Math.tan(fovY / 2)
|
||||
m[0] = f / aspect
|
||||
m[1] = 0
|
||||
m[2] = 0
|
||||
m[3] = 0
|
||||
m[4] = 0
|
||||
m[5] = f
|
||||
m[6] = 0
|
||||
m[7] = 0
|
||||
m[8] = 0
|
||||
m[9] = 0
|
||||
m[10] = (far + near) / (near - far)
|
||||
m[11] = -1
|
||||
m[12] = 0
|
||||
m[13] = 0
|
||||
m[14] = (2 * far * near) / (near - far)
|
||||
m[15] = 0
|
||||
return m
|
||||
}
|
||||
function mat4RotateX(m: Float32Array, angle: number): Float32Array {
|
||||
const c = Math.cos(angle)
|
||||
const s = Math.sin(angle)
|
||||
const t = mat4Identity(mat4())
|
||||
t[5] = c
|
||||
t[6] = s
|
||||
t[9] = -s
|
||||
t[10] = c
|
||||
return mat4Multiply(mat4(), t, m)
|
||||
}
|
||||
function mat4RotateY(m: Float32Array, angle: number): Float32Array {
|
||||
const c = Math.cos(angle)
|
||||
const s = Math.sin(angle)
|
||||
const t = mat4Identity(mat4())
|
||||
t[0] = c
|
||||
t[2] = -s
|
||||
t[8] = s
|
||||
t[10] = c
|
||||
return mat4Multiply(mat4(), t, m)
|
||||
}
|
||||
function mat4Translate(m: Float32Array, x: number, y: number, z: number): Float32Array {
|
||||
const t = mat4Identity(mat4())
|
||||
t[12] = x
|
||||
t[13] = y
|
||||
t[14] = z
|
||||
return mat4Multiply(mat4(), t, m)
|
||||
}
|
||||
function randFloat(lo: number, hi: number): number {
|
||||
return lo + Math.random() * (hi - lo)
|
||||
}
|
||||
function randFloatSpread(range: number): number {
|
||||
return randFloat(-range / 2, range / 2)
|
||||
}
|
||||
function degToRad(d: number): number {
|
||||
return (d * Math.PI) / 180
|
||||
}
|
||||
function intToRGB(i: number): [number, number, number] {
|
||||
return [((i >> 16) & 0xff) / 255, ((i >> 8) & 0xff) / 255, ((i >> 0) & 0xff) / 255]
|
||||
}
|
||||
|
||||
// --- Prepare Shaders ---
|
||||
function createShader(type: number, src: string): WebGLShader {
|
||||
if (!gl) throw 'Missing Global GL Context'
|
||||
const s = gl.createShader(type)
|
||||
if (!s) throw 'Shader Compilation Failed'
|
||||
|
||||
gl.shaderSource(s, src)
|
||||
gl.compileShader(s)
|
||||
return s
|
||||
}
|
||||
|
||||
function createProgram(vert: string, frag: string): WebGLProgram {
|
||||
if (!gl) throw 'Missing Global GL Context'
|
||||
const p = gl.createProgram()
|
||||
gl.attachShader(p, createShader(gl.VERTEX_SHADER, vert))
|
||||
gl.attachShader(p, createShader(gl.FRAGMENT_SHADER, frag))
|
||||
gl.linkProgram(p)
|
||||
return p
|
||||
}
|
||||
|
||||
const planeProg = createProgram(
|
||||
`attribute vec3 aPosition;
|
||||
uniform mat4 uMVP;
|
||||
uniform float uTime;
|
||||
varying float vDist;
|
||||
void main() {
|
||||
float dist = sqrt(aPosition.x * aPosition.x + aPosition.z * aPosition.z);
|
||||
float wave = sin(dist * 0.5 - uTime);
|
||||
float cave = -exp(-dist * 0.1) * 3.5;
|
||||
vec3 pos = vec3(aPosition.x, aPosition.y + wave + cave, aPosition.z);
|
||||
vDist = length((uMVP * vec4(pos, 1.0)).xyz);
|
||||
gl_Position = uMVP * vec4(pos, 1.0);
|
||||
}`,
|
||||
`precision mediump float;
|
||||
uniform vec3 uFogColor;
|
||||
uniform float uFogNear;
|
||||
uniform float uFogFar;
|
||||
varying float vDist;
|
||||
void main() {
|
||||
float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0);
|
||||
vec3 color = mix(vec3(${intToRGB(COLOR_FOREGROUND).join(',')}), uFogColor, fog);
|
||||
gl_FragColor = vec4(color, 1.0);
|
||||
}`,
|
||||
)
|
||||
|
||||
const particleProg = createProgram(
|
||||
`attribute vec3 aPosition;
|
||||
attribute vec4 aColor;
|
||||
uniform mat4 uMVP;
|
||||
varying vec4 vColor;
|
||||
varying float vDist;
|
||||
void main() {
|
||||
vec4 pos = uMVP * vec4(aPosition, 1.0);
|
||||
vDist = length(pos.xyz);
|
||||
vColor = aColor;
|
||||
gl_PointSize = 3.0;
|
||||
gl_Position = pos;
|
||||
}`,
|
||||
`precision mediump float;
|
||||
uniform vec3 uFogColor;
|
||||
uniform float uFogNear;
|
||||
uniform float uFogFar;
|
||||
varying vec4 vColor;
|
||||
varying float vDist;
|
||||
void main() {
|
||||
float fog = clamp((vDist - uFogNear) / (uFogFar - uFogNear), 0.0, 1.0);
|
||||
float alpha = vColor.a * (1.0 - fog);
|
||||
gl_FragColor = vec4(mix(vColor.rgb, uFogColor, fog), alpha);
|
||||
}`,
|
||||
)
|
||||
|
||||
const spriteProg = createProgram(
|
||||
`attribute vec2 aPosition;
|
||||
attribute vec2 aUV;
|
||||
uniform mat4 uMVP;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
vUV = aUV;
|
||||
gl_Position = uMVP * vec4(aPosition.x , 0, aPosition.y, 1);
|
||||
}`,
|
||||
`precision mediump float;
|
||||
uniform sampler2D uTex;
|
||||
varying vec2 vUV;
|
||||
void main() {
|
||||
gl_FragColor = texture2D(uTex, vUV);
|
||||
}`,
|
||||
)
|
||||
|
||||
const planeBuf = gl.createBuffer()
|
||||
const planeOrigBuf = gl.createBuffer()
|
||||
const planeIdxBuf = gl.createBuffer()
|
||||
const uTimePlane = gl.getUniformLocation(planeProg, 'uTime')
|
||||
const uMVPPlane = gl.getUniformLocation(planeProg, 'uMVP')
|
||||
const uFogColorPlane = gl.getUniformLocation(planeProg, 'uFogColor')
|
||||
const uFogNearPlane = gl.getUniformLocation(planeProg, 'uFogNear')
|
||||
const uFogFarPlane = gl.getUniformLocation(planeProg, 'uFogFar')
|
||||
|
||||
const particlePosBuf = gl.createBuffer()
|
||||
const particleColorBuf = gl.createBuffer()
|
||||
const uMVPParticle = gl.getUniformLocation(particleProg, 'uMVP')
|
||||
const uFogColorParticle = gl.getUniformLocation(particleProg, 'uFogColor')
|
||||
const uFogNearParticle = gl.getUniformLocation(particleProg, 'uFogNear')
|
||||
const uFogFarParticle = gl.getUniformLocation(particleProg, 'uFogFar')
|
||||
|
||||
const spriteImage = new Image()
|
||||
spriteImage.src = elemSprite.href
|
||||
|
||||
const spriteBuf = gl.createBuffer()
|
||||
const spriteIdxBuf = gl.createBuffer()
|
||||
const uMVPSprite = gl.getUniformLocation(spriteProg, 'uMVP')
|
||||
const uTexSprite = gl.getUniformLocation(spriteProg, 'uTex')
|
||||
|
||||
function spawnParticle(i: number) {
|
||||
// Put them in the center because its out of frame anyways save some resources
|
||||
const x = randFloat(-planeW / 2, planeW / 2)
|
||||
const z = randFloat(-planeH / 2, planeH / 2)
|
||||
const l = randFloat(3.0, 6.0)
|
||||
|
||||
particleData[i] = {
|
||||
x,
|
||||
y: 0,
|
||||
z,
|
||||
vx: randFloatSpread(0.05),
|
||||
vy: randFloat(0.02, 0.05),
|
||||
vz: randFloatSpread(0.05),
|
||||
life: l,
|
||||
maxLife: l,
|
||||
}
|
||||
const p = i * 3
|
||||
particlePos[p + 0] = x
|
||||
particlePos[p + 1] = 0
|
||||
particlePos[p + 2] = z
|
||||
|
||||
const c = i * 4
|
||||
const [r, g, b] = intToRGB(COLOR_PARTICLE)
|
||||
particleColor[c + 0] = r
|
||||
particleColor[c + 1] = g
|
||||
particleColor[c + 2] = b
|
||||
particleColor[c + 3] = 0
|
||||
}
|
||||
|
||||
function startup() {
|
||||
if (!gl) throw 'Missing Global GL Context'
|
||||
|
||||
// Render Resolution
|
||||
canvas.width = Math.floor(window.innerWidth * 0.3)
|
||||
canvas.height = Math.floor(window.innerHeight * 0.3)
|
||||
canvas.style.width = window.innerWidth + 'px'
|
||||
canvas.style.height = window.innerHeight + 'px'
|
||||
canvas.style.imageRendering = 'pixelated'
|
||||
|
||||
// Build Sprite
|
||||
spriteImage.onload = () => {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, 1, 1, -1, 0, 1, 1, 1, 0, 0, -1, 1, 1, 0]),
|
||||
gl.STATIC_DRAW,
|
||||
)
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf)
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, new Uint16Array([0, 1, 2, 0, 2, 3]), gl.STATIC_DRAW)
|
||||
|
||||
spriteModel = mat4Identity(mat4())
|
||||
spriteModel = mat4RotateY(spriteModel, degToRad(180))
|
||||
spriteModel = mat4Translate(spriteModel, 0, 4, 16)
|
||||
|
||||
// Upload Texture
|
||||
spriteTexture = gl.createTexture()
|
||||
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, spriteImage)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
|
||||
gl.useProgram(spriteProg)
|
||||
gl.uniform1i(uTexSprite, 0)
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
|
||||
}
|
||||
|
||||
// Build Plane
|
||||
gl.viewport(0, 0, canvas.width, canvas.height)
|
||||
gl.clearColor(...intToRGB(COLOR_BACKGROUND), 1)
|
||||
gl.enable(gl.BLEND)
|
||||
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
const ratio = canvas.width / canvas.height
|
||||
const scale = 24
|
||||
planeSegX = Math.round(12 * ratio)
|
||||
planeSegY = 12
|
||||
planeW = scale * ratio
|
||||
planeH = scale
|
||||
|
||||
const nx = planeSegX + 1
|
||||
const ny = planeSegY + 1
|
||||
planeVerts = new Float32Array(nx * ny * 3)
|
||||
planeOrig = new Float32Array(nx * ny * 3)
|
||||
|
||||
let vi = 0
|
||||
for (let iy = 0; iy < ny; iy++) {
|
||||
for (let ix = 0; ix < nx; ix++) {
|
||||
const x = (ix / planeSegX - 0.5) * planeW
|
||||
const z = (iy / planeSegY - 0.5) * planeH
|
||||
planeVerts[vi] = x
|
||||
planeVerts[vi + 1] = 0
|
||||
planeVerts[vi + 2] = z
|
||||
planeOrig[vi] = x
|
||||
planeOrig[vi + 1] = 0
|
||||
planeOrig[vi + 2] = z
|
||||
vi += 3
|
||||
}
|
||||
}
|
||||
const lines = []
|
||||
for (let iy = 0; iy < ny; iy++) {
|
||||
for (let ix = 0; ix < nx; ix++) {
|
||||
const idx = iy * nx + ix
|
||||
// wireframe indices two triangles per quad
|
||||
if (ix < planeSegX) {
|
||||
lines.push(idx, idx + 1)
|
||||
}
|
||||
if (iy < planeSegY) {
|
||||
lines.push(idx, idx + nx)
|
||||
}
|
||||
if (ix < planeSegX && iy < planeSegY) {
|
||||
lines.push(idx, idx + nx + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
planeIdx = new Uint16Array(lines)
|
||||
|
||||
// Upload plane buffers
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, planeVerts, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, planeOrigBuf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, planeOrig, gl.STATIC_DRAW)
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf)
|
||||
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, planeIdx, gl.STATIC_DRAW)
|
||||
|
||||
// Initialize particles if first time
|
||||
if (particleData.length === 0) {
|
||||
particlePos = new Float32Array(PARTICLE_COUNT * 3)
|
||||
particleColor = new Float32Array(PARTICLE_COUNT * 4)
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
spawnParticle(i)
|
||||
}
|
||||
}
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, particlePos, gl.DYNAMIC_DRAW)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
|
||||
gl.bufferData(gl.ARRAY_BUFFER, particleColor, gl.DYNAMIC_DRAW)
|
||||
|
||||
// Initialize Fog
|
||||
gl.useProgram(planeProg)
|
||||
gl.uniform3f(uFogColorPlane, ...intToRGB(COLOR_BACKGROUND))
|
||||
gl.uniform1f(uFogNearPlane, 2)
|
||||
gl.uniform1f(uFogFarPlane, 22)
|
||||
|
||||
gl.useProgram(particleProg)
|
||||
gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP)
|
||||
gl.uniform3f(uFogColorParticle, ...intToRGB(COLOR_BACKGROUND))
|
||||
gl.uniform1f(uFogNearParticle, 6)
|
||||
gl.uniform1f(uFogFarParticle, 24)
|
||||
|
||||
// Force dirty camera reset
|
||||
camVelRotY = 0.005
|
||||
}
|
||||
|
||||
function animate(now: number) {
|
||||
requestAnimationFrame(animate)
|
||||
|
||||
// Sleep (warning this sucks)
|
||||
if (now - lastFrame < frameTime) return
|
||||
const delta = (now - lastTime) * 0.00008 || 0
|
||||
lastFrame = now
|
||||
lastTime = now
|
||||
time += delta
|
||||
|
||||
if (!gl) throw 'Missing Global GL Context'
|
||||
gl.clear(gl.COLOR_BUFFER_BIT)
|
||||
|
||||
// Camera Movement
|
||||
if (keysHeld.has('ArrowLeft')) camVelRotY -= CAM_ACCEL
|
||||
if (keysHeld.has('ArrowRight')) camVelRotY += CAM_ACCEL
|
||||
if (keysHeld.has('ArrowUp')) camVelRotX -= CAM_ACCEL
|
||||
if (keysHeld.has('ArrowDown')) camVelRotX += CAM_ACCEL + 8
|
||||
|
||||
camVelRotX *= CAM_FRICTION
|
||||
camVelRotY *= CAM_FRICTION
|
||||
const dirty = Math.abs(camVelRotX) + Math.abs(camVelRotY) > 0.001
|
||||
|
||||
if (dirty) {
|
||||
cameraRotX += camVelRotX * delta * 1000
|
||||
cameraRotY += camVelRotY * delta * 1000
|
||||
|
||||
// Update Camera
|
||||
const ratio = canvas.width / canvas.height
|
||||
const proj = mat4Perspective(mat4(), degToRad(70), ratio, 0.1, 100)
|
||||
|
||||
let view = mat4Identity(mat4())
|
||||
view = mat4Translate(view, 0, -7.5, -15)
|
||||
view = mat4RotateX(view, degToRad(camVelRotX + 33.75))
|
||||
view = mat4RotateY(view, degToRad(camVelRotY))
|
||||
|
||||
cameraMVP = mat4Multiply(mat4(), proj, view)
|
||||
gl.useProgram(planeProg)
|
||||
gl.uniformMatrix4fv(uMVPPlane, false, cameraMVP)
|
||||
gl.useProgram(particleProg)
|
||||
gl.uniformMatrix4fv(uMVPParticle, false, cameraMVP)
|
||||
|
||||
frameTime = FRAME_INTERVAL / FRAME_TIME_ACTIVE
|
||||
} else {
|
||||
frameTime = FRAME_INTERVAL / FRAME_TIME_IDLE
|
||||
}
|
||||
|
||||
// Update Sprite
|
||||
if (spriteTexture) {
|
||||
const spriteMVP = mat4Multiply(mat4(), cameraMVP, spriteModel)
|
||||
gl.useProgram(spriteProg)
|
||||
gl.uniformMatrix4fv(uMVPSprite, false, spriteMVP)
|
||||
gl.uniform1i(uTexSprite, 0)
|
||||
gl.activeTexture(gl.TEXTURE0)
|
||||
gl.bindTexture(gl.TEXTURE_2D, spriteTexture)
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, spriteBuf)
|
||||
const aPos = gl.getAttribLocation(spriteProg, 'aPosition')
|
||||
const aUV = gl.getAttribLocation(spriteProg, 'aUV')
|
||||
gl.enableVertexAttribArray(aPos)
|
||||
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 16, 0)
|
||||
gl.enableVertexAttribArray(aUV)
|
||||
gl.vertexAttribPointer(aUV, 2, gl.FLOAT, false, 16, 8)
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, spriteIdxBuf)
|
||||
gl.drawElements(gl.TRIANGLES, 6, gl.UNSIGNED_SHORT, 0)
|
||||
}
|
||||
|
||||
{
|
||||
// Update Particles
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
const p = particleData[i]
|
||||
|
||||
// fade out
|
||||
p.life -= delta
|
||||
if (p.life <= 0) {
|
||||
spawnParticle(i)
|
||||
continue
|
||||
}
|
||||
particleColor[i * 4 + 3] = Math.min((p.life / p.maxLife) * 1.2, 1)
|
||||
|
||||
// drift away
|
||||
p.x += p.vx * delta * 40
|
||||
p.y += p.vy * delta * 20
|
||||
p.z += p.vz * delta * 40
|
||||
const pi = i * 3
|
||||
particlePos[pi + 0] = p.x
|
||||
particlePos[pi + 1] = p.y
|
||||
particlePos[pi + 2] = p.z
|
||||
}
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particlePos)
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, particleColor)
|
||||
|
||||
// Draw Particles
|
||||
gl.useProgram(particleProg)
|
||||
const aPos = gl.getAttribLocation(particleProg, 'aPosition')
|
||||
const aCol = gl.getAttribLocation(particleProg, 'aColor')
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, particlePosBuf)
|
||||
gl.enableVertexAttribArray(aPos)
|
||||
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, particleColorBuf)
|
||||
gl.enableVertexAttribArray(aCol)
|
||||
gl.vertexAttribPointer(aCol, 4, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.drawArrays(gl.POINTS, 0, PARTICLE_COUNT)
|
||||
}
|
||||
|
||||
{
|
||||
// Update Plane
|
||||
for (let i = 0; i < planeVerts.length; i += 3) {
|
||||
const x = planeOrig[i]
|
||||
const z = planeOrig[i + 2]
|
||||
const dist = Math.sqrt(x * x + z * z)
|
||||
planeVerts[i] = x
|
||||
planeVerts[i + 1] = Math.sin(dist * 0.5 - time) * 0.5 + -Math.exp(-dist * 0.1) * 3.5
|
||||
planeVerts[i + 2] = z
|
||||
}
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
|
||||
gl.bufferSubData(gl.ARRAY_BUFFER, 0, planeVerts)
|
||||
|
||||
// Draw Plane
|
||||
gl.useProgram(planeProg)
|
||||
gl.uniform1f(uTimePlane, time)
|
||||
|
||||
const aPos = gl.getAttribLocation(planeProg, 'aPosition')
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, planeBuf)
|
||||
gl.enableVertexAttribArray(aPos)
|
||||
gl.vertexAttribPointer(aPos, 3, gl.FLOAT, false, 0, 0)
|
||||
|
||||
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, planeIdxBuf)
|
||||
gl.drawElements(gl.LINES, planeIdx.length, gl.UNSIGNED_SHORT, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Prepare Canvas ---
|
||||
window.addEventListener('resize', startup)
|
||||
startup()
|
||||
animate(0)
|
||||
elemParent.append(canvas)
|
||||
})()
|
||||
@@ -0,0 +1,209 @@
|
||||
:root {
|
||||
--animation-duration: 500ms;
|
||||
--animation-transition: 200ms;
|
||||
--animation-load-delay: 200ms;
|
||||
--animation-step-delay: 50ms;
|
||||
|
||||
--border-thickness: 1px;
|
||||
|
||||
--background-tertiary: hsl(0, 0%, 0%);
|
||||
--background-secondary: hsl(0, 0%, 16%);
|
||||
--background-primary: hsl(0, 0%, 32%);
|
||||
--background-highlight: hsl(0, 0%, 80%);
|
||||
--background-translucent: hsla(0, 0%, 0%, 0.3);
|
||||
|
||||
--font-color-accent: hsl(0, 50%, 80%);
|
||||
--font-color-primary: hsl(0, 0%, 95%);
|
||||
--font-color-secondary: hsl(0, 0%, 65%);
|
||||
|
||||
--effect-glass-corner-thickness: 1px;
|
||||
--effect-glass-corner-offset: -16px;
|
||||
--effect-glass-corner-margin: 16px;
|
||||
--effect-glass-corner-color: hsl(0, 0%, 30%);
|
||||
--effect-glass-tint: hsla(0, 0%, 100%, 0.075);
|
||||
--effect-glass-blur: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
p,
|
||||
a,
|
||||
pre,
|
||||
code,
|
||||
span,
|
||||
input,
|
||||
textarea,
|
||||
label,
|
||||
button {
|
||||
display: block;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: var(--font-color-primary);
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-size: 1em;
|
||||
line-height: 1em;
|
||||
font-family: 'Terminus', monospace;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--font-color-secondary);
|
||||
}
|
||||
|
||||
/* Global Layout */
|
||||
span.layout-tooltip {
|
||||
border: var(--border-thickness) solid var(--background-primary);
|
||||
background-color: var(--background-tertiary);
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
div.layout-wrapper {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
div.layout-background {
|
||||
position: fixed;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
div.layout-foreground {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin: auto;
|
||||
margin-top: 16px;
|
||||
width: 100%;
|
||||
max-width: 1024px;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
.layout-scrolling {
|
||||
max-height: calc(100vh - var(--effect-glass-corner-margin) * 4);
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
nav.layout-sidebar {
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
main.layout-content {
|
||||
box-sizing: border-box;
|
||||
padding: 16px;
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
/* Global Effects */
|
||||
.animation-blink {
|
||||
animation: kf-blink 1s infinite step-start;
|
||||
}
|
||||
|
||||
@keyframes kf-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-scroll-in {
|
||||
animation: kf-scroll-in 1s forwards linear;
|
||||
box-sizing: border-box;
|
||||
max-width: fit-content;
|
||||
overflow: hidden;
|
||||
text-wrap-mode: nowrap;
|
||||
}
|
||||
|
||||
@keyframes kf-scroll-in {
|
||||
0% {
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
100% {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-fade-in {
|
||||
animation: kf-fade-in 500ms forwards linear;
|
||||
}
|
||||
|
||||
@keyframes kf-fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-fall-in {
|
||||
opacity: 0;
|
||||
animation: kf-fall-in var(--animation-transition) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes kf-fall-in {
|
||||
from {
|
||||
transform: scale(1.05);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
to {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animation-caution {
|
||||
position: relative;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.animation-caution::before {
|
||||
position: absolute;
|
||||
transform: translate(16px, 16px);
|
||||
clip-path: polygon(
|
||||
calc(100% - 12px) 0,
|
||||
100% 0,
|
||||
100% 100%,
|
||||
0 100%,
|
||||
0 calc(100% - 12px),
|
||||
calc(100% - 12px) calc(100% - 12px)
|
||||
);
|
||||
filter: opacity(0.33);
|
||||
animation: kf-caution 1200s infinite linear;
|
||||
box-sizing: border-box;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--background-primary) 0,
|
||||
var(--background-primary) 8px,
|
||||
black 8px,
|
||||
black 16px
|
||||
);
|
||||
content: '';
|
||||
}
|
||||
|
||||
@keyframes kf-caution {
|
||||
0% {
|
||||
background-position-y: 0px;
|
||||
}
|
||||
100% {
|
||||
background-position-y: 7200px;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1016 B |
Reference in New Issue
Block a user