Greasy Fork is available in English.
Image autodrawer for skribbl.io with color, quality controls, optional outlines, image URLs, and a Bert test image.
// ==UserScript==
// @name SKRIBBL BREAD DRAWER
// @namespace skribbl-bread-drawer
// @version 1.0.3
// @description Image autodrawer for skribbl.io with color, quality controls, optional outlines, image URLs, and a Bert test image.
// @match *://skribbl.io/*
// @match *://*.skribbl.io/*
// @run-at document-start
// @grant none
// @license MIT
// ==/UserScript==
(function () {
'use strict';
console.log("🍞 SKRIBBL BREAD DRAWER userscript starting");
function boot() {
if (!document.body) {
setTimeout(boot, 100);
return;
}
if (document.querySelector("#skribbl-bread-drawer")) {
return;
}
// ============================================================
// STATE
// ============================================================
let loadedImage = null;
let drawingNow = false;
let stopRequested = false;
let overlay = null;
let overlayCtx = null;
const sleep = ms =>
new Promise(resolve => setTimeout(resolve, ms));
const BERT_TEST_IMAGE =
"https://encrypted-tbn0.gstatic.com/images?q=tbn:ANd9GcROVGR3OrBVzqQf_fLmP_v-Ev5hO-XYr20phYcbeTZMSA&s=10";
const SETTINGS = {
darkness: 170,
minFeature: 2,
simplify: 0.75,
duplicateDistance: 3.5,
whiteCutoff: 242,
packetSize: 4
};
// ============================================================
// SKRIBBL PALETTE
// ============================================================
const PALETTE = [
{id:0, rgb:[255,255,255], hex:"#ffffff"},
{id:1, rgb:[0,0,0], hex:"#000000"},
{id:2, rgb:[193,193,193], hex:"#c1c1c1"},
{id:3, rgb:[80,80,80], hex:"#505050"},
{id:4, rgb:[239,19,11], hex:"#ef130b"},
{id:5, rgb:[116,11,7], hex:"#740b07"},
{id:6, rgb:[255,113,0], hex:"#ff7100"},
{id:7, rgb:[194,56,0], hex:"#c23800"},
{id:8, rgb:[255,228,0], hex:"#ffe400"},
{id:9, rgb:[232,162,0], hex:"#e8a200"},
{id:10, rgb:[0,204,0], hex:"#00cc00"},
{id:11, rgb:[0,85,16], hex:"#005510"},
{id:12, rgb:[0,255,145], hex:"#00ff91"},
{id:13, rgb:[0,120,93], hex:"#00785d"},
{id:14, rgb:[0,178,255], hex:"#00b2ff"},
{id:15, rgb:[0,86,158], hex:"#00569e"},
{id:16, rgb:[35,31,211], hex:"#231fd3"},
{id:17, rgb:[14,8,101], hex:"#0e0865"},
{id:18, rgb:[163,0,186], hex:"#a300ba"},
{id:19, rgb:[85,0,105], hex:"#550069"},
{id:20, rgb:[223,105,167], hex:"#df69a7"},
{id:21, rgb:[135,53,84], hex:"#873554"},
{id:22, rgb:[255,172,142], hex:"#ffac8e"},
{id:23, rgb:[204,119,77], hex:"#cc774d"},
{id:24, rgb:[160,82,45], hex:"#a0522d"},
{id:25, rgb:[99,48,13], hex:"#63300d"}
];
// ============================================================
// UI
// ============================================================
const panel = document.createElement("div");
panel.id = "skribbl-bread-drawer";
Object.assign(panel.style, {
position: "fixed",
top: "10px",
right: "10px",
zIndex: "2147483647",
width: "310px",
padding: "12px",
background: "#181818",
color: "#fff",
border: "1px solid #555",
borderRadius: "12px",
font: "13px Arial,sans-serif",
boxShadow: "0 6px 28px rgba(0,0,0,.55)"
});
panel.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div style="font-size:17px;font-weight:800">
🍞 SKRIBBL BREAD DRAWER
</div>
<button id="sk-close" style="
width:28px;height:28px;padding:0;
background:#303030;color:white;
border:1px solid #666;border-radius:6px;
cursor:pointer;font-size:16px">
×
</button>
</div>
<div id="sk-socket" style="
padding:7px 9px;margin-bottom:8px;
border-radius:6px;background:#492222;
color:white;font-weight:700">
🔴 draw one tiny line
</div>
<label for="sk-file" style="
display:block;text-align:center;padding:8px;
background:#353535;color:white;
border:1px solid #666;border-radius:7px;
cursor:pointer;font-weight:700">
📁 CHOOSE IMAGE
</label>
<input
id="sk-file"
type="file"
accept="image/*"
style="display:none"
>
<button id="sk-bert-test" style="
display:block;width:100%;margin-top:6px;padding:8px;
background:#6847a8;color:white;
border:1px solid #8b6dcc;border-radius:7px;
cursor:pointer;font-weight:700">
🧪 BERT TEST IMAGE
</button>
<div style="
display:flex;
gap:6px;
margin-top:6px;
">
<input
id="sk-url"
type="text"
placeholder="Image URL..."
style="
flex:1;
min-width:0;
padding:7px;
background:#262626;
color:white;
border:1px solid #555;
border-radius:7px;
outline:none;
box-sizing:border-box;
"
>
<button
id="sk-load-url"
style="
padding:7px 10px;
background:#355f8a;
color:white;
border:1px solid #5584b0;
border-radius:7px;
cursor:pointer;
font-weight:700;
"
>
LOAD
</button>
</div>
<div id="sk-previewbox" style="
display:none;height:85px;margin-top:7px;
overflow:hidden;background:white;
border-radius:7px;border:1px solid #444">
<img
id="sk-preview"
style="width:100%;height:100%;object-fit:contain"
>
</div>
<div style="margin-top:9px;padding-top:8px;border-top:1px solid #3d3d3d">
<div style="display:flex;justify-content:space-between">
<span>Picture quality</span>
<b id="sk-qualityv">3</b>
</div>
<input
id="sk-quality"
type="range"
min="1"
max="5"
value="3"
style="width:100%"
>
<div style="display:flex;justify-content:space-between">
<span>Color detail</span>
<b id="sk-colorstepv">6</b>
</div>
<input
id="sk-colorstep"
type="range"
min="3"
max="12"
value="6"
style="width:100%"
>
<div style="display:flex;justify-content:space-between">
<span>Color brush</span>
<b id="sk-colorbrushv">8</b>
</div>
<input
id="sk-colorbrush"
type="range"
min="3"
max="14"
value="8"
style="width:100%"
>
<div style="display:flex;justify-content:space-between">
<span>Outline brush</span>
<b id="sk-outlinebrushv">4</b>
</div>
<input
id="sk-outlinebrush"
type="range"
min="1"
max="12"
value="4"
style="width:100%"
>
<div style="display:flex;justify-content:space-between">
<span>Speed</span>
<b id="sk-speedv">3</b>
</div>
<input
id="sk-speed"
type="range"
min="1"
max="5"
value="3"
style="width:100%"
>
<div style="display:flex;gap:18px;margin-top:6px">
<label style="display:flex;align-items:center;gap:5px;cursor:pointer">
<input
id="sk-outline-enabled"
type="checkbox"
checked
>
Outline
</label>
<label style="display:flex;align-items:center;gap:5px;cursor:pointer">
<input
id="sk-local"
type="checkbox"
checked
>
Local preview
</label>
</div>
</div>
<div id="sk-status" style="
margin-top:8px;padding:7px 9px;min-height:17px;
background:#242424;color:#ccc;
border-radius:6px;font-size:12px">
waiting for image
</div>
<div style="
display:grid;
grid-template-columns:1fr 1fr;
gap:7px;
margin-top:8px
">
<button id="sk-draw" style="
padding:10px;background:#248b3b;color:white;
border:1px solid #51ad64;border-radius:7px;
font-weight:800;cursor:pointer;font-size:14px">
▶ DRAW
</button>
<button id="sk-stop" style="
padding:10px;background:#a52a24;color:white;
border:1px solid #d5554e;border-radius:7px;
font-weight:800;cursor:pointer;font-size:14px">
■ STOP
</button>
</div>
<button id="sk-clear" style="
width:100%;margin-top:6px;padding:6px;
background:#353535;color:#ddd;
border:1px solid #555;border-radius:7px;
cursor:pointer">
Clear local preview
</button>
`;
document.body.appendChild(panel);
const $ = selector =>
panel.querySelector(selector);
function status(text) {
$("#sk-status").textContent = text;
console.log("[SKRIBBL BREAD DRAWER]", text);
}
[
["#sk-quality", "#sk-qualityv"],
["#sk-colorstep", "#sk-colorstepv"],
["#sk-colorbrush", "#sk-colorbrushv"],
["#sk-outlinebrush", "#sk-outlinebrushv"],
["#sk-speed", "#sk-speedv"]
].forEach(([input, output]) => {
$(input).oninput = e => {
$(output).textContent = e.target.value;
};
});
// ============================================================
// IMAGE LOADING
// ============================================================
function setImage(img) {
loadedImage = img;
$("#sk-preview").src = img.src;
$("#sk-previewbox").style.display = "block";
status(
`${img.naturalWidth} × ${img.naturalHeight} loaded`
);
}
$("#sk-file").onchange = e => {
const file = e.target.files?.[0];
if (!file) {
return;
}
const img = new Image();
img.onload = () => setImage(img);
img.onerror = () => status("❌ couldn't load image");
img.src = URL.createObjectURL(file);
};
$("#sk-bert-test").onclick = () => {
status("loading Bert test image...");
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
status("🧪 Bert test image loaded");
};
img.onerror = () => {
status("❌ Bert test image failed to load");
};
img.src = BERT_TEST_IMAGE;
};
$("#sk-load-url").onclick = () => {
const url =
$("#sk-url").value.trim();
if (!url) {
status("❌ paste an image URL");
return;
}
status("loading image URL...");
const img = new Image();
img.crossOrigin = "anonymous";
img.onload = () => {
setImage(img);
status(
`🌐 ${img.naturalWidth} × ${img.naturalHeight} URL image loaded`
);
};
img.onerror = () => {
status(
"❌ URL image failed — try downloading/uploading it instead"
);
};
img.src = url;
};
$("#sk-url").addEventListener("keydown", e => {
if (e.key === "Enter") {
$("#sk-load-url").click();
}
});
// ============================================================
// SOCKET CAPTURE
// ============================================================
if (!window.__breadTrueSend) {
window.__breadTrueSend =
WebSocket.prototype.send;
}
const rawSend =
window.__breadTrueSend;
WebSocket.prototype.send =
function(data) {
if (
typeof data === "string" &&
data.startsWith('42["data"') &&
data.includes('"id":19')
) {
window.__breadSocket =
this;
const socketBox =
document.querySelector(
"#skribbl-bread-drawer #sk-socket"
);
if (socketBox) {
socketBox.textContent =
"🟢 socket captured";
socketBox.style.background =
"#184529";
}
console.log(
"🎯 Skribbl draw socket captured"
);
}
return rawSend.apply(
this,
arguments
);
};
function getSocket() {
return (
window.__breadSocket ||
window.__skColorSocket ||
window.__skBreadSocket
);
}
// ============================================================
// CANVAS
// ============================================================
function findCanvas() {
return [
...document.querySelectorAll("canvas")
]
.filter(canvas => {
const r =
canvas.getBoundingClientRect();
return (
r.width > 250 &&
r.height > 150 &&
r.bottom > 0 &&
r.right > 0
);
})
.sort((a, b) => {
const A =
a.getBoundingClientRect();
const B =
b.getBoundingClientRect();
return (
B.width * B.height -
A.width * A.height
);
})[0] || null;
}
// ============================================================
// OVERLAY
// ============================================================
function makeOverlay(canvas) {
document
.querySelector("#sk-bread-overlay")
?.remove();
const rect =
canvas.getBoundingClientRect();
overlay =
document.createElement("canvas");
overlay.id =
"sk-bread-overlay";
overlay.width =
canvas.width;
overlay.height =
canvas.height;
Object.assign(
overlay.style,
{
position: "fixed",
left: rect.left + "px",
top: rect.top + "px",
width: rect.width + "px",
height: rect.height + "px",
pointerEvents: "none",
zIndex: "2147483000"
}
);
document.body.appendChild(
overlay
);
overlayCtx =
overlay.getContext("2d");
overlayCtx.lineCap =
"round";
overlayCtx.lineJoin =
"round";
}
function clearOverlay() {
if (
overlay &&
overlayCtx
) {
overlayCtx.clearRect(
0,
0,
overlay.width,
overlay.height
);
}
}
$("#sk-clear").onclick =
clearOverlay;
// ============================================================
// COLOR MATCHING
// ============================================================
function nearestColor(
r,
g,
b
) {
let best =
PALETTE[0];
let bestDistance =
Infinity;
for (
const p
of PALETTE
) {
const dr =
r - p.rgb[0];
const dg =
g - p.rgb[1];
const db =
b - p.rgb[2];
const distance =
dr * dr * 0.30 +
dg * dg * 0.59 +
db * db * 0.11;
if (
distance <
bestDistance
) {
bestDistance =
distance;
best =
p;
}
}
return best;
}
// ============================================================
// QUALITY
// ============================================================
function qualityScale() {
const q =
+$("#sk-quality").value;
return ({
1: 0.45,
2: 0.60,
3: 0.75,
4: 0.88,
5: 1.00
})[q] || 0.75;
}
// ============================================================
// SOURCE IMAGE
// ============================================================
function makeSourceCanvas(
img,
targetW,
targetH
) {
const scaleQ =
qualityScale();
const W =
Math.max(
100,
Math.round(
targetW * scaleQ
)
);
const H =
Math.max(
100,
Math.round(
targetH * scaleQ
)
);
const canvas =
document.createElement("canvas");
canvas.width =
W;
canvas.height =
H;
const ctx =
canvas.getContext(
"2d",
{
willReadFrequently: true
}
);
ctx.fillStyle =
"#ffffff";
ctx.fillRect(
0,
0,
W,
H
);
const margin =
Math.round(
W * 0.05
);
const scale =
Math.min(
(W - margin * 2) /
img.naturalWidth,
(H - margin * 2) /
img.naturalHeight
);
const iw =
img.naturalWidth * scale;
const ih =
img.naturalHeight * scale;
ctx.drawImage(
img,
(W - iw) / 2,
(H - ih) / 2,
iw,
ih
);
canvas.__targetW =
targetW;
canvas.__targetH =
targetH;
return canvas;
}
// ============================================================
// COLOR COMMANDS
// ============================================================
function makeColorCommands(
source,
step,
brush
) {
const W =
source.width;
const H =
source.height;
const targetW =
source.__targetW || W;
const targetH =
source.__targetH || H;
const sx =
targetW / W;
const sy =
targetH / H;
const ctx =
source.getContext(
"2d",
{
willReadFrequently: true
}
);
const pixels =
ctx.getImageData(
0,
0,
W,
H
).data;
const commands = [];
function pixelAt(
x,
y
) {
const i =
(y * W + x) * 4;
return [
pixels[i],
pixels[i + 1],
pixels[i + 2],
pixels[i + 3]
];
}
for (
let y =
Math.floor(step / 2);
y < H;
y += step
) {
let runColor = null;
let runStart = null;
let lastX = null;
function finishRun() {
if (
runColor !== null &&
runStart !== null &&
lastX !== null
) {
commands.push([
0,
runColor,
brush,
Math.round(
runStart * sx
),
Math.round(
y * sy
),
Math.round(
Math.max(
runStart + 1,
lastX
) * sx
),
Math.round(
y * sy
)
]);
}
runColor = null;
runStart = null;
lastX = null;
}
for (
let x =
Math.floor(step / 2);
x < W;
x += step
) {
const [
r,
g,
b,
a
] =
pixelAt(
x,
y
);
if (
a < 40 ||
(
r >= SETTINGS.whiteCutoff &&
g >= SETTINGS.whiteCutoff &&
b >= SETTINGS.whiteCutoff
)
) {
finishRun();
continue;
}
const color =
nearestColor(
r,
g,
b
);
if (
color.id === 0 ||
color.id === 1
) {
finishRun();
continue;
}
if (
runColor === null
) {
runColor =
color.id;
runStart =
x;
lastX =
x;
continue;
}
if (
color.id ===
runColor
) {
lastX =
x;
continue;
}
finishRun();
runColor =
color.id;
runStart =
x;
lastX =
x;
}
finishRun();
}
return commands;
}
// ============================================================
// OUTLINE MASK
// ============================================================
function buildMask(
img,
targetW,
targetH
) {
const quality =
+$("#sk-quality").value;
const qualityWidths = {
1: 260,
2: 360,
3: 460,
4: 560,
5: 650
};
const W =
qualityWidths[
quality
] || 460;
const H =
Math.round(
W * targetH / targetW
);
const canvas =
document.createElement("canvas");
canvas.width =
W;
canvas.height =
H;
const ctx =
canvas.getContext(
"2d",
{
willReadFrequently: true
}
);
ctx.fillStyle =
"white";
ctx.fillRect(
0,
0,
W,
H
);
const margin =
Math.round(
W * 0.05
);
const scale =
Math.min(
(W - margin * 2) /
img.naturalWidth,
(H - margin * 2) /
img.naturalHeight
);
const iw =
img.naturalWidth * scale;
const ih =
img.naturalHeight * scale;
ctx.drawImage(
img,
(W - iw) / 2,
(H - ih) / 2,
iw,
ih
);
const data =
ctx.getImageData(
0,
0,
W,
H
).data;
const mask =
new Uint8Array(
W * H
);
for (
let i = 0,
p = 0;
i < mask.length;
i++,
p += 4
) {
const alpha =
data[p + 3] / 255;
let gray =
data[p] * 0.299 +
data[p + 1] * 0.587 +
data[p + 2] * 0.114;
gray =
gray * alpha +
255 * (1 - alpha);
mask[i] =
gray < SETTINGS.darkness
? 1
: 0;
}
return {
mask,
W,
H,
sx: targetW / W,
sy: targetH / H
};
}
// ============================================================
// BOUNDARY
// ============================================================
function getBoundary(
mask,
W,
H
) {
const edge =
new Uint8Array(
W * H
);
const I =
(x, y) =>
y * W + x;
for (
let y = 1;
y < H - 1;
y++
) {
for (
let x = 1;
x < W - 1;
x++
) {
if (
!mask[
I(x, y)
]
) {
continue;
}
if (
!mask[I(x - 1, y)] ||
!mask[I(x + 1, y)] ||
!mask[I(x, y - 1)] ||
!mask[I(x, y + 1)]
) {
edge[
I(x, y)
] = 1;
}
}
}
return edge;
}
// ============================================================
// TRACE EDGES
// ============================================================
function traceEdges(
edge,
W,
H
) {
const I =
(x, y) =>
y * W + x;
const dirs = [
[-1,-1],
[0,-1],
[1,-1],
[-1,0],
[1,0],
[-1,1],
[0,1],
[1,1]
];
function on(x, y) {
return (
x >= 0 &&
x < W &&
y >= 0 &&
y < H &&
edge[I(x, y)]
);
}
function edgeKey(a, b) {
const A =
`${a[0]},${a[1]}`;
const B =
`${b[0]},${b[1]}`;
return (
A < B
? `${A}|${B}`
: `${B}|${A}`
);
}
function neighbors(p) {
const out = [];
for (
const [dx, dy]
of dirs
) {
const x =
p[0] + dx;
const y =
p[1] + dy;
if (
on(x, y)
) {
out.push(
[x, y]
);
}
}
return out;
}
const used =
new Set();
function available(p) {
return neighbors(p)
.filter(
n =>
!used.has(
edgeKey(
p,
n
)
)
);
}
function score(
prev,
cur,
next
) {
const ax =
cur[0] - prev[0];
const ay =
cur[1] - prev[1];
const bx =
next[0] - cur[0];
const by =
next[1] - cur[1];
return (
ax * bx +
ay * by
) /
(
(
Math.hypot(
ax,
ay
) || 1
) *
(
Math.hypot(
bx,
by
) || 1
)
);
}
function walk(
start,
first
) {
const path = [
start,
first
];
used.add(
edgeKey(
start,
first
)
);
let prev =
start;
let cur =
first;
let guard =
0;
while (
guard++ < 30000
) {
let options =
available(cur);
options =
options.filter(
p =>
!(
p[0] === prev[0] &&
p[1] === prev[1]
)
);
if (
!options.length
) {
break;
}
let best =
options[0];
let bestScore =
-Infinity;
for (
const next
of options
) {
const s =
score(
prev,
cur,
next
);
if (
s > bestScore
) {
bestScore =
s;
best =
next;
}
}
used.add(
edgeKey(
cur,
best
)
);
path.push(
best
);
prev =
cur;
cur =
best;
if (
cur[0] === start[0] &&
cur[1] === start[1]
) {
break;
}
}
return path;
}
const pixels = [];
for (
let y = 1;
y < H - 1;
y++
) {
for (
let x = 1;
x < W - 1;
x++
) {
if (
edge[
I(x, y)
]
) {
pixels.push(
[x, y]
);
}
}
}
const endpoints =
pixels.filter(
p =>
neighbors(p).length === 1
);
const paths = [];
for (
const start
of endpoints
) {
let options;
while (
(
options =
available(start)
).length
) {
const path =
walk(
start,
options[0]
);
if (
path.length > 1
) {
paths.push(
path
);
}
}
}
for (
const start
of pixels
) {
let options;
let guard = 0;
while (
(
options =
available(start)
).length &&
guard++ < 20
) {
const path =
walk(
start,
options[0]
);
if (
path.length > 1
) {
paths.push(
path
);
}
}
}
return paths;
}
// ============================================================
// PATH HELPERS
// ============================================================
function pathLength(
path
) {
let total = 0;
for (
let i = 1;
i < path.length;
i++
) {
total +=
Math.hypot(
path[i][0] -
path[i - 1][0],
path[i][1] -
path[i - 1][1]
);
}
return total;
}
function bbox(path) {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (
const [x, y]
of path
) {
minX =
Math.min(
minX,
x
);
minY =
Math.min(
minY,
y
);
maxX =
Math.max(
maxX,
x
);
maxY =
Math.max(
maxY,
y
);
}
return {
minX,
minY,
maxX,
maxY,
w: maxX - minX,
h: maxY - minY
};
}
function lightSmooth(
path
) {
if (
path.length < 5
) {
return path;
}
const out = [
path[0]
];
for (
let i = 1;
i < path.length - 1;
i++
) {
const a =
path[i - 1];
const b =
path[i];
const c =
path[i + 1];
out.push([
a[0] * 0.025 +
b[0] * 0.95 +
c[0] * 0.025,
a[1] * 0.025 +
b[1] * 0.95 +
c[1] * 0.025
]);
}
out.push(
path[
path.length - 1
]
);
return out;
}
function resamplePath(
path,
count = 20
) {
if (
path.length < 2
) {
return path;
}
const cumulative = [
0
];
for (
let i = 1;
i < path.length;
i++
) {
cumulative[i] =
cumulative[i - 1] +
Math.hypot(
path[i][0] -
path[i - 1][0],
path[i][1] -
path[i - 1][1]
);
}
const total =
cumulative[
cumulative.length - 1
];
if (
total === 0
) {
return Array.from(
{
length: count
},
() => [
...path[0]
]
);
}
const result = [];
let segment = 1;
for (
let k = 0;
k < count;
k++
) {
const target =
total *
(
k /
(
count - 1
)
);
while (
segment <
cumulative.length - 1 &&
cumulative[segment] <
target
) {
segment++;
}
const a =
path[segment - 1];
const b =
path[segment];
const before =
cumulative[segment - 1];
const after =
cumulative[segment];
const span =
after - before;
const t =
span
? (
target -
before
) /
span
: 0;
result.push([
a[0] +
(
b[0] -
a[0]
) *
t,
a[1] +
(
b[1] -
a[1]
) *
t
]);
}
return result;
}
// ============================================================
// DUPLICATE SUPPRESSION
// ============================================================
function duplicateScore(
a,
b,
maxDistance
) {
const la =
pathLength(a);
const lb =
pathLength(b);
if (
la < 35 ||
lb < 35
) {
return false;
}
const ratio =
Math.min(
la,
lb
) /
Math.max(
la,
lb
);
if (
ratio < 0.68
) {
return false;
}
const ba =
bbox(a);
const bb =
bbox(b);
const wr =
Math.min(
Math.max(ba.w, 1),
Math.max(bb.w, 1)
) /
Math.max(
Math.max(ba.w, 1),
Math.max(bb.w, 1)
);
const hr =
Math.min(
Math.max(ba.h, 1),
Math.max(bb.h, 1)
) /
Math.max(
Math.max(ba.h, 1),
Math.max(bb.h, 1)
);
if (
Math.max(
wr,
hr
) < 0.65
) {
return false;
}
const A =
resamplePath(
a,
24
);
const B =
resamplePath(
b,
24
);
function compare(
P,
Q
) {
let total = 0;
let close = 0;
for (
let i = 0;
i < P.length;
i++
) {
const distance =
Math.hypot(
P[i][0] -
Q[i][0],
P[i][1] -
Q[i][1]
);
total +=
distance;
if (
distance <=
maxDistance
) {
close++;
}
}
return {
avg:
total /
P.length,
fraction:
close /
P.length
};
}
const forward =
compare(
A,
B
);
const reverse =
compare(
A,
[...B].reverse()
);
const best =
forward.avg <
reverse.avg
? forward
: reverse;
return (
best.avg <=
maxDistance * 1.05 &&
best.fraction >=
0.72
);
}
function suppressDuplicates(
paths
) {
const ordered =
[...paths]
.sort(
(a, b) =>
pathLength(b) -
pathLength(a)
);
const kept = [];
for (
const candidate
of ordered
) {
let duplicate =
false;
if (
pathLength(
candidate
) >= 35
) {
for (
const existing
of kept
) {
if (
pathLength(
existing
) < 35
) {
continue;
}
if (
duplicateScore(
candidate,
existing,
SETTINGS.duplicateDistance
)
) {
duplicate =
true;
break;
}
}
}
if (
!duplicate
) {
kept.push(
candidate
);
}
}
return kept;
}
// ============================================================
// SIMPLIFY
// ============================================================
function simplify(
points,
epsilon
) {
if (
epsilon <= 0 ||
points.length < 3
) {
return points;
}
function pointDistance(
p,
a,
b
) {
const dx =
b[0] - a[0];
const dy =
b[1] - a[1];
if (
!dx &&
!dy
) {
return Math.hypot(
p[0] -
a[0],
p[1] -
a[1]
);
}
let t =
(
(
p[0] - a[0]
) * dx +
(
p[1] - a[1]
) * dy
) /
(
dx * dx +
dy * dy
);
t =
Math.max(
0,
Math.min(
1,
t
)
);
return Math.hypot(
p[0] -
(
a[0] +
t * dx
),
p[1] -
(
a[1] +
t * dy
)
);
}
function recurse(
start,
end
) {
let maxDistance = 0;
let index = -1;
for (
let i = start + 1;
i < end;
i++
) {
const distance =
pointDistance(
points[i],
points[start],
points[end]
);
if (
distance >
maxDistance
) {
maxDistance =
distance;
index =
i;
}
}
if (
index !== -1 &&
maxDistance >
epsilon
) {
const left =
recurse(
start,
index
);
const right =
recurse(
index,
end
);
return [
...left.slice(
0,
-1
),
...right
];
}
return [
points[start],
points[end]
];
}
return recurse(
0,
points.length - 1
);
}
function scalePath(
path,
sx,
sy
) {
return path.map(
([x, y]) => [
x * sx,
y * sy
]
);
}
// ============================================================
// OUTLINE COMMANDS
// ============================================================
function makeOutlineCommands(
paths,
brush
) {
const commands = [];
for (
const path
of paths
) {
for (
let i = 1;
i < path.length;
i++
) {
const a =
path[i - 1];
const b =
path[i];
const x1 =
Math.round(
a[0]
);
const y1 =
Math.round(
a[1]
);
const x2 =
Math.round(
b[0]
);
const y2 =
Math.round(
b[1]
);
if (
x1 === x2 &&
y1 === y2
) {
continue;
}
commands.push([
0,
1,
brush,
x1,
y1,
x2,
y2
]);
}
}
return commands;
}
// ============================================================
// NETWORK
// ============================================================
function sendBatch(
socket,
batch
) {
const packet =
"42" +
JSON.stringify([
"data",
{
id: 19,
data: batch
}
]);
rawSend.call(
socket,
packet
);
}
function mirrorCommand(
command
) {
if (
!$("#sk-local").checked ||
!overlayCtx
) {
return;
}
const [
,
colorId,
brush,
x1,
y1,
x2,
y2
] =
command;
const color =
PALETTE.find(
item =>
item.id ===
colorId
) ||
PALETTE[1];
overlayCtx.strokeStyle =
color.hex;
overlayCtx.lineWidth =
brush;
overlayCtx.beginPath();
overlayCtx.moveTo(
x1,
y1
);
overlayCtx.lineTo(
x2,
y2
);
overlayCtx.stroke();
}
function speedToDelay() {
const speed =
+$("#sk-speed").value;
return ({
1: 70,
2: 50,
3: 35,
4: 24,
5: 15
})[speed] || 35;
}
async function transmit(
commands,
label
) {
const socket =
getSocket();
if (
!socket ||
socket.readyState !==
WebSocket.OPEN
) {
throw new Error(
"draw one tiny manual line first"
);
}
const packetSize =
SETTINGS.packetSize;
const delay =
speedToDelay();
let sent = 0;
for (
let i = 0;
i < commands.length;
i += packetSize
) {
if (
stopRequested
) {
return;
}
const batch =
commands.slice(
i,
i + packetSize
);
sendBatch(
socket,
batch
);
batch.forEach(
mirrorCommand
);
sent +=
batch.length;
status(
`${label} ${sent}/${commands.length}`
);
await sleep(
delay
);
}
}
// ============================================================
// DRAW
// ============================================================
$("#sk-draw").onclick =
async () => {
try {
if (
drawingNow
) {
return;
}
if (
!loadedImage
) {
status(
"❌ choose an image first"
);
return;
}
const socket =
getSocket();
if (
!socket ||
socket.readyState !==
WebSocket.OPEN
) {
status(
"❌ draw one tiny manual line first"
);
return;
}
const canvas =
findCanvas();
if (
!canvas
) {
status(
"❌ couldn't find Skribbl canvas"
);
return;
}
drawingNow =
true;
stopRequested =
false;
if (
$("#sk-local").checked
) {
makeOverlay(
canvas
);
}
const rect =
canvas.getBoundingClientRect();
const W =
canvas.width ||
Math.round(
rect.width
);
const H =
canvas.height ||
Math.round(
rect.height
);
// ======================================================
// COLORS
// ======================================================
status(
"🎨 preparing colors"
);
const source =
makeSourceCanvas(
loadedImage,
W,
H
);
const colorCommands =
makeColorCommands(
source,
+$("#sk-colorstep").value,
+$("#sk-colorbrush").value
);
if (
colorCommands.length >
15000
) {
throw new Error(
"too many color commands"
);
}
await transmit(
colorCommands,
"🎨"
);
if (
stopRequested
) {
drawingNow =
false;
status(
"■ stopped"
);
return;
}
// ======================================================
// OPTIONAL OUTLINE
// ======================================================
if (
$("#sk-outline-enabled").checked
) {
status(
"🖤 tracing outline"
);
const prep =
buildMask(
loadedImage,
W,
H
);
const edge =
getBoundary(
prep.mask,
prep.W,
prep.H
);
let paths =
traceEdges(
edge,
prep.W,
prep.H
);
paths =
paths.filter(
path =>
pathLength(
path
) >=
SETTINGS.minFeature
);
paths =
paths.map(
lightSmooth
);
paths =
suppressDuplicates(
paths
);
paths =
paths.map(
path =>
simplify(
path,
SETTINGS.simplify
)
);
paths =
paths.filter(
path =>
path.length >= 2
);
paths =
paths.map(
path =>
scalePath(
path,
prep.sx,
prep.sy
)
);
paths.sort(
(a, b) =>
pathLength(b) -
pathLength(a)
);
const outlineCommands =
makeOutlineCommands(
paths,
+$("#sk-outlinebrush").value
);
if (
outlineCommands.length >
12000
) {
throw new Error(
"outline command count suspiciously huge"
);
}
await transmit(
outlineCommands,
"🖤"
);
}
drawingNow =
false;
if (
!stopRequested
) {
status(
"✅ drawing complete"
);
}
} catch(error) {
drawingNow =
false;
console.error(
error
);
status(
`❌ ${error.message || error}`
);
}
};
// ============================================================
// STOP / CLOSE
// ============================================================
$("#sk-stop").onclick =
() => {
stopRequested =
true;
status(
"■ stopping..."
);
};
$("#sk-close").onclick =
() => {
stopRequested =
true;
overlay?.remove();
panel.remove();
};
status(
"choose image, paste URL, or use Bert test image"
);
console.log(
"🍞 SKRIBBL BREAD DRAWER menu created"
);
}
if (
document.readyState ===
"loading"
) {
document.addEventListener(
"DOMContentLoaded",
boot,
{
once: true
}
);
setTimeout(
boot,
1200
);
} else {
boot();
}
})();