Repository: okikio/inthistweet Branch: main Commit: 851617c3604e Files: 62 Total size: 197.2 KB Directory structure: gitextract_igadfnhp/ ├── .gitignore ├── .gitpod.yml ├── .vscode/ │ ├── extensions.json │ └── launch.json ├── LICENSE ├── README.md ├── astro.config.ts ├── package.json ├── public/ │ ├── _headers │ ├── _redirects │ ├── favicon/ │ │ └── browserconfig.xml │ ├── manifest.json │ ├── netlify.toml │ ├── open-search.xml │ └── robots.txt ├── range-requests.mjs ├── repl.ts ├── src/ │ ├── components/ │ │ ├── custom-toast.svelte │ │ ├── ffmpeg.svelte │ │ ├── ffmpeg.ts │ │ ├── register-sw.svelte │ │ ├── search.svelte │ │ ├── service-worker.ts │ │ └── transition.ts │ ├── env.d.ts │ ├── layouts/ │ │ └── Layout.astro │ ├── lib/ │ │ ├── codemirror.ts │ │ ├── ffmpeg.ts │ │ ├── get-tweet.ts │ │ ├── height.ts │ │ ├── m3u8/ │ │ │ ├── mod.ts │ │ │ ├── parser.ts │ │ │ ├── traverse.ts │ │ │ ├── types.ts │ │ │ └── urls.ts │ │ ├── path/ │ │ │ └── mod.ts │ │ ├── search.ts │ │ ├── shell-lang.ts │ │ ├── state.ts │ │ ├── transcode.ts │ │ ├── utils/ │ │ │ ├── chunk.ts │ │ │ ├── debounce.ts │ │ │ ├── diff.ts │ │ │ └── url.ts │ │ └── vendor/ │ │ ├── core.ts │ │ └── worker.ts │ ├── pages/ │ │ ├── api/ │ │ │ └── twitter/ │ │ │ └── index.ts │ │ ├── ffmpeg.astro │ │ └── index.astro │ ├── scripts/ │ │ └── measure.ts │ └── types/ │ ├── card.ts │ ├── edit.ts │ ├── entities.ts │ ├── index.ts │ ├── media.ts │ ├── photo.ts │ ├── tweet.ts │ ├── user.ts │ └── video.ts ├── tailwind.config.ts ├── tsconfig.json └── vercel.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # build output dist/ .output/ # dependencies node_modules/ # logs npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* # environment variables .env .env.production # macOS-specific files .DS_Store .netlify .vercel ================================================ FILE: .gitpod.yml ================================================ # This configuration file was automatically generated by Gitpod. # Please adjust to your needs (see https://www.gitpod.io/docs/config-gitpod-file) # and commit this file to your remote git repository to share the goodness with others. tasks: - init: pnpm install && pnpm run build command: pnpm run start vscode: extensions: - svelte.svelte-vscode - bradlc.vscode-tailwindcss ================================================ FILE: .vscode/extensions.json ================================================ { "recommendations": ["astro-build.astro-vscode"], "unwantedRecommendations": [] } ================================================ FILE: .vscode/launch.json ================================================ { "version": "0.2.0", "configurations": [ { "command": "./node_modules/.bin/astro dev", "name": "Development server", "request": "launch", "type": "node-terminal" } ] } ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2023 Okiki Ojo Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # inthistweet [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/okikio/inthistweet) [![Open with CodeSandbox](https://assets.codesandbox.io/github/button-edit-lime.svg)](https://codesandbox.io/s/github/okikio/inthistweet) [![Upvote on ProductHunt](./public/product-hunt-badge-dark.svg)](https://www.producthunt.com/posts/in-this-tweet?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-in-this-tweet) [![Follow on Twitter](./public/twitter-badge-dark.svg)](https://twitter.com/@inthistweet_dev) ![in-this-tweet light logo](public/logo-full-light.svg#gh-light-mode-only) ![in-this-tweet dark logo](public/logo-full-dark.svg#gh-dark-mode-only) ✨ Futuristic ✨ twitter image, gif and video downloader. Enter a Tweet URL, click search, and download the image/videos in it to share, create a meme, and/or to store, the world is your oyester. > **Note**: You can download images and videos for gallary tweets, quote tweets, normal image and video posts and even the preview images for links, [inthistweet.app](https://inthistweet.app) can handle it all. [inthistweet.app](https://inthistweet.app) ## 🚀 Project Structure Inside of your [Astro](https://astro.build) project, you'll see the following folders and files: ``` / ├── public/ │ ├── ... │ └── favicon.svg ├── src/ │ ├── components/ │ │ └── search.svelte │ ├── icons/ │ │ └── logo.svg │ ├── layouts/ │ │ └── Layout.astro │ ├── pages/ │ │ ├── api/ │ │ │ └── twitter.ts │ │ └── index.astro │ ├── scripts/ │ │ └── measure.ts │ └── utils.ts └── package.json ``` [Astro](https://astro.build) looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name. There's nothing special about `src/components/`, but that's where we like to put any [Astro](https://astro.build)/React/Vue/Svelte/Preact components. Any static assets, like images, can be placed in the `public/` directory. ## 🧞 Commands All commands are run from the root of the project, from a terminal: | Command | Action | | :--------------------- | :------------------------------------------------- | | `npm install` | Installs dependencies | | `npm run dev` | Starts local dev server at `localhost:3000` | | `npm run build` | Build your production site to `./dist/` | | `npm run preview` | Preview your build locally, before deploying | | `npm run astro ...` | Run CLI commands like `astro add`, `astro preview` | | `npm run astro --help` | Get help using the Astro CLI | ## 👀 Want to learn more? Feel free to check out the [Astro documentation](https://docs.astro.build) or jump into our the [Astro Discord server](https://astro.build/chat). ## License MIT - © 2022 [Okiki Ojo](https://okikio.dev) ================================================ FILE: astro.config.ts ================================================ import { defineConfig } from 'astro/config'; // https://astro.build/config import Icons from 'unplugin-icons/vite'; import { FileSystemIconLoader } from 'unplugin-icons/loaders'; // https://astro.build/config import svelte from "@astrojs/svelte"; import tailwind from "@astrojs/tailwind"; import sitemap from "@astrojs/sitemap"; import serviceWorker from "astrojs-service-worker"; import adapter from "astro-auto-adapter"; import type { RangeRequestsPlugin as RangeRequestsPluginType } from "workbox-range-requests" import RangeRequestsPlugin, { urlPattern } from './range-requests.mjs'; // https://astro.build/config export default defineConfig({ site: "https://inthistweet.app", integrations: [ svelte(), tailwind(), sitemap({ customPages: ['https://inthistweet.app/'] }), // serviceWorker({ // // enableInDevelopment: true, // registration: { autoRegister: true }, // // @ts-ignore // workbox: { // skipWaiting: true, // clientsClaim: true, // additionalManifestEntries: [ // "/", // "https://inthistweet.app" // ], // // globDirectory: outDir, // globPatterns: ["**/*.{html,js,css,svg,ttf,woff2,png,webp,jpg,jpeg,wasm,ico,json,xml}"], // // ignoreURLParametersMatching: [/index\.html\?(.*)/, /\\?(.*)/], // cleanupOutdatedCaches: true, // // Define runtime caching rules. // runtimeCaching: [ // { // // Match any request that starts with https://api.producthunt.com, https://api.countapi.xyz, https://opencollective.com, etc... // urlPattern, // // Apply a network-first strategy. // handler: "NetworkFirst", // method: "GET", // options: { // cacheableResponse: { // statuses: [0, 200] // }, // plugins: [ // new RangeRequestsPlugin() as unknown as RangeRequestsPluginType // ], // matchOptions: { // ignoreSearch: true, // ignoreVary: true // } // } // }, // { // // Match any request that starts with https://api.producthunt.com, https://api.countapi.xyz, https://opencollective.com, etc... // urlPattern: // /(?:^https:\/\/(?:.*)\.twimg\.com)|(?:\/api\/twitter)|(?:\/take-measurement$)|(?:^https:\/\/((?:api\.producthunt\.com)|(?:api\.countapi\.xyz)|(?:opencollective\.com)|(?:giscus\.bundlejs\.com)))/, // // Apply a network-first strategy. // handler: "NetworkFirst", // method: "GET", // options: { // cacheableResponse: { // statuses: [0, 200] // }, // } // }, // { // // Match any request that ends with .png, .jpg, .jpeg, .svg, etc.... // urlPattern: // /workbox\-(.*)\.js|\.(?:png|jpg|jpeg|svg|webp|map|ts|wasm|css)$|^https:\/\/(?:cdn\.polyfill\.io)/, // // Apply a stale-while-revalidate strategy. // handler: "NetworkFirst", // method: "GET", // options: { // cacheableResponse: { // statuses: [0, 200] // } // } // }, // { // // Cache `monaco-editor` etc... // urlPattern: // /(?:(?:chunks|assets|favicon|fonts|giscus)\/(.*)$)/, // // Apply a network-first strategy. // handler: "NetworkFirst", // method: "GET", // options: { // cacheableResponse: { // statuses: [0, 200] // }, // } // }, // ] // } // }) ], output: "hybrid", adapter: await adapter(undefined, { "deno": { port: 4321 } }), vite: { plugins: [ Icons({ // experimental autoInstall: true, compiler: 'svelte', customCollections: { // a helper to load icons from the file system // files under `./assets/icons` with `.svg` extension will be loaded as it's file name // you can also provide a transform callback to change each icon (optional) 'local': FileSystemIconLoader( './src/icons', svg => svg.replace(/^; rel=preconnect Link: ; rel=preconnect Link: ; rel=preload; as=video; crossorigin=anonymous /ffmpeg Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ================================================ FILE: public/_redirects ================================================ /take-measurement https://analytics.bundlejs.com/api/collect 200 ================================================ FILE: public/favicon/browserconfig.xml ================================================ #232323 ================================================ FILE: public/manifest.json ================================================ { "name": "In this tweet", "short_name": "In this tweet", "id": "inthistweet", "start_url": "/?utm_source=pwa", "scope": "/", "dir": "ltr", "display": "standalone", "author": "@okikio", "theme_color": "#232323", "background_color": "#232323", "description": "✨ Futuristic ✨ twitter image, video, and gif downloader and FFmpeg video conversion playground, m3u8 to mp4, webm to mp4, mp4 to gif, etc... Enter a Tweet URL, click search, and download the images and videos in it.", "icons": [ { "src": "/favicon/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" }, { "src": "/favicon/maskable_icon_x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { "src": "/favicon/maskable_icon_x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, { "src": "/favicon/maskable_icon.png", "sizes": "1024x1024", "type": "image/png", "purpose": "maskable" }, { "src": "/favicon/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/favicon/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "lang": "en", "screenshots": [ { "src": "/screenshot-mobile-1.png", "type": "image/png", "sizes": "1082x2400", "form_factor": "narrow" }, { "src": "/screenshot-mobile-2.png", "type": "image/png", "sizes": "1082x2400", "form_factor": "narrow" }, { "src": "/screenshot-desktop-1.png", "type": "image/png", "sizes": "3206x2184", "form_factor": "wide" }, { "src": "/screenshot-desktop-2.png", "type": "image/png", "sizes": "3206x2184", "form_factor": "wide" } ], "share_target": { "url_template": "/?q={q}&utm_source=share", "action": "/", "method": "GET", "enctype": "application/x-www-form-urlencoded", "params": { "text": "q" } }, "orientation": "natural", "categories": [ "productivity", "utility", "text", "social", "photo", "photo & video", "video", "multimedia", "developer tools" ] } ================================================ FILE: public/netlify.toml ================================================ [functions] directory = "dist/functions" ================================================ FILE: public/open-search.xml ================================================ inthistweet Quick and easy twitter image/video downloader. Enter a Tweet URL... https://inthistweet.app/favicon/favicon_x64.png UTF-8 hey@okikio.dev https://inthistweet.app ================================================ FILE: public/robots.txt ================================================ Sitemap: https://inthistweet.app/sitemap-index.xml User-agent: * Allow: / ================================================ FILE: range-requests.mjs ================================================ /* * This method throws if the supplied value is not an array. * The destructed values are required to produce a meaningful error for users. * The destructed and restructured object is so it's clear what is * needed. */ const isArray = (value, details) => { if (!Array.isArray(value)) { throw (({ moduleName, className, funcName, paramName }) => { if (!moduleName || !className || !funcName || !paramName) { throw new Error(`Unexpected input to 'not-an-array' error.`); } return (`The parameter '${paramName}' passed into ` + `'${moduleName}.${className}.${funcName}()' must be an array.`); })(details); } }; const hasMethod = (object, expectedMethod, details) => { const type = typeof object[expectedMethod]; if (type !== 'function') { details['expectedMethod'] = expectedMethod; throw (({ expectedMethod, paramName, moduleName, className, funcName, }) => { if (!expectedMethod || !paramName || !moduleName || !className || !funcName) { throw new Error(`Unexpected input to 'missing-a-method' error.`); } return (`${moduleName}.${className}.${funcName}() expected the ` + `'${paramName}' parameter to expose a '${expectedMethod}' method.`); })(details); } }; const isType = (object, expectedType, details) => { if (typeof object !== expectedType) { details['expectedType'] = expectedType; throw (({ paramName, moduleName, className, funcName, }) => { if (!expectedType || !paramName || !moduleName || !funcName) { throw new Error(`Unexpected input to 'incorrect-type' error.`); } const classNameStr = className ? `${className}.` : ''; return (`The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}` + `${funcName}()' must be of type ${expectedType}.`); })(details); } }; const isInstance = (object, // Need the general type to do the check later. // eslint-disable-next-line @typescript-eslint/ban-types expectedClass, details) => { if (!(object instanceof expectedClass)) { details['expectedClassName'] = expectedClass.name; throw (({ expectedClassName, paramName, moduleName, className, funcName, isReturnValueProblem, }) => { if (!expectedClassName || !moduleName || !funcName) { throw new Error(`Unexpected input to 'incorrect-class' error.`); } const classNameStr = className ? `${className}.` : ''; if (isReturnValueProblem) { return (`The return value from ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`); } return (`The parameter '${paramName}' passed into ` + `'${moduleName}.${classNameStr}${funcName}()' ` + `must be an instance of class ${expectedClassName}.`); })(details); } }; const isOneOf = (value, validValues, details) => { if (!validValues.includes(value)) { details['validValueDescription'] = `Valid values are ${JSON.stringify(validValues)}.`; throw (({ paramName, validValueDescription, value }) => { if (!paramName || !validValueDescription) { throw new Error(`Unexpected input to 'invalid-value' error.`); } return (`The '${paramName}' parameter was given a value with an ` + `unexpected value. ${validValueDescription} Received a value of ` + `${JSON.stringify(value)}.`); })(details); } }; const isArrayOfClass = (value, // Need general type to do check later. expectedClass, // eslint-disable-line details) => { const error = (({ value, moduleName, className, funcName, paramName, }) => { return (`The supplied '${paramName}' parameter must be an array of ` + `'${expectedClass}' objects. Received '${JSON.stringify(value)},'. ` + `Please check the call to ${moduleName}.${className}.${funcName}() ` + `to fix the issue.`); })(details); if (!Array.isArray(value)) { throw error; } for (const item of value) { if (!(item instanceof expectedClass)) { throw error; } } }; const assert = process.env.NODE_ENV === 'production' ? null : { hasMethod, isArray, isInstance, isOneOf, isType, isArrayOfClass, }; const logger = (process.env.NODE_ENV === 'production' ? null : (() => { // Don't overwrite this value if it's already set. // See https://github.com/GoogleChrome/workbox/pull/2284#issuecomment-560470923 if (!('__WB_DISABLE_DEV_LOGS' in globalThis)) { // @ts-ignore globalThis.__WB_DISABLE_DEV_LOGS = false; } let inGroup = false; const methodToColorMap = { debug: `#7f8c8d`, log: `#2ecc71`, warn: `#f39c12`, error: `#c0392b`, groupCollapsed: `#3498db`, groupEnd: null, // No colored prefix on groupEnd }; const print = function (method, args) { // @ts-ignore if (globalThis.__WB_DISABLE_DEV_LOGS) { return; } if (method === 'groupCollapsed') { // Safari doesn't print all console.groupCollapsed() arguments: // https://bugs.webkit.org/show_bug.cgi?id=182754 if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { // @ts-ignore console[method](...args); return; } } const styles = [ `background: ${methodToColorMap[method]}`, `border-radius: 0.5em`, `color: white`, `font-weight: bold`, `padding: 2px 0.5em`, ]; // When in a group, the workbox prefix is not displayed. const logPrefix = inGroup ? [] : ['%cworkbox', styles.join(';')]; // @ts-ignore console[method](...logPrefix, ...args); if (method === 'groupCollapsed') { inGroup = true; } if (method === 'groupEnd') { inGroup = false; } }; // eslint-disable-next-line @typescript-eslint/ban-types const api = {}; const loggerMethods = Object.keys(methodToColorMap); for (const key of loggerMethods) { const method = key; api[method] = (...args) => { print(method, args); }; } return api; })()); /** * @param {string} rangeHeader A Range: header value. * @return {Object} An object with `start` and `end` properties, reflecting * the parsed value of the Range: header. If either the `start` or `end` are * omitted, then `null` will be returned. * * @private */ export function parseRangeHeader(rangeHeader) { const normalizedRangeHeader = rangeHeader.trim().toLowerCase(); if (!normalizedRangeHeader.startsWith('bytes=')) { throw (({ normalizedRangeHeader }) => { if (!normalizedRangeHeader) { throw new Error(`Unexpected input to 'unit-must-be-bytes' error.`); } return (`The 'unit' portion of the Range header must be set to 'bytes'. ` + `The Range header provided was "${normalizedRangeHeader}"`) })({ normalizedRangeHeader }); } // Specifying multiple ranges separate by commas is valid syntax, but this // library only attempts to handle a single, contiguous sequence of bytes. // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range#Syntax if (normalizedRangeHeader.includes(',')) { throw (({ normalizedRangeHeader }) => { if (!normalizedRangeHeader) { throw new Error(`Unexpected input to 'single-range-only' error.`); } return (`Multiple ranges are not supported. Please use a single start ` + `value, and optional end value. The Range header provided was ` + `"${normalizedRangeHeader}"`); })({ normalizedRangeHeader }); } const rangeParts = /(\d*)-(\d*)/.exec(normalizedRangeHeader); // We need either at least one of the start or end values. if (!rangeParts || !(rangeParts[1] || rangeParts[2])) { throw (({ normalizedRangeHeader }) => { if (!normalizedRangeHeader) { throw new Error(`Unexpected input to 'invalid-range-values' error.`); } return (`The Range header is missing both start and end values. At least ` + `one of those values is needed. The Range header provided was ` + `"${normalizedRangeHeader}"`); })({ normalizedRangeHeader }); } return { start: rangeParts[1] === '' ? undefined : Number(rangeParts[1]), end: rangeParts[2] === '' ? undefined : Number(rangeParts[2]), }; } /** * @param {Blob} blob A source blob. * @param {number} [start] The offset to use as the start of the * slice. * @param {number} [end] The offset to use as the end of the slice. * @return {Object} An object with `start` and `end` properties, reflecting * the effective boundaries to use given the size of the blob. * * @private */ export function calculateEffectiveBoundaries(blob, start, end) { if (process.env.NODE_ENV !== 'production') { assert.isInstance(blob, Blob, { moduleName: 'workbox-range-requests', funcName: 'calculateEffectiveBoundaries', paramName: 'blob', }); } const blobSize = blob.size; if ((end && end > blobSize) || (start && start < 0)) { throw ((start, end, size ) => { return (`The start (${start}) and end (${end}) values in the Range are ` + `not satisfiable by the cached response, which is ${size} bytes.`); })({ start, end, size: blobSize }); } let effectiveStart; let effectiveEnd; if (start !== undefined && end !== undefined) { effectiveStart = start; // Range values are inclusive, so add 1 to the value. effectiveEnd = end + 1; } else if (start !== undefined && end === undefined) { effectiveStart = start; effectiveEnd = blobSize; } else if (end !== undefined && start === undefined) { effectiveStart = blobSize - end; effectiveEnd = blobSize; } return { start: effectiveStart, end: effectiveEnd, }; } /** * Given a `Request` and `Response` objects as input, this will return a * promise for a new `Response`. * * If the original `Response` already contains partial content (i.e. it has * a status of 206), then this assumes it already fulfills the `Range:` * requirements, and will return it as-is. * * @param {Request} request A request, which should contain a Range: * header. * @param {Response} originalResponse A response. * @return {Promise} Either a `206 Partial Content` response, with * the response body set to the slice of content specified by the request's * `Range:` header, or a `416 Range Not Satisfiable` response if the * conditions of the `Range:` header can't be met. * * @memberof workbox-range-requests */ export async function createPartialResponse(request, originalResponse) { try { if (originalResponse.status === 206) { // If we already have a 206, then just pass it through as-is; // see https://github.com/GoogleChrome/workbox/issues/1720 return originalResponse; } const rangeHeader = request.headers.get('range'); if (!rangeHeader) { throw (() => { return `No Range header was found in the Request provided.`; })(); } const boundaries = parseRangeHeader(rangeHeader); const originalBlob = await originalResponse.blob(); const effectiveBoundaries = calculateEffectiveBoundaries(originalBlob, boundaries.start, boundaries.end); const slicedBlob = originalBlob.slice(effectiveBoundaries.start, effectiveBoundaries.end); const slicedBlobSize = slicedBlob.size; const slicedResponse = new Response(slicedBlob, { // Status code 206 is for a Partial Content response. // See https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/206 status: 206, statusText: 'Partial Content', headers: originalResponse.headers, }); slicedResponse.headers.set('Content-Length', String(slicedBlobSize)); slicedResponse.headers.set('Content-Range', `bytes ${effectiveBoundaries.start}-${effectiveBoundaries.end - 1}/` + `${originalBlob.size}`); return slicedResponse; } catch (error) { if (process.env.NODE_ENV !== 'production') { logger.warn(`Unable to construct a partial response; returning a ` + `416 Range Not Satisfiable response instead.`); logger.groupCollapsed(`View details here.`); logger.log(error); logger.log(request); logger.log(originalResponse); logger.groupEnd(); } return new Response('', { status: 416, statusText: 'Range Not Satisfiable', }); } } /** * The range request plugin makes it easy for a request with a 'Range' header to * be fulfilled by a cached response. * * It does this by intercepting the `cachedResponseWillBeUsed` plugin callback * and returning the appropriate subset of the cached response body. * * @memberof workbox-range-requests */ export default class RangeRequestsPlugin { /** * @param {Object} options * @param {Request} options.request The original request, which may or may not * contain a Range: header. * @param {Response} options.cachedResponse The complete cached response. * @return {Promise} If request contains a 'Range' header, then a * new response with status 206 whose body is a subset of `cachedResponse` is * returned. Otherwise, `cachedResponse` is returned as-is. * * @private */ cachedResponseWillBeUsed = async ({ request, cachedResponse, }) => { // Only return a sliced response if there's something valid in the cache, // and there's a Range: header in the request. if (cachedResponse && request.headers.has('range')) { return await createPartialResponse(request, cachedResponse); } // If there was no Range: header, or if cachedResponse wasn't valid, just // pass it through as-is. return cachedResponse; }; } export function urlPattern({ request }) { const { destination } = request; return destination === 'video' || destination === 'audio'; } ================================================ FILE: repl.ts ================================================ // const json = await (await fetch("https://publish.twitter.com/oembed?url=https%3A%2F%2Ftwitter.com%2FCharlesPattson%2Fstatus%2F1697322795510759929&partner=&hide_thread=false")).json(); // https://cdn.syndication.twimg.com/tweet-result?features=tfw_timeline_list:;tfw_follower_count_sunset:true;tfw_tweet_edit_backend:on;tfw_refsrc_session:on;tfw_fosnr_soft_interventions_enabled:on;tfw_mixed_media_15897:treatment;tfw_experiments_cookie_expiration:1209600;tfw_show_birdwatch_pivots_enabled:on;tfw_duplicate_scribes_to_settings:on;tfw_use_profile_image_shape_enabled:on;tfw_video_hls_dynamic_manifests_15082:true_bitrate;tfw_legacy_timeline_sunset:true;tfw_tweet_edit_frontend:on&id=1697322795510759929&lang=en&token=444aooo9l2p&t8n1my=1aelk268vkd6&jn4oy2=8u9sk80svd23&wyo0fg=az18l82li41u&jtfsbw=ml8o7k16vky&it05xq=usii8m1tw0cd&35hnfy=80jor8y168vl&pplbo6=3qx25o8mtbgy&n8mica=p5a08ccc6lj const json = await(await fetch("https://cdn.syndication.twimg.com/tweet-result?id=1697322795510759929&lang=en&token=5")).json(); /* =1aelk268vkd6&jn4oy2=8u9sk80svd23&wyo0fg=az18l82li41u&jtfsbw=ml8o7k16vky&it05xq=usii8m1tw0cd&35hnfy=80jor8y168vl&pplbo6=3qx25o8mtbgy&n8mica=p5a08ccc6lj */ console.log({ json }) ================================================ FILE: src/components/custom-toast.svelte ================================================
{#if toast.icon}
{toast.icon}
{:else if toast.toastType === 'loading'}
{:else if toast.toastType === 'success'}
{:else if toast.toastType === 'error'}
{/if}
{resolveValue(toast.messageStr, toast)}
{#if toast.toastType === 'update'} {/if}
================================================ FILE: src/components/ffmpeg.svelte ================================================
{#if value && value.length > 0} { (value = ""); if (consoleView) { let transaction = consoleView.state.update({ changes: [{ from: 0, to: consoleView.state.doc.length }] }) consoleView.dispatch(transaction) } }} aria-label="Clear Search Button" > {/if} {#if $loading && !$initializing } { abortCtlr.abort(); if (ffmpeg && ffmpeg?.isLoaded?.()) { ffmpeg?.exit?.(); (async () => { loading.set(true); initializing.set(true); await ffmpeg?.load?.(); loading.set(false); initializing.set(false); })(); } }} > {:else} {/if}
{#if value && value.length > 0 && validURL(value)} {/if}
Examples
{#each samplesArr as sample, i} {/each}
Advanced FFmpeg Config
Logs
Results
{#if typeof $error == "string"} {$error} {:else if $loading || $initializing || results.length <= 0} {$loading || $initializing ? "Loading..." + (!$initializing ? Math.round($progress * 100) + "%" : "") : "Empty..."} {/if}
{#each $resultsDep as { url, type }, i (url)} {#if url && type && url.length > 0}
{#if type == "video" || type == "audio"} {:else} {/if}
{/if} {/each}
Convert videos and images into gifs, vice-versa, etc...

I created this as a side-quest to
  1. Try using FFmpeg WASM (FFmpeg's WASM package feels older than I am, probs due Emscripten 🤷‍♂️)
  2. Plus, it seemed pretty neat to be able convert a gif to mp4 and vice-versa without needing to open it to a server.

Thanks @fbritoferreira for creating fbritoferreira/m3u8-parser it really is a beautiful library.

The playground uses @ffmpeg.wasm/main. FFmpeg and the corresponding media format libraries are licenced under GPL.
================================================ FILE: src/components/ffmpeg.ts ================================================ import type { createFFmpeg as ffmpegCreate, CreateFFmpegOptions } from "@ffmpeg.wasm/main/src/index"; import type { StreamParser } from "@codemirror/language" declare module globalThis { var FFmpeg: { createFFmpeg: typeof ffmpegCreate } } const FFmpeg = globalThis?.FFmpeg ?? {}; /* * Create ffmpeg instance. * Each ffmpeg instance owns an isolated MEMFS and works * independently. * * For example: * * ``` * const ffmpeg = createFFmpeg({ * log: true, * logger: () => {}, * progress: () => {}, * corePath: '', * }) * ``` * * For the usage of these four arguments, check config.js * */ export const createFFmpeg = (options?: CreateFFmpegOptions) => FFmpeg.createFFmpeg(options); /** * Helper function for fetching files from various resources. * Sometimes the video/audio file you want to process may be located * in a remote URL or somewhere in your local file system. * * This helper function helps you to fetch the file and return an * Uint8Array variable for ffmpeg.wasm to consume. * * @param {string | ArrayBuffer | Blob | File} data - The data to be fetched. * @returns {Promise} - The fetched data as a Uint8Array. */ export async function fetchFile(data: string | ArrayBuffer | Blob | File, opts?: RequestInit): Promise { if (typeof data === 'string') { const response = await fetch(data, opts); const buffer = await response.arrayBuffer(); return new Uint8Array(buffer); } else if (data instanceof ArrayBuffer) { return Promise.resolve(new Uint8Array(data)); } else { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { if (event.target) { const { result } = event.target; if (result instanceof ArrayBuffer) { resolve(new Uint8Array(result)); } else { reject(new TypeError('Unexpected result type')); } } else { reject(new Error('FileReader event target is missing')); } }; reader.onerror = (error) => { reject(error); }; reader.readAsArrayBuffer(data as Blob); }); } } // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. /** * Splits the given array into chunks of the given size and returns them. * * @example * ```ts * import { chunk } from "https://deno.land/std@$STD_VERSION/collections/chunk.ts"; * import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"; * * const words = [ * "lorem", * "ipsum", * "dolor", * "sit", * "amet", * "consetetur", * "sadipscing", * ]; * const chunks = chunk(words, 3); * * assertEquals( * chunks, * [ * ["lorem", "ipsum", "dolor"], * ["sit", "amet", "consetetur"], * ["sadipscing"], * ], * ); * ``` */ export function chunk(array: readonly T[], size: number): T[][] { if (size <= 0 || !Number.isInteger(size)) { throw new Error( `Expected size to be an integer greater than 0 but found ${size}`, ); } if (array.length === 0) { return []; } const ret = Array.from({ length: Math.ceil(array.length / size) }); let readIndex = 0; let writeIndex = 0; while (readIndex < array.length) { ret[writeIndex] = array.slice(readIndex, readIndex + size); writeIndex += 1; readIndex += size; } return ret; } /** * Returns the difference between two arrays (unique elements in array1 that are not present in array2). * * @template T - The type of the elements in the input arrays. * @param {T[]} arr1 - The first input array. * @param {T[]} arr2 - The second input array. * @returns {T[]} - An array containing the unique elements of array1 not present in array2. */ export function diff(arr1: readonly T[], arr2: readonly T[]): T[] { const a = new Set(arr1); const b = new Set(arr2); return Array.from([...a].filter(x => !b.has(x))); } var words: Record = {}; function define(style: string, dict: string | any[]) { for (var i = 0; i < dict.length; i++) { words[dict[i]] = style; } }; var commonAtoms = ["true", "false"]; var commonKeywords = ["if", "then", "do", "else", "elif", "while", "until", "for", "in", "esac", "fi", "fin", "fil", "done", "exit", "set", "unset", "export", "function"]; var commonCommands = ["ab", "awk", "bash", "beep", "cat", "cc", "cd", "chown", "chmod", "chroot", "clear", "cp", "curl", "cut", "diff", "echo", "find", "gawk", "gcc", "get", "git", "grep", "hg", "kill", "killall", "ln", "ls", "make", "mkdir", "openssl", "mv", "nc", "nl", "node", "npm", "ping", "ps", "restart", "rm", "rmdir", "sed", "service", "sh", "shopt", "shred", "source", "sort", "sleep", "ssh", "start", "stop", "su", "sudo", "svn", "tee", "telnet", "top", "touch", "vi", "vim", "wall", "wc", "wget", "who", "write", "yes", "zsh"]; define('atom', commonAtoms); define('keyword', commonKeywords); define('builtin', commonCommands); function tokenBase(stream: { eatSpace: () => any; sol: () => any; next: () => string; eat: (arg0: string) => string; skipToEnd: () => void; eatWhile: (arg0: RegExp) => void; match: (arg0: string | RegExp) => any; eol: () => any; peek: () => string; current: () => any; }, state: { tokens: { (stream: any, state: any): any; (stream: any, state: any): any; (stream: any, state: any): string; }[]; }) { if (stream.eatSpace()) return null; var sol = stream.sol(); var ch = stream.next(); if (ch === '\\') { stream.next(); return null; } if (ch === '\'' || ch === '"' || ch === '`') { state.tokens.unshift(tokenString(ch, ch === "`" ? "quote" : "string")); return tokenize(stream, state); } if (ch === '#') { if (sol && stream.eat('!')) { stream.skipToEnd(); return 'meta'; // 'comment'? } stream.skipToEnd(); return 'comment'; } if (ch === '$') { state.tokens.unshift(tokenDollar); return tokenize(stream, state); } if (ch === '+' || ch === '=') { return 'operator'; } if (ch === '-') { stream.eat('-'); stream.eatWhile(/\w/); return 'attribute'; } if (ch == "<") { if (stream.match("<<")) return "operator" var heredoc = stream.match(/^<-?\s*['"]?([^'"]*)['"]?/) if (heredoc) { state.tokens.unshift(tokenHeredoc(heredoc[1])) return 'string.special' } } if (/\d/.test(ch)) { stream.eatWhile(/\d/); if (stream.eol() || !/\w/.test(stream.peek())) { return 'number'; } } stream.eatWhile(/[\w-]/); var cur = stream.current(); if (stream.peek() === '=' && /\w+/.test(cur)) return 'def'; return words.hasOwnProperty(cur) ? words[cur] : null; } function tokenString(quote: string, style: string) { var close = quote == "(" ? ")" : quote == "{" ? "}" : quote return function (stream: { next: () => any; peek: () => any; backUp: (arg0: number) => void; }, state: { tokens: { (stream: any, state: any): any; (stream: any, state: any): any; (stream: any, state: any): any; }[]; }) { var next, escaped = false; while ((next = stream.next()) != null) { if (next === close && !escaped) { state.tokens.shift(); break; } else if (next === '$' && !escaped && quote !== "'" && stream.peek() != close) { escaped = true; stream.backUp(1); state.tokens.unshift(tokenDollar); break; } else if (!escaped && quote !== close && next === quote) { state.tokens.unshift(tokenString(quote, style)) return tokenize(stream, state) } else if (!escaped && /['"]/.test(next) && !/['"]/.test(quote)) { state.tokens.unshift(tokenStringStart(next, "string")); stream.backUp(1); break; } escaped = !escaped && next === '\\'; } return style; }; }; function tokenStringStart(quote: any, style: string) { return function (stream: { next: () => void; }, state: { tokens: ((stream: any, state: any) => any)[]; }) { state.tokens[0] = tokenString(quote, style) stream.next() return tokenize(stream, state) } } var tokenDollar = function (stream: { eat: (arg0: string) => void; next: () => any; eatWhile: (arg0: RegExp) => void; }, state: { tokens: ((stream: any, state: any) => any)[] | void[]; }) { if (state.tokens.length > 1) stream.eat('$'); var ch = stream.next() if (/['"({]/.test(ch)) { state.tokens[0] = tokenString(ch, ch == "(" ? "quote" : ch == "{" ? "def" : "string"); return tokenize(stream, state); } if (!/\d/.test(ch)) stream.eatWhile(/\w/); state.tokens.shift(); return 'def'; }; function tokenHeredoc(delim: any) { return function (stream: { sol: () => any; string: any; skipToEnd: () => void; }, state: { tokens: void[]; }) { if (stream.sol() && stream.string == delim) state.tokens.shift() stream.skipToEnd() return "string.special" } } function tokenize(stream: any, state: { tokens: any[]; }) { return (state.tokens[0] || tokenBase)(stream, state); }; export const shell: StreamParser = { name: "shell", startState: function () { return { tokens: [] }; }, token: function (stream: any, state: any) { return tokenize(stream, state); }, languageData: { autocomplete: commonAtoms.concat(commonKeywords, commonCommands), closeBrackets: { brackets: ["(", "[", "{", "'", '"', "`"] }, commentTokens: { line: "#" } } }; // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. /** * A debounced function that will be delayed by a given `wait` * time in milliseconds. If the method is called again before * the timeout expires, the previous call will be aborted. */ export interface DebouncedFunction> { (...args: T): void; /** Clears the debounce timeout and omits calling the debounced function. */ clear(): void; /** Clears the debounce timeout and calls the debounced function immediately. */ flush(): void; /** Returns a boolean whether a debounce call is pending or not. */ readonly pending: boolean; } /** * Creates a debounced function that delays the given `func` * by a given `wait` time in milliseconds. If the method is called * again before the timeout expires, the previous call will be * aborted. * * @example * ``` * import { debounce } from "https://deno.land/std@$STD_VERSION/async/debounce.ts"; * * const log = debounce( * (event: Deno.FsEvent) => * console.log("[%s] %s", event.kind, event.paths[0]), * 200, * ); * * for await (const event of Deno.watchFs("./")) { * log(event); * } * // wait 200ms ... * // output: Function debounced after 200ms with baz * ``` * * @param fn The function to debounce. * @param wait The time in milliseconds to delay the function. */ // deno-lint-ignore no-explicit-any export function debounce>( fn: (this: DebouncedFunction, ...args: T) => void, wait: number, ): DebouncedFunction { let timeout: number | null = null; let flush: (() => void) | null = null; const debounced: DebouncedFunction = ((...args: T) => { debounced.clear(); flush = () => { debounced.clear(); fn.call(debounced, ...args); }; // @ts-ignore timeout = setTimeout(flush, wait); }) as DebouncedFunction; debounced.clear = () => { if (typeof timeout === "number") { clearTimeout(timeout); timeout = null; flush = null; } }; debounced.flush = () => { flush?.(); }; Object.defineProperty(debounced, "pending", { get: () => typeof timeout === "number", }); return debounced; } // const m3u8 = new M3U8Parser({ playlist: toText }); // await Promise.all( // m3u8.getPlaylist().items.map(async ({url}) => { // if (url) { // console.log(new URL(url, value).href) // ffmpeg.FS('writeFile', url, await fetchFile(new URL(url, value).href)); // } // }) // ); // console.log({ // m3u8, // getPlalist: m3u8.getPlaylist(), // }) ================================================ FILE: src/components/register-sw.svelte ================================================ ================================================ FILE: src/components/search.svelte ================================================
{#if value && value.length > 0} (value = "")} aria-label="Clear Search Button" > {/if}
{#if value && value.length > 0} {/if}
Examples: {#each samples as sample, i} {/each}
Results
{#if !(results.length > 0) || $error !== null} {#if typeof $error == "string"} {$error} {:else} {loading ? "Loading" : "Empty"}... {/if} {:else}
{#each results as { type, variants } (JSON.stringify(variants ?? "[]"))} {@const variant = variants[0]} {#if variant.url && type && variant.url.length > 0} {#if type == "video" || /video/.test(variant.mimeType)} {:else} {/if} {/if} {/each}
{/if}
You can store the videos, images, and gifs, share them and/or, create a meme from the them, the world is your oyester.

Download videos and images for gifs, gallary tweets, quote tweets, normal video and image posts and even the preview images for links, it can handle it all.

I created this as a side-quest to
  1. Try using fluent-svelte (Fluent UI is pretty cool)
  2. Use Astro to create a tool people may like to use
  3. I found the other twitter video and image downloaders kinda sus, thus, an open-source tool for downloading twitter videos and images (la pièce de résistance).
================================================ FILE: src/components/service-worker.ts ================================================ import type { Workbox as TypeWorkbox } from "workbox-window"; import * as workbox from "workbox-window"; import { writable } from "svelte/store"; const { Workbox, messageSW } = workbox; export interface RegisterSWOptions { immediate?: boolean onNeedRefresh?: (wb?: TypeWorkbox) => void onOfflineReady?: (wb?: TypeWorkbox) => void onRegistered?: (registration: ServiceWorkerRegistration | undefined, wb?: TypeWorkbox) => void onRegisterError?: (error: Error, wb?: TypeWorkbox) => void } const ServiceWorkerUrl = "/service-worker.js"; // __SW_AUTO_UPDATE__ will be replaced by virtual module // const autoUpdateMode = "false"; // '__SW_AUTO_UPDATE__' // __SW_SELF_DESTROYING__ will be replaced by virtual module // const selfDestroying = "false"; // '__SW_SELF_DESTROYING__' const auto = false; // autoUpdateMode === "true"; const autoDestroy = false; // selfDestroying === "true"; export function registerSW(options: RegisterSWOptions = {}) { const { immediate = false, onNeedRefresh, onOfflineReady, onRegistered, onRegisterError, } = options; let wb: TypeWorkbox | undefined; let registration: ServiceWorkerRegistration | undefined; const updateServiceWorker = async (reloadPage = true) => { if (!auto) { // Assuming the user accepted the update, set up a listener // that will reload the page as soon as the previously waiting // service worker has taken control. if (reloadPage) { wb?.addEventListener("controlling", (event) => { if (event.isUpdate) { window.location.reload(); } }); } if (registration && registration.waiting) { // Send a message to the waiting service worker, // instructing it to activate. // Note: for this to work, you have to add a message // listener in your service worker. See below. await messageSW(registration.waiting, { type: "SKIP_WAITING" }); } } }; if ("serviceWorker" in navigator) { // __SW__, __SCOPE__ and __TYPE__ will be replaced by virtual module wb = new Workbox(ServiceWorkerUrl); wb.addEventListener("activated", (event) => { // this will only controls the offline request. // event.isUpdate will be true if another version of the service // worker was controlling the page when this version was registered. if (event.isUpdate) auto && window.location.reload(); else if (!autoDestroy) onOfflineReady?.(wb); }); if (!auto) { const showSkipWaitingPrompt = () => { // \`event.wasWaitingBeforeRegister\` will be false if this is // the first time the updated service worker is waiting. // When \`event.wasWaitingBeforeRegister\` is true, a previously // updated service worker is still waiting. // You may want to customize the UI prompt accordingly. // Assumes your app has some sort of prompt UI element // that a user can either accept or reject. onNeedRefresh?.(wb); }; // Add an event listener to detect when the registered // service worker has installed but is waiting to activate. wb.addEventListener("waiting", showSkipWaitingPrompt); // @ts-expect-error event listener provided by workbox-window wb.addEventListener("externalwaiting", showSkipWaitingPrompt); } // register the service worker wb.register({ immediate }).then((r) => { registration = r; onRegistered?.(r, wb); }).catch((e) => { onRegisterError?.(e, wb); }); } return updateServiceWorker; } export function createServiceWorker(options: RegisterSWOptions = {}) { const { immediate = true, onNeedRefresh, onOfflineReady, onRegistered, onRegisterError, } = options; const needRefresh = writable(false); const offlineReady = writable(false); const updateServiceWorker = registerSW({ immediate, onOfflineReady() { needRefresh.set(true); onOfflineReady?.(); }, onNeedRefresh() { needRefresh.set(true); onNeedRefresh?.(); }, onRegistered, onRegisterError, }); return { needRefresh, offlineReady, updateServiceWorker, }; } ================================================ FILE: src/components/transition.ts ================================================ import { cubicInOut } from "svelte/easing"; export function blur(node, { delay = 0, duration = 400, easing = cubicInOut, amount = 5, opacity = 0 } = {}) { const style = getComputedStyle(node); const target_opacity = +style.opacity; const f = style.filter === 'none' ? '' : style.filter; const od = target_opacity * (1 - opacity); return { delay, duration, easing, css: (_t, u) => `opacity: ${target_opacity - (od * u)}; filter: ${f} blur(${u * amount}px);` }; } ================================================ FILE: src/env.d.ts ================================================ /// /// /// ================================================ FILE: src/layouts/Layout.astro ================================================ --- import "fluent-svelte/theme.css"; import "@fontsource-variable/inter-tight/index.css"; import "@fontsource-variable/inter/index.css"; // import RegisterSw from "../components/register-sw.svelte"; export interface Props { title: string; description: string; preload: boolean; } const { title, description, preload } = Astro.props; --- {title} ================================================ FILE: src/lib/codemirror.ts ================================================ import type { EditorStateConfig } from "@codemirror/state"; import { EditorView } from "codemirror"; import { writable } from "svelte/store"; import { onMount } from "svelte"; export function createCodeMirror(editorState: EditorStateConfig, parentEl?: HTMLElement) { console.log({ editorState }) return new EditorView({ parent: parentEl, ...editorState, }) } ================================================ FILE: src/lib/ffmpeg.ts ================================================ // import { FFmpeg } from "../../node_modules/.pnpm/@ffmpeg+ffmpeg@0.12.7/node_modules/@ffmpeg/ffmpeg/dist/esm/classes.js"; import { FFmpeg } from "@ffmpeg/ffmpeg"; import FFmpegCoreUrl from "../../node_modules/@ffmpeg/core-mt/dist/umd/ffmpeg-core.js?url"; import FFmpegWASMUrl from "../../node_modules/@ffmpeg/core-mt/dist/umd/ffmpeg-core.wasm?url"; import FFmpegWorkerUrl from "../../node_modules/@ffmpeg/core-mt/dist/umd/ffmpeg-core.worker.js?url"; // import FFmpegCoreUrl from "@ffmpeg/core-mt?url"; // import FFmpegWASMUrl from "@ffmpeg/core-mt/wasm?url"; // import FFmpegWorkerUrl from "./vendor/worker.ts?url"; // import FFmpegWorkerUrl from "@ffmpeg/core-mt/dist/esm/ffmpeg-core.worker.js?url"; // import FFmpegCoreUrl from "../../node_modules/@ffmpeg/core-mt/dist/esm/ffmpeg-core.js?url"; // import FFmpegWASMUrl from "../../node_modules/@ffmpeg/core-mt/dist/esm/ffmpeg-core.wasm?url"; // import FFmpegWorkerUrl from "../../node_modules/@ffmpeg/core-mt/dist/umd/ffmpeg-core.worker.js?url"; // import FFmpegWorkerRaw from "../../node_modules/@ffmpeg/core-mt/dist/esm/ffmpeg-core.worker.js?raw"; import { toBlobURL, toDataUrl } from "./utils/url.ts" type FFmpegLoadParams = Parameters<(typeof FFmpeg)['prototype']['load']>; type FFMessageLoadConfig = FFmpegLoadParams[0]; type FFMessageOptions = FFmpegLoadParams[1]; /* * Create ffmpeg instance. * Each ffmpeg instance owns an isolated MEMFS and works * independently. * * For example: * * ``` * const ffmpeg = createFFmpeg({ * log: true, * logger: () => {}, * progress: () => {}, * corePath: '', * }) * ``` * * For the usage of these four arguments, check config.js * */ export async function createFFmpeg (config?: FFMessageLoadConfig, opts?: FFMessageOptions) { const ffmpegInstance = new FFmpeg(); const initialConfig: FFMessageLoadConfig = { coreURL: FFmpegCoreUrl, wasmURL: FFmpegWASMUrl, // workerURL: toDataUrl(FFmpegWorkerRaw, "text/javascript"), workerURL: FFmpegWorkerUrl, // coreURL: await toBlobURL(FFmpegCoreUrl, 'text/javascript'), // wasmURL: await toBlobURL(FFmpegWASMUrl, 'application/wasm'), // workerURL: await toBlobURL(FFmpegWorkerUrl, 'text/javascript'), ...config } console.log(initialConfig) await ffmpegInstance.load(initialConfig, opts); return ffmpegInstance; } /** * An util function to fetch data from url string, base64, URL, File or Blob format. * * Examples: * ```ts * // URL * await fetchFile("http://localhost:3000/video.mp4"); * // base64 * await fetchFile("data:;base64,wL2dvYWwgbW9yZ..."); * // URL * await fetchFile(new URL("video.mp4", import.meta.url)); * // File * fileInput.addEventListener('change', (e) => { * await fetchFile(e.target.files[0]); * }); * // Blob * const blob = new Blob(...); * await fetchFile(blob); * ``` */ /** * Helper function for fetching files from various resources. * Sometimes the video/audio file you want to process may be located * in a remote URL or somewhere in your local file system. * * This helper function helps you to fetch the file and return an * Uint8Array variable for ffmpeg.wasm to consume. * * @param {string | ArrayBuffer | Blob | File} data - The data to be fetched. * @returns {Promise} - The fetched data as a Uint8Array. */ export async function fetchFile(data: string | ArrayBuffer | Blob | File, opts?: RequestInit): Promise { if (typeof data === 'string') { const response = await fetch(data, opts); const buffer = await response.arrayBuffer(); return new Uint8Array(buffer); } else if (data instanceof ArrayBuffer) { return Promise.resolve(new Uint8Array(data)); } else { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = (event) => { if (event.target) { const { result } = event.target; if (result instanceof ArrayBuffer) { resolve(new Uint8Array(result)); } else { reject(new TypeError('Unexpected result type')); } } else { reject(new Error('FileReader event target is missing')); } }; reader.onerror = (error) => { reject(error); }; reader.readAsArrayBuffer(data as Blob); }); } } ================================================ FILE: src/lib/get-tweet.ts ================================================ // Based on `react-tweet` (https://github.com/vercel/react-tweet) and `download-twitter-video` (https://github.com/egoist/download-twitter-video) import type { ImageValue, TwitterCard, UnifiedCardData } from '../types/card.ts'; import type { Tweet, MediaDetails, TweetParent, QuotedTweet, CardMediaEntity } from '../types/index.ts'; import "urlpattern-polyfill" export const EMBED_API_URL = "https://cdn.syndication.twimg.com"; /** * Custom error class for handling Twitter API errors. */ export class TwitterApiError extends Error { status: number data: any constructor({ message, status, data, }: { message: string status: number data: any }) { super(message) this.name = 'TwitterApiError' this.status = status this.data = data } } /** * Represents an item of media associated with a tweet, such as a photo or video. */ export interface MediaItem { type: string; // Type of the media item (e.g., photo, video) variants: MediaVariant[]; // Different variants of the media item } /** * Represents a variant of media included in a tweet, with details like URL and quality. * Think of a variant as a version of media, * e.g. the various qualties of videos, * e.g. a video variant of 360p and 720p, etc... */ export interface MediaVariant { url: string; quality: string; // Resolution quality (e.g., '720p', '1080p') aspectRatio: string; // Aspect ratio, important for display purposes mimeType: string; // MIME type, useful for rendering decisions fileSizeInBytes?: number; // Optional file size information altText?: string; // Optional alternative text for accessibility } /** * Approximates the resolution quality of a video based on its bitrate. * Higher bitrates generally indicate higher video quality. * @param bitrate - The bitrate of the video in bits per second. * @returns A string representing the approximated resolution (e.g., '720p', '1080p'). */ export function approximateResolution(bitrate: number): string { // Resolution is approximated based on common bitrate thresholds // Add more thresholds if needed to handle different resolutions if (bitrate > 5000000) return '1080p'; if (bitrate > 2000000) return '720p'; if (bitrate > 1000000) return '480p'; if (bitrate > 500000) return '360p'; return '240p'; } /** * Sorts media variants based on the type of media. * For photos, sorts by aspect ratio; for videos and GIFs, sorts by quality. * @param variants - Array of media variants to be sorted. * @param type - The type of media (photo, video, animated_gif). * @returns Sorted array of media variants. */ export const sortVariants = (variants: MediaVariant[], type: "photo" | "video" | "animated_gif" | (string & {})): MediaVariant[] => { // Sorting logic differs based on the media type // For example, photos might be sorted by aspect ratio for optimal display // Videos and GIFs are sorted by quality for best viewing experience switch (type) { case 'photo': // Sort by aspect ratio return variants.sort((a, b) => { const ratioA = aspectRatioToFloat(a.aspectRatio); const ratioB = aspectRatioToFloat(b.aspectRatio); return ratioB - ratioA; // Descending order }); case 'video': case 'animated_gif': // Sort by quality (high to low) return variants.sort((a, b) => qualityToNumber(b.quality) - qualityToNumber(a.quality)); default: return variants; } }; // Utility functions for internal calculations // aspectRatioToFloat and qualityToNumber help in sorting and comparing media variants // Converts aspect ratio string to a float for comparison export const aspectRatioToFloat = (aspectRatio: string): number => { const [width, height] = aspectRatio.split(':').map(Number); return width / height; }; // Converts quality string to a number for comparison export const qualityToNumber = (quality: string): number => { const qualityMap: { [key: string]: number } = { '1080p': 1080, '720p': 720, // Add more mappings as needed 'default': 0, }; return qualityMap[quality] || qualityMap['default']; }; /** * Processes and extracts media variants from a given MediaDetails object. * This function handles different types of media (photo, video, animated_gif) * and extracts relevant information for each type. * @param media - The MediaDetails object containing media information. * @returns Array of extracted media variants. */ const extractVariants = (media: MediaDetails) => { // The function handles different media types distinctly // For photos, it extracts JPEG format data // For videos and GIFs, it processes each variant and sorts them const variants: MediaVariant[] = []; switch (media.type) { case 'photo': // For photos, we assume a JPEG format; adjust as needed variants.push({ url: media.media_url_https, quality: 'original', aspectRatio: `${media.original_info.width}:${media.original_info.height}`, mimeType: 'image/jpeg', altText: media.ext_alt_text, }); break; case 'video': case 'animated_gif': // For videos and animated GIFs, sort and process each variant media.video_info.variants .filter(variant => variant.content_type === 'video/mp4') .sort((a, b) => (b.bitrate ?? 0) - (a.bitrate ?? 0)) .forEach(variant => { variants.push({ url: variant.url, quality: approximateResolution(variant.bitrate ?? 0), aspectRatio: media.video_info.aspect_ratio.join(':'), mimeType: variant.content_type, // Note: File size is not provided by the API }); }); break; } return sortVariants(variants, media.type) }; /** * Extracts media items from a Twitter card object. * Cards are used for non-conventional features of a tweet, like carousel ads or YouTube embeds. * The function handles different types of cards, including default embeds and unified cards. * @param card - The TwitterCard object containing card information. * @returns Array of MediaItem objects extracted from the card. */ const extractCardMedia = (card: TwitterCard) => { // The function parses and extracts media from different card types // It handles unified cards that contain a carousel of items // Each media item is processed and added to the result const additionalItems: MediaItem[] = []; const cards = card.binding_values; for (const key in cards) { const value = cards?.[key]; if (value && value?.image_value) { const image: ImageValue = value.image_value; const index = additionalItems.length; if (additionalItems.length <= 0) { additionalItems.push({ type: "photo", variants: [] }) } additionalItems?.[index]?.variants.push({ url: image.url, quality: 'original', // Twitter cards do not provide different qualities aspectRatio: `${image.width}:${image.height}`, mimeType: 'image/jpeg', // Assuming JPEG; adjust as needed // altText and fileSizeInBytes are not provided in the card }); } if (key === "unified_card" && value && value?.string_value) { // Attempt to parse the unified_card data from the card's binding_values const unifiedCard = value; try { // Parsing the stringified JSON data of the unified_card const unifiedCardData: UnifiedCardData = JSON.parse(unifiedCard?.string_value!); // Extracting media_entities from the unified_card // These entities provide a mapping from media IDs to media details const mediaEntities = unifiedCardData?.media_entities ?? {}; const componentObjects = unifiedCardData?.component_objects ?? {}; // Iterating over component objects to extract media references Array.from(Object.entries(componentObjects) ?? [])?.forEach(([, component]) => { if (component.type === "media" && component.data && component.data.id) { // Finding the media details using the media ID in the component data const mediaId = component.data.id; const media = mediaEntities[mediaId] as unknown as (MediaDetails & CardMediaEntity); if (media) { additionalItems.push({ type: media.type, variants: extractVariants(media) }) } } }); } catch (error) { console.error("Error parsing unified card data:", error); } } } // Sorting by width and height (larger images first as a proxy for higher quality) return additionalItems.map(({ type, variants }): MediaItem => { return { type, variants: sortVariants(variants, type) } }); }; /** * Extracts and formats media details from a tweet object. * This includes media from the main tweet, quoted tweets, parent tweets, and associated cards. * @param tweet - The tweet object to extract media from. * @returns An array of formatted MediaItem objects. */ export function extractAndFormatMedia(tweet: Tweet | TweetParent | QuotedTweet): MediaItem[] { // This function is a comprehensive handler for all media in a tweet // It ensures all media types (including from cards) are processed let mediaItems: MediaItem[] = []; // Process each media in the tweet // Extract media from the tweet, including quoted and parent tweets tweet?.mediaDetails?.forEach(media => { mediaItems.push({ type: media.type, variants: extractVariants(media) }); }); // Just-in case there's an edge case when const quoted_tweet = tweet?.quoted_tweet; const parent_tweet = tweet?.parent; quoted_tweet?.mediaDetails?.forEach(media => { mediaItems.push({ type: media.type, variants: extractVariants(media) }); }); parent_tweet?.mediaDetails?.forEach(media => { mediaItems.push({ type: media.type, variants: extractVariants(media) }); }); // Extract media from Twitter card if available if (tweet.card) { const cardMedia = extractCardMedia(tweet.card!); mediaItems = mediaItems.concat(cardMedia); } // Extract media from Twitter card if available if (quoted_tweet?.card) { const cardMedia = extractCardMedia(quoted_tweet.card!); mediaItems = mediaItems.concat(cardMedia); } // Extract media from Twitter card if available if (parent_tweet?.card) { const cardMedia = extractCardMedia(parent_tweet.card!); mediaItems = mediaItems.concat(cardMedia); } return mediaItems; } /** * Fetches tweet embed data from the provided URL. * Validates the URL and retrieves data using the Twitter syndication API. * @param url - The URL of the tweet to fetch embed data for. * @returns The embed data for the tweet, if available. */ export async function fetchEmbeddedTweet(url: string) { // This function interacts with the Twitter API to fetch embed data // It includes validations and error handling specific to Twitter's API const parsedURL = new URL(url); if (!/(ads-twitter\.com|periscope\.tv|pscp\.tv|t\.co|tweetdeck\.com|twimg\.com|twitpic\.com|twitter\.co|twitter\.com|twitterinc\.com|twitteroauth\.com|twitterstat\.us|twttr\.com|x\.com|fixupx\.com|fxtwitter\.com)/.test(parsedURL.hostname)) { throw new Error(`Invalid URL. "${url}" is not a twitter url`) } // Support all the various Twitter URLs parsedURL.hostname = "twitter.com"; const urlpattern = new URLPattern('http{s}?://twitter.com/:user/status/:id{/}??*'); const exec = urlpattern.exec(parsedURL.href); if (exec) { const id = exec.pathname.groups.id; const url = new URL(`${EMBED_API_URL}/tweet-result`) // https://cdn.syndication.twimg.com/tweet-result?features=tfw_timeline_list:;tfw_follower_count_sunset:true;tfw_tweet_edit_backend:on;tfw_refsrc_session:on;tfw_fosnr_soft_interventions_enabled:on;tfw_mixed_media_15897:treatment;tfw_experiments_cookie_expiration:1209600;tfw_show_birdwatch_pivots_enabled:on;tfw_duplicate_scribes_to_settings:on;tfw_use_profile_image_shape_enabled:on;tfw_video_hls_dynamic_manifests_15082:true_bitrate;tfw_legacy_timeline_sunset:true;tfw_tweet_edit_frontend:on&id=1754889044423741926&lang=en&token=49559whuarh&ovdffi=l78enholvprp&oreqaz=tlkb0e85f7bc&yb0xad=1fu83tf85hizn&3prkjy=16jgl8eyj9k5e&qr0fey=f8d8rnms2ae&nsosme=f8phiqfk7hr5&mwu396=17vqt618zs5d url.searchParams.set('id', id!) url.searchParams.set('lang', 'en') url.searchParams.set('token', '5') url.searchParams.set( 'features', [ 'tfw_timeline_list:', 'tfw_follower_count_sunset:true', 'tfw_tweet_edit_backend:on', 'tfw_refsrc_session:on', 'tfw_fosnr_soft_interventions_enabled:on', 'tfw_mixed_media_15897:treatment', 'tfw_experiments_cookie_expiration:1209600', 'tfw_show_birdwatch_pivots_enabled:on', 'tfw_use_profile_image_shape_enabled:on', 'tfw_video_hls_dynamic_manifests_15082:true_bitrate', 'tfw_show_business_verified_badge:on', 'tfw_duplicate_scribes_to_settings:on', 'tfw_show_blue_verified_badge:on', 'tfw_legacy_timeline_sunset:true', 'tfw_show_gov_verified_badge:on', 'tfw_show_business_affiliate_badge:on', 'tfw_tweet_edit_frontend:on', ].join(';') ) const res = await fetch(url); const isJson = res.headers.get('content-type')?.includes('application/json') const data = isJson ? await res.json() : undefined if (res.ok) return data if (res.status === 404) return throw new TwitterApiError({ message: typeof data.error === 'string' ? data.error : 'Bad request.', status: res.status, data, }) } } ================================================ FILE: src/lib/height.ts ================================================ import { writable } from "svelte/store"; export function syncHeight(el: HTMLElement, initial = 0) { return writable(initial, (set) => { if (!el) { return; } let ro = new ResizeObserver(() => { if (el) { return set(el.offsetHeight); } }); ro.observe(el); return () => ro.disconnect(); }); } ================================================ FILE: src/lib/m3u8/mod.ts ================================================ // From https://deno.land/x/m3u8@v0.8.0/src/mod.ts by @fbritoferreira // https://github.com/fbritoferreira/m3u8-parser/tree/main export { M3U8Parser } from "./parser.ts"; export { Attributes, Options, Parameters, PlaylistItemTvgValidator, PlaylistItemValidator, } from "./types.ts"; export type { ParsedLine, Playlist, PlaylistHeader, PlaylistItem, PlaylistItemTvg, } from "./types.ts"; export interface Manifest { allowCache: boolean; endList: boolean; mediaSequence: number; discontinuitySequence: number; playlistType: string; custom: Record; playlists: Array<{ attributes: Record; uri?: string; manifest: Manifest; }>; mediaGroups: { AUDIO: Record>; VIDEO: Record; 'CLOSED-CAPTIONS': Record; SUBTITLES: Record; }; dateTimeString: string; dateTimeObject: Date; targetDuration: number; totalDuration: number; discontinuityStarts: number[]; segments: Array<{ byterange: { length: number; offset: number; }; duration: number; attributes: Record; discontinuity: number; uri: string; timeline: number; key: { method: string; uri: string; iv: string; }; map: { uri: string; byterange: { length: number; offset: number; }; }; 'cue-out': string; 'cue-out-cont': string; 'cue-in': string; custom: Record; }>; } ================================================ FILE: src/lib/m3u8/parser.ts ================================================ import { Attributes, Options, Parameters, type ParsedLine, type Playlist, type PlaylistHeader, type PlaylistItem, PlaylistItemValidator, } from "./types.ts"; export class M3U8Parser { public rawPlaylist = ""; public filteredMap: Map = new Map(); public items: Map = new Map(); public header: PlaylistHeader = {} as PlaylistHeader; public groups: Set = new Set(); constructor({ playlist, url }: { playlist?: string; url?: string }) { if (playlist) { this.rawPlaylist = playlist; this.parse(playlist); } if (url) { this.fetchPlaylist({ url }); } } private parse(raw: string): void { let i = 0; const lines = raw.split("\n").map(this.parseLine); const firstLine = lines.find((l) => l.index === 0); if (!firstLine || !/^#EXTM3U/.test(firstLine.raw)) { throw new Error("Playlist is not valid"); } this.parseHeader(firstLine?.raw); for (const line of lines) { if (line.index === 0) continue; const string = line.raw.toString().trim(); if (string.startsWith("#EXTINF:")) { this.items.set(i, this.handleEXTINF(line)); } else if (string.startsWith("#EXTVLCOPT:")) { if (!this.items.get(i)) continue; this.handleEXTVLCOPT(string, i); } else if (string.startsWith("#EXTGRP:")) { if (!this.items.get(i)) continue; this.handleEXTGRP(string, i); } else { const item = this.items.get(i); if (!item) continue; const url = this.getUrl(string); const user_agent = this.getParameter(string, Parameters.USER_AGENT); const referrer = this.getParameter(string, Parameters.REFERER); this.groups.add(item.group.title); if (url) { this.items.set( i, PlaylistItemValidator.parse({ ...item, url, http: { ...item.http, user_agent, referrer, }, raw: this.mergeRaw(item, line), }), ); i++; } else { this.items.set( i, PlaylistItemValidator.parse({ ...item, raw: this.mergeRaw(item, line), }), ); } } } } private mergeRaw(item: PlaylistItem, line: ParsedLine | string) { if (typeof line === "string") { return item?.raw ? item.raw.concat(`\n${line}`) : `${line}`; } return item?.raw ? item.raw.concat(`\n${line.raw}`) : `${line.raw}`; } parseLine(line: string, index: number): ParsedLine { return { index, raw: line, }; } parseHeader(line: string) { const supportedAttrs = [Attributes.X_TVG_URL, Attributes.URL_TVG]; const attrs = new Map(); for (const attrName of supportedAttrs) { const tvgUrl = this.getAttribute(attrName, line); if (tvgUrl) { attrs.set(attrName, tvgUrl); } } this.header = { attrs: Object.fromEntries(attrs.entries()), raw: line, }; } private handleEXTGRP(line: string, index: number) { const item = this.items.get(index); if (!item) { return; } this.items.set( index, PlaylistItemValidator.parse({ ...item, group: { ...item.group, title: this.getValue(line) ?? item?.group.title, }, raw: this.mergeRaw(item, line), }), ); } private handleEXTVLCOPT(line: string, index: number) { const item = this.items.get(index); this.items.set( index, PlaylistItemValidator.parse({ ...item, http: { ...item?.http, "user-agent": this.getOption(line, Options.HTTP_USER_AGENT) ?? item?.http["user-agent"], referrer: this.getOption(line, Options.HTTP_REFERRER) ?? item?.http.referrer, }, raw: `\r\n${line}`, }), ); } private handleEXTINF(line: ParsedLine): PlaylistItem { return PlaylistItemValidator.parse({ name: this.getName(line.raw), tvg: { id: this.getAttribute(Attributes.TVG_ID, line.raw), name: this.getAttribute(Attributes.TVG_NAME, line.raw), logo: this.getAttribute(Attributes.TVG_LOGO, line.raw), url: this.getAttribute(Attributes.TVG_URL, line.raw), rec: this.getAttribute(Attributes.TVG_REC, line.raw), }, group: { title: this.getAttribute(Attributes.GROUP_TITLE, line.raw), }, http: { referrer: "", "user-agent": this.getAttribute(Attributes.USER_AGENT, line.raw), }, url: undefined, raw: line.raw, index: line.index + 1, catchup: { type: this.getAttribute(Attributes.CATCHUP, line.raw), days: this.getAttribute(Attributes.CATCHUP_DAYS, line.raw), source: this.getAttribute(Attributes.CATCHUP_SOURCE, line.raw), }, timeshift: this.getAttribute(Attributes.TIMESHIFT, line.raw), }); } private getAttribute(name: Attributes, line: string) { const regex = new RegExp(name + '="(.*?)"', "gi"); const match = regex.exec(line); return (match && match[1] ? match[1] : "")?.trimStart()?.trimEnd(); } private getName(line: string) { const name = line?.split(/[\r\n]+/)?.shift()?.split(",") .pop()?.trimStart()?.trimEnd(); return name || ""; } private getOption(line: string, name: Options) { const regex = new RegExp(":" + name + "=(.*)", "gi"); const match = regex.exec(line); return match && match[1] && typeof match[1] === "string" ? match[1].replace(/\"/g, "") : ""; } private getValue(line: string) { const regex = new RegExp(":(.*)", "gi"); const match = regex.exec(line); return match && match[1] && typeof match[1] === "string" ? match[1].replace(/\"/g, "") : ""; } private getUrl(line: string) { return line.split("|")[0] || ""; } private getParameter(line: string, name: Parameters) { const params = line.replace(/^(.*)\|/, ""); const regex = new RegExp(name + "=(\\w[^&]*)", "gi"); const match = regex.exec(params); return match && match[1] ? match[1] : ""; } public getPlaylist(): Playlist { return { header: this.header, items: Array.from(this.items.values()), raw: this.rawPlaylist, }; } public getPlaylistByGroup(group: string): Playlist { const key = group.split("").join("-"); const cached = this.filteredMap.get(key); if (cached) { return cached; } const playlist = { header: this.header, items: this.getPlaylistItems(group), }; this.filteredMap.set(key, playlist); return playlist; } private getPlaylistItems(group: string): PlaylistItem[] { return Array.from(this.items.values()).filter((item) => item?.group?.title?.toLowerCase().startsWith(group.toLowerCase()) ); } public getPlaylistsByGroups(groups: string[]): Playlist { const key = groups.join("-"); const cached = this.filteredMap.get(key); if (cached) { return cached; } const items = groups.reduce((acc: PlaylistItem[], group: string) => { const playlistItems = this.getPlaylistItems(group); return [ ...acc, ...playlistItems, ]; }, []); const playlist = { header: this.header, items, }; this.filteredMap.set(key, playlist); return playlist; } public get playlistGroups() { return Array.from(this.groups); } public write(): string { const playlist = this.getPlaylist(); return `${playlist.header.raw}\n`.concat( `${playlist.items.map((item) => item.raw).join("\n")}`, ); } public updateItems(items: Map) { this.items = items; } public updatePlaylist(playlist: Playlist) { const items = new Map(); let i = 0; if (playlist.items) { playlist.items.forEach((item) => { items.set(i, PlaylistItemValidator.parse(item)); i++; }); } this.items = items; } public async fetchPlaylist({ url }: { url: string }) { const response = await fetch(url); if (!response.ok) { throw new Error(`Failed to fetch playlist: ${response.status}`); } const playlist = await response.text(); this.rawPlaylist = playlist; this.parse(playlist); } public filterPlaylist( filters?: string[], ) { const groupsToFilter = filters?.map((filter) => this.playlistGroups.filter((p) => p.toLowerCase().startsWith(filter.toLowerCase()) ) ).flat(); if (groupsToFilter) { const filteredItems = this.getPlaylistsByGroups(groupsToFilter); this.updatePlaylist(filteredItems); } } } ================================================ FILE: src/lib/m3u8/traverse.ts ================================================ import { M3uMedia, M3uParser } from "m3u-parser-generator" import { urlToFilePath } from "./urls.ts"; /** * Converts an ArrayBuffer of an M3U8 file into a parsed representation. * * This function is crucial for processing M3U8 files, which are used for streaming media playlists. * It takes the raw binary data of the M3U8 file, decodes it to text, and then uses the m3u8Parser * library to parse the text into a structured format. The parsing process identifies and organizes * various elements of the M3U8 file like segments, playlists, etc., which are essential for * subsequent processing and traversal. * * The use of TextDecoder for converting ArrayBuffer to text ensures efficient handling of binary * data, especially for large files, which is common in streaming contexts. * * @param arrbuf - The ArrayBuffer containing the M3U8 file data. * @returns A parser object that represents the parsed structure of the M3U8 file. */ export function parseManifest(arrbuf: ArrayBuffer) { // Decode the ArrayBuffer to a text string using TextDecoder. const toText = new TextDecoder().decode(arrbuf); const playlist = M3uParser.parse(toText); return playlist; } /** * Traverse M3U8 manifests and fetches the referenced resources within a 2-minute limit. * * This function is optimized for performance by minimizing redundant array operations and * efficiently managing network requests. It processes an M3U8 file, fetching all unique URIs * referenced in it and any nested M3U8 files, and aborts if the operation exceeds 2 minutes. * * Performance is optimized by using Sets for deduplication and minimizing array transformations. * The function handles edge cases like undefined URIs and nested M3U8 files. * * @param arrbuf - An ArrayBuffer containing the contents of the M3U8 file. * @param baseUrl - The base URL used for resolving relative URIs in the M3U8 file. * @param batchSize - Batch the fetch requests in increments of `batchSize`, this is primarily used for resolving relative URIs in the M3U8 file. * @param timeout - Timeout for batch requests. */ export async function traverseM3U8Manifests(arrbuf: ArrayBuffer, baseUrl: URL, batchSize = 10, timeout = 120_000) { const abortCtrl = new AbortController(); const timeoutId = setTimeout(() => abortCtrl.abort(), timeout); // 2-minute timeout const fileMap = new Map() try { const playlist = parseManifest(arrbuf); const { medias } = playlist; const modifiedMedias: M3uMedia[] = []; // Initial deduplication of URIs using a Set to improve performance. const uris = new Set( (medias ?? []).map(item => { modifiedMedias.push({ ...item, location: urlToFilePath(item.location) }); return item.location; }).filter((uri): uri is string => uri !== undefined) ); const toProcess = Array.from(uris); // Array of URIs to process while (toProcess.length > 0) { // Processing URIs in batches to manage memory and network load. const batch = toProcess.splice(0, batchSize); // Fetching each URI in the batch in parallel for efficiency. const fetchedBuffers = await Promise.all(batch.map(async uri => { const url = new URL(uri, baseUrl); const buf = await fetch(url, { signal: abortCtrl.signal }).then(resp => resp.arrayBuffer()); return [url, buf] as const; })); // Post-fetch processing to parse nested M3U8 files and update URI lists. batch.forEach((uri, index) => { const [url, buf] = fetchedBuffers[index]; fileMap.set(urlToFilePath(uri), buf); // Checking and parsing nested M3U8 files for additional URIs. if (/\.(m3u8|m3u)$/.test(url.pathname)) { const subPlaylist = parseManifest(buf); const { medias: subMedias } = subPlaylist; const modifiedSubMedias: M3uMedia[] = []; (subMedias ?? []).forEach(item => { const _uri = item?.location; if (_uri && !uris.has(_uri)) { modifiedSubMedias.push({ ...item, location: urlToFilePath(item.location) }); uris.add(_uri); toProcess.push(_uri); // Adding new URIs for processing } }); subPlaylist.medias = modifiedSubMedias; fileMap.set(urlToFilePath(uri), new TextEncoder().encode(subPlaylist.getM3uString())); } }); } playlist.medias = modifiedMedias; fileMap.set(urlToFilePath(baseUrl.href), new TextEncoder().encode(playlist.getM3uString())); return fileMap } catch (error) { console.error("Error parsing M3U8 manifest:", error); // Error handling for network failures, parsing errors, etc. } finally { clearTimeout(timeoutId); // Cleaning up the timeout to prevent leaks } } ================================================ FILE: src/lib/m3u8/types.ts ================================================ import { z } from "zod"; export interface PlaylistHeader { attrs: { "x-tvg-url": string; }; raw: string; } export const PlaylistItemTvgValidator = z.object({ id: z.string(), name: z.string(), url: z.string(), logo: z.string(), rec: z.string(), }); export type PlaylistItemTvg = z.infer; export const PlaylistItemValidator = z.object({ name: z.string(), index: z.number(), tvg: PlaylistItemTvgValidator, group: z.object({ title: z.string(), }), http: z.object({ referrer: z.string(), "user-agent": z.string(), }), url: z.string().optional(), raw: z.string(), timeshift: z.string(), catchup: z.object({ type: z.string(), source: z.string(), days: z.string(), }), }); export type PlaylistItem = z.infer; export interface Playlist { header: PlaylistHeader; items: PlaylistItem[]; raw?: string; } export type ParsedLine = { index: number; raw: string; }; export enum Attributes { TVG_ID = "tvg-id", X_TVG_URL = "x-tvg-url", URL_TVG = "url-tvg", TVG_NAME = "tvg-name", TVG_LOGO = "tvg-logo", TVG_URL = "tvg-url", TVG_REC = "tvg-rec", GROUP_TITLE = "group-title", USER_AGENT = "user-agent", CATCHUP = "catchup", CATCHUP_DAYS = "catchup-days", CATCHUP_SOURCE = "catchup-source", TIMESHIFT = "timeshift", } export enum Options { HTTP_REFERRER = "http-referrer", HTTP_USER_AGENT = "http-user-agent", } export enum Parameters { USER_AGENT = "user-agent", REFERER = "referer", } ================================================ FILE: src/lib/m3u8/urls.ts ================================================ /** * Converts a URL to a file path including the origin. * * This function takes a URL and transforms it into a file path format. The origin of the URL * (protocol and domain) is included in the path, and special characters are handled to ensure * a valid file path is generated. This is useful for creating unique file paths based on URLs. * * @param urlStr - The URL string to be converted to a file path. * @returns A string representing the file path including the URL's origin. */ export function urlToFilePath(urlStr: string): string { const url = new URL(urlStr); // Replace special characters that are not valid in file paths. // Adjust the replacement logic based on your file system and requirements. const safePath = url.pathname.replace(/[^a-zA-Z0-9\-_\.\/]/g, '_'); // Combine the origin and the pathname to form the file path. // The origin replaces '://' with '_' and removes any trailing slashes for a cleaner path. return `${url.origin.replace(/[:\/]/g, '_')}${safePath}`; } ================================================ FILE: src/lib/path/mod.ts ================================================ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. // Non-alphabetic chars. export const CHAR_DOT = 46; /* . */ export const CHAR_FORWARD_SLASH = 47; /* / */ // Ported from https://github.com/browserify/path-browserify/ // This module is browser compatible. export function isPosixPathSeparator(code: number): boolean { return code === CHAR_FORWARD_SLASH; } export function assertPath(path: string) { if (typeof path !== "string") { throw new TypeError( `Path must be a string. Received ${JSON.stringify(path)}`, ); } } /** * Return the extension of the `path` with leading period. * @param path with extension * @returns extension (ex. for `file.ts` returns `.ts`) */ export function extname(path: string): string { assertPath(path); let startDot = -1; let startPart = 0; let end = -1; let matchedSlash = true; // Track the state of characters (if any) we see before our first dot and // after any path separator we find let preDotState = 0; for (let i = path.length - 1; i >= 0; --i) { const code = path.charCodeAt(i); if (isPosixPathSeparator(code)) { // If we reached a path separator that was not part of a set of path // separators at the end of the string, stop now if (!matchedSlash) { startPart = i + 1; break; } continue; } if (end === -1) { // We saw the first non-path separator, mark this as the end of our // extension matchedSlash = false; end = i + 1; } if (code === CHAR_DOT) { // If this is our first dot, mark it as the start of our extension if (startDot === -1) startDot = i; else if (preDotState !== 1) preDotState = 1; } else if (startDot !== -1) { // We saw a non-dot and non-path separator before our dot, so we should // have a good chance at having a non-empty extension preDotState = -1; } } if ( startDot === -1 || end === -1 || // We saw a non-dot character immediately before the dot preDotState === 0 || // The (right-most) trimmed path component is exactly '..' (preDotState === 1 && startDot === end - 1 && startDot === startPart + 1) ) { return ""; } return path.slice(startDot, end); } ================================================ FILE: src/lib/search.ts ================================================ import type { ChangeSpec } from "@codemirror/state"; // import type { FFmpeg } from "@ffmpeg/ffmpeg"; import type { FFmpeg } from "@ffmpeg.wasm/main"; import type { EditorView } from "codemirror"; import { get } from "svelte/store"; import { abortCtlr, error, loading, EMPTY_CONSOLE_TEXT } from "./state"; import { traverseM3U8Manifests } from "./m3u8/traverse"; import { urlToFilePath } from "./m3u8/urls"; import { transcode } from "./transcode"; import { fetchFile } from "../components/ffmpeg"; export async function onSearch(e?: Event, ffmpeg?: FFmpeg, value?: string, consoleView?: EditorView, popState = false) { e?.preventDefault?.(); abortCtlr.set(new AbortController()); error.set(null); if (!value) return; if (value && value.length <= 0) return; loading.set(true); try { if (!ffmpeg || !ffmpeg?.isLoaded?.()) return; const arrbuf = await fetchFile(value, { signal: get(abortCtlr).signal }); let inputArrBuf = arrbuf; let _url = new URL(value); console.log({ _url }); if (/\.(m3u8|m3u)$/.test(_url?.pathname)) { try { const map = await traverseM3U8Manifests(arrbuf.buffer, _url); if (map) { const modifiedInputArrBuf = map.get(urlToFilePath(_url.href)); if (modifiedInputArrBuf) inputArrBuf = new Uint8Array(modifiedInputArrBuf); map?.forEach?.((buf, url) => { if (!buf) return; try { ffmpeg.FS("writeFile", url, new Uint8Array(buf)); // ffmpeg.writeFile(url, new Uint8Array(buf)); if (!consoleView) return; const doc = consoleView.state.doc; let changes: ChangeSpec[] = []; if (doc.toString().trim() === EMPTY_CONSOLE_TEXT) { changes.push({ from: 0, to: doc.length }); } // (Assume view is an EditorView instance holding the document "123".) const message = `[url] ${url}\n`; changes.push({ from: doc.length, insert: message }); let transaction = consoleView.state.update({ changes }); // At this point the view still shows the old state. consoleView.dispatch(transaction); // And now it shows the new state. } catch (e) { console.log(url); console.warn(e); } }); } } catch (e) { console.warn(`Cannot parse "${value}" as m3u8 playlist`, e); } } await transcode({ target: { // @ts-ignore files: [inputArrBuf] } }, ffmpeg, value, popState); } catch (e) { error.set((e ?? "").toString()); console.warn(e); } finally { loading.set(false); } } ================================================ FILE: src/lib/shell-lang.ts ================================================ import type { StreamParser } from "@codemirror/language"; var words: Record = {}; function define(style: string, dict: string | any[]) { for (var i = 0; i < dict.length; i++) { words[dict[i]] = style; } }; var commonAtoms = ["true", "false"]; var commonKeywords = ["if", "then", "do", "else", "elif", "while", "until", "for", "in", "esac", "fi", "fin", "fil", "done", "exit", "set", "unset", "export", "function"]; var commonCommands = ["ab", "awk", "bash", "beep", "cat", "cc", "cd", "chown", "chmod", "chroot", "clear", "cp", "curl", "cut", "diff", "echo", "find", "gawk", "gcc", "get", "git", "grep", "hg", "kill", "killall", "ln", "ls", "make", "mkdir", "openssl", "mv", "nc", "nl", "node", "npm", "ping", "ps", "restart", "rm", "rmdir", "sed", "service", "sh", "shopt", "shred", "source", "sort", "sleep", "ssh", "start", "stop", "su", "sudo", "svn", "tee", "telnet", "top", "touch", "vi", "vim", "wall", "wc", "wget", "who", "write", "yes", "zsh"]; define('atom', commonAtoms); define('keyword', commonKeywords); define('builtin', commonCommands); function tokenBase(stream: { eatSpace: () => any; sol: () => any; next: () => string; eat: (arg0: string) => string; skipToEnd: () => void; eatWhile: (arg0: RegExp) => void; match: (arg0: string | RegExp) => any; eol: () => any; peek: () => string; current: () => any; }, state: { tokens: { (stream: any, state: any): any; (stream: any, state: any): any; (stream: any, state: any): string; }[]; }) { if (stream.eatSpace()) return null; var sol = stream.sol(); var ch = stream.next(); if (ch === '\\') { stream.next(); return null; } if (ch === '\'' || ch === '"' || ch === '`') { state.tokens.unshift(tokenString(ch, ch === "`" ? "quote" : "string")); return tokenize(stream, state); } if (ch === '#') { if (sol && stream.eat('!')) { stream.skipToEnd(); return 'meta'; // 'comment'? } stream.skipToEnd(); return 'comment'; } if (ch === '$') { state.tokens.unshift(tokenDollar); return tokenize(stream, state); } if (ch === '+' || ch === '=') { return 'operator'; } if (ch === '-') { stream.eat('-'); stream.eatWhile(/\w/); return 'attribute'; } if (ch == "<") { if (stream.match("<<")) return "operator" var heredoc = stream.match(/^<-?\s*['"]?([^'"]*)['"]?/) if (heredoc) { state.tokens.unshift(tokenHeredoc(heredoc[1])) return 'string.special' } } if (/\d/.test(ch)) { stream.eatWhile(/\d/); if (stream.eol() || !/\w/.test(stream.peek())) { return 'number'; } } stream.eatWhile(/[\w-]/); var cur = stream.current(); if (stream.peek() === '=' && /\w+/.test(cur)) return 'def'; return words.hasOwnProperty(cur) ? words[cur] : null; } function tokenString(quote: string, style: string) { var close = quote == "(" ? ")" : quote == "{" ? "}" : quote return function (stream: { next: () => any; peek: () => any; backUp: (arg0: number) => void; }, state: { tokens: { (stream: any, state: any): any; (stream: any, state: any): any; (stream: any, state: any): any; }[]; }) { var next, escaped = false; while ((next = stream.next()) != null) { if (next === close && !escaped) { state.tokens.shift(); break; } else if (next === '$' && !escaped && quote !== "'" && stream.peek() != close) { escaped = true; stream.backUp(1); state.tokens.unshift(tokenDollar); break; } else if (!escaped && quote !== close && next === quote) { state.tokens.unshift(tokenString(quote, style)) return tokenize(stream, state) } else if (!escaped && /['"]/.test(next) && !/['"]/.test(quote)) { state.tokens.unshift(tokenStringStart(next, "string")); stream.backUp(1); break; } escaped = !escaped && next === '\\'; } return style; }; }; function tokenStringStart(quote: any, style: string) { return function (stream: { next: () => void; }, state: { tokens: ((stream: any, state: any) => any)[]; }) { state.tokens[0] = tokenString(quote, style) stream.next() return tokenize(stream, state) } } var tokenDollar = function (stream: { eat: (arg0: string) => void; next: () => any; eatWhile: (arg0: RegExp) => void; }, state: { tokens: ((stream: any, state: any) => any)[] | void[]; }) { if (state.tokens.length > 1) stream.eat('$'); var ch = stream.next() if (/['"({]/.test(ch)) { state.tokens[0] = tokenString(ch, ch == "(" ? "quote" : ch == "{" ? "def" : "string"); return tokenize(stream, state); } if (!/\d/.test(ch)) stream.eatWhile(/\w/); state.tokens.shift(); return 'def'; }; function tokenHeredoc(delim: any) { return function (stream: { sol: () => any; string: any; skipToEnd: () => void; }, state: { tokens: void[]; }) { if (stream.sol() && stream.string == delim) state.tokens.shift() stream.skipToEnd() return "string.special" } } function tokenize(stream: any, state: { tokens: any[]; }) { return (state.tokens[0] || tokenBase)(stream, state); }; export const shell: StreamParser = { name: "shell", startState: function () { return { tokens: [] }; }, token: function (stream: any, state: any) { return tokenize(stream, state); }, languageData: { autocomplete: commonAtoms.concat(commonKeywords, commonCommands), closeBrackets: { brackets: ["(", "[", "{", "'", '"', "`"] }, commentTokens: { line: "#" } } }; ================================================ FILE: src/lib/state.ts ================================================ import { writable } from "svelte/store"; export interface FFmpegConfig { args: string[]; inFilename: string; outFilename: string; mediaType: string; forceUseArgs: string[] | null; } export const abortCtlr = writable(new AbortController()); export const progress = writable(0); export const loading = writable(false); export const initializing = writable(false); export const fileOpenMode = writable(false); export const error = writable(null); export const results = writable< Array<{ type?: string | null; url?: string | null }> >([]); export const samples = new Map([ [ "webm -> mp4", { args: ["-c:v", "libvpx"], inFilename: "video.webm", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: null, }, ], [ "avi -> mp4", { args: ["-c:v", "libx264"], inFilename: "video.avi", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: null, }, ], [ "mov -> mp4", { args: ["-vcodec", "copy", "-acodec", "copy"], inFilename: "video.mov", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: null, }, ], [ "wmv -> mp4", { args: [], inFilename: "video.wmv", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: null, }, ], [ "avi -> webm", { args: ["-c:v", "libvpx"], inFilename: "video.avi", outFilename: "video.webm", mediaType: "video/webm", forceUseArgs: null, }, ], [ "mp4 -> wmv", { args: [], inFilename: "video.mp4", outFilename: "video.wmv", mediaType: "video/x-ms-wmv", forceUseArgs: null, }, ], [ "gif -> mp4", { args: [ "-movflags", "faststart", "-pix_fmt", "yuv420p", "-vf", "scale=trunc(iw/2)*2:trunc(ih/2)*2", ], inFilename: "video.gif", outFilename: "image.mp4", mediaType: "video/mp4", forceUseArgs: null, }, ], [ "mp4 -> gif", { args: [], inFilename: "video.mp4", outFilename: "image.gif", mediaType: "image/gif", forceUseArgs: null, }, ], [ "mp3 -> mp4", { args: ["-c:v", "libvpx"], inFilename: "audio.mp3", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: null, }, ], [ "wav -> mp3", { args: ["-c:a", "libmp3lame"], inFilename: "audio.wav", outFilename: "audio.mp3", mediaType: "audio/mpeg", forceUseArgs: null, }, ], [ "mp4 -> mov", { args: ["-vcodec", "copy", "-acodec", "copy"], inFilename: "video.mp4", outFilename: "video.mov", mediaType: "video/quicktime", forceUseArgs: null, }, ], [ "mp4 -> mkv", { args: ["-c:v", "libvpx", "-c:a", "libvorbis"], inFilename: "video.mp4", outFilename: "video.mkv", mediaType: "video/x-matroska", forceUseArgs: null, }, ], [ "mp4 -> ogg", { args: ["-c:a", "libvorbis"], inFilename: "video.mp4", outFilename: "audio.ogg", mediaType: "audio/ogg", forceUseArgs: null, }, ], [ "webm -> mkv", { args: ["-c:v", "copy", "-c:a", "flac"], inFilename: "video.webm", outFilename: "video.mkv", mediaType: "video/x-matroska", forceUseArgs: null, }, ], [ "mp3 -> ogg", { args: ["-c:a", "libvorbis"], inFilename: "audio.mp3", outFilename: "audio.ogg", mediaType: "audio/ogg", forceUseArgs: null, }, ], [ "mp3 -> wav", { args: ["-c:a", "libmp3lame"], inFilename: "audio.mp3", outFilename: "audio.wav", mediaType: "video/x-ms-wmv", forceUseArgs: null, }, ], [ "mp4 -> mp3", { args: ["-c:a", "libmp3lame"], inFilename: "video.mp4", outFilename: "audio.mp3", mediaType: "audio/mpeg", forceUseArgs: null, }, ], [ "webm -> gif", { args: ["-crf", "20", "-movflags", "faststart"], inFilename: "video.webm", outFilename: "image.gif", mediaType: "image/gif", forceUseArgs: null, }, ], [ "gif -> webm", { args: [ "-c:v", "vp8", "-quality", "good", "-movflags", "faststart", "-pix_fmt", "yuv420p", "-crf", "30", ], inFilename: "image.gif", outFilename: "video.webm", mediaType: "video/webm", forceUseArgs: null, }, ], [ "mp4 -> webm", { args: "-c:v libvpx".split(" "), inFilename: "video.mp4", outFilename: "video.webm", mediaType: "video/webm", forceUseArgs: null, }, ], [ "mp4 -> avi", { args: ["-vcodec", "copy", "-acodec", "copy"], inFilename: "video.mp4", outFilename: "video.avi", mediaType: "video/x-msvideo", forceUseArgs: null, }, ], [ "webm -> avi", { args: ["-vcodec", "copy", "-acodec", "copy"], inFilename: "video.webm", outFilename: "video.avi", mediaType: "video/x-msvideo", forceUseArgs: null, }, ], [ "m3u8 -> mp4", { // args: ["-c", "copy", "-bsf:a", "aac_adtstoasc"], inFilename: "video.m3u8", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: [ "-protocol_whitelist", "file,http,https,tcp,tls,crypto", "-i", "video.m3u8", "-c", "copy", "-bsf:a", "aac_adtstoasc", "video.mp4", ], }, ], [ "mp4 -> m3u8", { args: "-b:v 1M -g 60 -hls_time 2 -hls_list_size 0 -hls_segment_size 500000".split( " " ), inFilename: "video.mp4", outFilename: "video.m3u8", mediaType: "vnd.apple.mpegURL", }, ], [ "mp4 -> ts", { args: [ "-c:v", "mpeg2video", "-qscale:v", "2", "-c:a", "mp2", "-b:a", "192k", ], inFilename: "video.mp4", outFilename: "video.ts", mediaType: "video/mp2t", }, ], ]); export const samplesArr = Array.from(samples.entries()); export const EMPTY_CONSOLE_TEXT = "No Logs..."; export const FFMPEG_DEFAULT_OPTS: FFmpegConfig = { args: ["-c:v", "libx264"], inFilename: "video.avi", outFilename: "video.mp4", mediaType: "video/mp4", forceUseArgs: null, }; export const ffmpegOpts = writable( Object.assign({}, FFMPEG_DEFAULT_OPTS) ); ================================================ FILE: src/lib/transcode.ts ================================================ // import type { FFmpeg } from "@ffmpeg/ffmpeg"; import type { FFmpeg } from "@ffmpeg.wasm/main"; import { get } from "svelte/store"; import { abortCtlr, error, ffmpegOpts, loading, results } from "./state"; import { fetchFile } from "../components/ffmpeg"; import { tryURL } from "./utils/url"; export async function transcode({ target }: Event & { currentTarget: EventTarget & HTMLInputElement }, ffmpeg: FFmpeg, value: string, popState = false) { const ffmpegOptions = get(ffmpegOpts); const { files } = target as HTMLInputElement; const file = files?.[0]; error.set(null); loading.set(true); try { if (!file) return; if (!ffmpeg || !ffmpeg?.isLoaded?.()) return; abortCtlr.set(new AbortController()); // await ffmpeg.writeFile( // ffmpegOptions.inFilename, // await fetchFile(file, { signal: get(abortCtlr).signal }) // ); await ffmpeg.FS( "writeFile", ffmpegOptions.inFilename, await fetchFile(file, { signal: get(abortCtlr).signal }) ); if (Array.isArray(ffmpegOptions.forceUseArgs)) { await ffmpeg.run(...ffmpegOptions.forceUseArgs); // await ffmpeg.exec(ffmpegOptions.forceUseArgs); } else { // await ffmpeg.exec([ // "-i", // ffmpegOptions.inFilename, // ...ffmpegOptions.args, // ffmpegOptions.outFilename, // ]); await ffmpeg.run(...[ "-i", ffmpegOptions.inFilename, ...ffmpegOptions.args, ffmpegOptions.outFilename, ]); } const { mediaType } = ffmpegOptions; // const data = await ffmpeg.readFile(ffmpegOptions.outFilename); const data = await ffmpeg.FS("readFile", ffmpegOptions.outFilename); const url = URL.createObjectURL( new Blob([data], { type: mediaType }) ); const tempResults = Array.from(get(results)); tempResults.unshift({ url, type: getMediaType(mediaType) }); results.set(tempResults); // ffmpeg.terminate(); ffmpeg.exit(); await ffmpeg.load(); if (!popState && tryURL(value)) { const newURL = new URL(globalThis.location.href); newURL.search = new URLSearchParams({ q: value, config: JSON.stringify(ffmpegOpts), }).toString(); globalThis?.history?.pushState?.(null, "", newURL); } } catch (e) { error.set((e ?? "").toString()); console.warn(e); } finally { loading.set(false); } } export function getMediaType(mediaType: string) { return ( (/^(video|audio)/.test(mediaType) || mediaType === "vnd.apple.mpegURL") ? "video" : "image" ) } ================================================ FILE: src/lib/utils/chunk.ts ================================================ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. /** * Splits the given array into chunks of the given size and returns them. * * @example * ```ts * import { chunk } from "https://deno.land/std@$STD_VERSION/collections/chunk.ts"; * import { assertEquals } from "https://deno.land/std@$STD_VERSION/testing/asserts.ts"; * * const words = [ * "lorem", * "ipsum", * "dolor", * "sit", * "amet", * "consetetur", * "sadipscing", * ]; * const chunks = chunk(words, 3); * * assertEquals( * chunks, * [ * ["lorem", "ipsum", "dolor"], * ["sit", "amet", "consetetur"], * ["sadipscing"], * ], * ); * ``` */ export function chunk(array: readonly T[], size: number): T[][] { if (size <= 0 || !Number.isInteger(size)) { throw new Error( `Expected size to be an integer greater than 0 but found ${size}`, ); } if (array.length === 0) { return []; } const ret = Array.from({ length: Math.ceil(array.length / size) }); let readIndex = 0; let writeIndex = 0; while (readIndex < array.length) { ret[writeIndex] = array.slice(readIndex, readIndex + size); writeIndex += 1; readIndex += size; } return ret; } ================================================ FILE: src/lib/utils/debounce.ts ================================================ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. // This module is browser compatible. /** * A debounced function that will be delayed by a given `wait` * time in milliseconds. If the method is called again before * the timeout expires, the previous call will be aborted. */ export interface DebouncedFunction> { (...args: T): void; /** Clears the debounce timeout and omits calling the debounced function. */ clear(): void; /** Clears the debounce timeout and calls the debounced function immediately. */ flush(): void; /** Returns a boolean whether a debounce call is pending or not. */ readonly pending: boolean; } /** * Creates a debounced function that delays the given `func` * by a given `wait` time in milliseconds. If the method is called * again before the timeout expires, the previous call will be * aborted. * * @example * ``` * import { debounce } from "https://deno.land/std@$STD_VERSION/async/debounce.ts"; * * const log = debounce( * (event: Deno.FsEvent) => * console.log("[%s] %s", event.kind, event.paths[0]), * 200, * ); * * for await (const event of Deno.watchFs("./")) { * log(event); * } * // wait 200ms ... * // output: Function debounced after 200ms with baz * ``` * * @param fn The function to debounce. * @param wait The time in milliseconds to delay the function. */ // deno-lint-ignore no-explicit-any export function debounce>( fn: (this: DebouncedFunction, ...args: T) => void, wait: number, ): DebouncedFunction { let timeout: number | null = null; let flush: (() => void) | null = null; const debounced: DebouncedFunction = ((...args: T) => { debounced.clear(); flush = () => { debounced.clear(); fn.call(debounced, ...args); }; // @ts-ignore timeout = setTimeout(flush, wait); }) as DebouncedFunction; debounced.clear = () => { if (typeof timeout === "number") { clearTimeout(timeout); timeout = null; flush = null; } }; debounced.flush = () => { flush?.(); }; Object.defineProperty(debounced, "pending", { get: () => typeof timeout === "number", }); return debounced; } ================================================ FILE: src/lib/utils/diff.ts ================================================ /** * Returns the difference between two arrays (unique elements in array1 that are not present in array2). * * @template T - The type of the elements in the input arrays. * @param {T[]} arr1 - The first input array. * @param {T[]} arr2 - The second input array. * @returns {T[]} - An array containing the unique elements of array1 not present in array2. */ export function diff(arr1: readonly T[], arr2: readonly T[]): T[] { const a = new Set(arr1); const b = new Set(arr2); return Array.from([...a].filter(x => !b.has(x))); } ================================================ FILE: src/lib/utils/url.ts ================================================ export const ERROR_RESPONSE_BODY_READER = new Error( "failed to get response body reader" ); export const ERROR_INCOMPLETED_DOWNLOAD = new Error( "failed to complete download" ); export const HeaderContentLength = "Content-Length"; export interface DownloadProgressEvent { url: string | URL; total: number; received: number; delta: number; done: boolean; } export type ProgressCallback = (event: DownloadProgressEvent) => void; export function tryURL(value: string) { try { new URL(value); return true; } catch (e) { } return false; } /** * Download content of a URL with progress. * * Progress only works when Content-Length is provided by the server. * */ export const downloadWithProgress = async ( url: string | URL, cb?: ProgressCallback ): Promise => { const resp = await fetch(url); let buf; try { // Set total to -1 to indicate that there is not Content-Type Header. const total = parseInt(resp.headers.get(HeaderContentLength) || "-1"); const reader = resp.body?.getReader(); if (!reader) throw ERROR_RESPONSE_BODY_READER; const chunks = []; let received = 0; for (; ;) { const { done, value } = await reader.read(); const delta = value ? value.length : 0; if (done) { if (total != -1 && total !== received) throw ERROR_INCOMPLETED_DOWNLOAD; cb && cb({ url, total, received, delta, done }); break; } chunks.push(value); received += delta; cb && cb({ url, total, received, delta, done }); } const data = new Uint8Array(received); let position = 0; for (const chunk of chunks) { data.set(chunk, position); position += chunk.length; } buf = data.buffer; } catch (e) { console.log(`failed to send download progress event: `, e); // Fetch arrayBuffer directly when it is not possible to get progress. buf = await resp.arrayBuffer(); cb && cb({ url, total: buf.byteLength, received: buf.byteLength, delta: 0, done: true, }); } return buf; }; /** * toBlobURL fetches data from an URL and return a blob URL. * * Example: * * ```ts * await toBlobURL("http://localhost:3000/ffmpeg.js", "text/javascript"); * ``` */ export const toBlobURL = async ( url: string, mimeType: string, progress = false, cb?: ProgressCallback ): Promise => { const buf = progress ? await downloadWithProgress(url, cb) : await (await fetch(url)).arrayBuffer(); const blob = new Blob([buf], { type: mimeType }); return URL.createObjectURL(blob); }; /** * Converts file content to a Base64-encoded data URL. * * This function takes the content of a file as a string and its MIME type, * then returns a data URL that represents the encoded content. Data URLs * can be used to embed the content directly into web documents or stylesheets. * * @param content - The content of the file as a string. * @param mimeType - The MIME type of the file, e.g., "image/png". * @returns The Base64-encoded data URL. * * @example * const imageUrl = toDataUrl('', 'image/png'); * console.log(imageUrl); // data:image/png;base64, */ export function toDataUrl(content: string, mimeType: string): string { // Encode the file content to Base64. We use btoa function which encodes // a string in base-64. This function is universally supported in JavaScript // environments, including Deno. It's important to ensure that the content // is properly encoded to avoid issues with binary data or special characters. const base64Content = btoa(content); // Construct the data URL by concatenating the parts together. // The format follows: "data:[];base64,[]" const dataUrl = `data:${mimeType};base64,${base64Content}`; return dataUrl; } ================================================ FILE: src/lib/vendor/core.ts ================================================ // @ts-ignore // import FFmpegCore from "../../../node_modules/@ffmpeg/core-mt/dist/esm/ffmpeg-core.js"; import FFmpegCore from "@ffmpeg/core-mt"; export { FFmpegCore } ================================================ FILE: src/lib/vendor/worker.ts ================================================ // @ts-ignore import "../../../node_modules/@ffmpeg/core-mt/dist/esm/ffmpeg-core.worker.js"; // import FFmpegWorker from "../../../node_modules/@ffmpeg/core-mt/dist/esm/ffmpeg-core.worker.js"; // export { FFmpegWorker } ================================================ FILE: src/pages/api/twitter/index.ts ================================================ import type { APIContext } from "astro"; import type { Tweet } from "../../../types/index"; import { extractAndFormatMedia, fetchEmbeddedTweet } from "../../../lib/get-tweet"; export const prerender = false; export async function GET({ url }: APIContext) { try { const _url = url?.searchParams?.get?.('url') ?? url?.searchParams?.get?.('q') ?? ''; console.log({ _url }) const tweet: Tweet = await fetchEmbeddedTweet(_url); const media = extractAndFormatMedia(tweet); return new Response(JSON.stringify(media), { status: 200, headers: { "Content-Type": "application/json", 'Cache-Control': 'public, max-age=604800' } }); } catch (e) { return new Response(JSON.stringify({ error: (e as Error).toString() }), { status: 400 }) } } ================================================ FILE: src/pages/ffmpeg.astro ================================================ --- import Layout from '../layouts/Layout.astro'; import FFmpegEditor from '../components/ffmpeg.svelte'; import ffmpegURL from "@ffmpeg.wasm/main/dist/ffmpeg.min.js?url"; import { Button, TextBlock, InfoBar } from 'fluent-svelte'; import Logo from "~icons/local/logo"; import ProductHuntLogo from "~icons/local/product-hunt-logo"; import FileIconsFfmpeg from '~icons/file-icons/ffmpeg'; import MdiGithub from '~icons/mdi/github'; import MdiTwitter from '~icons/mdi/twitter'; Astro.response.headers.set("Cross-Origin-Opener-Policy", "same-origin"); Astro.response.headers.set("Cross-Origin-Embedder-Policy", "require-corp"); export const prerender = false; ---
FFmpeg Playground
Enter a video or image URL, enter your ffmpeg config, click search, and enjoy. You can also open files by clicking the folder button.
================================================ FILE: src/pages/index.astro ================================================ --- import type { APIContext } from 'astro'; import Layout from '../layouts/Layout.astro'; import Search from '../components/search.svelte'; import { Button, TextBlock, InfoBar } from 'fluent-svelte'; import Logo from "~icons/local/logo"; import ProductHuntLogo from "~icons/local/product-hunt-logo"; import FileIconsFfmpeg from '~icons/file-icons/ffmpeg'; import MdiGithub from '~icons/mdi/github'; import MdiTwitter from '~icons/mdi/twitter'; Astro.response.headers.set("Cross-Origin-Opener-Policy", "unsafe-none"); Astro.response.headers.set("Cross-Origin-Embedder-Policy", "unsafe-none"); const url = Astro.url; const _url = url?.searchParams?.get?.('url') ?? url?.searchParams?.get?.('q') ?? ''; export const prerender = true; ---
In this tweet
Enter a Tweet URL, click search, and download the videos, gifs and images.
================================================ FILE: src/scripts/measure.ts ================================================ export const hook = (_this, method, callback: (...args: unknown[]) => unknown) => { const orig = _this[method]; return (...args) => { callback(...args); return orig.apply(_this, args); }; }; export const doNotTrack = () => { const { doNotTrack, navigator, external } = globalThis as typeof globalThis & { doNotTrack: boolean }; const msTrackProtection = "msTrackingProtectionEnabled"; const msTracking = () => { return external && msTrackProtection in external && external[msTrackProtection](); }; const dnt = doNotTrack || navigator.doNotTrack || msTracking(); return dnt == "1" || dnt === "yes"; }; export function removeTrailingSlash(url) { return url && url.length > 1 && url.endsWith("/") ? url.slice(0, -1) : url; } export default function (window: Window & typeof globalThis) { try { const apiRoute = "/take-measurement"; // "/api/collect"; const { screen: { width, height }, navigator: { language }, location: { hostname, pathname, search }, localStorage, document, history, } = window; // const script = document.querySelector('script[data-website-id]') as HTMLScriptElement; // if (!script) return; // const attr = script.getAttribute.bind(script); const attr = (id: string) => { return ({ "data-host-url": "https://inthistweet.app", "data-domains": "inthistweet.app,media.okikio.dev,okikio.dev,bundlejs.com,bundle.js.org,bundlesize.com", "data-website-id": "72683bf5-0839-42eb-84e4-5d34f619a31c" })[id]; }; const website = attr("data-website-id"); const hostUrl = attr("data-host-url"); const autoTrack = attr("data-auto-track") !== "false"; const dnt = attr("data-do-not-track"); const cssEvents = attr("data-css-events") !== "false"; const domain = attr("data-domains") || ""; const domains = domain.split(",").map(n => n.trim()); const eventClass = /^umami--([a-z]+)--([\w]+[\w-]*)$/; const eventSelect = "[class*='umami--']"; const trackingDisabled = () => (localStorage && localStorage.getItem("umami.disabled")) || (dnt && doNotTrack()) || (domain && !domains.includes(hostname)); const root = hostUrl ? removeTrailingSlash(hostUrl) : ""; // script.src.split('/').slice(0, -1).join('/'); const screen = `${width}x${height}`; const listeners = {}; let currentUrl = `${pathname}${search}`; let currentRef = document.referrer; let cache; /* Collect metrics */ const post = (url, data, callback) => { const req = new XMLHttpRequest(); req.open("POST", url, true); req.setRequestHeader("Content-Type", "application/json"); if (cache) req.setRequestHeader("x-umami-cache", cache); req.onreadystatechange = () => { if (req.readyState === 4) { callback(req.response); } }; req.send(JSON.stringify(data)); }; const getPayload = () => ({ website, hostname, screen, language, url: currentUrl, }); const assign = (a, b) => { Object.keys(b).forEach(key => { a[key] = b[key]; }); return a; }; const collect = (type, payload) => { if (trackingDisabled()) return; post( `${root}${apiRoute}`, { type, payload, }, res => (cache = res), ); }; const trackView = (url = currentUrl, referrer = currentRef, uuid = website) => { collect( "pageview", assign(getPayload(), { website: uuid, url, referrer, }), ); }; const trackEvent = (event_value, event_type = "custom", url = currentUrl, uuid = website) => { collect( "event", assign(getPayload(), { website: uuid, url, event_type, event_value, }), ); }; /* Handle events */ const sendEvent = (value, type) => { const payload = getPayload(); const data = JSON.stringify({ type: "event", payload: { ...payload, event_type: type, event_value: value }, }); navigator.sendBeacon(`${root}${apiRoute}`, data); }; const addEvents = node => { const elements = node.querySelectorAll(eventSelect); Array.prototype.forEach.call(elements, addEvent); }; const addEvent = element => { (element.getAttribute("class") || "").split(" ").forEach(className => { if (!eventClass.test(className)) return; const [, type, value] = className.split("--"); const listener = listeners[className] ? listeners[className] : (listeners[className] = () => { if (element.tagName === "A") { sendEvent(value, type); } else { trackEvent(value, type); } }); element.addEventListener(type, listener, true); }); }; /* Handle history changes */ const handlePush = (state, title, url) => { if (!url) return; currentRef = currentUrl; const newUrl = url.toString(); if (newUrl.substring(0, 4) === "http") { currentUrl = "/" + newUrl.split("/").splice(3).join("/"); } else { currentUrl = newUrl; } if (currentUrl !== currentRef) { trackView(); } }; const observeDocument = () => { const monitorMutate = mutations => { mutations.forEach(mutation => { const element = mutation.target; addEvent(element); addEvents(element); }); }; const observer = new MutationObserver(monitorMutate); observer.observe(document, { childList: true, subtree: true }); }; /* Global */ if (!(globalThis as typeof globalThis & { umami: object }).umami) { const umami = eventValue => trackEvent(eventValue); umami.trackView = trackView; umami.trackEvent = trackEvent; (globalThis as typeof globalThis & { umami: object }).umami = umami; } /* Start */ if (autoTrack && !trackingDisabled()) { history.pushState = hook(history, "pushState", handlePush); history.replaceState = hook(history, "replaceState", handlePush); const update = () => { if (document.readyState === "complete") { trackView(); if (cssEvents) { addEvents(document); observeDocument(); } } }; document.addEventListener("readystatechange", update, true); update(); } } catch (e) { console.warn(e) } }; ================================================ FILE: src/types/card.ts ================================================ import type { ImageColorValue, MediaDetails } from "./media.ts"; export interface TwitterCard { card_platform?: CardPlatform; name: string; url: string; binding_values: BindingValues; } export interface CardPlatform { platform: { audience: { name: string }; device: { name: string; version: string }; }; } export interface BindingValues { unified_card?: UnifiedCard; [key: string]: BindingValue | undefined; } export interface BindingValue { string_value?: string; image_value?: ImageValue; image_color_value?: ImageColorValue; type: "IMAGE" | "STRING" | (string & {}); scribe_key?: string; user_value?: UserValue; } export interface UnifiedCard extends BindingValue { string_value?: string; type: "STRING" } export interface ImageValue { height: number; width: number; url: string; } export interface UserValue { id_str: string; path: any[]; } // Represents the main structure of the unified_card data export interface UnifiedCardData { layout?: LayoutData; type?: string; // Example: "mixed_media_multi_dest_carousel_website" component_objects?: ComponentObjects; destination_objects: DestinationObjects; media_entities?: MediaEntities; } // Layout data structure export interface LayoutData { type: string; // Example: "swipeable" data: LayoutDataDetails; } // Specific details within the layout data export interface LayoutDataDetails { slides: Array>; // Array of arrays containing component keys } // Component objects within the unified_card export interface ComponentObjects { [key: string]: ComponentObject; } // Individual component object (e.g., media, details) export interface ComponentObject { type: string; // Example: "media" or "details" data: ComponentData; } // Data for each component object export interface ComponentData { // This structure will vary based on the type of the component // For media: { id: string, destination: string } // For details: { title: { content: string, is_rtl: boolean }, ... } [key: string]: any; } // Destination objects referenced in components export interface DestinationObjects { [key: string]: DestinationObject; } // Individual destination object export interface DestinationObject { type: string; // Example: "browser" data: DestinationData; } // Data for each destination object export interface DestinationData { url_data: { url: string; vanity: string; }; media_id?: string; // Present in case of browser_with_docked_media type } // Media entities mapping media IDs to media details export interface MediaEntities { [key: string]: CardMediaEntity; } // Represents a media entity export interface CardMediaEntity { id: number; id_str: string; media_url_https: string; type: "photo" | "video" | (string & {}); // Example: "photo" or "video" original_info: { width: number; height: number; focus_rects: Array; }; sizes: MediaSizes; } // Focus rectangles for media export interface FocusRect { x: number; y: number; w: number; h: number; } // Different size variants of media export interface MediaSizes { small: MediaSize; medium: MediaSize; large: MediaSize; thumb: MediaSize; } // Represents a single media size export interface MediaSize { w: number; h: number; resize: string; // Example: "fit" or "crop" } ================================================ FILE: src/types/edit.ts ================================================ export interface TweetEditControl { edit_tweet_ids: string[] editable_until_msecs: string is_edit_eligible: boolean edits_remaining: string } ================================================ FILE: src/types/entities.ts ================================================ export type Indices = [number, number] export interface HashtagEntity { indices: Indices text: string } export interface UserMentionEntity { id_str: string indices: Indices name: string screen_name: string } export interface MediaEntity { display_url: string expanded_url: string indices: Indices url: string } export interface UrlEntity { display_url: string expanded_url: string indices: Indices url: string } export interface SymbolEntity { indices: Indices text: string } export interface TweetEntities { hashtags: HashtagEntity[] urls: UrlEntity[] user_mentions: UserMentionEntity[] symbols: SymbolEntity[] media?: MediaEntity[] } ================================================ FILE: src/types/index.ts ================================================ export * from './edit.ts' export * from './entities.ts' export * from './media.ts' export * from './photo.ts' export * from './tweet.ts' export * from './user.ts' export * from './video.ts' export * from './card.ts' ================================================ FILE: src/types/media.ts ================================================ import type { Indices } from './entities.ts' export type RGB = { red: number green: number blue: number } export type Rect = { x: number y: number w: number h: number } export type Size = { h: number w: number resize: string } export interface VideoInfo { aspect_ratio: [number, number] variants: { bitrate?: number content_type: 'video/mp4' | 'application/x-mpegURL' url: string }[] } export interface ImageColorValue { palette: ColorPalette[]; } export interface ColorPalette { rgb: RGB; percentage: number; } interface MediaBase { display_url: string expanded_url: string ext_media_availability: { status: string } ext_media_color: ImageColorValue indices: Indices media_url_https: string original_info: { height: number width: number focus_rects: Rect[] } sizes: { large: Size medium: Size small: Size thumb: Size } url: string } export interface MediaPhoto extends MediaBase { type: 'photo' ext_alt_text?: string } export interface MediaAnimatedGif extends MediaBase { type: 'animated_gif' video_info: VideoInfo } export interface MediaVideo extends MediaBase { type: 'video' video_info: VideoInfo } export type MediaDetails = MediaPhoto | MediaAnimatedGif | MediaVideo ================================================ FILE: src/types/photo.ts ================================================ import type { Rect, RGB } from './media.ts' export interface TweetPhoto { backgroundColor: RGB cropCandidates: Rect[] expandedUrl: string url: string width: number height: number } ================================================ FILE: src/types/tweet.ts ================================================ import type { TwitterCard } from './card.ts' import type { TweetEditControl } from './edit.ts' import type { Indices, TweetEntities } from './entities.ts' import type { MediaDetails } from './media' import type { TweetPhoto } from './photo.ts' import type { TweetUser } from './user.ts' import type { TweetVideo } from './video.ts' /** * Base tweet information shared by a tweet, a parent tweet and a quoted tweet. */ export interface TweetBase { /** * Language code of the tweet. E.g "en", "es". */ lang: string /** * Creation date of the tweet in the format ISO 8601. */ created_at: string /** * Text range of the tweet text. */ display_text_range: Indices /** * All the entities that are part of the tweet. Like hashtags, mentions, urls, etc. */ entities: TweetEntities /** * The unique identifier of the tweet. */ id_str: string /** * The tweet text, including the raw text from the entities. */ text: string /** * Information about the user who posted the tweet. */ user: TweetUser /** * Edit information about the tweet. */ edit_control: TweetEditControl isEdited: boolean isStaleEdit: boolean } /** * A tweet as returned by the the Twitter syndication API. */ export interface Tweet extends TweetBase { __typename: 'Tweet' favorite_count: number mediaDetails?: MediaDetails[] photos?: TweetPhoto[] video?: TweetVideo card?: TwitterCard conversation_count: number news_action_type: 'conversation' quoted_tweet?: QuotedTweet in_reply_to_screen_name?: string in_reply_to_status_id_str?: string in_reply_to_user_id_str?: string parent?: TweetParent possibly_sensitive?: boolean } /** * The parent tweet of a tweet reply. */ export interface TweetParent extends Tweet { reply_count: number retweet_count: number favorite_count: number } /** * A tweet quoted by another tweet. */ export interface QuotedTweet extends Tweet { reply_count: number retweet_count: number favorite_count: number self_thread?: { id_str?: string } } ================================================ FILE: src/types/user.ts ================================================ export interface TweetUser { id_str: string name: string profile_image_url_https: string profile_image_shape: 'Circle' | 'Square' screen_name: string verified: boolean verified_type?: 'Business' | 'Government' is_blue_verified: boolean } ================================================ FILE: src/types/video.ts ================================================ export interface TweetVideo { aspectRatio: [number, number] contentType: string durationMs: number mediaAvailability: { status: string } poster: string variants: { type: string src: string }[] videoId: { type: string id: string } viewCount: number } ================================================ FILE: tailwind.config.ts ================================================ import type { Config } from "tailwindcss" const config: Config = { content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'], theme: { extend: { screens: { "3xl": "1633px", "1.5xl": "1333px", "lt-2xl": { max: "1535px" }, "lt-xl": { max: "1279px" }, "lt-lg": { max: "1023px" }, "lt-md": { max: "767px" }, "lt-sm": { max: "639px" }, "xsm": "439px", "lt-xsm": { max: "439px" }, "xxsm": "339px", "lt-xxsm": { max: "339px" }, 'coarse': { 'raw': '(pointer: coarse)' }, 'fine': { 'raw': '(pointer: fine)' }, }, colors: { "primary": "#60a5fa", "secondary": "#1d4ed8", "elevated": "#1C1C1E", "elevated-2": "#262628", "label": "#ddd", "tertiary": "#555", "quaternary": "#333", "center-container-dark": "#121212", }, }, }, plugins: [], } export default config ================================================ FILE: tsconfig.json ================================================ { "extends": "astro/tsconfigs/strict", "compilerOptions": { "allowArbitraryExtensions": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "target": "ESNext", "module": "ESNext", "moduleResolution": "Bundler" } } ================================================ FILE: vercel.json ================================================ { "cleanUrls": true, "trailingSlash": false, "headers": [ { "source": "/(.*)", "headers": [ { "key": "X-Frame-Options", "value": "SAMEORIGIN" }, { "key": "X-Content-Type-Options", "value": "nosniff" }, { "key": "X-XSS-Protection", "value": "1; mode=block" }, { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }, { "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }, { "key": "Cache-Control", "value": "max-age=480, must-revalidate, public" }, { "key": "Accept-CH", "value": "DPR, Viewport-Width, Width" }, { "key": "X-UA-Compatible", "value": "IE=edge" }, { "key": "Access-Control-Allow-Origin", "value": "*" }, { "key": "Content-Security-Policy", "value": "default-src 'self'; font-src 'self' https://fonts.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' https://api.producthunt.com data: blob: https:; script-src 'self' https://*.bundlejs.com https://bundlejs.com 'unsafe-eval' 'unsafe-inline' blob: https://vercel.live; connect-src 'self' https: blob: data:; block-all-mixed-content; upgrade-insecure-requests; base-uri 'self'; object-src 'none'; worker-src 'self' blob:; manifest-src 'self'; media-src 'self' https: data: blob:; form-action 'self'; frame-src 'self'; frame-ancestors 'self' https:;" }, { "key": "Permissions-Policy", "value": "sync-xhr=(self)" } ] }, { "source": "/", "headers": [ { "key": "Link", "value": "; rel=preconnect, ; rel=preconnect, ; rel=preload; as=video; crossorigin=anonymous" }, { "key": "Cross-Origin-Embedder-Policy", "value": "unsafe-none" }, { "key": "Cross-Origin-Opener-Policy", "value": "unsafe-none" } ] }, { "source": "/ffmpeg", "headers": [ { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }, { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" } ] } ], "rewrites": [ { "source": "/take-measurement", "destination": "https://analytics.bundlejs.com/api/collect" } ], "github": { "silent": true, "autoJobCancelation": true } }