├── .gitignore ├── README.md ├── demo.gif ├── index.html ├── index.ts ├── package-lock.json ├── package.json ├── samples ├── fish.png ├── monsterra.png ├── peaches.png └── skulls.png ├── stable-diffusion.ts ├── tile-buddy.css ├── tile-buddy.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tile buddy 2 | 3 | ![](demo.gif) 4 | 5 | A little web app to generate, scale, tile and download background images. 6 | 7 | I use this to take `--tile` midjourney images and generate larger background for use in my videos. 8 | 9 | ## Using 10 | 11 | Right now, you just drag + drop the image onto the browser, scale it up/down, and then take a screenshot. 12 | 13 | ## I'd like to 14 | 15 | 1. Store all images drag + dropped in the browser with the FS API, or Index DB 16 | 2. have the ability to download common sizes - similar to 17 | 3. Hook into an API for generating these? I tried Stable Diffusion, but the results were not as good. 18 | 19 | pretty simple app - would take PRs if anyone wants to help. 20 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/tile-buddy/86d2b2c004a205a1a4dd9fc9d970a0b3900002b2/demo.gif -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Tile Buddy - Repeating Backgrounds 7 | 8 | 9 | 10 | 11 |
12 |
13 | 21 | 22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | console.log("Hello via Bun!"); -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tile-buddy", 3 | "lockfileVersion": 3, 4 | "requires": true, 5 | "packages": { 6 | "": { 7 | "name": "tile-buddy", 8 | "dependencies": { 9 | "html2canvas-pro": "^1.5.8" 10 | }, 11 | "peerDependencies": { 12 | "typescript": "^5.0.0" 13 | } 14 | }, 15 | "node_modules/base64-arraybuffer": { 16 | "version": "1.0.2", 17 | "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", 18 | "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", 19 | "engines": { 20 | "node": ">= 0.6.0" 21 | } 22 | }, 23 | "node_modules/css-line-break": { 24 | "version": "2.1.0", 25 | "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", 26 | "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", 27 | "dependencies": { 28 | "utrie": "^1.0.2" 29 | } 30 | }, 31 | "node_modules/html2canvas-pro": { 32 | "version": "1.5.8", 33 | "resolved": "https://registry.npmjs.org/html2canvas-pro/-/html2canvas-pro-1.5.8.tgz", 34 | "integrity": "sha512-bVGAU7IvhBwBlRAmX6QhekX8lsaxmYoF6zIwf/HNlHscjx+KN8jw/U4PQRYqeEVm9+m13hcS1l5ChJB9/e29Lw==", 35 | "dependencies": { 36 | "css-line-break": "^2.1.0", 37 | "text-segmentation": "^1.0.3" 38 | }, 39 | "engines": { 40 | "node": ">=16.0.0" 41 | } 42 | }, 43 | "node_modules/text-segmentation": { 44 | "version": "1.0.3", 45 | "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", 46 | "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", 47 | "dependencies": { 48 | "utrie": "^1.0.2" 49 | } 50 | }, 51 | "node_modules/utrie": { 52 | "version": "1.0.2", 53 | "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", 54 | "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", 55 | "dependencies": { 56 | "base64-arraybuffer": "^1.0.2" 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tile-buddy", 3 | "module": "index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "start": "vite", 7 | "build": "vite build" 8 | }, 9 | "peerDependencies": { 10 | "typescript": "^5.0.0" 11 | }, 12 | "dependencies": { 13 | "html2canvas-pro": "^1.5.8" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /samples/fish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/tile-buddy/86d2b2c004a205a1a4dd9fc9d970a0b3900002b2/samples/fish.png -------------------------------------------------------------------------------- /samples/monsterra.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/tile-buddy/86d2b2c004a205a1a4dd9fc9d970a0b3900002b2/samples/monsterra.png -------------------------------------------------------------------------------- /samples/peaches.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/tile-buddy/86d2b2c004a205a1a4dd9fc9d970a0b3900002b2/samples/peaches.png -------------------------------------------------------------------------------- /samples/skulls.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wesbos/tile-buddy/86d2b2c004a205a1a4dd9fc9d970a0b3900002b2/samples/skulls.png -------------------------------------------------------------------------------- /stable-diffusion.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs"; 2 | 3 | const prompt = `"Seamless pattern in the style of vintage wall paper. Showcasing bass fish.`; 4 | 5 | const formData = new FormData(); 6 | formData.append("prompt", prompt); 7 | formData.append("output_format", "webp"); 8 | formData.append("aspect_ratio", "1:1"); 9 | formData.append("style_preset", "tile-texture"); 10 | 11 | // const engineId = `stable-diffusion-xl-1024-v1-0`; 12 | 13 | const engineId = 'stable-diffusion-v1-6' 14 | const apiHost = process.env.API_HOST ?? 'https://api.stability.ai' 15 | 16 | const response = await fetch( 17 | `${apiHost}/v1/generation/${engineId}/text-to-image`, 18 | { 19 | method: 'POST', 20 | headers: { 21 | 'Content-Type': 'application/json', 22 | Accept: 'application/json', 23 | Authorization: `Bearer ${process.env.STABILITY_API_KEY}`, 24 | }, 25 | body: JSON.stringify({ 26 | text_prompts: [ 27 | { 28 | text: prompt, 29 | }, 30 | ], 31 | cfg_scale: 5, 32 | height: 1024, 33 | width: 1024, 34 | steps: 30, 35 | samples: 1, 36 | style_preset: 'tile-texture', 37 | }), 38 | } 39 | ) 40 | 41 | if (!response.ok) { 42 | throw new Error(`Non-200 response: ${await response.text()}`) 43 | } 44 | 45 | interface GenerationResponse { 46 | artifacts: Array<{ 47 | base64: string 48 | seed: number 49 | finishReason: string 50 | }> 51 | } 52 | 53 | 54 | console.log(response.headers); 55 | const responseJSON = (await response.json()) as GenerationResponse 56 | 57 | responseJSON.artifacts.forEach((image, index) => { 58 | const { base64, ...rest } = image 59 | console.log(rest) 60 | fs.writeFileSync( 61 | `./output/${Date.now()}-${prompt}_${index}.png`, 62 | Buffer.from(image.base64, 'base64') 63 | ) 64 | }) 65 | 66 | 67 | 68 | // const response = await fetch( 69 | // "https://api.stability.ai/v2beta/stable-image/generate/core", 70 | // { 71 | // method: "POST", 72 | // headers: { 73 | // Authorization: `Bearer ${process.env.STABILITY_API_KEY}`, 74 | // Accept: "image/*" 75 | // }, 76 | // body: formData 77 | // } 78 | // ) 79 | 80 | 81 | // if (response.status === 200) { 82 | // const data = await response.arrayBuffer(); 83 | // fs.writeFileSync(`./output/${Date.now()}-${prompt}.webp`, Buffer.from(data)); 84 | // } else { 85 | // throw new Error(`${response.status}: ${await response.text()}`); 86 | // } 87 | 88 | async function getEngines() { 89 | const url = `https://api.stability.ai/v1/engines/list`; 90 | 91 | const response = await fetch(url, { 92 | method: "GET", 93 | headers: { 94 | Authorization: `Bearer ${process.env.STABILITY_API_KEY}`, 95 | Accept: "application/json" 96 | } 97 | }).then((response) => { return response.json() }); 98 | console.log(response); 99 | return response; 100 | } 101 | -------------------------------------------------------------------------------- /tile-buddy.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --bg: url("./samples/skulls.png"); 3 | } 4 | 5 | body { 6 | background-image: var(--bg); 7 | background-size: calc(var(--size) * 1px); 8 | background-position: center; 9 | height: 100vh; 10 | } 11 | 12 | body { 13 | margin: 0; 14 | padding: 0; 15 | } 16 | html { 17 | margin: 0; 18 | padding: 0; 19 | } 20 | 21 | .controls { 22 | position: absolute; 23 | height: 100%; 24 | inset: 0; 25 | display: flex; 26 | border-radius: 20px; 27 | align-items: center; 28 | justify-content: center; 29 | opacity: 0; 30 | input[type="range"] { 31 | width: 20%; 32 | appearance: none; 33 | height: 5px; 34 | border-radius: 5px; 35 | outline: none; 36 | height: 10px; 37 | background: rgba(255, 255, 255, 0.6); 38 | } 39 | } 40 | 41 | body:hover .controls { 42 | opacity: 1; 43 | } 44 | 45 | .reel { 46 | display: grid; 47 | grid-template-columns: repeat(3, 1fr); 48 | gap: 10px; 49 | padding: 10px; 50 | max-width: 1000px; 51 | margin: 0 auto; 52 | img { 53 | width: 100%; 54 | border-radius: 10px; 55 | box-shadow: 0 0 10px rgba(0, 0, 0, 0.2); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tile-buddy.ts: -------------------------------------------------------------------------------- 1 | import html2canvas from 'html2canvas-pro'; 2 | const dropzone = document.querySelector('.controls'); 3 | const variableBinders = document.querySelectorAll('[data-variable]'); 4 | const downloadButton = document.querySelector('.download'); 5 | const reel = document.querySelector('.reel'); 6 | interface PromiseWithResolvers { 7 | promise: Promise; 8 | resolve: (value: T | PromiseLike) => void; 9 | reject: (reason?: any) => void; 10 | } 11 | 12 | interface PromiseConstructor { 13 | withResolvers(): PromiseWithResolvers; 14 | } 15 | 16 | async function readFile(file: File) { 17 | const { promise, resolve, reject } = Promise.withResolvers(); 18 | const reader = new FileReader(); 19 | reader.onload = resolve; 20 | reader.onerror = reject; 21 | reader.onabort = reject; 22 | reader.readAsText(file); 23 | return promise; 24 | } 25 | 26 | function getItemAsText(item: DataTransferItem) { 27 | const { promise, resolve, reject } = Promise.withResolvers(); 28 | item.getAsString(resolve); 29 | return promise; 30 | } 31 | 32 | async function getDataTransferItemURL(items: DataTransferItemList) { 33 | // Return the first item that is a text/uri-list or a File 34 | const item = Array.from(items).find((item) => { 35 | return item.type === 'text/uri-list' || item.type.startsWith('image/'); 36 | }); 37 | 38 | if(!item) return null; 39 | if(item.kind === 'file') { 40 | const file = item.getAsFile(); 41 | // TODO Save the file to the file system 42 | return file? URL.createObjectURL(file) : null; 43 | } 44 | if(item.kind === 'string') { 45 | const url = await getItemAsText(item); 46 | return url; 47 | } 48 | 49 | } 50 | 51 | dropzone?.addEventListener('dragover', (e) => { 52 | e.preventDefault(); 53 | e.stopPropagation(); 54 | console.log('dragover'); 55 | }); 56 | 57 | 58 | 59 | dropzone?.addEventListener('drop', async (e) => { 60 | if(!(e instanceof DragEvent)) return console.error('Not a DragEvent'); 61 | if(!e.dataTransfer) return console.error('No dataTransfer'); 62 | e.preventDefault(); 63 | e.stopPropagation(); 64 | const url = await getDataTransferItemURL(e.dataTransfer.items); 65 | console.log(url); 66 | document.documentElement.style.setProperty('--bg', `url(${url})`); 67 | }); 68 | 69 | // Bind the variables to the input fields 70 | variableBinders.forEach((el) => { 71 | const variable = el.getAttribute('data-variable') || ''; 72 | el.addEventListener('input', (e) => { 73 | document.documentElement.style.setProperty(variable, el.value); 74 | }); 75 | }); 76 | 77 | downloadButton?.addEventListener('click', async () => { 78 | const canvas: HTMLCanvasElement = await html2canvas(document.body); 79 | const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png', 1)); 80 | if(!blob) return console.error('No blob'); 81 | const url = URL.createObjectURL(blob); 82 | const link = document.createElement('a'); 83 | link.href = url; 84 | link.download = 'image.png'; 85 | const img = new Image(); 86 | img.src = url; 87 | link.appendChild(img); 88 | reel?.appendChild(link); 89 | 90 | }); 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./**/*"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "lib": ["ESNext", "DOM", "DOM.Iterable"], 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "module": "nodenext", 9 | "target": "esnext" 10 | } 11 | } 12 | --------------------------------------------------------------------------------